diff --git a/.commitlintrc.yaml b/.commitlintrc.yaml deleted file mode 100644 index f3391b77c..000000000 --- a/.commitlintrc.yaml +++ /dev/null @@ -1,34 +0,0 @@ -extends: - - '@commitlint/config-conventional' - -rules: - type-enum: - - 2 - - always - - - feat - - fix - - docs - - style - - refactor - - perf - - test - - build - - ci - - chore - - revert - subject-case: - - 2 - - never - - - upper-case - - pascal-case - - start-case - subject-empty: - - 2 - - never - type-empty: - - 2 - - never - scope-case: - - 2 - - always - - lower-case diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 5386b6774..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,216 +0,0 @@ -name: Python - -on: - push: - branches: - - main - pull_request: - schedule: - - cron: "0 8 * * *" - -concurrency: - group: check-${{ github.ref }} - cancel-in-progress: true - -jobs: - validate-pr-title: - name: Validate PR Title - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - permissions: - pull-requests: read - steps: - - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - types: | - feat - fix - docs - style - refactor - perf - test - build - ci - chore - revert - requireScope: false - subjectPattern: ^(?![A-Z]).+$ - subjectPatternError: | - The subject "{subject}" found in the pull request title "{title}" - didn't match the configured pattern. Please ensure that the subject - doesn't start with an uppercase character. - wip: false - validateSingleCommit: false - validateSingleCommitMatchesPrTitle: false - - lint: - name: Lint & Type Check - runs-on: ubuntu-latest - permissions: {} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install uv - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - enable-cache: true - python-version: "3.13" - - name: Install dependencies - run: uv sync --all-extras --locked - - name: Ruff lint - run: uv run ruff check . - - name: Ruff format - run: uv run ruff format --check . - - name: Mypy - run: uv run mypy descope tests samples - - name: License check - run: uv run --with pylic pylic check - - build: - name: test with ${{ matrix.py }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - permissions: {} - strategy: - fail-fast: false - matrix: - py: - - "3.14" - - "3.13" - - "3.12" - - "3.11" - - "3.10" - - "3.9" - os: - - ubuntu-latest - - macos-latest - - windows-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - name: Install uv - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - enable-cache: true - python-version: ${{ matrix.py }} - - name: Install dependencies - run: uv sync --all-extras --locked - - name: Fetch production regions - # The region list devops generates daily (descope/etc#18332). A failed fetch fails the - # job on purpose: falling back to the embedded list would let a broken sync look green. - shell: bash - run: | - regions=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 --retry 3 --retry-all-errors \ - https://imgs.descope.com/regions/regions.json | jq --compact-output \ - '.prod | if type == "array" and length > 0 and all(.[]; (.symbol | type) == "string" and (.symbol | length) > 0) - then . else error("regions.json: prod must be a non-empty array of {symbol} objects") end') - echo "REGIONS=$regions" >> "$GITHUB_ENV" - - name: Run test suite - run: uv run coverage run -m pytest tests --ignore=tests/e2e - env: - COVERAGE_FILE: "coverage.${{ matrix.os }}.${{ matrix.py }}" - - name: Store coverage file - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: coverage.${{ matrix.os }}.${{ matrix.py }} - path: coverage.${{ matrix.os }}.${{ matrix.py }} - if-no-files-found: error - - coverage: - if: github.event_name != 'schedule' - name: Coverage - runs-on: ubuntu-latest - needs: build - permissions: - pull-requests: write - contents: write - env: - COVERAGE_FILE: "coverage" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - id: download - with: - pattern: coverage.* - merge-multiple: true - - - name: Coverage comment - id: coverage_comment - uses: py-cov-action/python-coverage-comment-action@b3d7e98bf5528b07d6951ef7a93e2b156f960112 # v3.1.0 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MERGE_COVERAGE_FILES: true - ANNOTATE_MISSING_LINES: true - VERBOSE: true - - - name: Store Pull Request comment to be posted - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' - with: - name: python-coverage-comment-action - path: python-coverage-comment-action.txt - - e2e-sandbox: - name: E2E Tests - runs-on: ubuntu-latest - permissions: - checks: write - if: > - github.event_name != 'pull_request' || - (github.event.pull_request.draft != true && - github.event.pull_request.head.repo.fork == false) - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Install uv - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - enable-cache: true - python-version: "3.13" - - name: Install dependencies - run: uv sync --all-extras --locked - - name: Run e2e tests - if: github.actor != 'renovate[bot]' - run: uv run pytest tests/e2e -v -m e2e --junitxml=e2e-report.xml - env: - DESCOPE_PROJECT_ID: ${{ secrets.DESCOPE_SANDBOX_PROJECT_ID }} - DESCOPE_MANAGEMENT_KEY: ${{ secrets.DESCOPE_SANDBOX_MANAGEMENT_KEY }} - DESCOPE_BASE_URI: ${{ secrets.DESCOPE_SANDBOX_BASE_URI }} - - name: Publish e2e test report - if: always() && github.actor != 'renovate[bot]' - uses: mikepenz/action-junit-report@6ef3fdcfcd5f0b5d14df3e659ef19b8c6820f93d # v6.6.1 - with: - report_paths: e2e-report.xml - check_name: E2E Test Report - - gitleaks: - name: gitleaks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - - done: - name: All Checks Passed - runs-on: ubuntu-latest - permissions: {} - needs: [validate-pr-title, lint, build, coverage, gitleaks, e2e-sandbox] - # Must run unconditionally so this required status check always reports a - # real conclusion. If it were skipped (e.g. via an `if` that drops out when - # a dependency fails), branch protection would treat the skip as a pass. - if: always() - steps: - - name: Fail if any dependency failed or was cancelled - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 - - name: Yey, all checks passed! - run: echo "All checks passed! :tada:" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index fccf5dece..000000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Release Please - -on: - push: - branches: - - main - -permissions: {} - -jobs: - release-please: - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - steps: - - name: Generate GitHub App Token - id: app-token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PEM }} - - - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 - id: release - with: - token: ${{ steps.app-token.outputs.token }} - config-file: release-please-config.json - manifest-file: .release-please-manifest.json - - publish: - needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} - runs-on: ubuntu-latest - environment: pypi - permissions: - contents: read - id-token: write - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.release-please.outputs.tag_name }} - - - name: Install uv - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - with: - enable-cache: true - python-version: "3.13" - - - name: Build - run: uv build - - - name: Publish to PyPI - run: uv publish --trusted-publishing always diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 8b4ec197a..000000000 --- a/.gitignore +++ /dev/null @@ -1,141 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# uv / ruff caches -.ruff_cache/ -uv-*.lock - -# Local agent state -.nomadworks/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# Mac OS -.DS_Store - -.history diff --git a/.gitleaks.toml b/.gitleaks.toml deleted file mode 100644 index 61551b1f7..000000000 --- a/.gitleaks.toml +++ /dev/null @@ -1,16 +0,0 @@ -title = "python-sdk gitleaks config" - -[allowlist] -description = "False positives: test fixture JWTs, coverage artifacts, README examples" -regexes = [ - # Fake JWTs used as test fixtures (kid: 2Bt5WLccLUey1Dp7utptZb3Fx9K) - '''eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpX''', - # Fake JWTs used as test fixtures (kid: P2CtzUhdqpIF2ys9gg7ms06UvtC4) - '''eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJK''', - # Example password in README docs - '''qYlvi65KaX''', -] -paths = [ - # Coverage report artifacts - '''htmlcov/''', -] diff --git a/.gitleaksignore b/.gitleaksignore deleted file mode 100644 index 33d7ddec3..000000000 --- a/.gitleaksignore +++ /dev/null @@ -1,93 +0,0 @@ -04146e7a83d6212e407c5a46008324d646878fc1:tests/test_auth.py:jwt:257 -1c0c3ca7205fa8fa481b2bc90933d7d1f0400253:tests/test_descope_client.py:jwt:563 -1c0c3ca7205fa8fa481b2bc90933d7d1f0400253:tests/test_descope_client.py:jwt:571 -3ce967512fed2bae5e89dc9ff973e67ff9bc3084:tests/test_password.py:jwt:167 -3ce967512fed2bae5e89dc9ff973e67ff9bc3084:tests/test_password.py:jwt:313 -3ce967512fed2bae5e89dc9ff973e67ff9bc3084:tests/test_password.py:jwt:87 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_descope_client.py:jwt:122 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_descope_client.py:jwt:178 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_descope_client.py:jwt:248 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_descope_client.py:jwt:249 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_descope_client.py:jwt:250 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_descope_client.py:jwt:266 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_descope_client.py:jwt:267 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_magiclink.py:jwt:341 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_magiclink.py:jwt:356 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_otp.py:jwt:246 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_webauthn.py:jwt:127 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_webauthn.py:jwt:220 -602af9d1f4ce24799cfdcb06fc76eaedb39ba31c:tests/test_webauthn.py:jwt:326 -621149e12da145ea6c65ca5a0246ce5f93f9124e:tests/test_magiclink.py:jwt:253 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:606 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:607 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:632 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:648 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:649 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:723 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:724 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:742 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:779 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:780 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:781 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:796 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:797 -66ec16354a25b8d8d22de85ed823c5151fda0940:tests/test_auth.py:jwt:814 -6d4b1f4dbcbef45e4a5bdce952d8bc2bf5a8dd72:tests/test_enchantedlink.py:jwt:246 -6d4b1f4dbcbef45e4a5bdce952d8bc2bf5a8dd72:tests/test_enchantedlink.py:jwt:261 -71ba33c612675bd34fa7830c6b884f1f01efda9b:tests/test_oauth.py:jwt:102 -71ba33c612675bd34fa7830c6b884f1f01efda9b:tests/test_saml.py:jwt:84 -84adfdb469398f4b2263d0e57f8a16389227629d:tests/test_totp.py:jwt:106 -8e5937752bd25ae682e56f9f8e31ee537ea5479d:tests/test_auth.py:jwt:285 -8e5937752bd25ae682e56f9f8e31ee537ea5479d:tests/test_auth.py:jwt:312 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:152 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:153 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:174 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:211 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:212 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:213 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:228 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:229 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:76 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:77 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_descope_client.py:jwt:78 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_exchanger.py:jwt:45 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_magiclink.py:jwt:339 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_magiclink.py:jwt:351 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_otp.py:jwt:245 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_totp.py:jwt:109 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_totp.py:jwt:85 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_webauthn.py:jwt:127 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_webauthn.py:jwt:212 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_webauthn.py:jwt:230 -910e8e7d14b8c53e566a06c55abfb04b5a16cb72:tests/test_webauthn.py:jwt:318 -a753d819979a4864cf4df0226fa9c9a5d06424c9:tests/test_password.py:jwt:399 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:385 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:402 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:403 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:404 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:478 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:479 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:497 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:534 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:535 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:549 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:550 -a76d544894ddac33781df1f9d7e73de4200d177f:tests/test_auth.py:jwt:567 -ad14b0225444f72e8c46527d10acfd19dd6e9352:tests/test_oauth.py:jwt:137 -ad14b0225444f72e8c46527d10acfd19dd6e9352:tests/test_saml.py:jwt:131 -ad14b0225444f72e8c46527d10acfd19dd6e9352:tests/test_totp.py:jwt:87 -ad14b0225444f72e8c46527d10acfd19dd6e9352:tests/test_webauthn.py:jwt:317 -ad14b0225444f72e8c46527d10acfd19dd6e9352:tests/test_webauthn.py:jwt:483 -bc5a7d8928ba27f9eeb2e0e5ddfe925eb5fda5a7:tests/test_sso.py:jwt:128 -e5d19171d5d9e188f1d99369b408388c1032583f:tests/test_descope_client.py:jwt:700 -e7f5ad4253ad82236a5cff5f8c06878bfb190b00:tests/test_descope_client.py:jwt:171 -e7f5ad4253ad82236a5cff5f8c06878bfb190b00:tests/test_descope_client.py:jwt:185 -e7f5ad4253ad82236a5cff5f8c06878bfb190b00:tests/test_descope_client.py:jwt:197 -ece761372c78a9ad8a57da5f6d13431d298a99db:tests/test_auth.py:jwt:562 -f3ec873c83a7067a1226d8b712b756b1b599fb3b:tests/test_descope_client.py:jwt:519 -e44fd5977ffd62c56f63c32e2422bf2c0e87c816:tests/test_descope_client_parity.py:jwt:100 -e44fd5977ffd62c56f63c32e2422bf2c0e87c816:tests/test_descope_client_parity.py:jwt:761 -b6a2e217be5dceb6c85332d2e193619894d3a36e:README.md:generic-api-key:1349 -b6a2e217be5dceb6c85332d2e193619894d3a36e:README.md:generic-api-key:1372 -c751b1e9df5dcb6e6b3a62601174065ebf03144f:README.md:generic-api-key:1358 -c751b1e9df5dcb6e6b3a62601174065ebf03144f:README.md:generic-api-key:1381 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index d5e4ec2cc..000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,42 +0,0 @@ -exclude: "docs/" - -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: check-yaml - - id: check-toml - - id: debug-statements - - id: end-of-file-fixer - - id: trailing-whitespace - - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.13.1 - hooks: - - id: uv-lock - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.17.0 - hooks: - - id: ruff-check - args: [--fix] - - id: ruff-format - - repo: https://github.com/pre-commit/pre-commit - rev: v4.7.0 - hooks: - - id: validate_manifest - - repo: https://github.com/gitleaks/gitleaks - rev: v8.30.1 - hooks: - - id: gitleaks - - repo: https://github.com/compilerla/conventional-pre-commit - rev: v4.4.0 - hooks: - - id: conventional-pre-commit - stages: [commit-msg] - args: [--strict] - - repo: local - hooks: - - id: print_statement - name: Check that print statement isnt in source code - types: [python] - entry: "print" - language: pygrep diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index 9a61761c4..000000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "2.17.0" -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 547863b8c..000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "recommendations": [ - "ms-python.python", - "ms-python.vscode-pylance", - "ms-python.mypy-type-checker", - "charliermarsh.ruff", - "bodil.prettier-toml" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index a6e85ca89..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": [ - "tests" - ], - "ruff.importStrategy": "fromEnvironment", - "mypy-type-checker.importStrategy": "fromEnvironment", - "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll.ruff": "explicit", - "source.organizeImports.ruff": "explicit" - } - }, - "workbench.colorCustomizations": { /* do not change please... */} -} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a40184968..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,317 +0,0 @@ -# Changelog - -## [2.17.0](https://github.com/descope/python-sdk/compare/descope-2.16.1...descope-2.17.0) (2026-11-05) - - -### Features - -* add create third party application consent to management ([#1756](https://github.com/descope/python-sdk/issues/1756)) ([6074818](https://github.com/descope/python-sdk/commit/6074818ece6f85104971e57672b7969318d4223e)) -* **mgmt:** allow updating management key roles ([#1744](https://github.com/descope/python-sdk/issues/1744)) ([9e5d2cd](https://github.com/descope/python-sdk/commit/9e5d2cdd0954b6461a78794f1e09c8a9cf2fe25d)) -* **mgmt:** support client type on federated and inbound apps ([#1728](https://github.com/descope/python-sdk/issues/1728)) ([6da9831](https://github.com/descope/python-sdk/commit/6da9831d184f4de05b4b005943382cc3e15714db)) - -## [2.16.1](https://github.com/descope/python-sdk/compare/descope-2.16.0...descope-2.16.1) (2026-10-22) - - -### Bug Fixes - -* **deps:** require certifi>=2026.10.19 to distrust compromised eMudhra CAs ([#1734](https://github.com/descope/python-sdk/issues/1734)) ([abcd3c9](https://github.com/descope/python-sdk/commit/abcd3c90040bb33e576471097fa807c8565377ca)) - -## [2.16.0](https://github.com/descope/python-sdk/compare/descope-2.15.0...descope-2.16.0) (2026-10-19) - - -### Features - -* **lists:** add check_texts to check multiple texts in one call ([#1727](https://github.com/descope/python-sdk/issues/1727)) ([6742eb1](https://github.com/descope/python-sdk/commit/6742eb1f70d5dd8b6da6c680a6d3e1c6c90054cc)) -* **sso:** usePkce on tenant OIDC SSO settings ([#1716](https://github.com/descope/python-sdk/issues/1716)) ([cf607b9](https://github.com/descope/python-sdk/commit/cf607b9623a884b86186876a8fb4ab33f51579ae)) -* support lock reason in user search ([#1712](https://github.com/descope/python-sdk/issues/1712)) ([6bd0f56](https://github.com/descope/python-sdk/commit/6bd0f5640ed5b5e12c57cb813b2accf3ab6f40d8)) - -## [2.15.0](https://github.com/descope/python-sdk/compare/descope-2.14.0...descope-2.15.0) (2026-10-04) - - -### Features - -* add family account management API ([#1701](https://github.com/descope/python-sdk/issues/1701)) ([14dc5e6](https://github.com/descope/python-sdk/commit/14dc5e6302d0f151db9aab8f621370d987c80477)) -* **enchantedlink:** add SMS delivery support ([#1690](https://github.com/descope/python-sdk/issues/1690)) ([912a420](https://github.com/descope/python-sdk/commit/912a42073c5efadb07c7115fcae285478d34adcc)) - - -### Bug Fixes - -* **sso:** allow clearing default SSO roles ([#1708](https://github.com/descope/python-sdk/issues/1708)) ([3a5c9d6](https://github.com/descope/python-sdk/commit/3a5c9d6e773129fce13bcc496abf571eaab27abf)) - -## [2.14.0](https://github.com/descope/python-sdk/compare/descope-2.13.0...descope-2.14.0) (2026-09-04) - - -### Features - -* add patch_tenant for partial tenant updates ([#1682](https://github.com/descope/python-sdk/issues/1682)) ([5d1bca3](https://github.com/descope/python-sdk/commit/5d1bca3d08a0c67676540ca00fecf6a8a55c54a4)) -* **sso:** allow disabling SAML AuthnRequest signing per SSO configuration ([#1684](https://github.com/descope/python-sdk/issues/1684)) ([bb45d1c](https://github.com/descope/python-sdk/commit/bb45d1c50dd7bc66b74ae82a1e5d0b3b84e7c04b)) - - -### Documentation - -* **xaa:** document the read-only project-level audience on the XAA load response ([#1677](https://github.com/descope/python-sdk/issues/1677)) ([3499f1d](https://github.com/descope/python-sdk/commit/3499f1d0562a7c287b2c6cf37e168c1222a49e4d)) - -## [2.13.0](https://github.com/descope/python-sdk/compare/descope-2.12.1...descope-2.13.0) (2026-08-24) - - -### Features - -* **sso:** add configure_auth_type to enable or disable an SSO configuration ([#1675](https://github.com/descope/python-sdk/issues/1675)) ([6aa30b7](https://github.com/descope/python-sdk/commit/6aa30b7a876521018ae74fa1133ebcbac154c17f)) -* **xaa:** add cross-app access (ID-JAG) mapping types ([#1664](https://github.com/descope/python-sdk/issues/1664)) ([5fb78ec](https://github.com/descope/python-sdk/commit/5fb78ecf6a715a2b2ab1246002c7fb6368478306)) - -## [2.12.1](https://github.com/descope/python-sdk/compare/descope-2.12.0...descope-2.12.1) (2026-08-20) - - -### Bug Fixes - -* add status field to create_test_user, matching create() ([#1668](https://github.com/descope/python-sdk/issues/1668)) ([a9d8f13](https://github.com/descope/python-sdk/commit/a9d8f1330e2ef16ea98043518bc49db4222a2a91)) -* **http-client:** make get_last_response report the actual last response ([#1655](https://github.com/descope/python-sdk/issues/1655)) ([2ff2c1f](https://github.com/descope/python-sdk/commit/2ff2c1f5848cf0eae2924e8e83f8d3541eb66cb9)) - -## [2.12.0](https://github.com/descope/python-sdk/compare/descope-2.11.2...descope-2.12.0) (2026-08-18) - - -### Features - -* support status field on user create, document invite caveat ([#1665](https://github.com/descope/python-sdk/issues/1665)) ([bfcb192](https://github.com/descope/python-sdk/commit/bfcb19275070fadc4b5cf07d3471896cead563be)) - -## [2.11.2](https://github.com/descope/python-sdk/compare/descope-2.11.1...descope-2.11.2) (2026-08-10) - - -### Bug Fixes - -* **http:** return last response for non-JSON bodies ([#1653](https://github.com/descope/python-sdk/issues/1653)) ([524aef3](https://github.com/descope/python-sdk/commit/524aef3ac1a7d846855d5e65743945e2a1911a53)) - -## [2.11.1](https://github.com/descope/python-sdk/compare/descope-2.11.0...descope-2.11.1) (2026-08-07) - - -### Bug Fixes - -* **role:** send roleNames on batch delete instead of an ignored roles array ([#1651](https://github.com/descope/python-sdk/issues/1651)) ([426d369](https://github.com/descope/python-sdk/commit/426d369e785cf5698663acb9a3394496eb52eb01)) - -## [2.11.0](https://github.com/descope/python-sdk/compare/descope-2.10.0...descope-2.11.0) (2026-08-07) - - -### Features - -* add remove_recovery_codes user management method ([#1643](https://github.com/descope/python-sdk/issues/1643)) ([8531622](https://github.com/descope/python-sdk/commit/8531622318b9146560a5bb6816f853483f817402)) -* **mgmt:** load external groups scoped to a specific SSO config ([#1641](https://github.com/descope/python-sdk/issues/1641)) ([972a025](https://github.com/descope/python-sdk/commit/972a025bce423fb05399e1ec307a9f4decad9b50)) - - -### Bug Fixes - -* **deps:** restore mypy <1.12 bound on the Python 3.9 types pin ([#1647](https://github.com/descope/python-sdk/issues/1647)) ([8386f19](https://github.com/descope/python-sdk/commit/8386f190aef992274b05046dd631572fdcfd5f5c)) - - -### Documentation - -* remove_recovery_codes accepts login ID or user ID ([#1649](https://github.com/descope/python-sdk/issues/1649)) ([e91d91e](https://github.com/descope/python-sdk/commit/e91d91e177cda6cd7dc16d6178d15a2e3697b759)) - -## [2.10.0](https://github.com/descope/python-sdk/compare/descope-2.9.1...descope-2.10.0) (2026-07-18) - - -### Features - -* document external-group source on group listing responses ([#1620](https://github.com/descope/python-sdk/issues/1620)) ([03be991](https://github.com/descope/python-sdk/commit/03be991aad24fa4f0ce340df22390dc43e1d7262)) -* document lastSuccessTestTime on SSO settings load response ([#1618](https://github.com/descope/python-sdk/issues/1618)) ([813c7db](https://github.com/descope/python-sdk/commit/813c7db489d7aeaa98f496722ddda6e330ffbd87)) -* **mgmt:** add outbound SCIM configuration management wrapper ([#1615](https://github.com/descope/python-sdk/issues/1615)) ([173213b](https://github.com/descope/python-sdk/commit/173213b0fbad100217269043e176588142fda4eb)) -* **tests:** add e2e test suite ([#1609](https://github.com/descope/python-sdk/issues/1609)) ([e083fd6](https://github.com/descope/python-sdk/commit/e083fd6e8b853e443fbd879ccd39e427ed853d1f)) - - -### Bug Fixes - -* **mgmt:** support template_id and template_options in invite_batch ([#1610](https://github.com/descope/python-sdk/issues/1610)) ([cac4a48](https://github.com/descope/python-sdk/commit/cac4a48bba952706fc2111262b968f9bbe9ae88f)) - -## [2.9.1](https://github.com/descope/python-sdk/compare/descope-2.9.0...descope-2.9.1) (2026-07-03) - - -### Bug Fixes - -* **management:** add missing endpoints for python-sdk go-sdk parity ([#1599](https://github.com/descope/python-sdk/issues/1599)) ([0c9d2ad](https://github.com/descope/python-sdk/commit/0c9d2adfd62bbc5724190fca73a6351b2ed685f1)) - -## [2.9.0](https://github.com/descope/python-sdk/compare/descope-2.8.0...descope-2.9.0) (2026-06-28) - - -### Features - -* **mgmt:** add engine management API ([#1596](https://github.com/descope/python-sdk/issues/1596)) ([d55b3ec](https://github.com/descope/python-sdk/commit/d55b3ec2a5fcb8bb6f81623c114aeec36671063d)) - -## [2.8.0](https://github.com/descope/python-sdk/compare/descope-2.7.0...descope-2.8.0) (2026-06-28) - - -### Features - -* **webauthn:** add mfa option to passkey enrollment (update) ([#1594](https://github.com/descope/python-sdk/issues/1594)) ([dc94421](https://github.com/descope/python-sdk/commit/dc94421625948a657ff5debca1c03d337fb07545)) - -## [2.7.0](https://github.com/descope/python-sdk/compare/descope-2.6.0...descope-2.7.0) (2026-06-27) - - -### Features - -* **outbound:** add token/api-key upload and connection-status list methods ([#1591](https://github.com/descope/python-sdk/issues/1591)) ([2a2a090](https://github.com/descope/python-sdk/commit/2a2a090709691bc53d81e0320966fa091b9d1241)) -* **sdk:** add async client (DescopeClientAsync) ([#1572](https://github.com/descope/python-sdk/issues/1572)) ([e0f053f](https://github.com/descope/python-sdk/commit/e0f053fb348afaf3163a081c33f540b8af713a6a)) - -## [2.6.0](https://github.com/descope/python-sdk/compare/descope-2.5.0...descope-2.6.0) (2026-06-22) - - -### Features - -* **http:** also retry on transient status code 520 ([#1581](https://github.com/descope/python-sdk/issues/1581)) ([2de7f59](https://github.com/descope/python-sdk/commit/2de7f5933b421edbb841da05ad4f73c1c2af0892)) -* **otp:** add mfa option to OTP update phone/email ([#1578](https://github.com/descope/python-sdk/issues/1578)) ([2fbbd8a](https://github.com/descope/python-sdk/commit/2fbbd8ae93e6dd6c062e2af48bfdf3c28bfb6779)) -* **tenant:** add optional user_id/login_id actor to generate_sso_configuration_link ([#1573](https://github.com/descope/python-sdk/issues/1573)) ([b3c5649](https://github.com/descope/python-sdk/commit/b3c564929589e964d0c5814b77b7d15f2551162d)) - -## [2.5.0](https://github.com/descope/python-sdk/compare/descope-2.4.0...descope-2.5.0) (2026-06-12) - - -### Features - -* add locale to invite and invite_batch ([#1557](https://github.com/descope/python-sdk/issues/1557)) ([536c4e3](https://github.com/descope/python-sdk/commit/536c4e30a79a5deda1c3d0c546a9c061353a2e90)) -* **sso:** add idp_entity_id to SSOSAMLSettingsByMetadata ([#1567](https://github.com/descope/python-sdk/issues/1567)) ([970de0a](https://github.com/descope/python-sdk/commit/970de0a12830d986f44ef4adc6b8f519c6922715)) - - -### Reverts - -* "chore(deps): update dependency mypy to v2.0.0" ([#1552](https://github.com/descope/python-sdk/issues/1552)) ([03c195a](https://github.com/descope/python-sdk/commit/03c195a7cc58f8596171cfb996b3fa5e880fba7a)) - -## [2.4.0](https://github.com/descope/python-sdk/compare/descope-2.3.0...descope-2.4.0) (2026-05-16) - - -### Features - -* add FGA mappings support to SSO tenant settings ([#1539](https://github.com/descope/python-sdk/issues/1539)) ([95bade5](https://github.com/descope/python-sdk/commit/95bade52a43e2dfd5a1a317392d701440117556f)) - -## [2.3.0](https://github.com/descope/python-sdk/compare/descope-2.2.0...descope-2.3.0) (2026-05-15) - - -### Features - -* add license handshake and x-descope-license header ([#1537](https://github.com/descope/python-sdk/issues/1537)) ([0ab28eb](https://github.com/descope/python-sdk/commit/0ab28ebe9520b0f81055d6492cc77df5064d81c4)) - -## [2.2.0](https://github.com/descope/python-sdk/compare/descope-2.1.0...descope-2.2.0) (2026-05-13) - - -### Features - -* add email and sso_id parameters to generate_sso_configuration_link ([#1534](https://github.com/descope/python-sdk/issues/1534)) ([b56a35d](https://github.com/descope/python-sdk/commit/b56a35d92e09f771c9aec74d02c43ce21e63942e)) - -## [2.1.0](https://github.com/descope/python-sdk/compare/descope-2.0.0...descope-2.1.0) (2026-05-13) - - -### Features - -* add generate_sso_configuration_link method to Tenant management ([#1530](https://github.com/descope/python-sdk/issues/1530)) ([463ff32](https://github.com/descope/python-sdk/commit/463ff32f51218c8d67bf60f5555b561f636cb62e)) - -## [2.0.0](https://github.com/descope/python-sdk/compare/descope-1.13.0...descope-2.0.0) (2026-05-06) - - -### ⚠ BREAKING CHANGES - -* drop support for python 3.8 ([#1371](https://github.com/descope/python-sdk/issues/1371)) -* **sdk:** migrate from requests to httpx ([#1123](https://github.com/descope/python-sdk/issues/1123)) - -### Features - -* drop support for python 3.8 ([#1371](https://github.com/descope/python-sdk/issues/1371)) ([5114a01](https://github.com/descope/python-sdk/commit/5114a019f6bd5cfbabbebff62d873c86f50e3d06)) -* **roles-permissions:** add id-based management methods and role_ids search ([#1456](https://github.com/descope/python-sdk/issues/1456)) ([3d3b0ac](https://github.com/descope/python-sdk/commit/3d3b0acf1a9b82fe4c7ceb1d9272550be6efb63a)) - - -### Bug Fixes - -* **deps:** pin pytest <9 on Python 3.9 to keep lock satisfiable ([#1512](https://github.com/descope/python-sdk/issues/1512)) ([e1666ca](https://github.com/descope/python-sdk/commit/e1666ca37e6cbd91df9cd97422c4603c235af808)) -* **deps:** update dependency httpx to ^0.28.0 ([#1450](https://github.com/descope/python-sdk/issues/1450)) ([4bd6275](https://github.com/descope/python-sdk/commit/4bd6275f652d74bffc8082083c4ed5318aa2f2b9)) - - -### Reverts - -* "chore(deps): update dependency pytest to >=9,<10" ([#1514](https://github.com/descope/python-sdk/issues/1514)) ([0f3cc5f](https://github.com/descope/python-sdk/commit/0f3cc5fa446f0a8d4fcba346c366d5cf70b16cb8)) - - -### Code Refactoring - -* **sdk:** migrate from requests to httpx ([#1123](https://github.com/descope/python-sdk/issues/1123)) ([5d4cfd5](https://github.com/descope/python-sdk/commit/5d4cfd53933dd96af1179885571b2d27cf171507)) - -## [1.13.0](https://github.com/descope/python-sdk/compare/descope-1.12.2...descope-1.13.0) (2026-04-20) - - -### Features - -* impersonate stepup support ([#1064](https://github.com/descope/python-sdk/issues/1064)) ([63d051a](https://github.com/descope/python-sdk/commit/63d051a69380895d7941da1b43076e1059285e15)) - -## [1.12.2](https://github.com/descope/python-sdk/compare/descope-1.12.1...descope-1.12.2) (2026-04-12) - - -### Bug Fixes - -* remove set active from set password ([#806](https://github.com/descope/python-sdk/issues/806)) ([4cd8caf](https://github.com/descope/python-sdk/commit/4cd8caf8fc51c3c9221a48639293f1c4616a9a0b)) - -## [1.12.1](https://github.com/descope/python-sdk/compare/descope-1.12.0...descope-1.12.1) (2026-03-29) - - -### Bug Fixes - -* add locale to LoginOptions and fix password sign_in docstring ([#798](https://github.com/descope/python-sdk/issues/798)) ([ad09985](https://github.com/descope/python-sdk/commit/ad09985b5980201817b229f41c264c06ab94a3f5)) - -## [1.12.0](https://github.com/descope/python-sdk/compare/descope-1.11.0...descope-1.12.0) (2026-03-23) - - -### Features - -* add batch operations for roles and permissions ([#789](https://github.com/descope/python-sdk/issues/789)) ([ce3021f](https://github.com/descope/python-sdk/commit/ce3021ffebd440773398fdc1c9e57a70c1887afc)) -* **http:** retry requests on transient error status codes ([#792](https://github.com/descope/python-sdk/issues/792)) ([011fadf](https://github.com/descope/python-sdk/commit/011fadfd43baf8588eb646cc372995f6fe838ac2)) -* **security:** enable cookie secure flag, fix redos, add jwt decode warn ([#781](https://github.com/descope/python-sdk/issues/781)) ([90af115](https://github.com/descope/python-sdk/commit/90af11503675b48961e67d44dbd5dcdbce66fa2a)) -* **sso-app:** add default signature method ([#787](https://github.com/descope/python-sdk/issues/787)) ([e79b0b8](https://github.com/descope/python-sdk/commit/e79b0b82a4de63c7efd90d44cc2180cf278f4011)) - -## [1.11.0](https://github.com/descope/python-sdk/compare/descope-1.10.1...descope-1.11.0) (2026-03-10) - - -### Features - -* add selectedTenant to AccessKeyLoginOptions for access key exchange ([#768](https://github.com/descope/python-sdk/issues/768)) ([0f60183](https://github.com/descope/python-sdk/commit/0f601835bad4c2136c18852b8d6458b687b2efe8)) -* add update_default_roles method to Tenant management ([#779](https://github.com/descope/python-sdk/issues/779)) ([c5d5d37](https://github.com/descope/python-sdk/commit/c5d5d37486889971a2cbc738efc2368fed49119f)) -* **authz:** route who_can_access and what_can_target_access through FGA cache ([#767](https://github.com/descope/python-sdk/issues/767)) ([b49d937](https://github.com/descope/python-sdk/commit/b49d937534beb429312c1d78546255c056179453)) - -## [1.10.1](https://github.com/descope/python-sdk/compare/descope-1.10.0...descope-1.10.1) (2026-02-18) - - -### Bug Fixes - -* add loginHint and forceAuthn to saml start ([1c197eb](https://github.com/descope/python-sdk/commit/1c197ebcd71d9fde02ddb15e6790782d30617f54)) -* add loginHint and forceAuthn to saml start ([#765](https://github.com/descope/python-sdk/issues/765)) ([1c197eb](https://github.com/descope/python-sdk/commit/1c197ebcd71d9fde02ddb15e6790782d30617f54)) - - -### Documentation - -* fix external groups functions docs ([#748](https://github.com/descope/python-sdk/issues/748)) ([2eff8cb](https://github.com/descope/python-sdk/commit/2eff8cbeb749330217dc92133096dc7b2bed95ee)) - -## [1.10.0](https://github.com/descope/python-sdk/compare/descope-1.9.1...descope-1.10.0) (2026-01-19) - - -### Features - -* access key custom attributes CRU ([#746](https://github.com/descope/python-sdk/issues/746)) ([25e6de9](https://github.com/descope/python-sdk/commit/25e6de943288d4e2ea69fa174c566b0de7530f7f)) - - -### Bug Fixes - -* recalculate SSO Mappings ([89a2a21](https://github.com/descope/python-sdk/commit/89a2a21a107ea70a82dc76ff8479e1a71102d2c0)) - -## [1.9.1](https://github.com/descope/python-sdk/compare/descope-1.9.0...descope-1.9.1) (2026-01-15) - - -### Bug Fixes - -* add enforcessolist and appids to tenant create and update ([#734](https://github.com/descope/python-sdk/issues/734)) ([2039053](https://github.com/descope/python-sdk/commit/203905381f0345906c359f2d9037c4cc8a2c32e5)) -* add group priority support ([#742](https://github.com/descope/python-sdk/issues/742)) ([262ad0f](https://github.com/descope/python-sdk/commit/262ad0f7ae3fda2873a684b9956071e889d67595)) - -## [1.9.0](https://github.com/descope/python-sdk/compare/descope-1.8.0...descope-1.9.0) (2025-12-30) - - -### Features - -* Add management flow operations, including sync and async runs and result functions ([#727](https://github.com/descope/python-sdk/issues/726)) ([ba2a163](https://github.com/descope/python-sdk/commit/ba2a1639f06c599ae97899e30fea3bf248be2c8b)) - -## [1.8.0](https://github.com/descope/python-sdk/compare/descope-1.7.14...descope-1.8.0) (2025-12-25) - - -### Features - -* add opt-in verbose mode for capturing HTTP response metadata ([#718](https://github.com/descope/python-sdk/issues/718)) ([91b364a](https://github.com/descope/python-sdk/commit/91b364a999c40c6ea13b1761398d5f7200c67831)) diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9c3dafcf4..000000000 --- a/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -ARG PYTHON_VERSION=3.13 -ARG UV_VERSION=0.11.16 - -FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv - -FROM python:${PYTHON_VERSION}-slim AS builder -COPY --from=uv /uv /uvx /bin/ - -ENV UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_PYTHON_DOWNLOADS=never \ - UV_PROJECT_ENVIRONMENT=/opt/venv - -WORKDIR /app -COPY pyproject.toml uv.lock README.md LICENSE ./ -COPY descope ./descope -RUN uv sync --frozen --no-dev --extra Flask - -FROM python:${PYTHON_VERSION}-slim AS production -ENV PATH="/opt/venv/bin:$PATH" \ - PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 - -# Run as a non-root user -RUN groupadd --system --gid 1001 app \ - && useradd --system --uid 1001 --gid app --create-home --home-dir /home/app app - -COPY --from=builder --chown=app:app /opt/venv /opt/venv -COPY --chown=app:app . /app -WORKDIR /app - -USER app - -CMD ["python", "samples/otp_web_sample_app.py"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 66b7e1930..000000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Descope - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index fa5210f04..a20803e51 100644 --- a/README.md +++ b/README.md @@ -1,2406 +1,74 @@ -# Descope SDK for Python - -The Descope SDK for python provides convenient access to the Descope user management and authentication API -for a backend written in python. You can read more on the [Descope Website](https://descope.com). - -## Requirements - -The SDK supports Python 3.9 and above. - -## Installing the SDK - -Install the package with: - -```bash -pip install descope -``` - -#### If you would like to use the Flask decorators, make sure to install the Flask extras: - -```bash -pip install descope[Flask] -``` - -## Setup - -A Descope `Project ID` is required to initialize the SDK. Find it on the -[project page in the Descope Console](https://app.descope.com/settings/project). - -**Note:** Authentication APIs public access can be disabled via the Descope console. -If disabled, it's still possible to use the authentication API by providing a management key with -the appropriate access (`Authentication` / `Full Access`). -If not provided directly, this value is retrieved from the `DESCOPE_AUTH_MANAGEMENT_KEY` environment variable instead. -If neither values are set then any disabled authentication methods API calls will fail. - -```python -from descope import DescopeClient - -# Initialized after setting the DESCOPE_PROJECT_ID and DESCOPE_AUTH_MANAGEMENT_KEY env vars -descope_client = DescopeClient() - -# ** Or directly (w/ optional base URL) ** -descope_client = DescopeClient( - project_id="", - auth_management_key=", - base_url="" -) -``` - -## Authentication Functions - -These sections show how to use the SDK to perform various authentication/authorization functions: - -1. [OTP Authentication](#otp-authentication) -2. [Magic Link](#magic-link) -3. [Enchanted Link](#enchanted-link) -4. [OAuth](#oauth) -5. [SSO (SAML / OIDC)](#sso-saml--oidc) -6. [TOTP Authentication](#totp-authentication) -7. [Passwords](#passwords) -8. [Session Validation](#session-validation) -9. [Roles & Permission Validation](#roles--permission-validation) -10. [Tenant selection](#tenant-selection) -11. [Logging Out](#logging-out) -12. [History](#history) -13. [My Tenants](#my-tenants) - -## API Management Function - -These sections show how to use the SDK to perform permission and user management functions. You will need to create an instance of `DescopeClient` by following the [Setup](#setup-1) guide, before you can use any of these functions: - -1. [Manage Tenants](#manage-tenants) -2. [Manage Users](#manage-users) -3. [Manage Families](#manage-families) -4. [Manage Access Keys](#manage-access-keys) -5. [Manage SSO Setting](#manage-sso-setting) -6. [Manage Permissions](#manage-permissions) -7. [Manage Roles](#manage-roles) -8. [Query SSO Groups](#query-sso-groups) -9. [Manage Flows](#manage-flows-and-theme) -10. [Manage JWTs](#manage-jwts) -11. [Impersonate](#impersonate) -12. [Embedded links](#embedded-links) -13. [Audit](#audit) -14. [Manage FGA (Fine-grained Authorization)](#manage-fga-fine-grained-authorization) -15. [Manage Project](#manage-project) -16. [Manage SSO Applications](#manage-sso-applications) -17. [Manage Outbound Applications](#manage-outbound-applications) -18. [Manage Descopers](#manage-descopers) -19. [Manage Management Keys](#manage-management-keys) -20. [Manage Engines](#manage-engines) - -If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section. - -If you're performing end-to-end testing, check out the [Utils for your end to end (e2e) tests and integration tests](#utils-for-your-end-to-end-e2e-tests-and-integration-tests) section. You will need to use the `DescopeClient` object created under [Setup](#setup-1) guide. - -For rate limiting information, please confer to the [API Rate Limits](#api-rate-limits) section. - -### OTP Authentication - -Send a user a one-time password (OTP) using your preferred delivery method (_email / SMS / Voice call / WhatsApp_). An email address or phone number must be provided accordingly. - -The user can either `sign up`, `sign in` or `sign up or in` - -```python -from descope import DeliveryMethod - -# Every user must have a login ID. All other user information is optional -email = "desmond@descope.com" -user = {"name": "Desmond Copeland", "phone": "212-555-1234", "email": email} -masked_address = descope_client.otp.sign_up(method=DeliveryMethod.EMAIL, login_id=email, user=user) -``` - -The user will receive a code using the selected delivery method. Verify that code using: - -```python -jwt_response = descope_client.otp.verify_code(method=DeliveryMethod.EMAIL, login_id=email, code=value) -session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") -refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) - -### Magic Link - -Send a user a Magic Link using your preferred delivery method (_email / SMS / Voice call / WhatsApp_). -The Magic Link will redirect the user to page where the its token needs to be verified. -This redirection can be configured in code, or generally in the [Descope Console](https://app.descope.com/settings/authentication/magiclink) - -The user can either `sign up`, `sign in` or `sign up or in` - -```python -from descope import DeliveryMethod - -masked_address = descope_client.magiclink.sign_up_or_in( - method=DeliveryMethod.EMAIL, - login_id="desmond@descope.com", - uri="http://myapp.com/verify-magic-link", # Set redirect URI here or via console -) -``` - -To verify a magic link, your redirect page must call the validation function on the token (`t`) parameter (`https://your-redirect-address.com/verify?t=`): - -```python -jwt_response = descope_client.magiclink.verify(token=token) -session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") -refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) - -### Enchanted Link - -Using the Enchanted Link APIs enables users to sign in by clicking a link -delivered to their email address. The email will include 3 different links, -and the user will have to click the right one, based on the 2-digit number that is -displayed when initiating the authentication process. - -This method is similar to [Magic Link](#magic-link) but differs in two major ways: - -- The user must choose the correct link out of the three, instead of having just one - single link. -- This supports cross-device clicking, meaning the user can try to log in on one device, - like a computer, while clicking the link on another device, for instance a mobile phone. - -The Enchanted Link will redirect the user to page where the its token needs to be verified. -This redirection can be configured in code per request, or set globally in the [Descope Console](https://app.descope.com/settings/authentication/enchantedlink). - -The user can either `sign up`, `sign in` or `sign up or in` - -```python -resp = descope_client.enchantedlink.sign_up_or_in( - login_id=email, - uri="http://myapp.com/verify-enchanted-link", # Set redirect URI here or via console -) -link_identifier = resp["linkId"] # Show the user which link they should press in their email -pending_ref = resp["pendingRef"] # Used to poll for a valid session -masked_email = resp["maskedEmail"] # The email that the message was sent to in a masked format -``` - -To deliver the link by SMS instead, use the phone variants — `sign_up_with_phone`, -`sign_in_with_phone` and `sign_up_or_in_with_phone`. They return `maskedPhone` in place -of `maskedEmail`. The SMS carries only the correct link, so there is nothing for the -user to choose: - -```python -resp = descope_client.enchantedlink.sign_up_or_in_with_phone( - phone=phone, - uri="http://myapp.com/verify-enchanted-link", # Set redirect URI here or via console -) -link_identifier = resp["linkId"] # Show the user which link they should press in their SMS -pending_ref = resp["pendingRef"] # Used to poll for a valid session -masked_phone = resp["maskedPhone"] # The phone number that the message was sent to in a masked format -``` - -An existing user's email or phone can be updated with an enchanted link sent to the new -address, which the user must click to confirm the change: - -```python -descope_client.enchantedlink.update_user_email(login_id, new_email, refresh_token) -descope_client.enchantedlink.update_user_phone(login_id, new_phone, refresh_token) -``` - -After sending the link, you must poll to receive a valid session using the `pending_ref` from -the previous step. A valid session will be returned only after the user clicks the right link. - -```python -i = 0 -while not done and i < max_tries: - try: - i = i + 1 - sleep(4) - jwt_response = descope_client.enchantedlink.get_session(pending_ref) - done = True - except AuthException as e: # Poll while still receiving 401 Unauthorized - if e.status_code != 401: # Other failures means something's wrong, abort - logging.info(f"Failed pending session, err: {e}") - done = True - -if jwt_response: - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -To verify an enchanted link, your redirect page must call the validation function on the token (`t`) parameter (`https://your-redirect-address.com/verify?t=`). Once the token is verified, the session polling will receive a valid `jwt_response`. - -```python -try: - descope_client.enchantedlink.verify(token=token) - # Token is valid -except AuthException as e: - # Token is invalid -``` - -The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) - -### OAuth - -Users can authenticate using their social logins, using the OAuth protocol. Configure your OAuth settings on the [Descope console](https://app.descope.com/settings/authentication/social). To start a flow call: - -```python -descope_client.oauth.start( - provider="google", # Choose an oauth provider out of the supported providers - return_url="https://my-app.com/handle-oauth", # Can be configured in the console instead of here -) -``` - -The user will authenticate with the authentication provider, and will be redirected back to the redirect URL, with an appended `code` HTTP URL parameter. Exchange it to validate the user: - -```python -jwt_response = descope_client.oauth.exchange_token(code) -session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") -refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) - -### SSO (SAML / OIDC) - -Users can authenticate to a specific tenant using SAML/OIDC based on the tenant settings. Configure your SAML/OIDC tenant settings on the [Descope console](https://app.descope.com/tenants). To start a flow call: - -```python -descope_client.sso.start( - tenant="my-tenant-ID", # Choose which tenant to log into - return_url="https://my-app.com/handle-sso", # Can be configured in the console instead of here -) -``` - -The user will authenticate with the authentication provider configured for that tenant, and will be redirected back to the redirect URL, with an appended `code` HTTP URL parameter. Exchange it to validate the user: - -```python -jwt_response = descope_client.sso.exchange_token(code) -session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") -refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) - -Note: the descope_client.saml.start(..) and descope_client.saml.exchange_token(..) functions are DEPRECATED, use the above sso functions instead - -### TOTP Authentication - -The user can authenticate using an authenticator app, such as Google Authenticator. -Sign up like you would using any other authentication method. The sign up response -will then contain a QR code `image` that can be displayed to the user to scan using -their mobile device camera app, or the user can enter the `key` manually or click -on the link provided by the `provisioning_url`. - -Existing users can add TOTP using the `update` function. - -```python -from descope import DeliveryMethod - -# Every user must have a login ID. All other user information is optional -email = "desmond@descope.com" -user = {"name": "Desmond Copeland", "phone": "212-555-1234", "email": email} -totp_response = descope_client.totp.sign_up(method=DeliveryMethod.EMAIL, login_id=email, user=user) - -# Use one of the provided options to have the user add their credentials to the authenticator -provisioning_url = totp_response["provisioningURL"] -image = totp_response["image"] -key = totp_response["key"] -``` - -There are 3 different ways to allow the user to save their credentials in -their authenticator app - either by clicking the provisioning URL, scanning the QR -image or inserting the key manually. After that, signing in is done using the code -the app produces. - -```python -jwt_response = descope_client.totp.sign_in_code( - login_id=email, - code=code, # Code from authenticator app -) -session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") -refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) - -#### Deleting the TOTP Seed - -Pass the loginId to the function to remove the user's TOTP seed. - -```python -response = descope_client.mgmt.user.remove_totp_seed(login_id=login_id) -``` - -#### Deleting Recovery Codes - -Pass the user's login ID or user ID to the function to revoke all of the user's recovery codes. - -```python -response = descope_client.mgmt.user.remove_recovery_codes(login_id=login_id) -``` - -### Passwords - -The user can also authenticate with a password, though it's recommended to -prefer passwordless authentication methods if possible. Sign up requires the -caller to provide a valid password that meets all the requirements configured -for the [password authentication method](https://app.descope.com/settings/authentication/password) in the Descope console. - -```python -# Every user must have a login_id and a password. All other user information is optional -login_id = "desmond@descope.com" -password = "qYlvi65KaX" -user = { - "name": "Desmond Copeland", - "email": login_id, -} -jwt_response = descope_client.password.sign_up( - login_id=login_id, - password=password, - user=user, -) -session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") -refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -The user can later sign in using the same login_id and password. - -```python -jwt_response = descope_client.password.sign_in(login_id, password) -session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") -refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) - -In case the user needs to update their password, one of two methods are available: Resetting their password or replacing their password - -**Changing Passwords** - -_NOTE: send_reset will only work if the user has a validated email address. Otherwise password reset prompts cannot be sent._ - -In the [password authentication method](https://app.descope.com/settings/authentication/password) in the Descope console, it is possible to define which alternative authentication method can be used in order to authenticate the user, in order to reset and update their password. - -```python -# Start the reset process by sending a password reset prompt. In this example we'll assume -# that magic link is configured as the reset method. The optional redirect URL is used in the -# same way as in regular magic link authentication. -login_id = "desmond@descope.com" -redirect_url = "https://myapp.com/password-reset" -descope_client.password.send_reset(login_id, redirect_url) -``` - -The magic link, in this case, must then be verified like any other magic link (see the [magic link section](#magic-link) for more details). However, after verifying the user, it is expected -to allow them to provide a new password instead of the old one. Since the user is now authenticated, this is possible via: - -```python -# The refresh token is required to make sure the user is authenticated. -err = descope_client.password.update(login_id, new_password, token) -``` - -`update` can always be called when the user is authenticated and has a valid session. - -Alternatively, it is also possible to replace an existing active password with a new one. - -```python -# Replaces the user's current password with a new one -jwt_response = descope_client.password.replace(login_id, old_password, new_password) -session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") -refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") -``` - -### Session Validation - -Every secure request performed between your client and server needs to be validated. The client sends -the session and refresh tokens with every request, and they are validated using one of the following: - -```python -# Validate the session. Will raise if expired -try: - jwt_response = descope_client.validate_session(session_token) -except AuthException: - # Session expired - -# If validate_session raises an exception, you will need to refresh the session using -jwt_response = descope_client.refresh_session(refresh_token) - -# Alternatively, you could combine the two and -# have the session validated and automatically refreshed when expired -jwt_response = descope_client.validate_and_refresh_session(session_token, refresh_token) -``` - -Choose the right session validation and refresh combination that suits your needs. - -Note: all those validation apis can receive an optional 'audience' parameter that should be provided when using jwt that has the 'aud' claim. - -Refreshed sessions return the same response as is returned when users first sign up / log in, -containing the session and refresh tokens, as well as all of the JWT claims. -Make sure to return the tokens from the response to the client, or updated the cookie if you're using it. - -Usually, the tokens can be passed in and out via HTTP headers or via a cookie. -The implementation can defer according to your framework of choice. See our [samples](#code-samples) for a few examples. - -If Roles & Permissions are used, validate them immediately after validating the session. See the [next section](#roles--permission-validation) -for more information. - -### Roles & Permission Validation - -When using Roles & Permission, it's important to validate the user has the required -authorization immediately after making sure the session is valid. Taking the `jwt_response` -received by the [session validation](#session-validation), call the following functions: - -For multi-tenant uses: - -```python -# You can validate specific permissions -valid_permissions = descope_client.validate_tenant_permissions( - jwt_response, "my-tenant-ID", ["Permission to validate"] -) -if not valid_permissions: - # Deny access - -# Or validate roles directly -valid_roles = descope_client.validate_tenant_roles( - jwt_response, "my-tenant-ID", ["Role to validate"] -) -if not valid_roles: - # Deny access - -# Or get the matched roles/permissions -matched_tenant_roles = descope_client.get_matched_tenant_roles( - jwt_response, "my-tenant-ID", ["role-name1", "role-name2"] -) - -matched_tenant_permissions = descope_client.get_matched_tenant_permissions( - jwt_response, "my-tenant-ID", ["permission-name1", "permission-name2"] -) -``` - -When not using tenants use: - -```python -# You can validate specific permissions -valid_permissions = descope_client.validate_permissions( - jwt_response, ["Permission to validate"] -) -if not valid_permissions: - # Deny access - -# Or validate roles directly -valid_roles = descope_client.validate_roles( - jwt_response, ["Role to validate"] -) -if not valid_roles: - # Deny access - -# Or get the matched roles/permissions -matched_roles = descope_client.get_matched_roles( - jwt_response, ["role-name1", "role-name2"] -) - -matched_permissions = descope_client.get_matched_permissions( - jwt_response, ["permission-name1", "permission-name2"] -) -``` - -### Tenant selection - -For a user that has permissions to multiple tenants, you can set a specific tenant as the current selected one -This will add an extra attribute to the refresh JWT and the session JWT with the selected tenant ID - -```python -tenant_id_ = "t1" -jwt_response = descope_client.select_tenant(tenant_id, refresh_token) -``` - -### Logging Out - -You can log out a user from an active session by providing their `refresh_token` for that session. -After calling this function, you must invalidate or remove any cookies you have created. - -```python -descope_client.logout(refresh_token) -``` - -It is also possible to sign the user out of all the devices they are currently signed-in with. Calling `logout_all` will -invalidate all user's refresh tokens. After calling this function, you must invalidate or remove any cookies you have created. - -```python -descope_client.logout_all(refresh_token) -``` - -### History - -You can get the current session user history. -The request requires a valid refresh token. - -```python -users_history_resp = descope_client.history(refresh_token) -for user_history in users_history_resp: - # Do something -``` - -### My Tenants - -You can get the current session user tenants. -The request requires a valid refresh token. -And either a boolean to receive the current selected tenant -Or a list of tenant IDs that this user is part of - -```python -tenants_resp = descope_client.my_tenants(refresh_token, False, ["tenant_id"]) -for tenant in tenants_resp.tenants: - # Do something -``` - -## Management API - -It is very common for some form of management or automation to be required. These can be performed -using the management API. Please note that these actions are more sensitive as they are administrative -in nature. Please use responsibly. - -### Setup - -To use the management API you'll need a `Management Key` along with your `Project ID`. -Create one in the [Descope Console](https://app.descope.com/settings/company/managementkeys). - -```python -from descope import DescopeClient - -# Initialized after setting the DESCOPE_PROJECT_ID and the DESCOPE_MANAGEMENT_KEY env vars -descope_client = DescopeClient() - -# ** Or directly ** -descope_client = DescopeClient(project_id="", management_key="") -``` - -### Verbose Mode for Debugging - -When debugging failed API requests, you can enable verbose mode to capture HTTP response metadata like headers (`cf-ray`, `x-request-id`), status codes, and raw response bodies. This is especially useful when working with Descope support to troubleshoot issues. - -```python -from descope import DescopeClient, AuthException -import logging - -logger = logging.getLogger(__name__) - -# Enable verbose mode during client initialization -client = DescopeClient( - project_id="", - management_key="", - verbose=True, # Enable response metadata capture -) - -try: - # Make any API call - client.mgmt.user.create(login_id="test@example.com", email="test@example.com") -except AuthException as e: - # Access the last response metadata for debugging - response = client.get_last_response() - if response: - logger.error(f"Request failed with status {response.status_code}") - logger.error(f"cf-ray: {response.headers.get('cf-ray')}") - logger.error(f"x-request-id: {response.headers.get('x-request-id')}") - logger.error(f"Response body: {response.text}") - - # Provide cf-ray to Descope support for debugging - print(f"Please provide this cf-ray to support: {response.headers.get('cf-ray')}") -``` - -**Important Notes:** -- Verbose mode is **disabled by default** (no performance impact when not needed) -- When enabled, only the **most recent** HTTP response is stored -- `get_last_response()` returns `None` when verbose mode is disabled -- The response object provides dict-like access to JSON data while also exposing HTTP metadata -- A response object is always truthy, including when the body is empty or not JSON, so `if response:` is a safe presence check - -**Available metadata on response objects:** -- `response.headers` - HTTP response headers (dict-like object) -- `response.status_code` - HTTP status code (int) -- `response.text` - Raw response body as text (str) -- `response.url` - Request URL (str) -- `response.ok` - Whether status code is < 400 (bool) -- `response.json()` - Parsed JSON response (dict/list), raises if the body is not JSON -- `response.is_json` - Whether the body can be parsed as JSON (bool) -- `response.raw` - The underlying `httpx.Response` -- `response["key"]` - Dict-like access to JSON data (for backward compatibility) - -For a complete example, see [samples/verbose_mode_example.py](https://github.com/descope/python-sdk/blob/main/samples/verbose_mode_example.py). - -### Manage Tenants - -You can create, update, delete or load tenants: - -```Python -# You can optionally set your own ID when creating a tenant -descope_client.mgmt.tenant.create( - name="My First Tenant", - id="my-custom-id", # This is optional. - self_provisioning_domains=["domain.com"], - custom_attributes={"attribute-name": "value"}, -) - -# Update will override all fields as is. Use carefully. -descope_client.mgmt.tenant.update( - id="my-custom-id", - name="My First Tenant", - self_provisioning_domains=["domain.com", "another-domain.com"], - custom_attributes={"attribute-name": "value"}, -) - -# Patch only updates the fields you pass in - everything else on the tenant is left untouched. -descope_client.mgmt.tenant.patch_tenant( - id="my-custom-id", - disabled=True, -) - -# Managing the tenant's settings -# Getting the settings -descope_client.mgmt.tenant.load_settings(id="my-custom-id") - -# updating the settings -descope_client.mgmt.tenant.update_settings(id="my-custom-id", self_provisioning_domains=["domain.com"], session_settings_enabled=True, refresh_token_expiration=1, refresh_token_expiration_unit="hours") - - -# Tenant deletion cannot be undone. Use carefully. -# Pass true to cascade value, in case you want to delete all users/keys associated only with this tenant -descope_client.mgmt.tenant.delete(id="my-custom-id", cascade=False) - -# Load tenant by id -tenant_resp = descope_client.mgmt.tenant.load("my-custom-id") - -# Load all tenants -tenants_resp = descope_client.mgmt.tenant.load_all() -tenants = tenants_resp["tenants"] - for tenant in tenants: - # Do something - -# search all tenants -tenants_resp = descope_client.mgmt.tenant.search_all(ids=["id1"], names=["name1"], custom_attributes={"k1":"v1"}, self_provisioning_domains=["spd1"]) -tenants = tenants_resp["tenants"] - for tenant in tenants: - # Do something - -# Generate tenant admin self-service link for SSO configuration -# The link will be valid for the specified duration in seconds (e.g., 21600 seconds = 6 hours) -sso_link = descope_client.mgmt.tenant.generate_sso_configuration_link( - tenant_id="my-custom-id", - expire_time=21600 # 6 hours in seconds -) - -# Optionally set an actor id, recorded as the audit actor for actions taken inside the SSO -# Suite (instead of the temporary user). It is used as-is for audit attribution and is not validated. -sso_link = descope_client.mgmt.tenant.generate_sso_configuration_link( - tenant_id="my-custom-id", - expire_time=21600, - actor_id="my-admin-actor-id", -) -``` - -### Manage Users - -You can create, update, patch, delete or load users, as well as setting new password, expire password and search according to filters: - -```Python -# A user must have a login ID, other fields are optional. -# Roles should be set directly if no tenants exist, otherwise set -# on a per-tenant basis. -descope_client.mgmt.user.create( - login_id="desmond@descope.com", - email="desmond@descope.com", - display_name="Desmond Copeland", - user_tenants=[ - AssociatedTenant("my-tenant-id", ["role-name1"]), - ], - sso_app_ids=["appId1"], -) - -# Alternatively, a user can be created and invited via an email message. -# Make sure to configure the invite URL in the Descope console prior to using this function, -# and that an email address is provided in the information. -descope_client.mgmt.user.invite( - login_id="desmond@descope.com", - email="desmond@descope.com", - display_name="Desmond Copeland", - user_tenants=[ - AssociatedTenant("my-tenant-id", ["role-name1"]), - ], - sso_app_ids=["appId1"], -# You can override the project's User Invitation Redirect URL with this parameter - invite_url="invite.me" -) - -# NOTE: `invite` only controls whether an invitation message is sent - it does NOT -# change the user's status. A newly invited user starts out in the "invited" status, -# not "enabled". If your use case requires the user to be active immediately, set -# `status="enabled"` explicitly via `create`/`patch`, or call `activate()` afterwards. - -# Batch invite -descope_client.mgmt.user.invite_batch( - users=[ - UserObj( - login_id="desmond@descope.com", - email="desmond@descope.com", - display_name="Desmond Copeland", - user_tenants=[ - AssociatedTenant("my-tenant-id", ["role-name1"]), - ], - custom_attributes={"ak": "av"}, - sso_app_ids=["appId1"], - ) - ], - invite_url="invite.me", - send_mail=True, - send_sms=True, -) - -# Update will override all fields as is, including family memberships: omitting -# family_associations removes the user from all of their families, and updating a dependent -# requires family_associations with the dependent's family. Use carefully, or use patch instead. -descope_client.mgmt.user.update( - login_id="desmond@descope.com", - email="desmond@descope.com", - display_name="Desmond Copeland", - user_tenants=[ - AssociatedTenant("my-tenant-id", ["role-name1", "role-name2"]), - ], - sso_app_ids=["appId1"], -) - -# Patch will override only the set fields in the user -descope_client.mgmt.user.patch( - login_id="desmond@descope.com", - email="desmond@descope.com", - display_name="Desmond Copeland", -) - -# Update explicit data for a user rather than overriding all fields -descope_client.mgmt.user.update_login_id( - login_id="desmond@descope.com", - new_login_id="bane@descope.com" -) -descope_client.mgmt.user.update_phone( - login_id="desmond@descope.com", - phone="+18005551234", - verified=True, -) -descope_client.mgmt.user.remove_tenant_roles( - login_id="desmond@descope.com", - tenant_id="my-tenant-id", - role_names=["role-name1"], -) - -# Set SSO applications association to a user. -user = descope_client.mgmt.user.set_sso_apps( - login_id="desmond@descope.com", - sso_app_ids=["appId1", "appId2"] -) - -# Add SSO applications association to a user. -user = descope_client.mgmt.user.add_sso_apps( - login_id="desmond@descope.com", - sso_app_ids=["appId1", "appId2"] -) - -# Remove SSO applications association from a user. -user = descope_client.mgmt.user.remove_sso_apps( - login_id="desmond@descope.com", - sso_app_ids=["appId1", "appId2"] -) - -# User deletion cannot be undone. Use carefully. -descope_client.mgmt.user.delete("desmond@descope.com") - -# Load specific user -user_resp = descope_client.mgmt.user.load("desmond@descope.com") -user = user_resp["user"] - -# If needed, users can be loaded using the user ID as well -user_resp = descope_client.mgmt.user.load_by_user_id("") -user = user_resp["user"] - -# Logout user from all devices by login ID -descope_client.mgmt.user.logout_user("") - -# Logout user from all devices by user ID -descope_client.mgmt.user.logout_user_by_user_id("") - -# Load users by their user id -users_resp = descope_client.mgmt.user.load_users(user_ids=[""]) -users = users_resp["users"] - for user in users: - # Do something - -# Search all users, optionally according to tenant and/or role filter -# results can be paginated using the limit and page parameters, as well as by time with the from_created_time, to_created_time, from_modified_time, and to_modified_time -users_resp = descope_client.mgmt.user.search_all(tenant_ids=["my-tenant-id"]) -users = users_resp["users"] - for user in users: - # Do something - -# Get users' authentication history -users_history_resp = descope_client.mgmt.user.history(["user-id-1", "user-id-2"]) - for user_history in users_history_resp: - # Do something -``` - -#### Set or Expire User Password - -You can set a new active password for a user that they can sign in with. -You can also set a temporary password that the user will be forced to change on the next login. -For a user that already has an active password, you can expire their current password, effectively requiring them to change it on the next login. - -```Python -# Set a user's temporary password -descope_client.mgmt.user.set_temporary_password("", "") -# Set a user's password -descope_client.mgmt.user.set_active_password("", "") -# Or alternatively, expire a user password -descope_client.mgmt.user.expirePassword("") -``` - -### Manage Families - -Family accounts group users - for example a guardian and their dependents - that share access and -family-scoped attributes. Enable family accounts for the project, then create families, add users to them -and manage dependents: - -```Python -from descope import AssociatedFamily, CustomAttribute - -# Get and configure the project's family account settings. Omitted fields are left unchanged. -settings = descope_client.mgmt.family.get_settings() -descope_client.mgmt.family.configure_settings( - enabled=True, - max_family_members=6, - allow_multiple_families_users=True, -) - -# Define custom attributes on the family entity itself (type 1 is a string attribute) -descope_client.mgmt.family.create_custom_attributes([CustomAttribute("plan", 1, display_name="Plan")]) -attributes = descope_client.mgmt.family.load_custom_attributes()["data"] -descope_client.mgmt.family.delete_custom_attributes(["plan"]) - -# Create a family. You can optionally set your own family ID. -family = descope_client.mgmt.family.create( - name="Demo Family", - custom_attributes={"plan": "free"}, - id="my-family-id", # This is optional. -)["family"] - -# Update only changes the fields you pass in - everything else on the family is left untouched. -# custom_attributes replaces all of the family's custom attributes, it is not merged. -descope_client.mgmt.family.update(id="my-family-id", name="Demo Family (renamed)") - -# Search families. Called with no arguments, returns all families. -families = descope_client.mgmt.family.search_all(ids=["my-family-id"])["families"] -for family in families: - print(family["name"]) - -# Family deletion cannot be undone. Use carefully. -descope_client.mgmt.family.delete(id="my-family-id") -``` - -Users are added to families with `AssociatedFamily`, which can also set the user's roles in the family and -family-scoped attribute values - user attributes whose values are held per family membership: - -```Python -# Define family-scoped user attributes (a separate set from the plain user custom attributes) -descope_client.mgmt.user.create_family_scoped_custom_attributes( - [CustomAttribute("nickname", 1, display_name="Nickname")] -) -attributes = descope_client.mgmt.user.load_family_scoped_custom_attributes()["data"] -descope_client.mgmt.user.delete_family_scoped_custom_attributes(["nickname"]) - -# Create a user straight into a family. family_associations is also accepted by -# create_test_user, invite, update, patch and on UserObj for batch operations. -descope_client.mgmt.user.create( - login_id="guardian@example.com", - email="guardian@example.com", - family_associations=[ - AssociatedFamily("my-family-id", ["Family Admin"], {"nickname": "Mom"}), - ], -) - -# Add a user to families. For a family the user already belongs to, given roles and -# family-scoped attributes are merged, and omitting them leaves the existing values unchanged. -descope_client.mgmt.user.add_families( - login_id="guardian@example.com", - family_associations=[AssociatedFamily("my-family-id", family_scoped_attributes={"nickname": "Mommy"})], -) - -# Remove a user from families -descope_client.mgmt.user.remove_families(login_id="guardian@example.com", family_ids=["my-family-id"]) - -# Search users by family, optionally only dependents -users = descope_client.mgmt.user.search_all(family_ids=["my-family-id"], dependent=True)["users"] -``` - -User responses include `dependent` (whether the user is a family dependent) and `userFamilies`, a list of -`{"familyId", "roleNames", "permissions", "familyScopedAttributes"}` entries. - -A dependent is a user with no login credentials of their own, managed by the family's members. Family members -holding the "Family Impersonate Dependents" permission in the family can impersonate its dependents: - -```Python -# Create a dependent. The login ID is derived from the name when omitted - the email and -# phone are never used as the login ID, since a dependent may share them with their guardian. -dependent = descope_client.mgmt.family.create_dependent( - family_id="my-family-id", - name="Demo Kid", - family_scoped_attributes={"nickname": "Kiddo"}, -)["user"] - -# Impersonate the dependent, optionally scoping the session to the dependent's family -jwt = descope_client.mgmt.family.impersonate_dependent( - impersonator_user_id_or_login_id="guardian@example.com", - dependent_login_id=dependent["loginIds"][0], - selected_family="my-family-id", -) - -# Stop impersonating and return to the family member's own session -jwt = descope_client.mgmt.family.stop_impersonation(jwt) - -# Delete a dependent. The family is inferred from the dependent. Regular family members -# are removed with user.remove_families instead. -descope_client.mgmt.family.delete_dependent(user_id=dependent["userId"]) -``` - -For a complete end-to-end example, see [samples/management/family_sample_app.py](https://github.com/descope/python-sdk/blob/main/samples/management/family_sample_app.py). - -### Manage Access Keys - -You can create, update, delete or load access keys, as well as search according to filters: - -```Python -# An access key must have a name and expiration, other fields are optional. -# Roles should be set directly if no tenants exist, otherwise set -# on a per-tenant basis. -# If user_id is supplied, then authorization would be ignored, and access key would be bound to the users authorization. -# If description is supplied, then the access key will hold a descriptive text. -# If permitted_ips is supplied, then the access key can only be used from that list of IP addresses or CIDR ranges -create_resp = descope_client.mgmt.access_key.create( - name="name", - expire_time=1677844931, - key_tenants=[ - AssociatedTenant("my-tenant-id", ["role-name1"]), - ], - description="this is my access key", - permitted_ips=['10.0.0.1', '192.168.1.0/24'], - custom_attributes={'attrName': 'attrValue'}, -) -key = create_resp["key"] -cleartext = create_resp["cleartext"] # make sure to save the returned cleartext securely. It will not be returned again. - -# Load a specific access key -access_key_resp = descope_client.mgmt.access_key.load("key-id") -access_key = access_key_resp["key"] - -# Search all access keys, optionally according to a tenant filter -keys_resp = descope_client.mgmt.access_key.search_all_access_keys(tenant_ids=["my-tenant-id"], bound_user_id='buid', - creating_user='cu', custom_attributes={'attrName': 'attrValue'}) -keys = keys_resp["keys"] - for key in keys: - # Do something - -# Update will override all fields as is. Use carefully. -descope_client.mgmt.access_key.update( - id="key-id", - name="new name", - custom_claims={"k1":"v1"}, - permitted_ips=['10.0.0.1', '192.168.1.0/24'], - custom_attributes={'attrName': 'attrValue'}, -) - -# Access keys can be deactivated to prevent usage. This can be undone using "activate". -descope_client.mgmt.access_key.deactivate("key-id") - -# Disabled access keys can be activated once again. -descope_client.mgmt.access_key.activate("key-id") - -# Access key deletion cannot be undone. Use carefully. -descope_client.mgmt.access_key.delete("key-id") -``` - -Exchange the access key and provide optional access key login options: - -```python -loc = AccessKeyLoginOptions(custom_claims={"k1": "v1"}) -jwt_response = descope_client.exchange_access_key(access_key="accessKey", login_options=loc) -``` - -### Manage SSO Setting - -You can manage SSO settings and map SSO group roles and user attributes. - -```Python -# You can load all tenant SSO settings -sso_settings_res = descope_client.mgmt.sso.load_settings("tenant-id") - -# import based on your configuration needs: -from descope import ( - SSOOIDCSettings, - OIDCAttributeMapping, - SSOSAMLSettings, - AttributeMapping, - RoleMapping, - SSOSAMLSettingsByMetadata, -) - -# You can Configure SSO SAML settings for a tenant manually. -settings = SSOSAMLSettings( - idp_url="https://dummy.com/saml", - idp_entity_id="entity1234", - idp_cert="my certificate", - attribute_mapping=AttributeMapping( - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - picture="picture", - email="email", - phone_number="phoneNumber", - group="groups", - ), - role_mappings=[RoleMapping(groups=["grp1"], role="rl1")], -) -descope_client.mgmt.sso.configure_saml_settings( - tenant_id, # Which tenant this configuration is for - settings, # The SAML settings - redirect_url="https://your.domain.com", # Global redirection after successful authentication - domains=["tenant-users.com"], # Users authentication with these domains will be logged in to this tenant -) - -# You can Configure SSO SAML settings for a tenant by fetching them from an IDP metadata URL. -settings = SSOSAMLSettingsByMetadata( - idp_metadata_url="https://dummy.com/metadata", - attribute_mapping=AttributeMapping( - name="myName", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - picture="picture", - email="email", - phone_number="phoneNumber", - group="groups", - ), - role_mappings=[RoleMapping(groups=["grp1"], role="rl1")], -) -descope_client.mgmt.sso.configure_saml_settings_by_metadata( - tenant_id, # Which tenant this configuration is for - settings, # The SAML settings - redirect_url="https://your.domain.com", # Global redirection after successful authentication - domains=["tenant-users.com"], # Users authentication with these domains will be logged in to this tenant -) - -# Descope signs the SAML AuthnRequest it sends to the IdP. A few IdPs reject a signed request because -# their trusted provider entry holds no signing certificate for Descope - pass disable_sign_request -# (available on both settings classes above) to send the request unsigned for that configuration only. -settings = SSOSAMLSettings( - idp_url="https://dummy.com", - idp_entity_id="my-idp-entity-id", - idp_cert="my-idp-certificate", - disable_sign_request=True, -) - -# You can Configure SSO OIDC settings for a tenant manually. -settings = SSOOIDCSettings( - name="myProvider", - client_id="myId", - client_secret="secret", - redirect_url="https://your.domain.com", - auth_url="https://dummy.com/auth", - token_url="https://dummy.com/token", - user_data_url="https://dummy.com/userInfo", - scope=["openid", "profile", "email"], - attribute_mapping=OIDCAttributeMapping( - login_id="subject", - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - email="email", - verified_email="verifiedEmail", - username="username", - phone_number="phoneNumber", - verified_phone="verifiedPhone", - picture="picture", - ), - use_pkce=True, # Optional, send a PKCE code challenge to the IdP; omit (None) to keep the stored value -) -descope_client.mgmt.sso.configure_oidc_settings( - tenant_id, # Which tenant this configuration is for - settings, # The OIDC provider settings - domains=["tenant-users.com"], # Users authentication with these domains will be logged in to this tenant -) - -# You can disable an SSO configuration without deleting it, and enable it again later. -# Its settings, mappings and domains are kept, so re-enabling needs no payload. -descope_client.mgmt.sso.configure_auth_type( - tenant_id, # Which tenant the configuration belongs to - "none", # "none" disables it; "saml" or "oidc" enable it on that protocol - sso_id="my-sso-id", # Optional, omit for the tenant's default SSO configuration -) - -# DEPRECATED (use load_settings(..) function instead) -# You can get SSO settings for a tenant -sso_settings_res = descope_client.mgmt.sso.get_settings("tenant-id") - -# DEPRECATED (use configure_saml_settings(..) function instead) -# You can configure SSO settings manually by setting the required fields directly -descope_client.mgmt.sso.configure( - tenant_id, # Which tenant this configuration is for - idp_url="https://idp.com", - entity_id="my-idp-entity-id", - idp_cert="", - redirect_url="https://your.domain.com", # Global redirection after successful authentication - domains=["tenant-users.com"], # Users authentication with these domains will be logged in to this tenant -) - -# DEPRECATED (use configure_saml_settings_by_metadata(..) function instead) -# Alternatively, configure using an SSO metadata URL -descope_client.mgmt.sso.configure_via_metadata( - tenant_id, # Which tenant this configuration is for - idp_metadata_url="https://idp.com/my-idp-metadata", - redirect_url="", # Redirect URL will have to be provided in every authentication call - domains=None, # Remove the current domains configuration if a value was previously set -) - -# DEPRECATED (use configure_saml_settings() or configure_saml_settings_by_metadata(..) functions instead) -# Map IDP groups to Descope roles, or map user attributes. -# This function overrides any previous mapping (even when empty). Use carefully. -descope_client.mgmt.sso.mapping( - tenant_id, # Which tenant this mapping is for - role_mappings=[RoleMapping(["IDP_ADMIN"], "Tenant Admin")], - attribute_mapping=AttributeMapping(name="IDP_NAME", phone_number="IDP_PHONE"), -) -``` - -Note: Certificates should have a similar structure to: - -``` ------BEGIN CERTIFICATE----- -Certificate contents ------END CERTIFICATE----- -``` - -### Manage Permissions - -You can create, update, delete or load permissions: - -```Python -# You can optionally set a description for a permission. -descope_client.mgmt.permission.create( - name="My Permission", - description="Optional description to briefly explain what this permission allows." -) - -# Create a batch of permissions in a single atomic transaction. -descope_client.mgmt.permission.create_batch( - permissions=[ - {"name": "Permission 1", "description": "First permission"}, - {"name": "Permission 2", "description": "Second permission"}, - ] -) - -# Update will override all fields as is. Use carefully. -descope_client.mgmt.permission.update( - name="My Permission", - new_name="My Updated Permission", - description="A revised description" -) - -# Update a batch of permissions in a single atomic transaction. -descope_client.mgmt.permission.update_batch( - permissions=[ - {"name": "Permission 1", "newName": "Updated Permission 1", "description": "Updated description"}, - ] -) - -# Permission deletion cannot be undone. Use carefully. -descope_client.mgmt.permission.delete("My Updated Permission") - -# Delete a batch of permissions in a single atomic transaction. Cannot be undone. Use carefully. -descope_client.mgmt.permission.delete_batch(["Permission 1", "Permission 2"]) - -# Load all permissions -permissions_resp = descope_client.mgmt.permission.load_all() -permissions = permissions_resp["permissions"] - for permission in permissions: - # Do something -``` - -### Manage Roles - -You can create, update, delete or load roles: - -```Python -# You can optionally set a description and associated permission for a roles. -descope_client.mgmt.role.create( - name="My Role", - description="Optional description to briefly explain what this role allows.", - permission_names=["My Updated Permission"], - tenant_id="Optionally scope this role for this specific tenant. If left empty, the role will be available to all tenants.", - default=False, # Optional, marks this role as default role - private=False # Optional, marks this role as private role -) - -# Create a batch of roles in a single atomic transaction. -descope_client.mgmt.role.create_batch( - roles=[ - {"name": "Role 1", "description": "First role", "permissionNames": ["Permission 1"]}, - {"name": "Role 2", "description": "Second role", "permissionNames": ["Permission 2"], "default": True}, - ] -) - -# Update will override all fields as is. Use carefully. -descope_client.mgmt.role.update( - name="My Role", - new_name="My Updated Role", - description="A revised description", - permission_names=["My Updated Permission", "Another Permission"], - tenant_id="The tenant ID to which this role is associated, leave empty, if role is a global one", - default=False, # Optional, marks this role as default role - private=True # Optional, marks this role as private role -) - -# Update a batch of roles in a single atomic transaction. -descope_client.mgmt.role.update_batch( - roles=[ - {"name": "Role 1", "newName": "Updated Role 1", "description": "Updated description", "permissionNames": ["Permission 1"]}, - ] -) - -# Delete a batch of roles in a single atomic transaction. This action is irreversible. Use carefully. -descope_client.mgmt.role.delete_batch( - roles=["Role 1", "Role 2"], - tenant_id="", # Optional, the tenant the roles belong to -) - -# Role objects are also accepted, as long as they all belong to the same tenant. -descope_client.mgmt.role.delete_batch( - roles=[ - {"name": "Role 1"}, - {"name": "Role 2", "tenantId": ""}, - ] -) - -# Roles can also be deleted in a batch by their IDs. -descope_client.mgmt.role.delete_batch_by_ids( - role_ids=["", ""], - tenant_id="", # Optional, the tenant the roles belong to -) - -# Role deletion cannot be undone. Use carefully. -descope_client.mgmt.role.delete("My Updated Role", "") - -# Load all roles -roles_resp = descope_client.mgmt.role.load_all() -roles = roles_resp["roles"] - for role in roles: - # Do something - -# Search roles -roles_resp = descope_client.mgmt.role.search(["t1", "t2"], ["r1", "r2"]) -roles = roles_resp["roles"] - for role in roles: - # Do something -``` - -### Manage Flows and Theme - -You can list your flows and also import and export flows and screens, or the project theme: - -```Python -# List all project flows -flows_resp = descope_client.mgmt.flow.list_flows() -print(f'Total number of flows: {flows_resp["total"]}') -flows = flows_resp["flows"] -for flow in flows: - # Do something - -# Delete flows by ids -descope_client.mgmt.flow.delete_flows( - flow_ids=["flow-1", "flow-2"], -) - -# Export a selected flow by id for the flow and matching screens. -exported_flow_and_screens = descope_client.mgmt.flow.export_flow( - flow_id="sign-up-or-in", -) - -# Import a given flow and screens to the flow matching the id provided. -imported_flow_and_screens = descope_client.mgmt.flow.import_flow( - flow_id="sign-up-or-in", - flow={}, - screens=[] -) - -# Export your project theme. -exported_theme = descope_client.mgmt.flow.export_theme() - -# Import a theme to your project. -imported_theme = descope_client.mgmt.flow.import_flow( - theme={} -) - -# Run a flow with the given flow id and options. -# You can use the FlowRunOptions class or pass a dict directly. -from descope import FlowRunOptions - -# Using FlowRunOptions class -result = descope_client.mgmt.flow.run_flow( - flow_id="my-flow-id", - options=FlowRunOptions( - input={"key": "value"}, - preview=True, - tenant="tenant-id", - ), -) - -# Or using a dict -result = descope_client.mgmt.flow.run_flow( - flow_id="my-flow-id", - options={ - "input": {"key": "value"}, - "preview": True, - "tenant": "tenant-id", - }, -) - -# Run a flow asynchronously for long-running flows. -# This returns immediately with an execution ID that can be used to check the result later. -from descope import FlowRunOptions - -# Start an async flow run -async_result = descope_client.mgmt.flow.run_flow_async( - flow_id="my-flow-id", - options=FlowRunOptions( - flow_input={"key": "value"}, - preview=True, - tenant="tenant-id", - ), -) -execution_id = async_result["executionId"] - -# Or using a dict -async_result = descope_client.mgmt.flow.run_flow_async( - flow_id="my-flow-id", - options={ - "input": {"key": "value"}, - "preview": True, - "tenant": "tenant-id", - }, -) -execution_id = async_result["executionId"] - -# Check the result of an async flow execution -# Poll this endpoint until the flow completes -result = descope_client.mgmt.flow.get_flow_async_result( - execution_id=execution_id, -) -``` - -### Query SSO Groups - -You can query SSO groups: - -```Python -# Load all groups for a given tenant id -groups_resp = descope_client.mgmt.group.load_all_groups( - tenant_id="tenant-id", -) - -# Load only the groups that came from a specific SSO configuration (each returned group -# carries an "ssoId" field identifying its origin; use "default_ssoid" for the tenant's -# default SSO configuration) -groups_resp = descope_client.mgmt.group.load_all_groups( - tenant_id="tenant-id", - sso_id="sso-config-id", -) - -# Load all groups for the given user IDs (can be found in the user's JWT) -groups_resp = descope_client.mgmt.group.load_all_groups_for_members( - tenant_id="tenant-id", - user_ids=["user-id-1", "user-id-2"], -) - -# Load all groups for the given user's login IDs (used for sign-in) -groups_resp = descope_client.mgmt.group.load_all_groups_for_members( - tenant_id="tenant-id", - login_ids=["login-id-1", "login-id-2"], -) - -# Load all group's members by the given group id -groups_resp = descope_client.mgmt.group.load_all_group_members( - tenant_id="tenant-id", - group_id="group-id, -) - -for group in groups_resp: - # Do something -``` - -### Manage JWTs - -You can add custom claims to a valid JWT. - -```python -updated_jwt = descope_client.mgmt.jwt.update_jwt( - jwt="original-jwt", - custom_claims={"custom-key1": "custom-value1", "custom-key2": "custom-value2"}, -) -``` - -Generate a JWT for a user, simulating a signin request. - -```python -jwt_response = descope_client.mgmt.jwt.sign_in(login_id="dummy") -``` - -Generate a JWT for a user, simulating a signup request. - -```python -jwt_response = descope_client.mgmt.jwt.sign_up(login_id="dummy") -``` - -Generate a JWT for a user, simulating a signup or in request. - -```python -jwt_response = descope_client.mgmt.jwt.sign_up_or_in(login_id="dummy") -``` - -### Impersonate - -You can impersonate to another user -The impersonator user must have the `impersonation` permission in order for this request to work. -The response would be a refresh JWT of the impersonated user - -```python -refresh_jwt = descope_client.mgmt.jwt.impersonate( - impersonator_id="", - login_id="", - validate_consent=True, - custom_claims={"key1": "value1"}, - tenant_id="", -) -``` - -# Note 1: The generate code/link functions, work only for test users, will not work for regular users. - -# Note 2: In case of testing sign-in / sign-up operations with test users, need to make sure to generate the code prior calling the sign-in / sign-up operations. - -### Embedded links - -Embedded links can be created to directly receive a verifiable token without sending it. - -This token can then be verified using the magic link 'verify' function, either directly or through a flow. - -```python -token = descope_client.mgmt.user.generate_embedded_link("desmond@descope.com", {"key1": "value1"}, 120) -``` - -### Audit - -You can perform an audit search for either specific values or full-text across the fields. Audit search is limited to the last 30 days. -Below are some examples. For a full list of available search criteria options, see the function documentation. - -```python -# Full text search on last 10 days -audits = descope_client.mgmt.audit.search(text="some-text", from_ts=datetime.now(timezone.utc) - timedelta(days=10)) -# Search successful logins in the last 30 days -audits = descope_client.mgmt.audit.search(actions=["LoginSucceed"]) -``` - -You can also create audit event with data - -```python -await descopeClient.management.audit.create_event( - action="pencil.created", - type="info", # info/warn/error - actor_id="UXXX", - tenant_id="tenant-id" - data={"some": "data"} -) -``` - -### Manage FGA (Fine-grained Authorization) - -Descope supports full relation based access control (ReBAC) using a zanzibar like schema and operations. -A schema is comprized of types (entities like documents, folders, orgs, etc.) and each type has relation definitions and permission to define relations to other types. - -A simple example for a file system like schema would be: - -```yaml -model AuthZ 1.0 - -type user - -type org - relation member: user - relation parent: org - -type folder - relation parent: folder - relation owner: user | org#member - relation editor: user - relation viewer: user - - permission can_create: owner | parent.owner - permission can_edit: editor | can_create - permission can_view: viewer | can_edit - -type doc - relation parent: folder - relation owner: user | org#member - relation editor: user - relation viewer: user - - permission can_create: owner | parent.owner - permission can_edit: editor | can_create - permission can_view: viewer | can_edit -``` - -Descope SDK allows you to fully manage the schema and relations as well as perform simple (and not so simple) checks regarding the existence of relations. - -```python -# Save schema (where schema is an str as defined above) -descope_client.mgmt.fga.save_schema(schema) - -# Create a relation between a resource and user -descope_client.mgmt.fga.create_relations( - [ - { - "resource": "some-doc", - "resourceType": "doc", - "relation": "owner", - "target": "u1", - "targetType": "user", - } - ] -) - -# Check if target has a relevant relation -# The answer should be true because an owner can also view -relations = descope_client.mgmt.fga.check( - [ - { - "resource": "some-doc", - "resourceType": "doc", - "relation": "owner", - "target": "u1", - "targetType": "user", - } - ] -) -``` - -Response times of repeated FGA `check` calls, especially in high volume scenarios, can be reduced to sub-millisecond scales by re-directing the calls to a Descope FGA Cache Proxy running in the same backend cluster as your application. -After setting up the proxy server via the Descope provided Docker image, set the `fga_cache_url` parameter to be equal to the proxy URL to enable its use in the SDK, as shown in the example below: - -```python -# Initialize client with FGA cache URL -descope_client = DescopeClient( - project_id="", - management_key="", - fga_cache_url="https://10.0.0.4", # example FGA Cache Proxy URL, running inside the same backend cluster -) -``` - -When the `fga_cache_url` is configured, the following FGA methods will automatically use the cache proxy instead of the default Descope API: - -- `save_schema` -- `create_relations` -- `delete_relations` -- `check` - -Other FGA operations like `load_schema` will continue to use the standard Descope API endpoints. - -### Manage Project - -You can change the project name, as well as clone the current project to -create a new one. - -```python -# Change the project name -descope_client.mgmt.project.change_name("new-project-name") - -# Change project's tags -descope_client.mgmt.project.update_tags(["new", "python"]) - -# Clone the current project, including its settings and configurations. -# Note that this action is supported only with a pro license or above. -# Users, tenants and access keys are not cloned. -clone_resp = descope_client.mgmt.project.clone("new-project-name") -``` - -You can manage your project's settings and configurations by exporting your -project's environment. You can also import previously exported data into -the same project or a different one. - -```python -# Exports the current state of the project -export = descope_client.mgmt.project.export_project() - -# Import the previously exported data into the current project -descope_client.mgmt.project.import_project(export) -``` - -### Manage SSO Applications - -You can create, update, delete or load sso applications: - -```python -# Create OIDC SSO application -descope_client.mgmt.sso_application.create_oidc_application( - name="My First sso app", - login_page_url="http://dummy.com", - id="my-custom-id", # This is optional. - client_type="confidential", # "confidential" or "public". If omitted, the account's default is used. -) - -# Create SAML SSO application -descope_client.mgmt.sso_application.create_saml_application( - name="My First sso app", - login_page_url="http://dummy.com", - id="my-custom-id", # This is optional. - use_metadata_info=True, - metadata_url="http://dummy.com/metadata, - default_relay_state="relayState", - force_authentication=False, - logout_redirect_url="http://dummy.com/logout", -) - -# Update OIDC SSO application -# Update will override all fields as is. Use carefully. -descope_client.mgmt.sso_application.update_oidc_application( - id="my-custom-id", - name="My First sso app", - login_page_url="http://dummy.com", - client_type="confidential", # Update overrides all fields, so pass the app's current client type. -) - -# Update SAML SSO application -# Update will override all fields as is. Use carefully. -descope_client.mgmt.sso_application.update_saml_application( - id="my-custom-id", - name="My First sso app", - login_page_url="http://dummy.com", - use_metadata_info=False, - entity_id="ent1234", - acs_url="http://dummy.com/acs, - certificate="my cert" -) - -# SSO application deletion cannot be undone. Use carefully. -descope_client.mgmt.sso_application.delete("my-custom-id") - -# Load SSO application by id -app_resp = descope_client.mgmt.sso_application.load("my-custom-id") - -# Load all SSO applications -apps_resp = descope_client.mgmt.sso_application.load_all() -apps = apps_resp["apps"] - for app in apps: - # Do something -``` - -### Manage Outbound Applications - -You can create, update, delete, load outbound applications and fetch tokens for them: - -```python -# Create a basic outbound application -response = descope_client.mgmt.outbound_application.create_application( - name="my new app", - description="my desc", - client_secret="secret123", # Optional - id="my-custom-id", # Optional -) -app_id = response["app"]["id"] - -# Create a full OAuth outbound application with all parameters -from descope.management.common import URLParam, AccessType, PromptType - -# Create URL parameters for authorization -auth_params = [ - URLParam("response_type", "code"), - URLParam("client_id", "my-client-id"), - URLParam("redirect_uri", "https://myapp.com/callback") -] - -# Create URL parameters for token endpoint -token_params = [ - URLParam("grant_type", "authorization_code"), - URLParam("client_id", "my-client-id") -] - -# Create prompt types -prompts = [PromptType.LOGIN, PromptType.CONSENT] - -full_app = descope_client.mgmt.outbound_application.create_application( - name="My OAuth App", - description="A full OAuth outbound application", - logo="https://example.com/logo.png", - id="my-custom-id", # Optional custom ID - client_secret="my-secret-key", - client_id="my-client-id", - discovery_url="https://accounts.google.com/.well-known/openid_configuration", - authorization_url="https://accounts.google.com/o/oauth2/v2/auth", - authorization_url_params=auth_params, - token_url="https://oauth2.googleapis.com/token", - token_url_params=token_params, - revocation_url="https://oauth2.googleapis.com/revoke", - default_scopes=["https://www.googleapis.com/auth/userinfo.profile"], - default_redirect_url="https://myapp.com/callback", - callback_domain="myapp.com", - pkce=True, # Enable PKCE - access_type=AccessType.OFFLINE, # Request refresh tokens - prompt=prompts -) - -# Update an outbound application with all parameters -# Update will override all fields as is. Use carefully. -descope_client.mgmt.outbound_application.update_application( - id="my-app-id", - name="my updated app", - description="updated description", - logo="https://example.com/logo.png", - client_secret="new-secret", # Optional - client_id="new-client-id", - discovery_url="https://accounts.google.com/.well-known/openid_configuration", - authorization_url="https://accounts.google.com/o/oauth2/v2/auth", - authorization_url_params=auth_params, - token_url="https://oauth2.googleapis.com/token", - token_url_params=token_params, - revocation_url="https://oauth2.googleapis.com/revoke", - default_scopes=["https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"], - default_redirect_url="https://myapp.com/updated-callback", - callback_domain="myapp.com", - pkce=True, - access_type=AccessType.OFFLINE, - prompt=[PromptType.LOGIN, PromptType.CONSENT, PromptType.SELECT_ACCOUNT] -) - -# Delete an outbound application by id -# Outbound application deletion cannot be undone. Use carefully. -descope_client.mgmt.outbound_application.delete_application("my-app-id") - -# Load an outbound application by id -app = descope_client.mgmt.outbound_application.load_application("my-app-id") - -# Load all outbound applications -apps_resp = descope_client.mgmt.outbound_application.load_all_applications() -apps = apps_resp["apps"] -for app in apps: - # Do something with each app - -# Fetch user token with specific scopes -user_token = descope_client.mgmt.outbound_application.fetch_token_by_scopes( - "my-app-id", - "user-id", - ["read", "write"], - {"refreshToken": True}, # Optional - "tenant-id" # Optional -) - -# Fetch latest user token -latest_user_token = descope_client.mgmt.outbound_application.fetch_token( - "my-app-id", - "user-id", - "tenant-id", # Optional - {"forceRefresh": True} # Optional -) - -# Fetch tenant token with specific scopes -tenant_token = descope_client.mgmt.outbound_application.fetch_tenant_token_by_scopes( - "my-app-id", - "tenant-id", - ["read", "write"], - {"refreshToken": True} # Optional -) - -# Fetch latest tenant token -latest_tenant_token = descope_client.mgmt.outbound_application.fetch_tenant_token( - "my-app-id", - "tenant-id", - {"forceRefresh": True} # Optional -) - -# List the IDs of the outbound apps a user currently holds a valid token for. -# Use this for connection-status views instead of calling fetch_token once per app. -connected = descope_client.mgmt.outbound_application.list_apps_with_user_token( - "user-id", - tenant_id="tenant-id", # Optional -) -# connected => {"appIds": ["app-1", "app-2"]} - -# Store a static API key for a user / tenant on an apikey-type outbound app -descope_client.mgmt.outbound_application.upload_user_api_key( - "my-app-id", - "user-id", - "the-users-api-key", - tenant_id="tenant-id", # Optional -) -descope_client.mgmt.outbound_application.upload_tenant_api_key( - "my-app-id", - "tenant-id", - "the-tenants-api-key", -) - -# Upload (migrate) an existing OAuth token for a user / tenant on an oauth-type outbound app, -# without requiring the user to re-run the OAuth flow. -descope_client.mgmt.outbound_application.upload_user_token( - "my-app-id", - "user-id", - refresh_token="the-refresh-token", - scopes=["read", "write"], -) -descope_client.mgmt.outbound_application.upload_tenant_token( - "my-app-id", - "tenant-id", - access_token="the-access-token", -) - -# Batch upload OAuth tokens (all-or-nothing): inspect "failures" to see rejected items -batch_resp = descope_client.mgmt.outbound_application.batch_upload_user_tokens([ - {"appId": "my-app-id", "userId": "user-1", "accessToken": "token-1"}, - {"appId": "my-app-id", "userId": "user-2", "accessToken": "token-2"}, -]) -# batch_resp => {"failures": [{"appId": ..., "userId": ..., "errorCode": ..., "reason": ...}, ...]} -``` - -Fetch outbound application tokens using an inbound application token that includes the "outbound.token.fetch" scope (no management key required) - -```python -# Fetch user token with specific scopes -user_token = descope_client.mgmt.outbound_application_by_token.fetch_token_by_scopes( - "inbound-app-token", - "my-app-id", - "user-id", - ["read", "write"], - {"refreshToken": True}, # Optional - "tenant-id", # Optional -) - -# Fetch latest user token -latest_user_token = descope_client.mgmt.outbound_application_by_token.fetch_token( - "inbound-app-token", - "my-app-id", - "user-id", - "tenant-id", # Optional - {"forceRefresh": True}, # Optional -) - -# Fetch tenant token with specific scopes -tenant_token = descope_client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( - "inbound-app-token", - "my-app-id", - "tenant-id", - ["read", "write"], - {"refreshToken": True}, # Optional -) - -# Fetch latest tenant token -latest_tenant_token = descope_client.mgmt.outbound_application_by_token.fetch_tenant_token( - "inbound-app-token", - "my-app-id", - "tenant-id", - {"forceRefresh": True}, # Optional -) -``` - -### Manage Third Party Applications - -Third party applications let external apps use Descope as an identity provider via OAuth/OIDC. - -```python -# Create a third party application (returns the generated id and secret) -resp = descope_client.mgmt.third_party_application.create( - name="my-app", - login_page_url="https://my-app.com/login", - description="My third party app", # Optional - approved_callback_urls=["https://my-app.com/callback"], # Optional - permissions_scopes=[{"name": "read", "description": "Read access", "values": ["roleA"]}], # Optional - force_pkce=True, # Optional - client_type="confidential", # "confidential" or "public". If omitted, the account's default is used. -) -app_id = resp["id"] - -# Update will override all fields as is. Use carefully. -descope_client.mgmt.third_party_application.update( - id=app_id, - name="my-app", - login_page_url="https://my-app.com/login", - client_type="confidential", # Update overrides all fields, so pass the app's current client type. -) - -# Patch only the provided fields -descope_client.mgmt.third_party_application.patch(id=app_id, description="updated") - -# Load / load all -app = descope_client.mgmt.third_party_application.load(app_id) -all_apps = descope_client.mgmt.third_party_application.load_all() - -# Rotate or fetch the application secret -new_secret = descope_client.mgmt.third_party_application.rotate_secret(app_id) -secret = descope_client.mgmt.third_party_application.get_secret(app_id) - -# Create a user consent, e.g. to migrate consents users already granted in another system. -# Scopes are merged into the user's existing consent for the same application and tenant. -consent = descope_client.mgmt.third_party_application.create_consent( - app_id=app_id, - user_id="user-id", - scopes=["openid", "read"], # Must include the application's mandatory scopes - tenant_id="tenant-id", # Optional, the user must be a member of the tenant - expire_time=1767225600, # Optional, seconds since the epoch. 0 (default) means never expires -)["consent"] - -# Search and delete user consents -consents = descope_client.mgmt.third_party_application.search_consents(app_id=app_id, limit=10) -descope_client.mgmt.third_party_application.delete_consents(app_id=app_id, user_ids=["u1"]) -descope_client.mgmt.third_party_application.delete_tenant_consents("tenant-id") - -# Application deletion cannot be undone. Use carefully. -descope_client.mgmt.third_party_application.delete(app_id) -descope_client.mgmt.third_party_application.delete_batch([app_id]) -``` - -### Manage Cross-App Access (XAA / ID-JAG) - -Cross-App Access (XAA), built on the OAuth identity-assertion authorization grant (ID-JAG), lets a tenant trust external OIDC issuers so a token minted by a trusted issuer can be exchanged for a Descope token (the RFC 7523 `jwt-bearer` grant). XAA trust is configured **per SSO configuration** of a tenant through the SSO management API, addressed by its `sso_id` (omit it, or pass an empty string, for the tenant's default SSO configuration). The trusted issuers are set together with the config-level shared group/role mapping. The exported `XAASettings`, `XAAJWTBearerSettings`, and `XAAIssuerSettings` classes describe the write payload; the same shape is returned by the dedicated Load XAA settings API (`load_xaa_settings` / `load_all_xaa_settings`), not on tenant load. - -```python -from descope import AttributeMapping, RoleMapping, XAAIssuerSettings, XAAJWTBearerSettings, XAASettings - -# Configure XAA trust for a single SSO configuration ('' / omit sso_id = the default SSO configuration). -descope_client.mgmt.sso.configure_xaa_settings( - "my-tenant-id", - XAASettings( - enabled=True, - settings=XAAJWTBearerSettings( - issuers={ - # Key is the trusted issuer URL. - "https://issuer.example.com": XAAIssuerSettings( - jwks_uri="https://issuer.example.com/.well-known/jwks.json", - sign_algorithm="RS256", - user_info_uri="https://issuer.example.com/userinfo", - external_id_field_name="sub", # assertion claim used as the login id - jit_disabled=False, # JIT provisioning on - attribute_mapping=AttributeMapping( - email="email", - name="name", - group="groups", # assertion claim that carries the user's groups - ), - ), - }, - ), - # Config-level shared group/role mapping (shared across SAML / OIDC / SCIM / XAA for this sso_id). - role_mappings=[RoleMapping(groups=["admins"], role_name="Tenant Admin")], - default_sso_roles=["Member"], - ), - sso_id="", # optional; omit for the default SSO configuration -) - -# Load the XAA settings for a single SSO configuration. -xaa = descope_client.mgmt.sso.load_xaa_settings("my-tenant-id", "") - -# Load the XAA settings for every SSO configuration of the tenant. -all_xaa = descope_client.mgmt.sso.load_all_xaa_settings("my-tenant-id") - -# Delete the XAA settings of a single SSO configuration. -descope_client.mgmt.sso.delete_xaa_settings("my-tenant-id", "") -``` - -> Group-to-role mapping is **not** configured per issuer. `role_mappings`, `default_sso_roles`, and `fga_mappings` passed to `configure_xaa_settings` are the config-level shared mapping: the same mapping is shared across SAML / OIDC / SCIM / XAA for that `sso_id`. Each issuer only maps the assertion's groups claim (via `attribute_mapping.group`); how those group names resolve to roles is defined once, per SSO configuration. On load, this shared mapping is returned as `groupsMapping` (role references by id and name), mirroring the SAML settings load shape. - -### Manage Lists - -Manage allow/deny lists of texts, IPs, or arbitrary JSON. - -```python -# Create a list. list_type is one of "texts", "ips", or "json". -resp = descope_client.mgmt.list.create( - name="blocked-ips", - list_type="ips", - description="Blocked addresses", # Optional - data=["1.2.3.0/24"], # Optional initial data -) -list_id = resp["list"]["id"] - -# Load -descope_client.mgmt.list.load(list_id) -descope_client.mgmt.list.load_by_name("blocked-ips") -descope_client.mgmt.list.load_all() - -# Update / delete (deletion cannot be undone) -descope_client.mgmt.list.update(id=list_id, name="blocked-ips", description="updated") -descope_client.mgmt.list.delete(list_id) - -# Import multiple lists at once -descope_client.mgmt.list.import_lists([{"name": "deny", "type": "texts", "data": ["foo"]}]) - -# IP lists -descope_client.mgmt.list.add_ips(list_id, ["10.0.0.1"]) -descope_client.mgmt.list.remove_ips(list_id, ["10.0.0.1"]) -is_blocked = descope_client.mgmt.list.check_ip(list_id, "10.0.0.1") - -# Text lists -descope_client.mgmt.list.add_texts(list_id, ["spam@example.com"]) -descope_client.mgmt.list.remove_texts(list_id, ["spam@example.com"]) -has_text = descope_client.mgmt.list.check_text(list_id, "spam@example.com") -# Check up to 100 texts in one call: True if any exists, or only if all exist with match_all=True -has_any = descope_client.mgmt.list.check_texts(list_id, ["spam@example.com", "+15555555555"]) -has_all = descope_client.mgmt.list.check_texts(list_id, ["spam@example.com", "+15555555555"], match_all=True) - -# Clear all entries -descope_client.mgmt.list.clear(list_id) -``` - -### Manage Password Settings - -Get and configure the password policy for the project or a specific tenant. - -```python -# Get password settings (omit tenant_id for project-level settings) -settings = descope_client.mgmt.password.get_settings() -tenant_settings = descope_client.mgmt.password.get_settings("tenant-id") - -# Configure settings. The settings dict mirrors the fields returned by get_settings, -# e.g. {"minLength": 8, "expiration": True, "expirationWeeks": 4, "reuse": True, "reuseAmount": 6} -descope_client.mgmt.password.configure_settings( - tenant_id="tenant-id", - settings={"minLength": 10, "nonAlphanumeric": True, "number": True, "uppercase": True}, -) -``` - -### Analytics - -Search analytics records, optionally filtered and grouped. - -```python -from datetime import datetime, timedelta - -results = descope_client.mgmt.analytics.search( - from_ts=datetime.now() - timedelta(days=7), # Optional, no older than 12 months - methods=["password", "otp"], # Optional auth methods - geos=["US", "IL"], # Optional country codes - group_by_action=True, # Optional grouping flags -) -``` - -### Manage Scope Claim Mapping - -Map OAuth/OIDC scopes to JWT claims for the project. - -```python -# Get the current mappings -mappings = descope_client.mgmt.scope_claim_mapping.get() - -# Set replaces all existing mappings. Each entry: {"scope": str, "claims": dict, "description": str} -descope_client.mgmt.scope_claim_mapping.set( - mappings=[{"scope": "profile", "claims": {"name": "name"}, "description": "Profile scope"}], -) - -# Delete all mappings -descope_client.mgmt.scope_claim_mapping.delete() -``` - -### Manage Descopers - -You can create, update, delete, load or list Descopers (users who have access to the Descope console): - -```python -from descope import ( - DescoperAttributes, - DescoperCreate, - DescoperProjectRole, - DescoperRBAC, - DescoperRole, -) - -# Create a new Descoper -resp = descope_client.mgmt.descoper.create( - descopers=[ - DescoperCreate( - login_id="user@example.com", - attributes=DescoperAttributes( - display_name="John Doe", - email="user@example.com", - phone="+1234567890", - ), - send_invite=True, # Send an invitation email - rbac=DescoperRBAC( - is_company_admin=False, - projects=[ - DescoperProjectRole( - project_ids=["project-id-1"], - role=DescoperRole.ADMIN, - ) - ], - ), - ) - ] -) -descopers = resp["descopers"] -total = resp["total"] - -# Load a Descoper by ID -resp = descope_client.mgmt.descoper.load("descoper-id") -descoper = resp["descoper"] - -# Update a Descoper's attributes and/or RBAC -# Note: All fields that are set will override existing values -resp = descope_client.mgmt.descoper.update( - id="descoper-id", - attributes=DescoperAttributes( - display_name="Updated Name", - ), - rbac=DescoperRBAC( - is_company_admin=True, - ), -) -updated_descoper = resp["descoper"] - -# List all Descopers -resp = descope_client.mgmt.descoper.list() -descopers = resp["descopers"] -total = resp["total"] -for descoper in descopers: - # Do something - -# Delete a Descoper -# Descoper deletion cannot be undone. Use carefully. -descope_client.mgmt.descoper.delete("descoper-id") -``` - -### Manage Management Keys - -You can create, update, delete, load or search management keys: - -```python -from descope import ( - MgmtKeyReBac, - MgmtKeyProjectRole, - MgmtKeyTagRole, - MgmtKeyStatus, -) - -# Create a new management key with RBAC configuration -# The rebac parameter defines the key's access permissions -rebac = MgmtKeyReBac( - company_roles=["company-full-access"], # Company-level roles - project_roles=[ # Project-specific roles - MgmtKeyProjectRole( - project_ids=["project-id-1", "project-id-2"], - roles=["project-admin"] - ) - ], - tag_roles=[ # Tag-based roles - MgmtKeyTagRole( - tags=["production"], - roles=["read-only"] - ) - ], -) - -create_resp = descope_client.mgmt.management_key.create( - name="My Management Key", - rebac=rebac, - description="Optional description for the management key", - expires_in=0, # Expiration time in seconds (0 for no expiration) - permitted_ips=["10.0.0.1", "192.168.1.0/24"], # Optional IP allowlist -) -key = create_resp["key"] -cleartext = create_resp["cleartext"] # Save this securely - it will not be returned again! - -# Load a specific management key by ID -load_resp = descope_client.mgmt.management_key.load("key-id") -loaded_key = load_resp["key"] - -# Search all management keys -search_resp = descope_client.mgmt.management_key.search() -keys = search_resp["keys"] -for key in keys: - # Do something - -# Update a management key -# IMPORTANT: All parameters will override existing values, except rebac. Use carefully. -update_resp = descope_client.mgmt.management_key.update( - id="key-id", - name="Updated Key Name", - description="Updated description", - permitted_ips=["10.0.0.2"], - status=MgmtKeyStatus.ACTIVE, # Can be ACTIVE or INACTIVE -) -updated_key = update_resp["key"] - -# Update a management key's roles -# The optional rebac parameter replaces the key's current roles without rotating its secret. -# When rebac is omitted, the key keeps its current roles. -update_resp = descope_client.mgmt.management_key.update( - id="key-id", - name="Updated Key Name", - description="Updated description", - permitted_ips=["10.0.0.2"], - status=MgmtKeyStatus.ACTIVE, - rebac=MgmtKeyReBac( - project_roles=[ - MgmtKeyProjectRole( - project_ids=["project-id-1"], - roles=["read-only"] - ) - ], - ), -) -updated_key = update_resp["key"] - -# Delete management keys -# IMPORTANT: This action is irreversible. Use carefully. -delete_resp = descope_client.mgmt.management_key.delete(["key-id-1", "key-id-2"]) -total_deleted = delete_resp["total"] -``` - -### Manage Engines - -You can create, update, delete, load engines, and rotate their secrets. The engine secret -is returned only on create and rotate — store it securely, as it cannot be retrieved again. - -```python -# Create a new engine. The response includes the generated id and secret. -create_resp = descope_client.mgmt.engine.create(name="my-engine") -engine_id = create_resp["engine"]["id"] -engine_secret = create_resp["engine"]["secret"] # Save this securely! - -# Update an engine's name (the response does not include the secret). -descope_client.mgmt.engine.update(id=engine_id, name="renamed-engine") - -# Load a specific engine by id (the secret is always empty). -engine = descope_client.mgmt.engine.load(engine_id)["engine"] - -# Load all engines (secrets are always empty). -engines = descope_client.mgmt.engine.load_all()["engines"] -for engine in engines: - # Do something - pass - -# Rotate an engine's secret. The previous secret is invalidated and the new one returned. -new_secret = descope_client.mgmt.engine.rotate_secret(engine_id)["secret"] - -# Delete an engine. IMPORTANT: This action is irreversible. Use carefully. -descope_client.mgmt.engine.delete(engine_id) -``` - -### Utils for your end to end (e2e) tests and integration tests - -To ease your e2e tests, we exposed dedicated management methods, -that way, you don't need to use 3rd party messaging services in order to receive sign-in/up Email, SMS, Voice call, WhatsApp, and avoid the need of parsing the code and token from them. - -```Python -# User for test can be created, this user will be able to generate code/link without -# the need of 3rd party messaging services. -# Test user must have a loginId, other fields are optional. -# Roles should be set directly if no tenants exist, otherwise set -# on a per-tenant basis. -descope_client.mgmt.user.create_test_user( - login_id="desmond@descope.com", - email="desmond@descope.com", - display_name="Desmond Copeland", - user_tenants=[ - AssociatedTenant("my-tenant-id", ["role-name1"]), - ], -) - -# Search all test users, optionally according to tenant and/or role filter -# results can be paginated using the limit and page parameters -users_resp = descope_client.mgmt.user.search_all_test_users() -users = users_resp["users"] - for user in users: - # Do something - -# Now test user got created, and this user will be available until you delete it, -# you can use any management operation for test user CRUD. -# You can also delete all test users. -descope_client.mgmt.user.delete_all_test_users() - -# OTP code can be generated for test user, for example: -resp = descope_client.mgmt.user.generate_otp_for_test_user( - DeliveryMethod.EMAIL, "login-id" -) -code = resp["code"] -# Now you can verify the code is valid (using descope_client.*.verify for example) -# login_options can be provided to set custom claims to the generated jwt. - -# Same as OTP, magic link can be generated for test user, for example: -resp = descope_client.mgmt.user.generate_magic_link_for_test_user( - DeliveryMethod.EMAIL, "login-id", "" -) -link = resp["link"] - -# Enchanted link can be generated for test user, for example: -resp = descope_client.mgmt.user.generate_enchanted_link_for_test_user( - "login-id", "" -) -link = resp["link"] -pending_ref = resp["pendingRef"] -``` - -## API Rate Limits - -Handle API rate limits by comparing the exception to the APIRateLimitExceeded exception, which includes the RateLimitParameters map with the key "Retry-After." This key indicates how many seconds until the next valid API call can take place. - -```python -try: - descope_client.magiclink.sign_up_or_in( - method=DeliveryMethod.EMAIL, - login_id="desmond@descope.com", - uri="http://myapp.com/verify-magic-link", - ) -except RateLimitException as e: - retry_after_seconds = e.rate_limit_parameters.get(API_RATE_LIMIT_RETRY_AFTER_HEADER) - # This variable indicates how many seconds until the next valid API call can take place. -``` - -## Code Samples - -You can find various usage samples in the [samples folder](https://github.com/descope/python-sdk/blob/main/samples). - -## Run Locally - -### Prerequisites - -- Python 3.9 or higher -- [uv](https://docs.astral.sh/uv/) installed - -### Install dependencies - -```bash -uv sync --all-extras -``` - -### Run tests - -Running all tests: - -```bash -uv run pytest tests -``` - -Running all tests with coverage: - -```bash -uv run pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ --cov-report=xml:/tmp/cov.xml -``` - -### Lint and format - -```bash -uv run ruff check --fix . -uv run ruff format . -``` - -## Learn More - -To learn more please see the [Descope Documentation and API reference page](https://docs.descope.com/). - -## Contact Us - -If you need help you can email [Descope Support](mailto:support@descope.com) - -## License - -The Descope SDK for Python is licensed for use under the terms and conditions of the [MIT license Agreement](https://github.com/descope/python-sdk/blob/main/LICENSE). +# Repository Coverage + +[Full report](https://htmlpreview.github.io/?https://github.com/descope/python-sdk/blob/python-coverage-comment-action-data/htmlcov/index.html) + +| Name | Stmts | Miss | Cover | Missing | +|-------------------------------------------------------- | -------: | -------: | ------: | --------: | +| descope/\_auth\_base.py | 185 | 1 | 99% | 98 | +| descope/\_authmethod\_base.py | 13 | 1 | 92% | 15 | +| descope/\_client\_base.py | 89 | 3 | 97% |49, 140, 190 | +| descope/\_http\_base.py | 11 | 1 | 91% | 8 | +| descope/auth.py | 90 | 5 | 94% |96-98, 102, 141, 174 | +| descope/auth\_async.py | 127 | 1 | 99% | 169 | +| descope/authmethod/\_enchantedlink\_base.py | 64 | 7 | 89% |74, 103, 105, 107, 127, 129, 131 | +| descope/authmethod/\_magiclink\_base.py | 61 | 7 | 89% |74, 103, 105, 107, 127, 129, 131 | +| descope/authmethod/\_otp\_base.py | 64 | 7 | 89% |60, 97, 99, 101, 124, 126, 128 | +| descope/authmethod/enchantedlink.py | 79 | 2 | 97% | 58, 80 | +| descope/authmethod/enchantedlink\_async.py | 78 | 2 | 97% | 63, 86 | +| descope/authmethod/magiclink.py | 53 | 1 | 98% | 46 | +| descope/authmethod/magiclink\_async.py | 51 | 1 | 98% | 50 | +| descope/authmethod/password.py | 49 | 1 | 98% | 119 | +| descope/authmethod/password\_async.py | 43 | 1 | 98% | 62 | +| descope/descope\_client.py | 126 | 3 | 98% | 127-129 | +| descope/descope\_client\_async.py | 154 | 1 | 99% | 207 | +| descope/jwt\_common.py | 51 | 4 | 92% | 27-29, 96 | +| descope/management/\_lists\_base.py | 43 | 1 | 98% | 20 | +| descope/management/\_outbound\_application\_base.py | 58 | 4 | 93% |92, 96, 98, 100 | +| descope/management/\_sso\_settings\_base.py | 98 | 1 | 99% | 7 | +| descope/management/\_third\_party\_application\_base.py | 26 | 4 | 85% |33, 35, 37, 39 | +| descope/management/\_user\_base.py | 162 | 11 | 93% |71-76, 142, 202, 252, 254, 256 | +| descope/management/audit.py | 49 | 11 | 78% |75, 77, 79, 81, 83, 85, 87, 89, 91, 93, 95 | +| descope/management/audit\_async.py | 49 | 11 | 78% |77, 79, 81, 83, 85, 87, 89, 91, 93, 95, 97 | +| descope/management/common.py | 545 | 1 | 99% | 86 | +| descope/management/group.py | 24 | 2 | 92% | 104, 154 | +| descope/management/group\_async.py | 25 | 2 | 92% | 108, 158 | +| descope/management/outbound\_application.py | 117 | 1 | 99% | 613 | +| descope/management/outbound\_application\_async.py | 119 | 1 | 99% | 613 | +| descope/management/role.py | 47 | 1 | 98% | 321 | +| descope/management/role\_async.py | 47 | 1 | 98% | 323 | +| descope/management/user.py | 318 | 4 | 99% |757, 1131, 1133, 1135 | +| descope/management/user\_async.py | 319 | 4 | 99% |762, 1136, 1138, 1140 | +| **TOTAL** | **6804** | **109** | **98%** | | + +78 files skipped due to complete coverage. + + +## Setup coverage badge + +Below are examples of the badges you can use in your main branch `README` file. + +### Direct image + +[![Coverage badge](https://raw.githubusercontent.com/descope/python-sdk/python-coverage-comment-action-data/badge.svg)](https://htmlpreview.github.io/?https://github.com/descope/python-sdk/blob/python-coverage-comment-action-data/htmlcov/index.html) + +This is the one to use if your repository is private or if you don't want to customize anything. + +### [Shields.io](https://shields.io) Json Endpoint + +[![Coverage badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/descope/python-sdk/python-coverage-comment-action-data/endpoint.json)](https://htmlpreview.github.io/?https://github.com/descope/python-sdk/blob/python-coverage-comment-action-data/htmlcov/index.html) + +Using this one will allow you to [customize](https://shields.io/endpoint) the look of your badge. +It won't work with private repositories. It won't be refreshed more than once per five minutes. + +### [Shields.io](https://shields.io) Dynamic Badge + +[![Coverage badge](https://img.shields.io/badge/dynamic/json?color=brightgreen&label=coverage&query=%24.message&url=https%3A%2F%2Fraw.githubusercontent.com%2Fdescope%2Fpython-sdk%2Fpython-coverage-comment-action-data%2Fendpoint.json)](https://htmlpreview.github.io/?https://github.com/descope/python-sdk/blob/python-coverage-comment-action-data/htmlcov/index.html) + +This one will always be the same color. It won't work for private repos. I'm not even sure why we included it. + +## What is that? + +This branch is part of the +[python-coverage-comment-action](https://github.com/marketplace/actions/python-coverage-comment) +GitHub Action. All the files in this branch are automatically generated and may be +overwritten at any moment. \ No newline at end of file diff --git a/badge.svg b/badge.svg new file mode 100644 index 000000000..a3f05b016 --- /dev/null +++ b/badge.svg @@ -0,0 +1 @@ +Coverage: 98%Coverage98% \ No newline at end of file diff --git a/data.json b/data.json new file mode 100644 index 000000000..9159427af --- /dev/null +++ b/data.json @@ -0,0 +1 @@ +{"coverage": 98.39800117577896} \ No newline at end of file diff --git a/descope/__init__.py b/descope/__init__.py deleted file mode 100644 index 73a553e4e..000000000 --- a/descope/__init__.py +++ /dev/null @@ -1,117 +0,0 @@ -from descope.common import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_COOKIE_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - AccessKeyLoginOptions, - DeliveryMethod, - LoginOptions, - SignUpOptions, -) -from descope.descope_client import DescopeClient -from descope.descope_client_async import DescopeClientAsync -from descope.exceptions import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - ERROR_TYPE_SERVER_ERROR, - AuthException, - RateLimitException, -) -from descope.http_client import DescopeResponse -from descope.management.common import ( - AssociatedFamily, - AssociatedTenant, - CustomAttribute, - CustomAttributeOption, - DescoperAttributes, - DescoperCreate, - DescoperProjectRole, - DescoperRBAC, - DescoperRole, - DescoperTagRole, - FlowRunOptions, - MgmtKeyProjectRole, - MgmtKeyReBac, - MgmtKeyStatus, - MgmtKeyTagRole, - SAMLIDPAttributeMappingInfo, - SAMLIDPGroupsMappingInfo, - SAMLIDPRoleGroupMappingInfo, -) -from descope.management.sso_settings import ( - AttributeMapping, - FGAGroupMapping, - FGAGroupMappingRelation, - OIDCAttributeMapping, - RoleMapping, - SSOOIDCSettings, - SSOSAMLSettings, - SSOSAMLSettingsByMetadata, - XAAIssuerSettings, - XAAJWTBearerSettings, - XAASettings, -) -from descope.management.user import UserObj -from descope.management.user_pwd import ( - UserPassword, - UserPasswordBcrypt, - UserPasswordDjango, - UserPasswordFirebase, - UserPasswordPbkdf2, -) - -__all__ = [ - "COOKIE_DATA_NAME", - "REFRESH_SESSION_COOKIE_NAME", - "REFRESH_SESSION_TOKEN_NAME", - "SESSION_COOKIE_NAME", - "SESSION_TOKEN_NAME", - "AccessKeyLoginOptions", - "DeliveryMethod", - "LoginOptions", - "SignUpOptions", - "DescopeClientAsync", - "DescopeClient", - "API_RATE_LIMIT_RETRY_AFTER_HEADER", - "ERROR_TYPE_API_RATE_LIMIT", - "ERROR_TYPE_SERVER_ERROR", - "AuthException", - "RateLimitException", - "DescopeResponse", - "AssociatedFamily", - "AssociatedTenant", - "CustomAttribute", - "CustomAttributeOption", - "DescoperAttributes", - "DescoperCreate", - "DescoperProjectRole", - "DescoperRBAC", - "DescoperRole", - "DescoperTagRole", - "FlowRunOptions", - "MgmtKeyProjectRole", - "MgmtKeyReBac", - "MgmtKeyStatus", - "MgmtKeyTagRole", - "SAMLIDPAttributeMappingInfo", - "SAMLIDPGroupsMappingInfo", - "SAMLIDPRoleGroupMappingInfo", - "AttributeMapping", - "FGAGroupMapping", - "FGAGroupMappingRelation", - "OIDCAttributeMapping", - "RoleMapping", - "SSOOIDCSettings", - "SSOSAMLSettings", - "SSOSAMLSettingsByMetadata", - "XAAIssuerSettings", - "XAAJWTBearerSettings", - "XAASettings", - "UserObj", - "UserPassword", - "UserPasswordBcrypt", - "UserPasswordDjango", - "UserPasswordFirebase", - "UserPasswordPbkdf2", -] diff --git a/descope/_auth_base.py b/descope/_auth_base.py deleted file mode 100644 index 4d6a61dc6..000000000 --- a/descope/_auth_base.py +++ /dev/null @@ -1,379 +0,0 @@ -from __future__ import annotations - -import json -import os -import re -from http import HTTPStatus -from typing import Iterable, Optional - -import jwt -from email_validator import EmailNotValidError, validate_email -from jwt import ExpiredSignatureError, ImmatureSignatureError - -from descope.common import ( - DEFAULT_BASE_URL, - DEFAULT_DOMAIN, - DEFAULT_URL_PREFIX, - PHONE_REGEX, - DeliveryMethod, -) -from descope.exceptions import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - ERROR_TYPE_INVALID_ARGUMENT, - ERROR_TYPE_INVALID_PUBLIC_KEY, - ERROR_TYPE_INVALID_TOKEN, - ERROR_TYPE_SERVER_ERROR, - AuthException, - RateLimitException, -) -from descope.jwt_common import adjust_properties as jwt_adjust_properties -from descope.jwt_common import generate_auth_info as jwt_generate_auth_info -from descope.jwt_common import generate_jwt_response as jwt_generate_jwt_response - - -class AuthBase: - """Pure-CPU base shared by ``Auth`` and ``AuthAsync``. - - Holds project identity, the JWKS cache, and JWT helpers. Subclasses add - the HTTP client, lock primitive, and I/O methods. - """ - - ALGORITHM_KEY = "alg" - - def __init__( - self, - project_id: Optional[str] = None, - public_key: Optional[dict | str] = None, - jwt_validation_leeway: int = 5, - ): - if not project_id: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - ( - "Unable to init Auth object because project_id cannot be empty. " - "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." - ), - ) - self.project_id = project_id - self.jwt_validation_leeway = jwt_validation_leeway - - public_key = public_key or os.getenv("DESCOPE_PUBLIC_KEY") - if not public_key: - self.public_keys: dict = {} - else: - kid, pub_key, alg = self._validate_and_load_public_key(public_key) - self.public_keys = {kid: (pub_key, alg)} - - def _raise_rate_limit_exception(self, response): - try: - resp = response.json() - raise RateLimitException( - resp.get("errorCode", HTTPStatus.TOO_MANY_REQUESTS), - ERROR_TYPE_API_RATE_LIMIT, - resp.get("errorDescription", ""), - resp.get("errorMessage", ""), - rate_limit_parameters={API_RATE_LIMIT_RETRY_AFTER_HEADER: self._parse_retry_after(response.headers)}, - ) - except RateLimitException: - raise - except Exception: - raise RateLimitException( - status_code=HTTPStatus.TOO_MANY_REQUESTS, - error_type=ERROR_TYPE_API_RATE_LIMIT, - error_message=ERROR_TYPE_API_RATE_LIMIT, - error_description=ERROR_TYPE_API_RATE_LIMIT, - ) - - def _parse_retry_after(self, headers): - try: - return int(headers.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, 0)) - except (ValueError, TypeError): - return 0 - - def _raise_from_response(self, response): - """Raise appropriate exception from response, does nothing if response.ok is True.""" - if response.ok: - return - - if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: - self._raise_rate_limit_exception(response) # Raise RateLimitException - - raise AuthException( - response.status_code, - ERROR_TYPE_SERVER_ERROR, - response.text, - ) - - @staticmethod - def base_url_for_project_id(project_id): - if len(project_id) >= 32: - region = project_id[1:5] - return ".".join([DEFAULT_URL_PREFIX, region, DEFAULT_DOMAIN]) - return DEFAULT_BASE_URL - - @staticmethod - def adjust_and_verify_delivery_method(method: DeliveryMethod, login_id: str, user: dict) -> bool: - if not login_id: - return False - - if not isinstance(user, dict): - return False - - if method == DeliveryMethod.EMAIL: - if not user.get("email", None): - user["email"] = login_id - try: - validate_email(user["email"], check_deliverability=False) - return True - except EmailNotValidError: - return False - elif method == DeliveryMethod.SMS: - if not user.get("phone", None): - user["phone"] = login_id - if not re.match(PHONE_REGEX, user["phone"]): - return False - elif method == DeliveryMethod.VOICE: - if not user.get("phone", None): - user["phone"] = login_id - if not re.match(PHONE_REGEX, user["phone"]): - return False - elif method == DeliveryMethod.WHATSAPP: - if not user.get("phone", None): - user["phone"] = login_id - if not re.match(PHONE_REGEX, user["phone"]): - return False - else: - return False - - return True - - @staticmethod - def compose_url(base: str, method: DeliveryMethod) -> str: - suffix = { - DeliveryMethod.EMAIL: "email", - DeliveryMethod.SMS: "sms", - DeliveryMethod.VOICE: "voice", - DeliveryMethod.WHATSAPP: "whatsapp", - }.get(method) - - if not suffix: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}") - - return f"{base}/{suffix}" - - @staticmethod - def get_login_id_by_method(method: DeliveryMethod, user: dict) -> tuple[str, str]: - login_id = { - DeliveryMethod.EMAIL: ("email", user.get("email", "")), - DeliveryMethod.SMS: ("phone", user.get("phone", "")), - DeliveryMethod.VOICE: ("voice", user.get("phone", "")), - DeliveryMethod.WHATSAPP: ("whatsapp", user.get("phone", "")), - }.get(method) - - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}") - - return login_id - - @staticmethod - def validate_email(email: str): - if email == "": - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "email address argument cannot be empty", - ) - - try: - validate_email(email, check_deliverability=False) - except EmailNotValidError as ex: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, f"Invalid email address: {ex}") - - @staticmethod - def validate_phone(method: DeliveryMethod, phone: str): - if phone == "": - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "Phone number argument cannot be empty", - ) - - if not re.match(PHONE_REGEX, phone): - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Invalid phone number") - - if method != DeliveryMethod.SMS and method != DeliveryMethod.VOICE and method != DeliveryMethod.WHATSAPP: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Invalid delivery method") - - @staticmethod - def _compose_exchange_body(code: str) -> dict: - return {"code": code} - - @staticmethod - def _validate_and_load_public_key(public_key) -> tuple[str, jwt.PyJWK, str]: - if not isinstance(public_key, (str, dict)): - raise AuthException( - 500, - ERROR_TYPE_INVALID_PUBLIC_KEY, - "Unable to load public key. Invalid public key error: (unknown type)", - ) - - if isinstance(public_key, str): - try: - public_key = json.loads(public_key) - except (ValueError, TypeError) as e: - raise AuthException( - 500, - ERROR_TYPE_INVALID_PUBLIC_KEY, - f"Unable to load public key. error: {e}", - ) - - alg = public_key.get(AuthBase.ALGORITHM_KEY, None) - if alg is None: - raise AuthException( - 500, - ERROR_TYPE_INVALID_PUBLIC_KEY, - "Unable to load public key. Missing property: alg", - ) - - kid = public_key.get("kid", None) - if kid is None: - raise AuthException( - 500, - ERROR_TYPE_INVALID_PUBLIC_KEY, - "Unable to load public key. Missing property: kid", - ) - try: - # Load and validate public key - return (kid, jwt.PyJWK(public_key), alg) - except (jwt.PyJWKError, jwt.InvalidKeyError) as e: - raise AuthException( - 500, - ERROR_TYPE_INVALID_PUBLIC_KEY, - f"Unable to load public key {e}", - ) - - def adjust_properties(self, jwt_response: dict, user_jwt: bool): - # Delegate to shared JWT utilities for normalization - return jwt_adjust_properties(jwt_response, user_jwt) - - def _generate_auth_info( - self, - response_body: dict, - refresh_token: str | None, - user_jwt: bool, - audience: str | None | Iterable[str] = None, - ) -> dict: - return jwt_generate_auth_info( - response_body, - refresh_token, - user_jwt, - audience, - token_validator=self._validate_token, # type: ignore[attr-defined] - ) - - def generate_jwt_response( - self, - response_body: dict, - refresh_cookie: str | None, - audience: str | None | Iterable[str] = None, - ) -> dict: - return jwt_generate_jwt_response( - response_body, - refresh_cookie, - audience, - token_validator=self._validate_token, # type: ignore[attr-defined] - ) - - @staticmethod - def extract_masked_address(response: dict, method: DeliveryMethod) -> str: - if method == DeliveryMethod.SMS or method == DeliveryMethod.VOICE or method == DeliveryMethod.WHATSAPP: - return response["maskedPhone"] - elif method == DeliveryMethod.EMAIL: - return response["maskedEmail"] - return "" - - @staticmethod - def _kid_alg_from_token(token: str) -> tuple[str, str]: - """Return ``(kid, alg)`` from the unverified JWT header; raises on missing/invalid fields.""" - if not token: - raise AuthException(500, ERROR_TYPE_INVALID_TOKEN, "Token validation received empty token") - - try: - unverified_header = jwt.get_unverified_header(token) - except jwt.exceptions.PyJWTError as e: - raise AuthException(500, ERROR_TYPE_INVALID_TOKEN, f"Unable to parse token header. Error: {e}") from e - - alg_header = unverified_header.get(AuthBase.ALGORITHM_KEY, None) - if alg_header is None or alg_header == "none": - raise AuthException(500, ERROR_TYPE_INVALID_TOKEN, "Token header is missing property: alg") - - kid = unverified_header.get("kid", None) - if kid is None: - raise AuthException(500, ERROR_TYPE_INVALID_TOKEN, "Token header is missing property: kid") - - return kid, alg_header - - def _decode_and_verify_token( - self, - token: str, - audience: str | None | Iterable[str], - copy_key, - alg_header: str, - ) -> dict: - """Pure-CPU JWT verification. Assumes ``copy_key`` was already retrieved from the JWKS cache.""" - alg_from_key = copy_key[1] - if alg_header != alg_from_key: - raise AuthException( - 500, - ERROR_TYPE_INVALID_PUBLIC_KEY, - "Algorithm signature in JWT header does not match the algorithm signature in the public key", - ) - - validation_audience = audience - if audience is None: - try: - unverified_claims = jwt.decode( - jwt=token, - key=copy_key[0].key, - algorithms=[alg_header], - options={"verify_aud": False}, - leeway=self.jwt_validation_leeway, - ) - token_audience = unverified_claims.get("aud") - if token_audience and self.project_id: - if isinstance(token_audience, list): - if self.project_id in token_audience: - validation_audience = self.project_id - else: - if token_audience == self.project_id: - validation_audience = self.project_id - except Exception: - # Best-effort audience sniff. Any failure falls through to the - # verified decode below, which raises the proper error message. - pass - - try: - claims = jwt.decode( - jwt=token, - key=copy_key[0].key, - algorithms=[alg_header], - audience=validation_audience, - leeway=self.jwt_validation_leeway, - ) - except ImmatureSignatureError: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Received Invalid token (nbf in future) during jwt validation. Error can be due to time glitch (between machines), try to set the jwt_validation_leeway parameter (in DescopeClient) to higher value than 5sec which is the default", - ) - except ExpiredSignatureError: - raise AuthException( - 401, - ERROR_TYPE_INVALID_TOKEN, - "Received expired token (exp in past) during jwt validation. (sometimes can be due to time glitch (between machines), try to set the jwt_validation_leeway parameter (in DescopeClient) to higher value than 5sec which is the default)", - ) - - claims["jwt"] = token - return claims diff --git a/descope/_authmethod_base.py b/descope/_authmethod_base.py deleted file mode 100644 index 69d8878ff..000000000 --- a/descope/_authmethod_base.py +++ /dev/null @@ -1,27 +0,0 @@ -# This is not part of the public API but a code helper. -# -# The sync / async classes below are structurally identical — same body, -# same derived ``_http``. They stay split so type checkers can ensure a -# sync auth-method class is never handed an ``AuthAsync`` (or vice versa); -# collapsing into one ``Generic[T]`` would work but loses concrete-type -# tooltips at the call sites. -from __future__ import annotations - -from typing import TYPE_CHECKING - -from descope.auth import Auth - -if TYPE_CHECKING: - from descope.auth_async import AuthAsync - - -class AuthMethodBase: - def __init__(self, auth: Auth): - self._auth = auth - self._http = auth.http_client - - -class AsyncAuthMethodBase: - def __init__(self, auth: AuthAsync): - self._auth = auth - self._http = auth.http_client diff --git a/descope/_client_base.py b/descope/_client_base.py deleted file mode 100644 index f358b54a0..000000000 --- a/descope/_client_base.py +++ /dev/null @@ -1,200 +0,0 @@ -from __future__ import annotations - -import logging -import os -import warnings - -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - -logger = logging.getLogger(__name__) - - -class DescopeClientBase: - """Shared base for ``DescopeClient`` and ``DescopeClientAsync``. - - Holds: - - project_id resolution + validation, ``skip_verify`` warning - - The pure-computation permission / role helpers that work on a - decoded JWT dict and need no I/O. - - ``Auth`` / ``AuthAsync`` construction and any operation that touches the - network (``validate_session``, ``refresh_session``, ``logout``, etc.) - lives on each concrete subclass. - """ - - def __init__( - self, - project_id: str, - public_key: dict | None, - skip_verify: bool, - timeout_seconds: float, - jwt_validation_leeway: int, - auth_management_key: str | None, - *, - base_url: str | None, - verbose: bool, - ): - project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "") - if not project_id: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - ( - "Unable to init client because project_id cannot be empty. " - "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." - ), - ) - - if skip_verify: - warnings.warn( - "⚠️ SECURITY WARNING: TLS certificate verification is DISABLED (skip_verify=True). " - "This makes your application vulnerable to man-in-the-middle attacks. " - "ONLY use this for local development with self-signed certificates. " - "NEVER use skip_verify=True in production environments.", - category=UserWarning, - # stacklevel 3: warn → base.__init__ → subclass.__init__ → user code - stacklevel=3, - ) - - self._project_id = project_id - self._public_key = public_key - self._skip_verify = skip_verify - self._timeout_seconds = timeout_seconds - self._jwt_validation_leeway = jwt_validation_leeway - self._auth_management_key_arg = auth_management_key - self._base_url_arg = base_url - self._verbose = verbose - - @staticmethod - def _ensure_present(value, message: str, error_type: str = ERROR_TYPE_INVALID_ARGUMENT) -> None: - """Raise AuthException(400, error_type, message) if *value* is falsy.""" - if not value: - raise AuthException(400, error_type, message) - - @staticmethod - def _require_refresh_token(refresh_token) -> None: - """Guard for ops that act on a refresh token (logout, me, history, my_tenants). - - Uses an ``is None`` check (not falsy) to preserve the historical error - message that echoes the token value. - """ - if refresh_token is None: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"signed refresh token {refresh_token} is empty", - ) - - @staticmethod - def _require_access_key(access_key) -> None: - """Guard for exchange_access_key.""" - if not access_key: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Access key cannot be empty") - - @staticmethod - def _validate_tenant_selector(dct: bool, ids) -> None: - """Guard for my_tenants: exactly one of *dct* or *ids* must be supplied.""" - if dct is True and ids is not None and len(ids) > 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Only one of 'dct' or 'ids' should be supplied") - if dct is False and (ids is None or len(ids) == 0): - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Only one of 'dct' or 'ids' should be supplied") - - def validate_permissions(self, jwt_response: dict, permissions: list[str]) -> bool: - """ - Validate that jwt_response has been granted the specified permissions. - For multi-tenant use validate_tenant_permissions. - """ - return self.validate_tenant_permissions(jwt_response, "", permissions) - - def get_matched_permissions(self, jwt_response: dict, permissions: list[str]) -> list[str]: - """Return the subset of permissions that jwt_response has been granted.""" - return self.get_matched_tenant_permissions(jwt_response, "", permissions) - - def validate_tenant_permissions(self, jwt_response: dict, tenant: str, permissions: list[str]) -> bool: - """ - Validate that jwt_response has been granted the specified permissions on tenant. - Returns True only if all permissions are granted. - """ - if not jwt_response: - return False - - if isinstance(permissions, str): - permissions = [permissions] - - granted = [] - if tenant == "": - granted = jwt_response.get("permissions", []) - else: - if tenant not in jwt_response.get("tenants", {}): - return False - granted = jwt_response.get("tenants", {}).get(tenant, {}).get("permissions", []) - - return all(p in granted for p in permissions) - - def get_matched_tenant_permissions(self, jwt_response: dict, tenant: str, permissions: list[str]) -> list[str]: - """Return the subset of permissions that jwt_response has been granted on tenant.""" - if not jwt_response: - return [] - - if isinstance(permissions, str): - permissions = [permissions] - - if tenant != "" and tenant not in jwt_response.get("tenants", {}): - return [] - - granted = ( - jwt_response.get("permissions", []) - if tenant == "" - else jwt_response.get("tenants", {}).get(tenant, {}).get("permissions", []) - ) - return [p for p in permissions if p in granted] - - def validate_roles(self, jwt_response: dict, roles: list[str]) -> bool: - """ - Validate that jwt_response has been granted the specified roles. - For multi-tenant use validate_tenant_roles. - """ - return self.validate_tenant_roles(jwt_response, "", roles) - - def get_matched_roles(self, jwt_response: dict, roles: list[str]) -> list[str]: - """Return the subset of roles that jwt_response has been granted.""" - return self.get_matched_tenant_roles(jwt_response, "", roles) - - def validate_tenant_roles(self, jwt_response: dict, tenant: str, roles: list[str]) -> bool: - """ - Validate that jwt_response has been granted the specified roles on tenant. - Returns True only if all roles are granted. - """ - if not jwt_response: - return False - - if isinstance(roles, str): - roles = [roles] - - granted = [] - if tenant == "": - granted = jwt_response.get("roles", []) - else: - if tenant not in jwt_response.get("tenants", {}): - return False - granted = jwt_response.get("tenants", {}).get(tenant, {}).get("roles", []) - - return all(r in granted for r in roles) - - def get_matched_tenant_roles(self, jwt_response: dict, tenant: str, roles: list[str]) -> list[str]: - """Return the subset of roles that jwt_response has been granted on tenant.""" - if not jwt_response: - return [] - - if isinstance(roles, str): - roles = [roles] - - if tenant != "" and tenant not in jwt_response.get("tenants", {}): - return [] - - granted = ( - jwt_response.get("roles", []) - if tenant == "" - else jwt_response.get("tenants", {}).get(tenant, {}).get("roles", []) - ) - return [r for r in roles if r in granted] diff --git a/descope/_http_base.py b/descope/_http_base.py deleted file mode 100644 index 0324f96b7..000000000 --- a/descope/_http_base.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from descope.http_client import HTTPClient - -if TYPE_CHECKING: - from descope.http_client_async import HTTPClientAsync - - -class HTTPBase: - """Base class for classes that only need HTTP access.""" - - def __init__(self, http_client: HTTPClient): - self._http = http_client - - -class AsyncHTTPBase: - """Base for async management classes.""" - - def __init__(self, http_client: HTTPClientAsync): - self._http = http_client diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py deleted file mode 100644 index 90a9f2b6e..000000000 --- a/descope/_http_client_base.py +++ /dev/null @@ -1,332 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -import contextvars -import os -import platform -import ssl -import threading -from functools import cached_property -from http import HTTPStatus -from importlib.metadata import version - -import certifi -import httpx - -from descope.common import ( - DEFAULT_BASE_URL, - DEFAULT_DOMAIN, - DEFAULT_TIMEOUT_SECONDS, - DEFAULT_URL_PREFIX, -) -from descope.exceptions import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - ERROR_TYPE_INVALID_ARGUMENT, - ERROR_TYPE_SERVER_ERROR, - AuthException, - RateLimitException, -) - - -def sdk_version(): - return version("descope") - - -# Longest non-JSON body echoed into str()/repr() of a response -_MAX_TEXT_PREVIEW = 200 - -# HTTP status codes that should trigger automatic retries -_RETRY_STATUS_CODES = {503, 520, 521, 522, 524, 530} -# Delays in seconds between retries: first retry after 100ms, subsequent retries after 5s -_RETRY_DELAYS_SECONDS = [0.1, 5.0, 5.0] - -_default_headers = { - "Content-Type": "application/json", - "x-descope-sdk-name": "python", - "x-descope-sdk-python-version": platform.python_version(), - "x-descope-sdk-version": sdk_version(), -} - - -class DescopeResponse: - """ - Wrapper around httpx.Response that provides dict-like access to JSON data - while preserving access to HTTP metadata (headers, status_code, etc.). - - This allows backward compatibility (acting like a dict) while exposing - HTTP metadata like cf-ray headers for debugging. - - Members that need the parsed body (``json()``, ``__getitem__``, ``get``, - ``keys``, ``values``, ``items``, ``__len__``, ``__iter__``, ``__contains__``) - raise on a non-JSON body. Inspecting the response itself never does: - ``bool()`` is always True, and ``str()``/``repr()`` fall back to the raw - text, so a response is always loggable. Use ``is_json`` to check first. - """ - - def __init__(self, response: httpx.Response): - self.raw = response - - @cached_property - def _json_data(self): - return self.raw.json() - - def json(self): - """Get the parsed JSON response, cached after first access.""" - return self._json_data - - @cached_property - def is_json(self) -> bool: - """True if the response body can be parsed as JSON.""" - try: - self.json() - except ValueError: - return False - return True - - # Dict-like interface for backward compatibility - def __getitem__(self, key): - return self.json()[key] - - def __contains__(self, key): - return key in self.json() - - def keys(self): - return self.json().keys() - - def values(self): - return self.json().values() - - def items(self): - return self.json().items() - - def get(self, key, default=None): - return self.json().get(key, default) - - def _text_preview(self): - """Bounded view of a non-JSON body: its size is upstream-controlled.""" - text = self.raw.text - if len(text) <= _MAX_TEXT_PREVIEW: - return text - return f"{text[:_MAX_TEXT_PREVIEW]}... ({len(text)} chars, use .text for the full body)" - - # Inspection dunders never parse-fail: a non-JSON body (an nginx 502 HTML - # page, for example) must still be loggable and truthy as a response object. - def __str__(self): - try: - return str(self.json()) - except ValueError: - return self._text_preview() - - def __repr__(self): - try: - return f"DescopeResponse({repr(self.json())})" - except ValueError: - return f"DescopeResponse(status_code={self.raw.status_code}, text={self._text_preview()!r})" - - def __bool__(self): - # A response object is always truthy: truthiness answers "did I get a - # response", not "is the body non-empty". Must stay explicit — without - # it Python falls back to __len__, which parses the body. - return True - - def __len__(self): - return len(self.json()) - - def __eq__(self, other): - try: - if isinstance(other, DescopeResponse): - return self.json() == other.json() - return self.json() == other - except ValueError: - return self is other - - def __ne__(self, other): - return not self.__eq__(other) - - def __iter__(self): - return iter(self.json()) - - # HTTP metadata properties - @property - def headers(self): - """Access response headers (e.g., response.headers.get('cf-ray')).""" - return self.raw.headers - - @property - def status_code(self): - """HTTP status code.""" - return self.raw.status_code - - @property - def cookies(self): - """Response cookies.""" - return self.raw.cookies - - @property - def text(self): - """Raw response text.""" - return self.raw.text - - @property - def content(self): - """Raw response content (bytes).""" - return self.raw.content - - @property - def url(self): - """Request URL.""" - return self.raw.url - - @property - def ok(self): - """True if status code indicates success (2xx).""" - return self.raw.is_success - - -class ThreadLocalLastResponseStore: - """One last-response slot, isolated per thread.""" - - def __init__(self) -> None: - self._local = threading.local() - - def set(self, response: DescopeResponse) -> None: - self._local.last_response = response - - def get(self) -> DescopeResponse | None: - return getattr(self._local, "last_response", None) - - -class ContextVarLastResponseStore: - """One last-response slot, isolated per async task. - - ContextVar rather than threading.local: every asyncio task runs on the same - event-loop thread, so a thread-local slot would be a single slot shared by - all concurrent tasks. - """ - - def __init__(self) -> None: - self._var: contextvars.ContextVar[DescopeResponse | None] = contextvars.ContextVar( - "descope_async_last_response", default=None - ) - - def set(self, response: DescopeResponse) -> None: - self._var.set(response) - - def get(self) -> DescopeResponse | None: - return self._var.get() - - -class HTTPClientBase: - """Shared, I/O-free base for HTTP client classes. - - Holds only validation guards, SSL setup, header composition, and response - parsing — no network I/O, no ``__init__`` transport. The two concrete - subclasses add the network layer: - - - ``HTTPClient(HTTPClientBase)`` — sync, uses httpx module-level functions - - ``HTTPClientAsync(HTTPClientBase)`` — async, uses ``httpx.AsyncClient`` - """ - - def __init__( - self, - project_id: str, - base_url: str | None = None, - *, - timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - secure: bool = True, - management_key: str | None = None, - verbose: bool = False, - ) -> None: - if not project_id: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - ( - "Project ID is required to initialize HTTP client" - "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." - ), - ) - - env_base = os.getenv("DESCOPE_BASE_URI") - self.base_url = base_url or env_base or self.base_url_for_project_id(project_id) - - self.project_id = project_id - self.timeout_seconds = timeout_seconds - self.secure = secure - self.management_key = management_key - self.verbose = verbose - # Populated by the license handshake when a management key is configured. - # Sent in the x-descope-license header so Cloudflare can apply the right - # rate limit bucket per customer tier. - self.rate_limit_tier: str | None = None - - self.client_verify: bool | ssl.SSLContext = False - if secure: - ssl_ctx = ssl.create_default_context( - cafile=os.environ.get("SSL_CERT_FILE", certifi.where()), - capath=os.environ.get("SSL_CERT_DIR"), - ) - if os.environ.get("REQUESTS_CA_BUNDLE"): - ssl_ctx.load_verify_locations(cafile=os.environ.get("REQUESTS_CA_BUNDLE")) - self.client_verify = ssl_ctx - - @staticmethod - def base_url_for_project_id(project_id: str) -> str: - if len(project_id) >= 32: - region = project_id[1:5] - return ".".join([DEFAULT_URL_PREFIX, region, DEFAULT_DOMAIN]) - return DEFAULT_BASE_URL - - def get_default_headers(self, pswd: str | None = None) -> dict: - return self._get_default_headers(pswd) - - def _parse_retry_after(self, headers) -> int: - try: - return int(headers.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, 0)) - except (ValueError, TypeError): - return 0 - - def _raise_rate_limit_exception(self, response): - try: - resp = response.json() - raise RateLimitException( - resp.get("errorCode", HTTPStatus.TOO_MANY_REQUESTS), - ERROR_TYPE_API_RATE_LIMIT, - resp.get("errorDescription", ""), - resp.get("errorMessage", ""), - rate_limit_parameters={API_RATE_LIMIT_RETRY_AFTER_HEADER: self._parse_retry_after(response.headers)}, - ) - except RateLimitException: - raise - except Exception: - raise RateLimitException( - status_code=HTTPStatus.TOO_MANY_REQUESTS, - error_type=ERROR_TYPE_API_RATE_LIMIT, - error_message=ERROR_TYPE_API_RATE_LIMIT, - error_description=ERROR_TYPE_API_RATE_LIMIT, - ) - - def _raise_from_response(self, response): - if response.is_success: - return - if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: - self._raise_rate_limit_exception(response) - raise AuthException( - response.status_code, - ERROR_TYPE_SERVER_ERROR, - response.text, - ) - - def _get_default_headers(self, pswd: str | None = None): - headers = _default_headers.copy() - headers["x-descope-project-id"] = self.project_id - bearer = self.project_id - if pswd: - bearer = f"{self.project_id}:{pswd}" - if self.management_key: - bearer = f"{bearer}:{self.management_key}" - headers["Authorization"] = f"Bearer {bearer}" - if self.rate_limit_tier: - headers["x-descope-license"] = self.rate_limit_tier - return headers diff --git a/descope/auth.py b/descope/auth.py deleted file mode 100644 index d4db9ce7d..000000000 --- a/descope/auth.py +++ /dev/null @@ -1,201 +0,0 @@ -from __future__ import annotations - -import copy -import json -from threading import Lock -from typing import Iterable, Optional - -from descope._auth_base import AuthBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - AccessKeyLoginOptions, - EndpointsV1, - EndpointsV2, -) -from descope.exceptions import ( - ERROR_TYPE_INVALID_ARGUMENT, - ERROR_TYPE_INVALID_PUBLIC_KEY, - ERROR_TYPE_INVALID_TOKEN, - AuthException, -) -from descope.http_client import HTTPClient - - -class Auth(AuthBase): - def __init__( - self, - project_id: Optional[str] = None, - public_key: Optional[dict | str] = None, - jwt_validation_leeway: int = 5, - *, - http_client: HTTPClient, - ): - self.lock_public_keys = Lock() - super().__init__(project_id, public_key, jwt_validation_leeway) - self._http = http_client - - @property - def http_client(self) -> HTTPClient: - return self._http - - def exchange_token(self, uri, code: str, audience: Optional[Iterable[str] | str] = None) -> dict: - if not code: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "exchange code is empty", - ) - - body = Auth._compose_exchange_body(code) - response = self._http.post(uri, body=body) - resp = response.json() - jwt_response = self.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME), audience) - return jwt_response - - def exchange_access_key( - self, - access_key: str, - audience: str | Iterable[str] | None = None, - login_options: AccessKeyLoginOptions | None = None, - ) -> dict: - uri = EndpointsV1.exchange_auth_access_key_path - body = { - "loginOptions": ( - {k: v for k, v in login_options.__dict__.items() if v is not None} if login_options else {} - ), - } - server_response = self._http.post(uri, body=body, pswd=access_key) - json_body = server_response.json() - return self._generate_auth_info( - response_body=json_body, - refresh_token=None, - user_jwt=False, - audience=audience, - ) - - def _fetch_public_keys(self) -> None: - # This function called under mutex protection so no need to acquire it once again - response = self._http.get( - f"{EndpointsV2.public_key_path}/{self.project_id}", - ) - - jwks_data = response.text - try: - jwkeys_wrapper = json.loads(jwks_data) - jwkeys = jwkeys_wrapper["keys"] - except (ValueError, TypeError, KeyError) as e: - raise AuthException(500, ERROR_TYPE_INVALID_PUBLIC_KEY, f"Unable to load jwks. Error: {e}") - - # Load all public keys for this project - self.public_keys = {} - for key in jwkeys: - try: - loaded_kid, pub_key, alg = Auth._validate_and_load_public_key(key) - self.public_keys[loaded_kid] = (pub_key, alg) - except AuthException: - # just continue to the next key - pass - - # public method to validate a token from the management class - def validate_token(self, token: str, audience: str | None | Iterable[str] = None) -> dict: - return self._validate_token(token, audience) - - # Validate a token and load the public key if needed - def _validate_token(self, token: str, audience: str | None | Iterable[str] = None) -> dict: - kid, alg_header = self._kid_alg_from_token(token) - - with self.lock_public_keys: - if self.public_keys == {} or self.public_keys.get(kid, None) is None: - self._fetch_public_keys() - - found_key = self.public_keys.get(kid, None) - if found_key is None: - raise AuthException( - 500, - ERROR_TYPE_INVALID_PUBLIC_KEY, - "Unable to validate public key. Public key not found.", - ) - # save reference to the founded key - # (as another thread can change the self.public_keys dict) - copy_key = found_key - - return self._decode_and_verify_token(token, audience, copy_key, alg_header) - - def validate_session(self, session_token: str, audience: str | None | Iterable[str] = None) -> dict: - if not session_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Session token is required for validation", - ) - - res = self._validate_token(session_token, audience) - res[SESSION_TOKEN_NAME] = copy.deepcopy( - res - ) # Duplicate for saving backward compatibility but keep the same structure as the refresh operation response - return self.adjust_properties(res, True) - - def refresh_session(self, refresh_token: str, audience: str | None | Iterable[str] = None) -> dict: - if not refresh_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Refresh token is required to refresh a session", - ) - - self._validate_token(refresh_token, audience) - - uri = EndpointsV1.refresh_token_path - response = self._http.post(uri, body={}, pswd=refresh_token) - - resp = response.json() - refresh_token = response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) or refresh_token - return self.generate_jwt_response(resp, refresh_token, audience) - - def validate_and_refresh_session( - self, - session_token: str, - refresh_token: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - if not session_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Session token is missing", - ) - - try: - return self.validate_session(session_token, audience) - except AuthException: - # Session is invalid - try to refresh it - if not refresh_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Refresh token is missing", - ) - return self.refresh_session(refresh_token, audience) - - def select_tenant( - self, - tenant_id: str, - refresh_token: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - if not refresh_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Refresh token is required to refresh a session", - ) - - uri = EndpointsV1.select_tenant_path - response = self._http.post(uri, body={"tenant": tenant_id}, pswd=refresh_token) - - resp = response.json() - jwt_response = self.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - return jwt_response diff --git a/descope/auth_async.py b/descope/auth_async.py deleted file mode 100644 index 2ced6dd13..000000000 --- a/descope/auth_async.py +++ /dev/null @@ -1,270 +0,0 @@ -from __future__ import annotations - -import asyncio -import copy -import json -from typing import Iterable, Optional - -from descope._auth_base import AuthBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - AccessKeyLoginOptions, - EndpointsV1, - EndpointsV2, -) -from descope.exceptions import ( - ERROR_TYPE_INVALID_ARGUMENT, - ERROR_TYPE_INVALID_PUBLIC_KEY, - ERROR_TYPE_INVALID_TOKEN, - AuthException, -) -from descope.http_client_async import HTTPClientAsync - - -class AuthAsync(AuthBase): - """Async counterpart of ``Auth``. - - All network I/O is awaitable; JWT validation stays sync and runs after - JWKS is pre-warmed by ``ensure_keys``. - """ - - def __init__( - self, - project_id: Optional[str] = None, - public_key: Optional[dict | str] = None, - jwt_validation_leeway: int = 5, - *, - http_client: HTTPClientAsync, - ): - super().__init__(project_id, public_key, jwt_validation_leeway) - self._http = http_client - # Lazy-init: constructed on first ``ensure_keys`` to avoid - # "no running event loop" if the client is built before the loop starts. - self._lock_public_keys: asyncio.Lock | None = None - - @property - def http_client(self) -> HTTPClientAsync: - return self._http - - @property - def lock_public_keys(self) -> asyncio.Lock: - if self._lock_public_keys is None: - self._lock_public_keys = asyncio.Lock() - return self._lock_public_keys - - async def _fetch_public_keys(self) -> None: - """Fetch JWKS via async HTTP. Caller must hold ``lock_public_keys``.""" - response = await self._http.get(f"{EndpointsV2.public_key_path}/{self.project_id}") - jwks_data = response.text - try: - jwkeys_wrapper = json.loads(jwks_data) - jwkeys = jwkeys_wrapper["keys"] - except (ValueError, TypeError, KeyError) as e: - raise AuthException(500, ERROR_TYPE_INVALID_PUBLIC_KEY, f"Unable to load jwks. Error: {e}") - - new_keys: dict = {} - for key in jwkeys: - try: - loaded_kid, pub_key, alg = AuthAsync._validate_and_load_public_key(key) - new_keys[loaded_kid] = (pub_key, alg) - except AuthException: - # Skip invalid JWKS entries and continue loading the rest of the keys. - continue - self.public_keys = new_keys - - async def ensure_keys(self, kid: str | None = None) -> None: - """Pre-warm the JWKS cache. - - Idempotent: returns immediately when the cache already contains the - requested kid (or any key, when kid is None). Otherwise serializes - concurrent callers behind ``lock_public_keys`` and fetches once. - """ - if self._cache_has_kid(kid): - return - async with self.lock_public_keys: - if self._cache_has_kid(kid): - return - await self._fetch_public_keys() - - def _cache_has_kid(self, kid: str | None) -> bool: - if not self.public_keys: - return False - if kid is None: - return True - return kid in self.public_keys - - def _validate_token(self, token: str, audience: str | None | Iterable[str] = None) -> dict: - """Sync JWT validation. Caller must have awaited ``ensure_keys(kid)`` first. - - Stays sync because ``AuthBase`` passes ``self._validate_token`` as the - ``token_validator`` callback into ``jwt_common.generate_jwt_response`` / - ``generate_auth_info`` (see ``_auth_base.py``), which invoke it - synchronously. The async ``prepare_jwt_response`` / ``prepare_auth_info`` - wrappers warm JWKS *before* that sync flow runs. - """ - kid, alg_header = self._kid_alg_from_token(token) - found_key = self.public_keys.get(kid) - if found_key is None: - raise AuthException( - 500, - ERROR_TYPE_INVALID_PUBLIC_KEY, - "Unable to validate public key. Public key not found.", - ) - return self._decode_and_verify_token(token, audience, found_key, alg_header) - - async def _validate_token_async(self, token: str, audience: str | None | Iterable[str] = None) -> dict: - """Async direct-validation entrypoint: warms JWKS then runs sync validation.""" - kid, _ = self._kid_alg_from_token(token) - await self.ensure_keys(kid) - return self._validate_token(token, audience) - - async def prepare_jwt_response( - self, - response_body: dict, - refresh_cookie: str | None, - audience: str | None | Iterable[str] = None, - ) -> dict: - """Pre-warm JWKS then build the JWT response.""" - await self._ensure_keys_for_jwt_response(response_body, refresh_cookie) - return self.generate_jwt_response(response_body, refresh_cookie, audience) - - async def prepare_auth_info( - self, - response_body: dict, - refresh_token: str | None, - user_jwt: bool, - audience: str | None | Iterable[str] = None, - ) -> dict: - """Pre-warm JWKS then build the auth info dict.""" - await self._ensure_keys_for_jwt_response(response_body, refresh_token) - return self._generate_auth_info(response_body, refresh_token, user_jwt, audience) - - async def _ensure_keys_for_jwt_response(self, response_body: dict, refresh_token: str | None = None) -> None: - """Pre-warm JWKS for tokens that ``generate_jwt_response`` will validate. - - Mirrors the token-extraction logic in ``jwt_common.generate_auth_info``: - validates ``sessionJwt`` and ``refreshJwt`` from the body, or the - provided ``refresh_token`` when ``refreshJwt`` is absent. - """ - kids_seen: set[str] = set() - for body_key in ("sessionJwt", "refreshJwt"): - tok = response_body.get(body_key, "") - if not tok: - continue - try: - kid, _ = self._kid_alg_from_token(tok) - except AuthException: - continue - if kid in kids_seen: - continue - kids_seen.add(kid) - await self.ensure_keys(kid) - if not response_body.get("refreshJwt") and refresh_token: - try: - kid, _ = self._kid_alg_from_token(refresh_token) - except AuthException: - return - if kid not in kids_seen: - await self.ensure_keys(kid) - - async def exchange_token(self, uri: str, code: str, audience: Optional[Iterable[str] | str] = None) -> dict: - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") - - body = AuthAsync._compose_exchange_body(code) - response = await self._http.post(uri, body=body) - return await self.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME), audience - ) - - async def exchange_access_key( - self, - access_key: str, - audience: str | Iterable[str] | None = None, - login_options: AccessKeyLoginOptions | None = None, - ) -> dict: - body = { - "loginOptions": ( - {k: v for k, v in login_options.__dict__.items() if v is not None} if login_options else {} - ), - } - server_response = await self._http.post(EndpointsV1.exchange_auth_access_key_path, body=body, pswd=access_key) - return await self.prepare_auth_info( - response_body=server_response.json(), - refresh_token=None, - user_jwt=False, - audience=audience, - ) - - async def validate_token(self, token: str, audience: str | None | Iterable[str] = None) -> dict: - """Public token-validation entrypoint (parity with ``Auth.validate_token``).""" - return await self._validate_token_async(token, audience) - - async def validate_session(self, session_token: str, audience: str | None | Iterable[str] = None) -> dict: - if not session_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Session token is required for validation", - ) - - res = await self._validate_token_async(session_token, audience) - res[SESSION_TOKEN_NAME] = copy.deepcopy(res) - return self.adjust_properties(res, True) - - async def refresh_session(self, refresh_token: str, audience: str | None | Iterable[str] = None) -> dict: - if not refresh_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Refresh token is required to refresh a session", - ) - - await self._validate_token_async(refresh_token, audience) - - response = await self._http.post(EndpointsV1.refresh_token_path, body={}, pswd=refresh_token) - effective_refresh = response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) or refresh_token - return await self.prepare_jwt_response(response.json(), effective_refresh, audience) - - async def validate_and_refresh_session( - self, - session_token: str, - refresh_token: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - if not session_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Session token is missing", - ) - - try: - return await self.validate_session(session_token, audience) - except AuthException: - if not refresh_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Refresh token is missing", - ) - return await self.refresh_session(refresh_token, audience) - - async def select_tenant( - self, - tenant_id: str, - refresh_token: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - if not refresh_token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_TOKEN, - "Refresh token is required to refresh a session", - ) - - response = await self._http.post(EndpointsV1.select_tenant_path, body={"tenant": tenant_id}, pswd=refresh_token) - return await self.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) diff --git a/descope/authmethod/__init__.py b/descope/authmethod/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/descope/authmethod/_enchantedlink_base.py b/descope/authmethod/_enchantedlink_base.py deleted file mode 100644 index 82be388bb..000000000 --- a/descope/authmethod/_enchantedlink_base.py +++ /dev/null @@ -1,136 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from descope.auth import Auth -from descope.common import ( - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - signup_options_to_dict, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class EnchantedLinkBase: - """Shared, I/O-free base for EnchantedLink auth-method classes. - - Holds only static validation guards, URL composers and body builders — no - network I/O, no ``__init__``. The two concrete subclasses add the network layer: - - - ``EnchantedLink(EnchantedLinkBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``EnchantedLinkAsync(EnchantedLinkBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` - """ - - @staticmethod - def _validate_sign_in_login_id(login_id: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id is empty") - - @staticmethod - def _validate_login_id(login_id: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - @staticmethod - def _compose_signin_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_in_auth_enchantedlink_path, method) - - @staticmethod - def _compose_signup_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_auth_enchantedlink_path, method) - - @staticmethod - def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_enchantedlink_path, method) - - @staticmethod - def _compose_update_phone_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.update_user_phone_enchantedlink_path, method) - - @staticmethod - def _compose_signin_body( - login_id: str, - uri: str, - login_options: LoginOptions | None = None, - ) -> dict: - return { - "loginId": login_id, - "URI": uri, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_signup_body( - method: DeliveryMethod, - login_id: str, - uri: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = {"loginId": login_id, "URI": uri} - - if signup_options is not None: - body["loginOptions"] = signup_options_to_dict(signup_options) - - if user is not None: - body["user"] = user - method_str, val = Auth.get_login_id_by_method(method, user) - body[method_str] = val - return body - - @staticmethod - def _compose_verify_body(token: str) -> dict: - return {"token": token} - - @staticmethod - def _compose_update_user_email_body( - login_id: str, - email: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "email": email, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - return body - - @staticmethod - def _compose_update_user_phone_body( - login_id: str, - phone: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "phone": phone, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - return body - - @staticmethod - def _compose_get_session_body(pending_ref: str) -> dict: - return {"pendingRef": pending_ref} diff --git a/descope/authmethod/_magiclink_base.py b/descope/authmethod/_magiclink_base.py deleted file mode 100644 index 608356722..000000000 --- a/descope/authmethod/_magiclink_base.py +++ /dev/null @@ -1,132 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from descope.auth import Auth -from descope.common import ( - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - signup_options_to_dict, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class MagicLinkBase: - """Shared, I/O-free base for MagicLink auth-method classes. - - Holds only static validation guards, URL composers and body builders — no - network I/O, no ``__init__``. The two concrete subclasses add the network layer: - - - ``MagicLink(MagicLinkBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``MagicLinkAsync(MagicLinkBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) - """ - - @staticmethod - def _validate_sign_in_login_id(login_id: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier is empty") - - @staticmethod - def _validate_login_id(login_id: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - @staticmethod - def _compose_signin_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_in_auth_magiclink_path, method) - - @staticmethod - def _compose_signup_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_auth_magiclink_path, method) - - @staticmethod - def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_magiclink_path, method) - - @staticmethod - def _compose_update_phone_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.update_user_phone_magiclink_path, method) - - @staticmethod - def _compose_signin_body( - login_id: str, - uri: str, - login_options: LoginOptions | None = None, - ) -> dict: - return { - "loginId": login_id, - "URI": uri, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_signup_body( - method: DeliveryMethod, - login_id: str, - uri: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = {"loginId": login_id, "URI": uri} - - if signup_options is not None: - body["loginOptions"] = signup_options_to_dict(signup_options) - - if user is not None: - body["user"] = user - method_str, val = Auth.get_login_id_by_method(method, user) - body[method_str] = val - return body - - @staticmethod - def _compose_verify_body(token: str) -> dict: - return {"token": token} - - @staticmethod - def _compose_update_user_email_body( - login_id: str, - email: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "email": email, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - return body - - @staticmethod - def _compose_update_user_phone_body( - login_id: str, - phone: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "phone": phone, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - return body diff --git a/descope/authmethod/_oauth_base.py b/descope/authmethod/_oauth_base.py deleted file mode 100644 index 4dcd0a78a..000000000 --- a/descope/authmethod/_oauth_base.py +++ /dev/null @@ -1,26 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - - -class OAuthBase: - """Shared, I/O-free base for OAuth auth-method classes. - - Holds only static validation guards and body/params composers — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: - - - ``OAuth(OAuthBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``OAuthAsync(OAuthBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) - """ - - @staticmethod - def _verify_provider(oauth_provider: str) -> bool: - if oauth_provider == "" or oauth_provider is None: - return False - return True - - @staticmethod - def _compose_start_params(provider: str, return_url: str = "") -> dict: - res: dict = {"provider": provider} - if return_url: - res["redirectURL"] = return_url - return res diff --git a/descope/authmethod/_otp_base.py b/descope/authmethod/_otp_base.py deleted file mode 100644 index f40be6a89..000000000 --- a/descope/authmethod/_otp_base.py +++ /dev/null @@ -1,131 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from descope.auth import Auth -from descope.common import ( - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - signup_options_to_dict, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class OTPBase: - """Shared, I/O-free base for OTP auth-method classes. - - Holds only static validation guards, URL composers and body builders — no - network I/O, no ``__init__``. The two concrete subclasses add the network layer: - - - ``OTP(OTPBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``OTPAsync(OTPBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) - """ - - @staticmethod - def _validate_login_id(login_id: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - @staticmethod - def _compose_signup_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_auth_otp_path, method) - - @staticmethod - def _compose_signin_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_in_auth_otp_path, method) - - @staticmethod - def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_otp_path, method) - - @staticmethod - def _compose_verify_code_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.verify_code_auth_path, method) - - @staticmethod - def _compose_update_phone_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.update_user_phone_otp_path, method) - - @staticmethod - def _compose_signup_body( - method: DeliveryMethod, - login_id: str, - user: dict, - signup_options: SignUpOptions | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = {"loginId": login_id} - - if signup_options is not None: - body["loginOptions"] = signup_options_to_dict(signup_options) - - if user is not None: - body["user"] = user - method_str, val = Auth.get_login_id_by_method(method, user) - body[method_str] = val - return body - - @staticmethod - def _compose_signin_body(login_id: str, login_options: LoginOptions | None = None) -> dict: - return { - "loginId": login_id, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_verify_code_body(login_id: str, code: str) -> dict: - return {"loginId": login_id, "code": code} - - @staticmethod - def _compose_update_user_email_body( - login_id: str, - email: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - mfa: bool = False, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "email": email, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - if mfa: - body["mfa"] = mfa - return body - - @staticmethod - def _compose_update_user_phone_body( - login_id: str, - phone: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - mfa: bool = False, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "phone": phone, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - if mfa: - body["mfa"] = mfa - return body diff --git a/descope/authmethod/_password_base.py b/descope/authmethod/_password_base.py deleted file mode 100644 index d616d1346..000000000 --- a/descope/authmethod/_password_base.py +++ /dev/null @@ -1,52 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class PasswordBase: - """Shared, I/O-free base for Password auth-method classes. - - Holds only static validation guards and body composers — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: - - - ``Password(PasswordBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``PasswordAsync(PasswordBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) - """ - - @staticmethod - def _validate_login_id(login_id: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - @staticmethod - def _validate_password(password: str) -> None: - if not password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "password cannot be empty") - - @staticmethod - def _validate_sign_in_password(password: str) -> None: - if not password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Password cannot be empty") - - @staticmethod - def _validate_new_password(new_password: str) -> None: - if not new_password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty") - - @staticmethod - def _validate_old_password(old_password: str) -> None: - if not old_password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "old_password cannot be empty") - - @staticmethod - def _validate_refresh_token(refresh_token: str) -> None: - if not refresh_token: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") - - @staticmethod - def _compose_signup_body(login_id: str, password: str, user: dict | None) -> dict: - body: dict[str, str | bool | dict] = {"loginId": login_id, "password": password} - if user is not None: - body["user"] = user - return body diff --git a/descope/authmethod/_saml_base.py b/descope/authmethod/_saml_base.py deleted file mode 100644 index 590e2540e..000000000 --- a/descope/authmethod/_saml_base.py +++ /dev/null @@ -1,34 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from typing import Optional - -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class SAMLBase: - """Shared, I/O-free base for SAML auth-method classes (deprecated — use SSO). - - Holds only static validation guards and body/params composers — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: - - - ``SAML(SAMLBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``SAMLAsync(SAMLBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) - """ - - @staticmethod - def _validate_tenant(tenant: str) -> None: - if not tenant: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Tenant cannot be empty") - - @staticmethod - def _validate_return_url(return_url: Optional[str]) -> None: - if not return_url: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Return url cannot be empty") - - @staticmethod - def _compose_start_params(tenant: str, return_url: Optional[str]) -> dict: - res: dict = {"tenant": tenant} - if return_url is not None and return_url != "": - res["redirectURL"] = return_url - return res diff --git a/descope/authmethod/_sso_base.py b/descope/authmethod/_sso_base.py deleted file mode 100644 index 09bb6db62..000000000 --- a/descope/authmethod/_sso_base.py +++ /dev/null @@ -1,44 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from typing import Any, Dict, Optional - -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class SSOBase: - """Shared, I/O-free base for SSO auth-method classes. - - Holds only static validation guards and body/params composers — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: - - - ``SSO(SSOBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``SSOAsync(SSOBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) - """ - - @staticmethod - def _validate_tenant(tenant: str) -> None: - if not tenant: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Tenant cannot be empty") - - @staticmethod - def _compose_start_params( - tenant: str, - return_url: str, - prompt: str, - sso_id: str, - login_hint: str, - force_authn: Optional[bool], - ) -> dict: - res: Dict[str, Any] = {"tenant": tenant} - if return_url is not None and return_url != "": - res["redirectURL"] = return_url - if prompt is not None and prompt != "": - res["prompt"] = prompt - if sso_id is not None and sso_id != "": - res["ssoId"] = sso_id - if login_hint is not None and login_hint != "": - res["loginHint"] = login_hint - if force_authn is not None: - res["forceAuthn"] = force_authn - return res diff --git a/descope/authmethod/_totp_base.py b/descope/authmethod/_totp_base.py deleted file mode 100644 index c47e05001..000000000 --- a/descope/authmethod/_totp_base.py +++ /dev/null @@ -1,52 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from typing import Optional - -from descope.common import LoginOptions -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class TOTPBase: - """Shared, I/O-free base for TOTP auth-method classes. - - Holds only static validation guards and body composers — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: - - - ``TOTP(TOTPBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``AsyncTOTP(TOTPBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``AsyncHTTPClient``) - """ - - @staticmethod - def _validate_login_id(login_id: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - @staticmethod - def _validate_code(code: str) -> None: - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Code cannot be empty") - - @staticmethod - def _validate_refresh_token(refresh_token: str) -> None: - if not refresh_token: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") - - @staticmethod - def _compose_signup_body(login_id: str, user: Optional[dict]) -> dict: - body: dict[str, str | dict] = {"loginId": login_id} - if user is not None: - body["user"] = user - return body - - @staticmethod - def _compose_signin_body(login_id: str, code: str, login_options: Optional[LoginOptions] = None) -> dict: - return { - "loginId": login_id, - "code": code, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_update_user_body(login_id: str) -> dict: - return {"loginId": login_id} diff --git a/descope/authmethod/_webauthn_base.py b/descope/authmethod/_webauthn_base.py deleted file mode 100644 index fec8f120d..000000000 --- a/descope/authmethod/_webauthn_base.py +++ /dev/null @@ -1,77 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from typing import Optional - -from descope.common import LoginOptions -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class WebAuthnBase: - """Shared, I/O-free base for WebAuthn auth-method classes. - - Holds only static validation guards and body composers — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: - - - ``WebAuthn(WebAuthnBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``WebAuthnAsync(WebAuthnBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) - """ - - @staticmethod - def _validate_login_id(login_id: Optional[str]) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - @staticmethod - def _validate_origin(origin: Optional[str]) -> None: - if not origin: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") - - @staticmethod - def _validate_transaction_id(transaction_id: str) -> None: - if not transaction_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") - - @staticmethod - def _validate_response(response) -> None: - if not response: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") - - @staticmethod - def _validate_refresh_token(refresh_token: str) -> None: - if not refresh_token: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") - - @staticmethod - def _compose_sign_up_start_body(login_id: Optional[str], user: dict, origin: Optional[str]) -> dict: - user.update({"loginId": login_id}) - return {"user": user, "origin": origin} - - @staticmethod - def _compose_sign_in_start_body(login_id: str, origin: str, login_options: Optional[LoginOptions] = None) -> dict: - return { - "loginId": login_id, - "origin": origin, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_sign_up_or_in_start_body(login_id: str, origin: str) -> dict: - return {"loginId": login_id, "origin": origin} - - @staticmethod - def _compose_sign_up_in_finish_body(transaction_id: str, response) -> dict: - return {"transactionId": transaction_id, "response": response} - - @staticmethod - def _compose_update_start_body(login_id: str, origin: str, mfa: bool = False) -> dict: - body: dict = {"loginId": login_id} - if origin: - body["origin"] = origin - if mfa: - body["mfa"] = mfa - return body - - @staticmethod - def _compose_update_finish_body(transaction_id: str, response: str) -> dict: - return {"transactionId": transaction_id, "response": response} diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py deleted file mode 100644 index 3b1e30f11..000000000 --- a/descope/authmethod/enchantedlink.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -from descope._authmethod_base import AuthMethodBase -from descope.auth import Auth -from descope.authmethod._enchantedlink_base import EnchantedLinkBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - validate_refresh_token_provided, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class EnchantedLink(EnchantedLinkBase, AuthMethodBase): - def sign_in( - self, - login_id: str, - uri: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> dict: - self._validate_sign_in_login_id(login_id) - - validate_refresh_token_provided(login_options, refresh_token) - - body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_signin_url(DeliveryMethod.EMAIL) - response = self._http.post(url, body=body, pswd=refresh_token) - return response.json() - - def sign_in_with_phone( - self, - phone: str, - uri: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> dict: - self._validate_sign_in_login_id(phone) - - validate_refresh_token_provided(login_options, refresh_token) - - body = self._compose_signin_body(phone, uri, login_options) - url = self._compose_signin_url(DeliveryMethod.SMS) - response = self._http.post(url, body=body, pswd=refresh_token) - return response.json() - - def sign_up( - self, - login_id: str, - uri: str, - user: dict | None, - signup_options: SignUpOptions | None = None, - ) -> dict: - if not user: - user = {} - - if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, login_id, user): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Login ID {login_id} is not valid for email", - ) - - body = self._compose_signup_body(DeliveryMethod.EMAIL, login_id, uri, user, signup_options) - url = self._compose_signup_url(DeliveryMethod.EMAIL) - response = self._http.post(url, body=body) - return response.json() - - def sign_up_with_phone( - self, - phone: str, - uri: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> dict: - if not user: - user = {} - - if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, phone, user): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Login ID {phone} is not valid for phone", - ) - - body = self._compose_signup_body(DeliveryMethod.SMS, phone, uri, user, signup_options) - url = self._compose_signup_url(DeliveryMethod.SMS) - response = self._http.post(url, body=body) - return response.json() - - def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: - login_options: LoginOptions | None = None - if signup_options is not None: - login_options = LoginOptions( - revoke_other_sessions=signup_options.revokeOtherSessions, - custom_claims=signup_options.customClaims, - template_options=signup_options.templateOptions, - template_id=signup_options.templateId, - ) - - body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) - response = self._http.post(url, body=body) - return response.json() - - def sign_up_or_in_with_phone(self, phone: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: - login_options: LoginOptions | None = None - if signup_options is not None: - login_options = LoginOptions( - revoke_other_sessions=signup_options.revokeOtherSessions, - custom_claims=signup_options.customClaims, - template_options=signup_options.templateOptions, - template_id=signup_options.templateId, - ) - - body = self._compose_signin_body(phone, uri, login_options) - url = self._compose_sign_up_or_in_url(DeliveryMethod.SMS) - response = self._http.post(url, body=body) - return response.json() - - def get_session(self, pending_ref: str) -> dict: - uri = EndpointsV1.get_session_enchantedlink_auth_path - body = self._compose_get_session_body(pending_ref) - response = self._http.post(uri, body=body) - resp = response.json() - return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None) - - def verify(self, token: str) -> None: - uri = EndpointsV1.verify_enchantedlink_auth_path - body = self._compose_verify_body(token) - self._http.post(uri, body=body) - - def update_user_email( - self, - login_id: str, - email: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - self._validate_login_id(login_id) - - Auth.validate_email(email) - - body = self._compose_update_user_email_body( - login_id, - email, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - ) - uri = EndpointsV1.update_user_email_enchantedlink_path - response = self._http.post(uri, body=body, pswd=refresh_token) - return response.json() - - def update_user_phone( - self, - login_id: str, - phone: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - self._validate_login_id(login_id) - - Auth.validate_phone(DeliveryMethod.SMS, phone) - - body = self._compose_update_user_phone_body( - login_id, - phone, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - ) - url = self._compose_update_phone_url(DeliveryMethod.SMS) - response = self._http.post(url, body=body, pswd=refresh_token) - return response.json() diff --git a/descope/authmethod/enchantedlink_async.py b/descope/authmethod/enchantedlink_async.py deleted file mode 100644 index 26311fab4..000000000 --- a/descope/authmethod/enchantedlink_async.py +++ /dev/null @@ -1,203 +0,0 @@ -from __future__ import annotations - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.auth import Auth -from descope.authmethod._enchantedlink_base import EnchantedLinkBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - validate_refresh_token_provided, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class EnchantedLinkAsync(EnchantedLinkBase, AsyncAuthMethodBase): - """Async EnchantedLink auth-method. All network calls are coroutines; validation is sync (no I/O).""" - - async def sign_in( - self, - login_id: str, - uri: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> dict: - """Send an enchanted-link email for sign-in; returns the pending-ref and link-id.""" - self._validate_sign_in_login_id(login_id) - - validate_refresh_token_provided(login_options, refresh_token) - - body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_signin_url(DeliveryMethod.EMAIL) - response = await self._http.post(url, body=body, pswd=refresh_token) - return response.json() - - async def sign_in_with_phone( - self, - phone: str, - uri: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> dict: - """Send an enchanted-link SMS for sign-in; returns the pending-ref and link-id.""" - self._validate_sign_in_login_id(phone) - - validate_refresh_token_provided(login_options, refresh_token) - - body = self._compose_signin_body(phone, uri, login_options) - url = self._compose_signin_url(DeliveryMethod.SMS) - response = await self._http.post(url, body=body, pswd=refresh_token) - return response.json() - - async def sign_up( - self, - login_id: str, - uri: str, - user: dict | None, - signup_options: SignUpOptions | None = None, - ) -> dict: - """Send an enchanted-link email for sign-up; returns the pending-ref and link-id.""" - if not user: - user = {} - - if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, login_id, user): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Login ID {login_id} is not valid for email", - ) - - body = self._compose_signup_body(DeliveryMethod.EMAIL, login_id, uri, user, signup_options) - url = self._compose_signup_url(DeliveryMethod.EMAIL) - response = await self._http.post(url, body=body) - return response.json() - - async def sign_up_with_phone( - self, - phone: str, - uri: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> dict: - """Send an enchanted-link SMS for sign-up; returns the pending-ref and link-id.""" - if not user: - user = {} - - if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, phone, user): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Login ID {phone} is not valid for phone", - ) - - body = self._compose_signup_body(DeliveryMethod.SMS, phone, uri, user, signup_options) - url = self._compose_signup_url(DeliveryMethod.SMS) - response = await self._http.post(url, body=body) - return response.json() - - async def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: - """Send an enchanted-link for sign-up or sign-in depending on whether the user exists.""" - login_options: LoginOptions | None = None - if signup_options is not None: - login_options = LoginOptions( - revoke_other_sessions=signup_options.revokeOtherSessions, - custom_claims=signup_options.customClaims, - template_options=signup_options.templateOptions, - template_id=signup_options.templateId, - ) - - body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) - response = await self._http.post(url, body=body) - return response.json() - - async def sign_up_or_in_with_phone(self, phone: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: - """Send an enchanted-link SMS for sign-up or sign-in depending on whether the user exists.""" - login_options: LoginOptions | None = None - if signup_options is not None: - login_options = LoginOptions( - revoke_other_sessions=signup_options.revokeOtherSessions, - custom_claims=signup_options.customClaims, - template_options=signup_options.templateOptions, - template_id=signup_options.templateId, - ) - - body = self._compose_signin_body(phone, uri, login_options) - url = self._compose_sign_up_or_in_url(DeliveryMethod.SMS) - response = await self._http.post(url, body=body) - return response.json() - - async def get_session(self, pending_ref: str) -> dict: - """Poll for session JWTs once the user has clicked the enchanted link.""" - uri = EndpointsV1.get_session_enchantedlink_auth_path - body = self._compose_get_session_body(pending_ref) - response = await self._http.post(uri, body=body) - return await self._auth.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None - ) - - async def verify(self, token: str) -> None: - """Mark the enchanted-link token as clicked (called by the link-handler endpoint).""" - uri = EndpointsV1.verify_enchantedlink_auth_path - body = self._compose_verify_body(token) - await self._http.post(uri, body=body) - - async def update_user_email( - self, - login_id: str, - email: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - """Send an enchanted-link to a new email address to verify the update.""" - self._validate_login_id(login_id) - - Auth.validate_email(email) - - body = self._compose_update_user_email_body( - login_id, - email, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - ) - uri = EndpointsV1.update_user_email_enchantedlink_path - response = await self._http.post(uri, body=body, pswd=refresh_token) - return response.json() - - async def update_user_phone( - self, - login_id: str, - phone: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - """Send an enchanted-link SMS to a new phone number to verify the update.""" - self._validate_login_id(login_id) - - Auth.validate_phone(DeliveryMethod.SMS, phone) - - body = self._compose_update_user_phone_body( - login_id, - phone, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - ) - url = self._compose_update_phone_url(DeliveryMethod.SMS) - response = await self._http.post(url, body=body, pswd=refresh_token) - return response.json() diff --git a/descope/authmethod/magiclink.py b/descope/authmethod/magiclink.py deleted file mode 100644 index eb3707b23..000000000 --- a/descope/authmethod/magiclink.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -from descope._authmethod_base import AuthMethodBase -from descope.auth import Auth -from descope.authmethod._magiclink_base import MagicLinkBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - validate_refresh_token_provided, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class MagicLink(MagicLinkBase, AuthMethodBase): - def sign_in( - self, - method: DeliveryMethod, - login_id: str, - uri: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> str: - self._validate_sign_in_login_id(login_id) - - validate_refresh_token_provided(login_options, refresh_token) - - body = MagicLink._compose_signin_body(login_id, uri, login_options) - url = MagicLink._compose_signin_url(method) - response = self._http.post(url, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), method) - - def sign_up( - self, - method: DeliveryMethod, - login_id: str, - uri: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> str: - if not user: - user = {} - - if not self._auth.adjust_and_verify_delivery_method(method, login_id, user): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Login ID {login_id} is not valid by delivery method {method}", - ) - - body = MagicLink._compose_signup_body(method, login_id, uri, user, signup_options) - url = MagicLink._compose_signup_url(method) - response = self._http.post(url, body=body) - return Auth.extract_masked_address(response.json(), method) - - def sign_up_or_in( - self, - method: DeliveryMethod, - login_id: str, - uri: str, - signup_options: SignUpOptions | None = None, - ) -> str: - login_options: LoginOptions | None = None - if signup_options is not None: - login_options = LoginOptions( - revoke_other_sessions=signup_options.revokeOtherSessions, - custom_claims=signup_options.customClaims, - template_options=signup_options.templateOptions, - template_id=signup_options.templateId, - ) - body = MagicLink._compose_signin_body( - login_id, - uri, - login_options, - ) - url = MagicLink._compose_sign_up_or_in_url(method) - response = self._http.post(url, body=body) - return Auth.extract_masked_address(response.json(), method) - - def verify(self, token: str, audience: str | None | Iterable[str] = None) -> dict: - url = EndpointsV1.verify_magiclink_auth_path - body = MagicLink._compose_verify_body(token) - response = self._http.post(url, body=body) - resp = response.json() - jwt_response = self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - return jwt_response - - def update_user_email( - self, - login_id: str, - email: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> str: - self._validate_login_id(login_id) - - Auth.validate_email(email) - - body = MagicLink._compose_update_user_email_body( - login_id, - email, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - ) - url = EndpointsV1.update_user_email_magiclink_path - response = self._http.post(url, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) - - def update_user_phone( - self, - method: DeliveryMethod, - login_id: str, - phone: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> str: - self._validate_login_id(login_id) - - Auth.validate_phone(method, phone) - - body = MagicLink._compose_update_user_phone_body( - login_id, - phone, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - ) - url = EndpointsV1.update_user_phone_magiclink_path - response = self._http.post(url, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), DeliveryMethod.SMS) diff --git a/descope/authmethod/magiclink_async.py b/descope/authmethod/magiclink_async.py deleted file mode 100644 index f345d344d..000000000 --- a/descope/authmethod/magiclink_async.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.auth import Auth -from descope.authmethod._magiclink_base import MagicLinkBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - validate_refresh_token_provided, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class MagicLinkAsync(MagicLinkBase, AsyncAuthMethodBase): - """Async MagicLink auth-method. All network calls are coroutines; validation is sync (no I/O).""" - - async def sign_in( - self, - method: DeliveryMethod, - login_id: str, - uri: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> str: - """Send a magic link for sign-in; returns the masked delivery address.""" - self._validate_sign_in_login_id(login_id) - - validate_refresh_token_provided(login_options, refresh_token) - - body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_signin_url(method) - response = await self._http.post(url, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), method) - - async def sign_up( - self, - method: DeliveryMethod, - login_id: str, - uri: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> str: - """Send a magic link for sign-up to a new user; returns the masked delivery address.""" - if not user: - user = {} - - if not self._auth.adjust_and_verify_delivery_method(method, login_id, user): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Login ID {login_id} is not valid by delivery method {method}", - ) - - body = self._compose_signup_body(method, login_id, uri, user, signup_options) - url = self._compose_signup_url(method) - response = await self._http.post(url, body=body) - return Auth.extract_masked_address(response.json(), method) - - async def sign_up_or_in( - self, - method: DeliveryMethod, - login_id: str, - uri: str, - signup_options: SignUpOptions | None = None, - ) -> str: - """Send a magic link for sign-up or sign-in depending on whether the user exists.""" - login_options: LoginOptions | None = None - if signup_options is not None: - login_options = LoginOptions( - revoke_other_sessions=signup_options.revokeOtherSessions, - custom_claims=signup_options.customClaims, - template_options=signup_options.templateOptions, - template_id=signup_options.templateId, - ) - body = self._compose_signin_body(login_id, uri, login_options) - url = self._compose_sign_up_or_in_url(method) - response = await self._http.post(url, body=body) - return Auth.extract_masked_address(response.json(), method) - - async def verify(self, token: str, audience: str | None | Iterable[str] = None) -> dict: - """Verify a magic link token and return session JWTs.""" - url = EndpointsV1.verify_magiclink_auth_path - body = self._compose_verify_body(token) - response = await self._http.post(url, body=body) - return await self._auth.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - - async def update_user_email( - self, - login_id: str, - email: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> str: - """Send a magic link to a new email to verify the update; returns the masked address.""" - self._validate_login_id(login_id) - - Auth.validate_email(email) - - body = self._compose_update_user_email_body( - login_id, - email, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - ) - url = EndpointsV1.update_user_email_magiclink_path - response = await self._http.post(url, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) - - async def update_user_phone( - self, - method: DeliveryMethod, - login_id: str, - phone: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> str: - """Send a magic link to a new phone number to verify the update; returns the masked address.""" - self._validate_login_id(login_id) - - Auth.validate_phone(method, phone) - - body = self._compose_update_user_phone_body( - login_id, - phone, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - ) - url = EndpointsV1.update_user_phone_magiclink_path - response = await self._http.post(url, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), DeliveryMethod.SMS) diff --git a/descope/authmethod/oauth.py b/descope/authmethod/oauth.py deleted file mode 100644 index 520738b43..000000000 --- a/descope/authmethod/oauth.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._authmethod_base import AuthMethodBase -from descope.authmethod._oauth_base import OAuthBase -from descope.common import ( - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class OAuth(OAuthBase, AuthMethodBase): - def start( - self, - provider: str, - return_url: str = "", - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - ) -> dict: - """ """ - if not self._verify_provider(provider): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Unknown OAuth provider: {provider}", - ) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.oauth_start_path - params = OAuth._compose_start_params(provider, return_url) - response = self._http.post( - uri, - body=login_options.__dict__ if login_options else {}, - params=params, - pswd=refresh_token, - ) - - return response.json() - - def exchange_token(self, code: str) -> dict: - return self._auth.exchange_token(EndpointsV1.oauth_exchange_token_path, code) diff --git a/descope/authmethod/oauth_async.py b/descope/authmethod/oauth_async.py deleted file mode 100644 index cf0d152cb..000000000 --- a/descope/authmethod/oauth_async.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.authmethod._oauth_base import OAuthBase -from descope.common import ( - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class OAuthAsync(OAuthBase, AsyncAuthMethodBase): - """Async OAuth auth-method. All network calls are coroutines; validation is sync (no I/O).""" - - async def start( - self, - provider: str, - return_url: str = "", - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - ) -> dict: - """Start an OAuth flow; returns the redirect URL to send the user to.""" - if not self._verify_provider(provider): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Unknown OAuth provider: {provider}", - ) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.oauth_start_path - params = self._compose_start_params(provider, return_url) - response = await self._http.post( - uri, - body=login_options.__dict__ if login_options else {}, - params=params, - pswd=refresh_token, - ) - return response.json() - - async def exchange_token(self, code: str) -> dict: - """Exchange an OAuth code for session JWTs.""" - return await self._auth.exchange_token(EndpointsV1.oauth_exchange_token_path, code) diff --git a/descope/authmethod/otp.py b/descope/authmethod/otp.py deleted file mode 100644 index 1b6cff941..000000000 --- a/descope/authmethod/otp.py +++ /dev/null @@ -1,254 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -from descope._authmethod_base import AuthMethodBase -from descope.auth import Auth -from descope.authmethod._otp_base import OTPBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - validate_refresh_token_provided, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class OTP(OTPBase, AuthMethodBase): - def sign_in( - self, - method: DeliveryMethod, - login_id: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> str: - """ - Sign in (log in) an existing user with the unique login_id you provide. (See 'sign_up' function for an explanation of the - login_id field.) Provide the DeliveryMethod required for this user. If the login_id value cannot be used for the - DeliverMethod selected (for example, 'login_id = 4567qq445km' and 'DeliveryMethod = email') - - Args: - method (DeliveryMethod): The method to use for delivering the OTP verification code to the user, for example - Email, SMS, Voice call, or WhatsApp - login_id (str): The login ID of the user being validated for example phone or email - login_options (LoginOptions): Optional advanced controls over login parameters - refresh_token: Optional refresh token is needed for specific login options - - Raise: - AuthException: raised if sign-in operation fails - """ - self._validate_login_id(login_id) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = OTP._compose_signin_url(method) - body = OTP._compose_signin_body(login_id, login_options) - response = self._http.post(uri, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), method) - - def sign_up( - self, - method: DeliveryMethod, - login_id: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> str: - """ - Sign up (create) a new user using their email or phone number. Choose a delivery method for OTP - verification, for example Email, SMS, Voice call, or WhatsApp. - (optional) Include additional user metadata that you wish to preserve. - - Args: - method (DeliveryMethod): The method to use for delivering the OTP verification code, for example phone or email - login_id (str): The login ID of the user being validated - user (dict) optional: Preserve additional user metadata in the form of - {"name": "Joe Person", "phone": "2125551212", "email": "joe@somecompany.com"} - - Raise: - AuthException: raised if sign-up operation fails - """ - - if not user: - user = {} - - if not self._auth.adjust_and_verify_delivery_method(method, login_id, user): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Login ID {login_id} is not valid by delivery method {method}", - ) - - uri = OTP._compose_signup_url(method) - body = OTP._compose_signup_body(method, login_id, user, signup_options) - response = self._http.post(uri, body=body) - return Auth.extract_masked_address(response.json(), method) - - def sign_up_or_in( - self, - method: DeliveryMethod, - login_id: str, - signup_options: SignUpOptions | None = None, - ) -> str: - """ - Sign_up_or_in lets you handle both sign up and sign in with a single call. Sign-up_or_in will first determine if - login_id is a new or existing end user. If login_id is new, a new end user user will be created and then - authenticated using the OTP DeliveryMethod specified. If login_id exists, the end user will be authenticated - using the OTP DeliveryMethod specified. - - Args: - method (DeliveryMethod): The method to use for delivering the OTP verification code, for example Email, SMS, Voice call, or WhatsApp - login_id (str): The Login ID of the user being validated - - Raise: - AuthException: raised if either the sign_up or sign_in operation fails - """ - self._validate_login_id(login_id) - - uri = OTP._compose_sign_up_or_in_url(method) - login_options: LoginOptions | None = None - if signup_options is not None: - login_options = LoginOptions( - revoke_other_sessions=signup_options.revokeOtherSessions, - custom_claims=signup_options.customClaims, - template_options=signup_options.templateOptions, - template_id=signup_options.templateId, - ) - body = OTP._compose_signin_body( - login_id, - login_options, - ) - response = self._http.post(uri, body=body) - return Auth.extract_masked_address(response.json(), method) - - def verify_code( - self, - method: DeliveryMethod, - login_id: str, - code: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - """ - Verify the validity of an OTP code entered by an end user during sign_in or sign_up. - (This function is not needed if you are using the sign_up_or_in function. - - Args: - method (DeliveryMethod): The method to use for delivering the OTP verification code, for example Email, SMS, Voice call, or WhatsApp - login_id (str): The Login ID of the user being validated - code (str): The authorization code enter by the end user during signup/signin - - Return value (dict): - Return dict in the format - {"jwts": [], "user": "", "firstSeen": "", "error": ""} - Includes all the jwts tokens (session token, refresh token), token claims, and user information - - Raise: - AuthException: raised if the OTP code is not valid or if token verification failed - """ - self._validate_login_id(login_id) - - uri = OTP._compose_verify_code_url(method) - body = OTP._compose_verify_code_body(login_id, code) - response = self._http.post(uri, body=body) - - resp = response.json() - jwt_response = self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - return jwt_response - - def update_user_email( - self, - login_id: str, - email: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - mfa: bool = False, - ) -> str: - """ - Update the email address of an end user, after verifying the authenticity of the end user using OTP. - - Args: - login_id (str): The login ID of the user whose information is being updated - email (str): The new email address. If an email address already exists for this end user, it will be overwritten - refresh_token (str): The session's refresh token (used for verification) - mfa (bool): Defaults to false. When true, the auth methods already on the refresh token are preserved and - the updated factor is added to them (so the resulting amr keeps the previously-passed factors) instead - of replacing it with a single factor. Requires a valid refresh token. - - Raise: - AuthException: raised if OTP verification fails or if token verification fails - """ - - self._validate_login_id(login_id) - - Auth.validate_email(email) - - uri = EndpointsV1.update_user_email_otp_path - body = OTP._compose_update_user_email_body( - login_id, - email, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - mfa, - ) - response = self._http.post(uri, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) - - def update_user_phone( - self, - method: DeliveryMethod, - login_id: str, - phone: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - mfa: bool = False, - ) -> str: - """ - Update the phone number of an existing end user, after verifying the authenticity of the end user using OTP. - - Args: - method (DeliveryMethod): The method to use for delivering the OTP verification code, for example Email, SMS, Voice call, or WhatsApp - login_id (str): The login ID of the user whose information is being updated - phone (str): The new phone number. If a phone number already exists for this end user, it will be overwritten - refresh_token (str): The session's refresh token (used for OTP verification) - add_to_login_ids (bool): Defaults to false, determine whether to add this email to the login ids of hte user or not - on_merge_use_existing (bool): Defaults to false, In case add_to_login_ids and there is such a user already - determine whether keep the existing user, or this new one - mfa (bool): Defaults to false. When true, the auth methods already on the refresh token are preserved and - the updated factor is added to them (so the resulting amr keeps the previously-passed factors) instead - of replacing it with a single factor. Requires a valid refresh token. - - Raise: - AuthException: raised if OTP verification fails or if token verification fails - """ - - self._validate_login_id(login_id) - - Auth.validate_phone(method, phone) - - uri = OTP._compose_update_phone_url(method) - body = OTP._compose_update_user_phone_body( - login_id, - phone, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - mfa, - ) - response = self._http.post(uri, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), method) diff --git a/descope/authmethod/otp_async.py b/descope/authmethod/otp_async.py deleted file mode 100644 index 4266fbc70..000000000 --- a/descope/authmethod/otp_async.py +++ /dev/null @@ -1,162 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.auth import Auth -from descope.authmethod._otp_base import OTPBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - DeliveryMethod, - EndpointsV1, - LoginOptions, - SignUpOptions, - validate_refresh_token_provided, -) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - - -class OTPAsync(OTPBase, AsyncAuthMethodBase): - """Async OTP auth-method. All network calls are coroutines; validation is sync (no I/O).""" - - async def sign_in( - self, - method: DeliveryMethod, - login_id: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> str: - """Send an OTP to the user's delivery address for sign-in; returns the masked address.""" - self._validate_login_id(login_id) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = self._compose_signin_url(method) - body = self._compose_signin_body(login_id, login_options) - response = await self._http.post(uri, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), method) - - async def sign_up( - self, - method: DeliveryMethod, - login_id: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> str: - """Send an OTP to a new user's delivery address for sign-up; returns the masked address.""" - if not user: - user = {} - - if not self._auth.adjust_and_verify_delivery_method(method, login_id, user): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Login ID {login_id} is not valid by delivery method {method}", - ) - - uri = self._compose_signup_url(method) - body = self._compose_signup_body(method, login_id, user, signup_options) - response = await self._http.post(uri, body=body) - return Auth.extract_masked_address(response.json(), method) - - async def sign_up_or_in( - self, - method: DeliveryMethod, - login_id: str, - signup_options: SignUpOptions | None = None, - ) -> str: - """Send an OTP for sign-up or sign-in depending on whether the user exists.""" - self._validate_login_id(login_id) - - uri = self._compose_sign_up_or_in_url(method) - login_options: LoginOptions | None = None - if signup_options is not None: - login_options = LoginOptions( - revoke_other_sessions=signup_options.revokeOtherSessions, - custom_claims=signup_options.customClaims, - template_options=signup_options.templateOptions, - template_id=signup_options.templateId, - ) - body = self._compose_signin_body(login_id, login_options) - response = await self._http.post(uri, body=body) - return Auth.extract_masked_address(response.json(), method) - - async def verify_code( - self, - method: DeliveryMethod, - login_id: str, - code: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - """Verify an OTP code and return session JWTs.""" - self._validate_login_id(login_id) - - uri = self._compose_verify_code_url(method) - body = self._compose_verify_code_body(login_id, code) - response = await self._http.post(uri, body=body) - return await self._auth.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - - async def update_user_email( - self, - login_id: str, - email: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - mfa: bool = False, - ) -> str: - """Send an OTP to a new email address to verify the update; returns the masked address.""" - self._validate_login_id(login_id) - - Auth.validate_email(email) - - uri = EndpointsV1.update_user_email_otp_path - body = self._compose_update_user_email_body( - login_id, - email, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - mfa, - ) - response = await self._http.post(uri, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) - - async def update_user_phone( - self, - method: DeliveryMethod, - login_id: str, - phone: str, - refresh_token: str, - add_to_login_ids: bool = False, - on_merge_use_existing: bool = False, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - mfa: bool = False, - ) -> str: - """Send an OTP to a new phone number to verify the update; returns the masked address.""" - self._validate_login_id(login_id) - - Auth.validate_phone(method, phone) - - uri = self._compose_update_phone_url(method) - body = self._compose_update_user_phone_body( - login_id, - phone, - add_to_login_ids, - on_merge_use_existing, - template_options, - template_id, - provider_id, - mfa, - ) - response = await self._http.post(uri, body=body, pswd=refresh_token) - return Auth.extract_masked_address(response.json(), method) diff --git a/descope/authmethod/password.py b/descope/authmethod/password.py deleted file mode 100644 index 5a6b5f51c..000000000 --- a/descope/authmethod/password.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -from descope._authmethod_base import AuthMethodBase -from descope.authmethod._password_base import PasswordBase -from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 - - -class Password(PasswordBase, AuthMethodBase): - def sign_up( - self, - login_id: str, - password: str, - user: dict | None = None, - audience: str | None | Iterable[str] = None, - ) -> dict: - """ - Sign up (create) a new user using a login ID and password. - (optional) Include additional user metadata that you wish to save. - - Args: - login_id (str): The login ID of the user being signed up - password (str): The new user's password - user (dict) optional: Preserve additional user metadata in the form of - {"name": "Desmond Copeland", "phone": "2125551212", "email": "des@cope.com"} - - Return value (dict): - Return dict in the format - {"jwts": [], "user": "", "firstSeen": "", "error": ""} - Includes all the jwts tokens (session token, refresh token), token claims, and user information - - Raise: - AuthException: raised if sign-up operation fails - """ - - self._validate_login_id(login_id) - self._validate_password(password) - - uri = EndpointsV1.sign_up_password_path - body = Password._compose_signup_body(login_id, password, user) - response = self._http.post(uri, body=body) - - resp = response.json() - jwt_response = self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - return jwt_response - - def sign_in( - self, - login_id: str, - password: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - """ - Sign in by verifying the validity of a password entered by an end user. - - Args: - login_id (str): The login ID of the user being validated - password (str): The password to be validated - - Return value (dict): - Return dict in the format - {"sessionToken": {"jwt": "..."}, "refreshSessionToken": {"jwt": "..."}, ...} - Includes the session token, refresh session token, token claims, and user information - - Raise: - AuthException: raised if sign in operation fails - """ - - self._validate_login_id(login_id) - self._validate_sign_in_password(password) - - uri = EndpointsV1.sign_in_password_path - response = self._http.post(uri, body={"loginId": login_id, "password": password}) - - resp = response.json() - jwt_response = self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - return jwt_response - - def send_reset( - self, - login_id: str, - redirect_url: str | None = None, - template_options: dict | None = None, - ) -> dict: - """ - Sends a password reset prompt to the user with the given - login_id according to the password settings defined in the Descope console. - NOTE: The user must be verified according to the configured password reset method. - - Args: - login_id (str): The login ID of the user to receive a password reset prompt - redirect_url (str): Optional parameter that is used by Magic Link or Enchanted Link - if those are the chosen reset methods. See the Magic Link and Enchanted Link sections - for more details. - - Return value (dict): - Return dict in the format - {"resetMethod": "", "pendingRef": "", "linkId": "", "maskedEmail": ""} - The contents will differ according to the chosen reset method. 'pendingRef' - and 'linkId' will only appear of 'resetMethod' == 'enchantedlink' - - Raise: - AuthException: raised if send reset operation fails - """ - - self._validate_login_id(login_id) - - uri = EndpointsV1.send_reset_password_path - body: dict[str, str | bool | dict | None] = { - "loginId": login_id, - "redirectUrl": redirect_url, - } - if template_options is not None: - body["templateOptions"] = template_options - - response = self._http.post(uri, body=body) - - return response.json() - - def update(self, login_id: str, new_password: str, refresh_token: str) -> None: - """ - Update a password for an existing logged in user using their refresh token. - - Args: - login_id (str): The login ID of the user whose information is being updated - new_password (str): The new password to use - refresh_token (str): The session's refresh token (used for verification) - - Raise: - AuthException: raised if refresh token is invalid or update operation fails - """ - - self._validate_login_id(login_id) - self._validate_new_password(new_password) - self._validate_refresh_token(refresh_token) - - uri = EndpointsV1.update_password_path - self._http.post( - uri, - body={"loginId": login_id, "newPassword": new_password}, - pswd=refresh_token, - ) - - def replace( - self, - login_id: str, - old_password: str, - new_password: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - """ - Replace a valid active password with a new one. The old_password is used to - authenticate the user. If the user cannot be authenticated, this operation - will fail. - - Args: - login_id (str): The login ID of the user whose information is being updated - old_password (str): The user's current active password - new_password (str): The new password to use - - Return value (dict): - Return dict in the format - {"jwts": [], "user": "", "firstSeen": false, "error": ""} - Includes all the jwts tokens (session token, refresh token), token claims, and user information - - Raise: - AuthException: raised if replace operation fails - """ - - self._validate_login_id(login_id) - self._validate_old_password(old_password) - self._validate_new_password(new_password) - - uri = EndpointsV1.replace_password_path - response = self._http.post( - uri, - body={ - "loginId": login_id, - "oldPassword": old_password, - "newPassword": new_password, - }, - ) - - resp = response.json() - jwt_response = self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - return jwt_response - - def get_policy(self) -> dict: - """ - Get a subset of the password policy defined in the Descope console and enforced - by Descope. The goal is to enable client-side validations to give users a better UX - - Return value (dict): - Return dict in the format - {"minLength": 8, "lowercase": true, "uppercase": true, "number": true, "nonAlphanumeric": true} - minLength - the minimum length of a password - lowercase - the password required at least one lowercase character - uppercase - the password required at least one uppercase character - number - the password required at least one number character - nonAlphanumeric - the password required at least one non alphanumeric character - - Raise: - AuthException: raised if get policy operation fails - """ - - response = self._http.get(uri=EndpointsV1.password_policy_path) - return response.json() diff --git a/descope/authmethod/password_async.py b/descope/authmethod/password_async.py deleted file mode 100644 index c779a4fb6..000000000 --- a/descope/authmethod/password_async.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.authmethod._password_base import PasswordBase -from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 - - -class PasswordAsync(PasswordBase, AsyncAuthMethodBase): - """Async Password auth-method. All network calls are coroutines; validation is sync (no I/O).""" - - async def sign_up( - self, - login_id: str, - password: str, - user: dict | None = None, - audience: str | None | Iterable[str] = None, - ) -> dict: - """Create a new user with a password and return session JWTs.""" - self._validate_login_id(login_id) - self._validate_password(password) - - uri = EndpointsV1.sign_up_password_path - body = self._compose_signup_body(login_id, password, user) - response = await self._http.post(uri, body=body) - return await self._auth.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - - async def sign_in( - self, - login_id: str, - password: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - """Verify the user's password and return session JWTs.""" - self._validate_login_id(login_id) - self._validate_sign_in_password(password) - - uri = EndpointsV1.sign_in_password_path - response = await self._http.post(uri, body={"loginId": login_id, "password": password}) - return await self._auth.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - - async def send_reset( - self, - login_id: str, - redirect_url: str | None = None, - template_options: dict | None = None, - ) -> dict: - """Send a password reset prompt to the user per the configured reset method.""" - self._validate_login_id(login_id) - - uri = EndpointsV1.send_reset_password_path - body: dict[str, str | bool | dict | None] = { - "loginId": login_id, - "redirectUrl": redirect_url, - } - if template_options is not None: - body["templateOptions"] = template_options - - response = await self._http.post(uri, body=body) - return response.json() - - async def update(self, login_id: str, new_password: str, refresh_token: str) -> None: - """Update the password for an existing logged-in user.""" - self._validate_login_id(login_id) - self._validate_new_password(new_password) - self._validate_refresh_token(refresh_token) - - uri = EndpointsV1.update_password_path - await self._http.post( - uri, - body={"loginId": login_id, "newPassword": new_password}, - pswd=refresh_token, - ) - - async def replace( - self, - login_id: str, - old_password: str, - new_password: str, - audience: str | None | Iterable[str] = None, - ) -> dict: - """Authenticate with old_password, then replace it with new_password; returns session JWTs.""" - self._validate_login_id(login_id) - self._validate_old_password(old_password) - self._validate_new_password(new_password) - - uri = EndpointsV1.replace_password_path - response = await self._http.post( - uri, - body={ - "loginId": login_id, - "oldPassword": old_password, - "newPassword": new_password, - }, - ) - return await self._auth.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - - async def get_policy(self) -> dict: - """Return the project's password policy (min length, character requirements, etc.).""" - response = await self._http.get(uri=EndpointsV1.password_policy_path) - return response.json() diff --git a/descope/authmethod/saml.py b/descope/authmethod/saml.py deleted file mode 100644 index 1967af3d8..000000000 --- a/descope/authmethod/saml.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._authmethod_base import AuthMethodBase -from descope.authmethod._saml_base import SAMLBase -from descope.common import ( - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) - - -# This class is DEPRECATED please use SSO instead -class SAML(SAMLBase, AuthMethodBase): - def start( - self, - tenant: str, - return_url: Optional[str] = None, - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - ) -> dict: - """ - DEPRECATED - """ - self._validate_tenant(tenant) - self._validate_return_url(return_url) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.auth_saml_start_path - params = self._compose_start_params(tenant, return_url) - response = self._http.post( - uri, - body=login_options.__dict__ if login_options else {}, - params=params, - pswd=refresh_token, - ) - - return response.json() - - def exchange_token(self, code: str) -> dict: - return self._auth.exchange_token(EndpointsV1.saml_exchange_token_path, code) diff --git a/descope/authmethod/saml_async.py b/descope/authmethod/saml_async.py deleted file mode 100644 index 8c09cfc8d..000000000 --- a/descope/authmethod/saml_async.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.authmethod._saml_base import SAMLBase -from descope.common import ( - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) - - -# This class is DEPRECATED please use SSOAsync instead -class SAMLAsync(SAMLBase, AsyncAuthMethodBase): - """Async SAML auth-method (deprecated — use SSOAsync). All network calls are coroutines.""" - - async def start( - self, - tenant: str, - return_url: Optional[str] = None, - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - ) -> dict: - """Start a SAML flow; returns the redirect URL to send the user to.""" - self._validate_tenant(tenant) - self._validate_return_url(return_url) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.auth_saml_start_path - params = self._compose_start_params(tenant, return_url) - response = await self._http.post( - uri, - body=login_options.__dict__ if login_options else {}, - params=params, - pswd=refresh_token, - ) - return response.json() - - async def exchange_token(self, code: str) -> dict: - """Exchange a SAML code for session JWTs.""" - return await self._auth.exchange_token(EndpointsV1.saml_exchange_token_path, code) diff --git a/descope/authmethod/sso.py b/descope/authmethod/sso.py deleted file mode 100644 index 75b49849e..000000000 --- a/descope/authmethod/sso.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._authmethod_base import AuthMethodBase -from descope.authmethod._sso_base import SSOBase -from descope.common import ( - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) - - -class SSO(SSOBase, AuthMethodBase): - def start( - self, - tenant: str, - return_url: Optional[str] = None, - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - prompt: Optional[str] = None, - sso_id: Optional[str] = None, - login_hint: Optional[str] = None, - force_authn: Optional[bool] = None, - ) -> dict: - """ - Start tenant sso session (saml/oidc based on tenant settings) - - Args: - tenant (str): The tenant ID or name - return_url (str, optional): URL to redirect to after authentication - login_options (LoginOptions, optional): Login options for the authentication - refresh_token (str, optional): Refresh token for stepup/MFA authentication - prompt (str, optional): Prompt parameter for the authentication - sso_id (str, optional): SSO configuration ID to use - login_hint (str, optional): Hint about the user's login identifier - force_authn (bool, optional): Force re-authentication even if user is already authenticated - - Return value (dict): the redirect url for the login page - Return dict in the format - {'url': 'http://dummy.com/login..'} - """ - self._validate_tenant(tenant) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.auth_sso_start_path - params = self._compose_start_params( - tenant, - return_url if return_url else "", - prompt if prompt else "", - sso_id if sso_id else "", - login_hint if login_hint else "", - force_authn, - ) - response = self._http.post( - uri, - body=login_options.__dict__ if login_options else {}, - params=params, - pswd=refresh_token, - ) - - return response.json() - - def exchange_token(self, code: str) -> dict: - return self._auth.exchange_token(EndpointsV1.sso_exchange_token_path, code) diff --git a/descope/authmethod/sso_async.py b/descope/authmethod/sso_async.py deleted file mode 100644 index 4bd1ca233..000000000 --- a/descope/authmethod/sso_async.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.authmethod._sso_base import SSOBase -from descope.common import ( - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) - - -class SSOAsync(SSOBase, AsyncAuthMethodBase): - """Async SSO auth-method. All network calls are coroutines; validation is sync (no I/O).""" - - async def start( - self, - tenant: str, - return_url: Optional[str] = None, - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - prompt: Optional[str] = None, - sso_id: Optional[str] = None, - login_hint: Optional[str] = None, - force_authn: Optional[bool] = None, - ) -> dict: - """Start an SSO flow; returns the redirect URL to send the user to.""" - self._validate_tenant(tenant) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.auth_sso_start_path - params = self._compose_start_params( - tenant, - return_url if return_url else "", - prompt if prompt else "", - sso_id if sso_id else "", - login_hint if login_hint else "", - force_authn, - ) - response = await self._http.post( - uri, - body=login_options.__dict__ if login_options else {}, - params=params, - pswd=refresh_token, - ) - return response.json() - - async def exchange_token(self, code: str) -> dict: - """Exchange an SSO code for session JWTs.""" - return await self._auth.exchange_token(EndpointsV1.sso_exchange_token_path, code) diff --git a/descope/authmethod/totp.py b/descope/authmethod/totp.py deleted file mode 100644 index 4f67200a9..000000000 --- a/descope/authmethod/totp.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -from typing import Iterable, Optional, Union - -from descope._authmethod_base import AuthMethodBase -from descope.authmethod._totp_base import TOTPBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) - - -class TOTP(TOTPBase, AuthMethodBase): - def sign_up(self, login_id: str, user: Optional[dict] = None) -> dict: - """ - Sign up (create) a new user using their email or phone number. - (optional) Include additional user metadata that you wish to save. - - Args: - login_id (str): The login ID of the user being validated - user (dict) optional: Preserve additional user metadata in the form of, - {"name": "Desmond Copeland", "phone": "2125551212", "email": "des@cope.com"} - - Return value (dict): - Return dict in the format - {"provisioningURL": "", "image": "", "key": ""} - Includes 3 different ways to allow the user to save their credentials in - their authenticator app, either by clicking the provisioning URL, scanning the QR - image or inserting the key manually. - - Raise: - AuthException: raised if sign-up operation fails - """ - self._validate_login_id(login_id) - - uri = EndpointsV1.sign_up_auth_totp_path - body = self._compose_signup_body(login_id, user) - response = self._http.post(uri, body=body) - return response.json() - - def sign_in_code( - self, - login_id: str, - code: str, - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - audience: Union[str, None, Iterable[str]] = None, - ) -> dict: - """ - Sign in by verifying the validity of a TOTP code entered by an end user. - - Args: - login_id (str): The login ID of the user being validated - code (str): The authenticator app code provided by the end user - login_options (LoginOptions): Optional advanced controls over login parameters - refresh_token: Optional refresh token is needed for specific login options - - Return value (dict): - Return dict in the format - {"jwts": [], "user": "", "firstSeen": "", "error": ""} - Includes all the jwts tokens (session token, refresh token), token claims, and user information - - Raise: - AuthException: raised if the TOTP code is not valid or if code verification failed - """ - self._validate_login_id(login_id) - self._validate_code(code) - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.verify_totp_path - body = self._compose_signin_body(login_id, code, login_options) - response = self._http.post(uri, body=body, pswd=refresh_token) - - resp = response.json() - return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) - - def update_user(self, login_id: str, refresh_token: str) -> None: - """ - Add TOTP to an existing logged in user using their refresh token. - - Args: - login_id (str): The login ID of the user whose information is being updated - refresh_token (str): The session's refresh token (used for verification) - - Return value (dict): - Return dict in the format - {"provisioningURL": "", "image": "", "key": ""} - Includes 3 different ways to allow the user to save their credentials in - their authenticator app, either by clicking the provisioning URL, scanning the QR - image or inserting the key manually. - - Raise: - AuthException: raised if refresh token is invalid or update operation fails - """ - self._validate_login_id(login_id) - self._validate_refresh_token(refresh_token) - - uri = EndpointsV1.update_totp_path - body = self._compose_update_user_body(login_id) - response = self._http.post(uri, body=body, pswd=refresh_token) - return response.json() diff --git a/descope/authmethod/totp_async.py b/descope/authmethod/totp_async.py deleted file mode 100644 index 50a463628..000000000 --- a/descope/authmethod/totp_async.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from typing import Iterable, Optional, Union - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.authmethod._totp_base import TOTPBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) - - -class TOTPAsync(TOTPBase, AsyncAuthMethodBase): - """Async TOTP auth-method. All network calls are coroutines; validation is sync (no I/O).""" - - async def sign_up(self, login_id: str, user: Optional[dict] = None) -> dict: - """Sign up a new user via TOTP; returns provisioningURL, image, and key.""" - self._validate_login_id(login_id) - - uri = EndpointsV1.sign_up_auth_totp_path - body = self._compose_signup_body(login_id, user) - response = await self._http.post(uri, body=body) - return response.json() - - async def sign_in_code( - self, - login_id: str, - code: str, - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - audience: Union[str, None, Iterable[str]] = None, - ) -> dict: - """Verify a TOTP code and return session JWTs.""" - self._validate_login_id(login_id) - self._validate_code(code) - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.verify_totp_path - body = self._compose_signin_body(login_id, code, login_options) - response = await self._http.post(uri, body=body, pswd=refresh_token) - return await self._auth.prepare_jwt_response( - response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - - async def update_user(self, login_id: str, refresh_token: str) -> dict: - """Add TOTP to an existing user; returns provisioningURL, image, and key.""" - self._validate_login_id(login_id) - self._validate_refresh_token(refresh_token) - - uri = EndpointsV1.update_totp_path - body = self._compose_update_user_body(login_id) - response = await self._http.post(uri, body=body, pswd=refresh_token) - return response.json() diff --git a/descope/authmethod/webauthn.py b/descope/authmethod/webauthn.py deleted file mode 100644 index 4a4eb3948..000000000 --- a/descope/authmethod/webauthn.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -from typing import Iterable, Optional, Union - -from descope._authmethod_base import AuthMethodBase -from descope.authmethod._webauthn_base import WebAuthnBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) - - -class WebAuthn(WebAuthnBase, AuthMethodBase): - def sign_up_start( - self, - login_id: Optional[str], - origin: Optional[str], - user: Optional[dict] = None, - ) -> dict: - """ - Docs - """ - self._validate_login_id(login_id) - self._validate_origin(origin) - - if not user: - user = {} - - uri = EndpointsV1.sign_up_auth_webauthn_start_path - body = self._compose_sign_up_start_body(login_id, user, origin) - response = self._http.post(uri, body=body) - return response.json() - - def sign_up_finish( - self, - transaction_id: str, - response, - audience: Union[str, None, Iterable[str]] = None, - ) -> dict: - """ - Docs - """ - self._validate_transaction_id(transaction_id) - self._validate_response(response) - uri = EndpointsV1.sign_up_auth_webauthn_finish_path - body = self._compose_sign_up_in_finish_body(transaction_id, response) - http_response = self._http.post(uri, body=body) - resp = http_response.json() - return self._auth.generate_jwt_response( - resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - - def sign_in_start( - self, - login_id: str, - origin: str, - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - ) -> dict: - """ - Docs - """ - self._validate_login_id(login_id) - self._validate_origin(origin) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.sign_in_auth_webauthn_start_path - body = self._compose_sign_in_start_body(login_id, origin, login_options) - response = self._http.post(uri, body=body, pswd=refresh_token) - return response.json() - - def sign_in_finish( - self, - transaction_id: str, - response, - audience: Union[str, None, Iterable[str]] = None, - ) -> dict: - """ - Docs - """ - self._validate_transaction_id(transaction_id) - self._validate_response(response) - - uri = EndpointsV1.sign_in_auth_webauthn_finish_path - body = self._compose_sign_up_in_finish_body(transaction_id, response) - http_response = self._http.post(uri, body=body) - resp = http_response.json() - return self._auth.generate_jwt_response( - resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - - def sign_up_or_in_start( - self, - login_id: str, - origin: str, - ) -> dict: - """ - Docs - """ - self._validate_login_id(login_id) - self._validate_origin(origin) - - uri = EndpointsV1.sign_up_or_in_auth_webauthn_start_path - body = self._compose_sign_up_or_in_start_body(login_id, origin) - response = self._http.post(uri, body=body) - return response.json() - - def update_start( - self, - login_id: str, - refresh_token: str, - origin: str, - mfa: bool = False, - ) -> dict: - """ - Start adding a new WebAuthn authenticator (passkey) to an existing user. - - Pass mfa=True so that update_finish returns a single session whose amr merges the user's - previously-passed factors with the new passkey, instead of having to run a separate - sign-in afterwards. - """ - self._validate_login_id(login_id) - self._validate_refresh_token(refresh_token) - - uri = EndpointsV1.update_auth_webauthn_start_path - body = self._compose_update_start_body(login_id, origin, mfa) - response = self._http.post(uri, body=body, pswd=refresh_token) - return response.json() - - def update_finish( - self, - transaction_id: str, - response: str, - audience: Union[str, None, Iterable[str]] = None, - ) -> Optional[dict]: - """ - Complete adding a new WebAuthn authenticator (passkey) to an existing user. - - When the matching update_start opted into mfa/stepup, returns a JWT response whose amr - merges the prior factors with the new passkey; otherwise returns None (the credential is - enrolled but no new session is minted). - """ - self._validate_transaction_id(transaction_id) - self._validate_response(response) - - uri = EndpointsV1.update_auth_webauthn_finish_path - body = self._compose_update_finish_body(transaction_id, response) - http_response = self._http.post(uri, body=body) - resp = http_response.json() - jwt = resp.get("jwt") if isinstance(resp, dict) else None - if not jwt: - return None - return self._auth.generate_jwt_response( - jwt, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) diff --git a/descope/authmethod/webauthn_async.py b/descope/authmethod/webauthn_async.py deleted file mode 100644 index 58624cf8c..000000000 --- a/descope/authmethod/webauthn_async.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -from typing import Iterable, Optional, Union - -from descope._authmethod_base import AsyncAuthMethodBase -from descope.authmethod._webauthn_base import WebAuthnBase -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, - validate_refresh_token_provided, -) - - -class WebAuthnAsync(WebAuthnBase, AsyncAuthMethodBase): - """Async WebAuthn auth-method. All network calls are coroutines; validation is sync (no I/O).""" - - async def sign_up_start( - self, - login_id: Optional[str], - origin: Optional[str], - user: Optional[dict] = None, - ) -> dict: - """Start a WebAuthn sign-up flow; returns the challenge options for the browser.""" - self._validate_login_id(login_id) - self._validate_origin(origin) - - if not user: - user = {} - - uri = EndpointsV1.sign_up_auth_webauthn_start_path - body = self._compose_sign_up_start_body(login_id, user, origin) - response = await self._http.post(uri, body=body) - return response.json() - - async def sign_up_finish( - self, - transaction_id: str, - response, - audience: Union[str, None, Iterable[str]] = None, - ) -> dict: - """Complete a WebAuthn sign-up and return session JWTs.""" - self._validate_transaction_id(transaction_id) - self._validate_response(response) - - uri = EndpointsV1.sign_up_auth_webauthn_finish_path - body = self._compose_sign_up_in_finish_body(transaction_id, response) - http_response = await self._http.post(uri, body=body) - return await self._auth.prepare_jwt_response( - http_response.json(), - http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), - audience, - ) - - async def sign_in_start( - self, - login_id: str, - origin: str, - login_options: Optional[LoginOptions] = None, - refresh_token: Optional[str] = None, - ) -> dict: - """Start a WebAuthn sign-in flow; returns the challenge options for the browser.""" - self._validate_login_id(login_id) - self._validate_origin(origin) - - validate_refresh_token_provided(login_options, refresh_token) - - uri = EndpointsV1.sign_in_auth_webauthn_start_path - body = self._compose_sign_in_start_body(login_id, origin, login_options) - response = await self._http.post(uri, body=body, pswd=refresh_token) - return response.json() - - async def sign_in_finish( - self, - transaction_id: str, - response, - audience: Union[str, None, Iterable[str]] = None, - ) -> dict: - """Complete a WebAuthn sign-in and return session JWTs.""" - self._validate_transaction_id(transaction_id) - self._validate_response(response) - - uri = EndpointsV1.sign_in_auth_webauthn_finish_path - body = self._compose_sign_up_in_finish_body(transaction_id, response) - http_response = await self._http.post(uri, body=body) - return await self._auth.prepare_jwt_response( - http_response.json(), - http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), - audience, - ) - - async def sign_up_or_in_start( - self, - login_id: str, - origin: str, - ) -> dict: - """Start a WebAuthn sign-up-or-in flow; returns the challenge options for the browser.""" - self._validate_login_id(login_id) - self._validate_origin(origin) - - uri = EndpointsV1.sign_up_or_in_auth_webauthn_start_path - body = self._compose_sign_up_or_in_start_body(login_id, origin) - response = await self._http.post(uri, body=body) - return response.json() - - async def update_start( - self, - login_id: str, - refresh_token: str, - origin: str, - mfa: bool = False, - ) -> dict: - """Start adding a new WebAuthn authenticator (passkey) to an existing user. - - Pass mfa=True so that update_finish returns a single session whose amr merges the user's - previously-passed factors with the new passkey. - """ - self._validate_login_id(login_id) - self._validate_refresh_token(refresh_token) - - uri = EndpointsV1.update_auth_webauthn_start_path - body = self._compose_update_start_body(login_id, origin, mfa) - response = await self._http.post(uri, body=body, pswd=refresh_token) - return response.json() - - async def update_finish( - self, - transaction_id: str, - response: str, - audience: Union[str, None, Iterable[str]] = None, - ) -> Optional[dict]: - """Complete adding a new WebAuthn authenticator (passkey) to an existing user. - - When the matching update_start opted into mfa/stepup, returns a JWT response whose amr - merges the prior factors with the new passkey; otherwise returns None. - """ - self._validate_transaction_id(transaction_id) - self._validate_response(response) - - uri = EndpointsV1.update_auth_webauthn_finish_path - body = self._compose_update_finish_body(transaction_id, response) - http_response = await self._http.post(uri, body=body) - resp = http_response.json() - jwt = resp.get("jwt") if isinstance(resp, dict) else None - if not jwt: - return None - return await self._auth.prepare_jwt_response( - jwt, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) diff --git a/descope/common.py b/descope/common.py deleted file mode 100644 index b11bbad75..000000000 --- a/descope/common.py +++ /dev/null @@ -1,206 +0,0 @@ -from enum import Enum -from typing import Any, Optional - -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException - -DEFAULT_URL_PREFIX = "https://api" -DEFAULT_DOMAIN = "descope.com" -DEFAULT_BASE_URL = DEFAULT_URL_PREFIX + "." + DEFAULT_DOMAIN # pragma: no cover -DEFAULT_TIMEOUT_SECONDS = 60 - -# Simple phone validation to prevent ReDoS (catastrophic backtracking) -# Optional leading +, then digits, spaces, hyphens, parentheses, dots, # for extension -# Requires at least 4 consecutive digits, length 7-25, at most one leading + -PHONE_REGEX = r"""^(?=.*\d{4,})\+?[\d\s\-\(\)\.#xX]{6,24}$""" - -SESSION_COOKIE_NAME = "DS" -REFRESH_SESSION_COOKIE_NAME = "DSR" - -SESSION_TOKEN_NAME = "sessionToken" -REFRESH_SESSION_TOKEN_NAME = "refreshSessionToken" -COOKIE_DATA_NAME = "cookieData" - -REDIRECT_LOCATION_COOKIE_NAME = "Location" - - -class EndpointsV1: - refresh_token_path = "/v1/auth/refresh" - select_tenant_path = "/v1/auth/tenant/select" - logout_path = "/v1/auth/logout" - logout_all_path = "/v1/auth/logoutall" - me_path = "/v1/auth/me" - my_tenants_path = "/v1/auth/me/tenants" - history_path = "/v1/auth/me/history" - - # accesskey - exchange_auth_access_key_path = "/v1/auth/accesskey/exchange" - - # otp - sign_up_auth_otp_path = "/v1/auth/otp/signup" - sign_in_auth_otp_path = "/v1/auth/otp/signin" - sign_up_or_in_auth_otp_path = "/v1/auth/otp/signup-in" - verify_code_auth_path = "/v1/auth/otp/verify" - update_user_email_otp_path = "/v1/auth/otp/update/email" - update_user_phone_otp_path = "/v1/auth/otp/update/phone" - - # magiclink - sign_up_auth_magiclink_path = "/v1/auth/magiclink/signup" - sign_in_auth_magiclink_path = "/v1/auth/magiclink/signin" - sign_up_or_in_auth_magiclink_path = "/v1/auth/magiclink/signup-in" - verify_magiclink_auth_path = "/v1/auth/magiclink/verify" - get_session_magiclink_auth_path = "/v1/auth/magiclink/pending-session" - update_user_email_magiclink_path = "/v1/auth/magiclink/update/email" - update_user_phone_magiclink_path = "/v1/auth/magiclink/update/phone" - - # enchantedlink - sign_up_auth_enchantedlink_path = "/v1/auth/enchantedlink/signup" - sign_in_auth_enchantedlink_path = "/v1/auth/enchantedlink/signin" - sign_up_or_in_auth_enchantedlink_path = "/v1/auth/enchantedlink/signup-in" - verify_enchantedlink_auth_path = "/v1/auth/enchantedlink/verify" - get_session_enchantedlink_auth_path = "/v1/auth/enchantedlink/pending-session" - update_user_email_enchantedlink_path = "/v1/auth/enchantedlink/update/email" - update_user_phone_enchantedlink_path = "/v1/auth/enchantedlink/update/phone" - - # oauth - oauth_start_path = "/v1/auth/oauth/authorize" - oauth_exchange_token_path = "/v1/auth/oauth/exchange" - - # #### DEPRECATED - # saml - auth_saml_start_path = "/v1/auth/saml/authorize" - saml_exchange_token_path = "/v1/auth/saml/exchange" - ################# - - # sso (saml/oidc) - auth_sso_start_path = "/v1/auth/sso/authorize" - sso_exchange_token_path = "/v1/auth/sso/exchange" - - # totp - sign_up_auth_totp_path = "/v1/auth/totp/signup" - verify_totp_path = "/v1/auth/totp/verify" - update_totp_path = "/v1/auth/totp/update" - - # webauthn - sign_up_auth_webauthn_start_path = "/v1/auth/webauthn/signup/start" - sign_up_auth_webauthn_finish_path = "/v1/auth/webauthn/signup/finish" - sign_in_auth_webauthn_start_path = "/v1/auth/webauthn/signin/start" - sign_in_auth_webauthn_finish_path = "/v1/auth/webauthn/signin/finish" - sign_up_or_in_auth_webauthn_start_path = "/v1/auth/webauthn/signup-in/start" - update_auth_webauthn_start_path = "/v1/auth/webauthn/update/start" - update_auth_webauthn_finish_path = "/v1/auth/webauthn/update/finish" - - # password - sign_up_password_path = "/v1/auth/password/signup" - sign_in_password_path = "/v1/auth/password/signin" - send_reset_password_path = "/v1/auth/password/reset" - update_password_path = "/v1/auth/password/update" - replace_password_path = "/v1/auth/password/replace" - password_policy_path = "/v1/auth/password/policy" - - -class EndpointsV2: - public_key_path = "/v2/keys" - - -class DeliveryMethod(Enum): - WHATSAPP = 1 - SMS = 2 - EMAIL = 3 - EMBEDDED = 4 - VOICE = 5 - - -class LoginOptions: - def __init__( - self, - stepup: bool = False, - mfa: bool = False, - revoke_other_sessions: Optional[bool] = None, - custom_claims: Optional[dict] = None, - template_options: Optional[ - dict - ] = None, # for providing messaging template options (templates that are being sent via email / text message) - template_id: Optional[ - str - ] = None, # for overriding the default template (templates that are being sent via email / text message) - locale: Optional[str] = None, - ): - self.stepup = stepup - self.customClaims = custom_claims - self.mfa = mfa - if revoke_other_sessions is not None: - self.revokeOtherSessions = revoke_other_sessions - if template_options is not None: - self.templateOptions = template_options - if template_id is not None: - self.templateId = template_id - if locale is not None: - self.locale = locale - - -class AccessKeyLoginOptions: - def __init__( - self, - custom_claims: Optional[dict] = None, - selected_tenant: Optional[str] = None, - ): - self.customClaims = custom_claims - self.selectedTenant = selected_tenant - - -def validate_refresh_token_provided(login_options: Optional[LoginOptions] = None, refresh_token: Optional[str] = None): - refresh_required = login_options is not None and (login_options.mfa or login_options.stepup) - refresh_missing = refresh_token is None or refresh_token == "" - if refresh_required and refresh_missing: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "Missing refresh token for stepup/mfa", - ) - - -class SignUpOptions: - def __init__( - self, - revoke_other_sessions: Optional[bool] = None, - custom_claims: Optional[dict] = None, - template_options: Optional[ - dict - ] = None, # for providing messaging template options (templates that are being sent via email / text message) - template_id: Optional[ - str - ] = None, # for overriding the default template (templates that are being sent via email / text message) - ): - self.revokeOtherSessions = revoke_other_sessions - self.customClaims = custom_claims - self.templateOptions = template_options - self.templateId = template_id - - -def signup_options_to_dict(signup_options: Optional[SignUpOptions] = None) -> dict: - res: dict[str, Any] = {} - if signup_options is not None: - if signup_options.customClaims is not None: - res["customClaims"] = signup_options.customClaims - if signup_options.templateId is not None: - res["templateId"] = signup_options.templateId - if signup_options.templateOptions is not None: - res["templateOptions"] = signup_options.templateOptions - if signup_options.revokeOtherSessions is not None: - res["revokeOtherSessions"] = signup_options.revokeOtherSessions - return res - - -def get_method_string(method: DeliveryMethod) -> str: - name = { - DeliveryMethod.EMAIL: "email", - DeliveryMethod.SMS: "sms", - DeliveryMethod.VOICE: "voice", - DeliveryMethod.WHATSAPP: "whatsapp", - DeliveryMethod.EMBEDDED: "Embedded", - }.get(method) - - if not name: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}") - - return name diff --git a/descope/descope_client.py b/descope/descope_client.py deleted file mode 100644 index 5c934b232..000000000 --- a/descope/descope_client.py +++ /dev/null @@ -1,399 +0,0 @@ -from __future__ import annotations - -import logging -import os -from typing import Iterable - -import httpx - -from descope._client_base import DescopeClientBase -from descope._http_client_base import ThreadLocalLastResponseStore -from descope.auth import Auth -from descope.authmethod.enchantedlink import EnchantedLink # noqa: F401 -from descope.authmethod.magiclink import MagicLink # noqa: F401 -from descope.authmethod.oauth import OAuth # noqa: F401 -from descope.authmethod.otp import OTP # noqa: F401 -from descope.authmethod.password import Password # noqa: F401 -from descope.authmethod.saml import SAML # noqa: F401 -from descope.authmethod.sso import SSO # noqa: F401 -from descope.authmethod.totp import TOTP # noqa: F401 -from descope.authmethod.webauthn import WebAuthn # noqa: F401 -from descope.common import DEFAULT_TIMEOUT_SECONDS, AccessKeyLoginOptions, EndpointsV1 -from descope.http_client import HTTPClient -from descope.management.common import MgmtV1 -from descope.mgmt import MGMT # noqa: F401 - -logger = logging.getLogger(__name__) - -LICENSE_HANDSHAKE_TIMEOUT_SECONDS = 5.0 - - -class DescopeClient(DescopeClientBase): - ALGORITHM_KEY = "alg" - - def __init__( - self, - project_id: str, - public_key: dict | None = None, - skip_verify: bool = False, - management_key: str | None = None, - timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - jwt_validation_leeway: int = 5, - auth_management_key: str | None = None, - fga_cache_url: str | None = None, - *, - base_url: str | None = None, - verbose: bool = False, - ): - super().__init__( - project_id, - public_key, - skip_verify, - timeout_seconds, - jwt_validation_leeway, - auth_management_key, - base_url=base_url, - verbose=verbose, - ) - self._last_response_store = ThreadLocalLastResponseStore() - auth_http_client = HTTPClient( - project_id=self._project_id, - base_url=base_url, - timeout_seconds=timeout_seconds, - secure=not skip_verify, - management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), - verbose=verbose, - last_response_store=self._last_response_store, - ) - self._auth = Auth( - self._project_id, - public_key, - jwt_validation_leeway, - http_client=auth_http_client, - ) - - self._magiclink = MagicLink(self._auth) - self._enchantedlink = EnchantedLink(self._auth) - self._oauth = OAuth(self._auth) - self._saml = SAML(self._auth) # deprecated - self._sso = SSO(self._auth) - self._otp = OTP(self._auth) - self._totp = TOTP(self._auth) - self._webauthn = WebAuthn(self._auth) - self._password = Password(self._auth) - - # Management Initialization - mgmt_http_client = HTTPClient( - project_id=project_id, - base_url=auth_http_client.base_url, - timeout_seconds=auth_http_client.timeout_seconds, - secure=auth_http_client.secure, - management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), - verbose=verbose, - last_response_store=self._last_response_store, - ) - self._mgmt = MGMT( - http_client=mgmt_http_client, - auth=self._auth, - fga_cache_url=fga_cache_url, - ) - - self._auth_http_client = auth_http_client - self._mgmt_http_client = mgmt_http_client - - # Synchronous license handshake so the first management request after - # construction can carry the x-descope-license header. Backend skips - # license-header validation for the GetLicense endpoint itself, so the - # initial request is safe before the tier is cached. - if mgmt_http_client.management_key: - self._fetch_rate_limit_tier(mgmt_http_client) - - def _fetch_rate_limit_tier(self, mgmt_http) -> None: - """Sync license handshake so the x-descope-license header is ready for the first mgmt call.""" - try: - response = httpx.get( - f"{mgmt_http.base_url}{MgmtV1.license_get_path}", - headers={"Authorization": f"Bearer {mgmt_http.project_id}:{mgmt_http.management_key}"}, - follow_redirects=True, - verify=mgmt_http.client_verify, - timeout=LICENSE_HANDSHAKE_TIMEOUT_SECONDS, - ) - if not response.is_success: - logger.warning( - "License handshake returned non-success status %s", - response.status_code, - ) - return - tier = response.json().get("rateLimitTier") - if tier: - mgmt_http.rate_limit_tier = tier - except Exception as e: - logger.warning("License handshake failed: %s", e) - - def validate_session(self, session_token: str, audience: Iterable[str] | str | None = None) -> dict: - """Validate a session token. Pure CPU once JWKS is cached — no I/O on the hot path.""" - return self._auth.validate_session(session_token, audience) - - @property - def mgmt(self): - return self._mgmt - - @property - def magiclink(self): - return self._magiclink - - @property - def enchantedlink(self): - return self._enchantedlink - - @property - def otp(self): - return self._otp - - @property - def totp(self): - return self._totp - - @property - def oauth(self): - return self._oauth - - # ######## deprecated (use sso instead) - @property - def saml(self): - return self._saml - - # ################### - - @property - def sso(self): - return self._sso - - @property - def webauthn(self): - return self._webauthn - - @property - def password(self): - return self._password - - def refresh_session(self, refresh_token: str, audience: Iterable[str] | str | None = None) -> dict: - """ - Refresh a session. Call this function when a session expires and needs to be refreshed. - - Args: - refresh_token (str): The refresh token that will be used to refresh the session - audience (str|Iterable[str]|None): Optional recipients that the JWT is intended for (must be equal to the 'aud' claim on the provided token) - - Return value (dict): - Return dict includes the session token, refresh token, and all JWT claims - - Raise: - AuthException: Exception is raised if refresh token is not authorized or any other error occurs - """ - return self._auth.refresh_session(refresh_token, audience) - - def validate_and_refresh_session( - self, - session_token: str, - refresh_token: str, - audience: Iterable[str] | str | None = None, - ) -> dict: - """ - Validate the session token and refresh it if it has expired, the session token will automatically be refreshed. - Either the session_token or the refresh_token must be provided. - Call this function for every incoming request to your - private endpoints. Alternatively, use validate_session to only validate the session. - - Args: - session_token (str): The session token to be validated - refresh_token (str): The refresh token that will be used to refresh the session token, if needed - audience (str|Iterable[str]|None): Optional recipients that the JWT is intended for (must be equal to the 'aud' claim on the provided token) - - Return value (dict): - Return dict includes the session token, refresh token, and all JWT claims - - Raise: - AuthException: Exception is raised if session is not authorized or another error occurs - """ - return self._auth.validate_and_refresh_session(session_token, refresh_token, audience) - - def logout(self, refresh_token: str) -> httpx.Response: - """ - Logout user from current session and revoke the refresh_token. After calling this function, - you must invalidate or remove any cookies you have created. - - Args: - refresh_token (str): The refresh token - - Return value (httpx.Response): returns the response from the Descope server - - Raise: - AuthException: Exception is raised if session is not authorized or another error occurs - """ - self._require_refresh_token(refresh_token) - uri = EndpointsV1.logout_path - return self._auth.http_client.post(uri, body={}, pswd=refresh_token) - - def logout_all(self, refresh_token: str) -> httpx.Response: - """ - Logout user from all active sessions and revoke the refresh_token. After calling this function, - you must invalidate or remove any cookies you have created. - - Args: - refresh_token (str): The refresh token - - Return value (httpx.Response): returns the response from the Descope server - - Raise: - AuthException: Exception is raised if session is not authorized or another error occurs - """ - self._require_refresh_token(refresh_token) - uri = EndpointsV1.logout_all_path - return self._auth.http_client.post(uri, body={}, pswd=refresh_token) - - def me(self, refresh_token: str) -> dict: - """ - Retrieve user details for the refresh token. The returned data includes email, name, phone, - list of loginIds and boolean flags for verifiedEmail, verifiedPhone. - - Args: - refresh_token (str): The refresh token - - Return value (dict): returns the user details from the server - (email:str, name:str, phone:str, loginIds[str], verifiedEmail:bool, verifiedPhone:bool) - - Raise: - AuthException: Exception is raised if session is not authorized or another error occurs - """ - self._require_refresh_token(refresh_token) - uri = EndpointsV1.me_path - response = self._auth.http_client.get(uri=uri, allow_redirects=None, pswd=refresh_token) - return response.json() - - def my_tenants( - self, - refresh_token: str, - dct: bool = False, - ids: list[str] | None = None, - ) -> dict: - """ - Retrieve tenant attributes that user belongs to, one of dct/ids must be populated . - - Args: - dct (bool): Get only the selected tenant from jwt - ids (List[str]): Get the list of tenants - refresh_token (str): The refresh token - - Return value (dict): returns the tenant requested from the server - (id:str, name:str, customAttributes:dict) - - Raise: - AuthException: Exception is raised if session is not authorized or another error occurs - """ - self._require_refresh_token(refresh_token) - self._validate_tenant_selector(dct, ids) - - body: dict[str, bool | list[str]] = {"dct": dct} - if ids is not None: - body["ids"] = ids - - uri = EndpointsV1.my_tenants_path - response = self._auth.http_client.post(uri, body=body, pswd=refresh_token) - return response.json() - - def history(self, refresh_token: str) -> list[dict]: - """ - Retrieve user authentication history for the refresh token - - Args: - refresh_token (str): The refresh token - - Return value (List[dict]): - Return List in the format - [ - { - "userId": "User's ID", - "loginTime": "User'sLogin time", - "city": "User's city", - "country": "User's country", - "ip": User's IP - } - ] - - Raise: - AuthException: Exception is raised if session is not authorized or another error occurs - """ - self._require_refresh_token(refresh_token) - uri = EndpointsV1.history_path - response = self._auth.http_client.get(uri=uri, allow_redirects=None, pswd=refresh_token) - return response.json() - - def exchange_access_key( - self, - access_key: str, - audience: Iterable[str] | str | None = None, - login_options: AccessKeyLoginOptions | None = None, - ) -> dict: - """ - Return a new session token for the given access key - - Args: - access_key (str): The access key - audience (str|Iterable[str]|None): Optional recipients that the JWT is intended for (must be equal to the 'aud' claim on the provided token) - login_options (AccessKeyLoginOptions): Optional advanced controls over login parameters - - Return dict includes the session token and all JWT claims - - Raise: - AuthException: Exception is raised if access key is not valid or another error occurs - """ - self._require_access_key(access_key) - return self._auth.exchange_access_key(access_key, audience, login_options) - - def select_tenant( - self, - tenant_id: str, - refresh_token: str, - ) -> dict: - """ - Add to JWT a selected tenant claim - - Args: - refresh_token (str): The refresh token that will be used to refresh the session token, if needed - tenant_id (str): The tenant id to place on JWT - - Return value (dict): - Return dict includes the session token, refresh token, with the tenant id on the jwt - - Raise: - AuthException: Exception is raised if session is not authorized or another error occurs - """ - return self._auth.select_tenant(tenant_id, refresh_token) - - def get_last_response(self): - """ - Get the last HTTP response from either auth or management operations. - - Only available when verbose mode is enabled during client initialization. - This provides access to HTTP metadata like headers (cf-ray), status codes, - and raw response data for debugging failed requests. - - Returns: - DescopeResponse: The last response if verbose mode is enabled. - Returns the most recent response across auth and mgmt - operations, whichever ran last. - None if verbose mode is disabled or no requests have been made. - - Example: - client = DescopeClient(project_id, management_key, verbose=True) - try: - client.mgmt.user.create(login_id="test@example.com") - except AuthException: - resp = client.get_last_response() - if resp: - # Access metadata for debugging - cf_ray = resp.headers.get("cf-ray") - status = resp.status_code - """ - return self._last_response_store.get() diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py deleted file mode 100644 index 1763f61dc..000000000 --- a/descope/descope_client_async.py +++ /dev/null @@ -1,331 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import os -from typing import Iterable - -import httpx - -from descope._client_base import DescopeClientBase -from descope._http_client_base import ContextVarLastResponseStore -from descope.auth_async import AuthAsync -from descope.authmethod.enchantedlink_async import EnchantedLinkAsync -from descope.authmethod.magiclink_async import MagicLinkAsync -from descope.authmethod.oauth_async import OAuthAsync -from descope.authmethod.otp_async import OTPAsync -from descope.authmethod.password_async import PasswordAsync -from descope.authmethod.saml_async import SAMLAsync -from descope.authmethod.sso_async import SSOAsync -from descope.authmethod.totp_async import TOTPAsync -from descope.authmethod.webauthn_async import WebAuthnAsync -from descope.common import ( - DEFAULT_TIMEOUT_SECONDS, - AccessKeyLoginOptions, - EndpointsV1, -) -from descope.exceptions import ( - ERROR_TYPE_INVALID_TOKEN, - AuthException, -) -from descope.http_client_async import HTTPClientAsync -from descope.management.common import MgmtV1 -from descope.mgmt_async import MGMTAsync - -logger = logging.getLogger(__name__) - -LICENSE_HANDSHAKE_TIMEOUT_SECONDS = 5.0 - - -class DescopeClientAsync(DescopeClientBase): - """ - Async counterpart of DescopeClient. - - All network-bound operations — including ``validate_session`` — are - ``async def`` because JWKS may need to be fetched. Pure JWT-claim helpers - (``validate_permissions``, ``validate_roles``, etc.) are inherited sync - from ``DescopeClientBase`` — they take a decoded JWT dict and run no I/O. - - The license handshake (rate-limit tier) is run lazily on the first - management request and is also runnable eagerly via ``aopen`` / - ``__aenter__``. Either way, it never blocks the event loop in - ``__init__``. - - Usage (recommended — context manager): - async with DescopeClientAsync(project_id="P...") as client: - jwt = await client.refresh_session(refresh_token) - - Usage (explicit close): - client = DescopeClientAsync(project_id="P...") - try: - jwt = await client.refresh_session(refresh_token) - finally: - await client.aclose() - """ - - def __init__( - self, - project_id: str, - public_key: dict | None = None, - skip_verify: bool = False, - management_key: str | None = None, - timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - jwt_validation_leeway: int = 5, - auth_management_key: str | None = None, - fga_cache_url: str | None = None, - *, - base_url: str | None = None, - verbose: bool = False, - ): - super().__init__( - project_id, - public_key, - skip_verify, - timeout_seconds, - jwt_validation_leeway, - auth_management_key, - base_url=base_url, - verbose=verbose, - ) - - self._last_response_store = ContextVarLastResponseStore() - self._auth_http = HTTPClientAsync( - project_id=self._project_id, - base_url=base_url, - timeout_seconds=timeout_seconds, - secure=not skip_verify, - management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), - verbose=verbose, - last_response_store=self._last_response_store, - ) - self._mgmt_http = HTTPClientAsync( - project_id=self._project_id, - base_url=base_url, - timeout_seconds=timeout_seconds, - secure=not skip_verify, - management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), - verbose=verbose, - last_response_store=self._last_response_store, - ) - self._auth = AuthAsync( - self._project_id, - public_key, - jwt_validation_leeway, - http_client=self._auth_http, - ) - self._fga_cache_url = fga_cache_url - self._mgmt = MGMTAsync( - http_client=self._mgmt_http, - auth=self._auth, - fga_cache_url=fga_cache_url, - ) - - self._magiclink = MagicLinkAsync(self._auth) - self._enchantedlink = EnchantedLinkAsync(self._auth) - self._oauth = OAuthAsync(self._auth) - self._saml = SAMLAsync(self._auth) # deprecated - self._sso = SSOAsync(self._auth) - self._otp = OTPAsync(self._auth) - self._totp = TOTPAsync(self._auth) - self._webauthn = WebAuthnAsync(self._auth) - self._password = PasswordAsync(self._auth) - - # Lazy license handshake: any request through ``_mgmt_http`` triggers - # ``_ensure_license`` once. Constructed on first use to avoid binding - # the lock to an event loop that may not exist at __init__ time. - self._license_lock: asyncio.Lock | None = None - self._license_attempted = False - self._mgmt_http._pre_request_hook = self._ensure_license - - @property - def magiclink(self) -> MagicLinkAsync: - return self._magiclink - - @property - def enchantedlink(self) -> EnchantedLinkAsync: - return self._enchantedlink - - @property - def otp(self) -> OTPAsync: - return self._otp - - @property - def totp(self) -> TOTPAsync: - return self._totp - - @property - def oauth(self) -> OAuthAsync: - return self._oauth - - # deprecated (use sso instead) - @property - def saml(self) -> SAMLAsync: - return self._saml - - @property - def sso(self) -> SSOAsync: - return self._sso - - @property - def webauthn(self) -> WebAuthnAsync: - return self._webauthn - - @property - def password(self) -> PasswordAsync: - return self._password - - @property - def mgmt(self) -> MGMTAsync: - return self._mgmt - - async def aopen(self) -> DescopeClientAsync: - """Pre-warm the license handshake. Optional. - - The handshake also runs lazily on the first management request, so - callers may safely skip this. Use it when you want to pay the - handshake latency at startup rather than on the first mgmt call. - Idempotent — subsequent calls are no-ops. - """ - await self._ensure_license() - return self - - async def _ensure_license(self) -> None: - """Idempotent license handshake, safe to call from concurrent requests. - - Wired as ``_mgmt_http._pre_request_hook`` so every management request - awaits it; the lock+flag pair ensures the actual network call runs at - most once per client instance. - """ - if self._license_attempted: - return - if not self._mgmt_http.management_key: - return - if self._license_lock is None: - self._license_lock = asyncio.Lock() - async with self._license_lock: - if self._license_attempted: - return - await self._fetch_rate_limit_tier_async(self._mgmt_http) - self._license_attempted = True - - async def _fetch_rate_limit_tier_async(self, mgmt_http: HTTPClientAsync) -> None: - """Async license handshake using a one-shot ``httpx.AsyncClient``.""" - try: - async with httpx.AsyncClient( - verify=mgmt_http.client_verify, - timeout=LICENSE_HANDSHAKE_TIMEOUT_SECONDS, - ) as client: - response = await client.get( - f"{mgmt_http.base_url}{MgmtV1.license_get_path}", - headers={"Authorization": (f"Bearer {mgmt_http.project_id}:{mgmt_http.management_key}")}, - follow_redirects=True, - ) - if not response.is_success: - logger.warning( - "License handshake returned non-success status %s", - response.status_code, - ) - return - tier = response.json().get("rateLimitTier") - if tier: - mgmt_http.rate_limit_tier = tier - except Exception as e: - logger.warning("License handshake failed: %s", e) - - async def aclose(self) -> None: - """Close the underlying async HTTP clients and release connections.""" - await self._auth_http.aclose() - await self._mgmt_http.aclose() - # OutboundApplicationByTokenAsync owns a separate no-management-key client. - await self._mgmt._outbound_application_by_token.aclose() - - async def __aenter__(self) -> DescopeClientAsync: - await self.aopen() - return self - - async def __aexit__(self, *args) -> None: - await self.aclose() - - async def validate_session(self, session_token: str, audience: Iterable[str] | str | None = None) -> dict: - """Validate a session token. Awaitable so JWKS can be fetched without blocking the loop.""" - return await self._auth.validate_session(session_token, audience) - - async def refresh_session(self, refresh_token: str, audience: Iterable[str] | str | None = None) -> dict: - """Refresh a session using the refresh token. Makes an async network call.""" - self._ensure_present(refresh_token, "Refresh token is required to refresh a session", ERROR_TYPE_INVALID_TOKEN) - return await self._auth.refresh_session(refresh_token, audience) - - async def validate_and_refresh_session( - self, - session_token: str, - refresh_token: str, - audience: Iterable[str] | str | None = None, - ) -> dict: - """Validate the session token; refresh it if expired.""" - self._ensure_present(session_token, "Session token is missing", ERROR_TYPE_INVALID_TOKEN) - try: - return await self._auth.validate_session(session_token, audience) - except AuthException: - self._ensure_present(refresh_token, "Refresh token is missing", ERROR_TYPE_INVALID_TOKEN) - return await self.refresh_session(refresh_token, audience) - - async def logout(self, refresh_token: str): - """Logout the user from the current session and revoke the refresh token.""" - self._require_refresh_token(refresh_token) - return await self._auth_http.post(EndpointsV1.logout_path, body={}, pswd=refresh_token) - - async def logout_all(self, refresh_token: str): - """Logout the user from all active sessions and revoke the refresh token.""" - self._require_refresh_token(refresh_token) - return await self._auth_http.post(EndpointsV1.logout_all_path, body={}, pswd=refresh_token) - - async def me(self, refresh_token: str) -> dict: - """Retrieve user details for the refresh token.""" - self._require_refresh_token(refresh_token) - response = await self._auth_http.get(EndpointsV1.me_path, allow_redirects=None, pswd=refresh_token) - return response.json() - - async def my_tenants( - self, - refresh_token: str, - dct: bool = False, - ids: list[str] | None = None, - ) -> dict: - """Retrieve tenant attributes that the user belongs to.""" - self._require_refresh_token(refresh_token) - self._validate_tenant_selector(dct, ids) - - body: dict[str, bool | list[str]] = {"dct": dct} - if ids is not None: - body["ids"] = ids - response = await self._auth_http.post(EndpointsV1.my_tenants_path, body=body, pswd=refresh_token) - return response.json() - - async def history(self, refresh_token: str) -> list[dict]: - """Retrieve user authentication history for the refresh token.""" - self._require_refresh_token(refresh_token) - response = await self._auth_http.get(EndpointsV1.history_path, allow_redirects=None, pswd=refresh_token) - return response.json() - - async def exchange_access_key( - self, - access_key: str, - audience: Iterable[str] | str | None = None, - login_options: AccessKeyLoginOptions | None = None, - ) -> dict: - """Return a new session token for the given access key.""" - self._require_access_key(access_key) - return await self._auth.exchange_access_key(access_key, audience, login_options) - - async def select_tenant(self, tenant_id: str, refresh_token: str) -> dict: - """Add a selected tenant claim to the JWT.""" - self._ensure_present(refresh_token, "Refresh token is required to refresh a session", ERROR_TYPE_INVALID_TOKEN) - return await self._auth.select_tenant(tenant_id, refresh_token) - - def get_last_response(self): - """Get the last HTTP response when verbose mode is enabled. - - Returns the most recent response across auth and mgmt operations, - whichever ran last. - """ - return self._last_response_store.get() diff --git a/descope/exceptions.py b/descope/exceptions.py deleted file mode 100644 index fac11b565..000000000 --- a/descope/exceptions.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Optional - -ERROR_TYPE_INVALID_ARGUMENT = "invalid argument" -ERROR_TYPE_SERVER_ERROR = "server error" -ERROR_TYPE_INVALID_PUBLIC_KEY = "invalid public key" -ERROR_TYPE_INVALID_TOKEN = "invalid token" -ERROR_TYPE_API_RATE_LIMIT = "API rate limit exceeded" - -API_RATE_LIMIT_RETRY_AFTER_HEADER = "Retry-After" - - -class AuthException(Exception): - def __init__( - self, - status_code: Optional[int] = None, - error_type: Optional[str] = None, - error_message: Optional[str] = None, - **kwargs, - ): - self.status_code = status_code - self.error_type = error_type - self.error_message = error_message - - def __repr__(self): - return f"Error {self.__dict__}" - - def __str__(self): - return str(self.__dict__) - - -class RateLimitException(Exception): - def __init__( - self, - status_code: Optional[int] = None, - error_type: Optional[str] = None, - error_description: Optional[str] = None, - error_message: Optional[str] = None, - rate_limit_parameters: Optional[dict] = None, - **kwargs, - ): - self.status_code = status_code - self.error_type = error_type - self.error_description = error_description - self.error_message = error_message - self.rate_limit_parameters = {} if rate_limit_parameters is None else rate_limit_parameters - - def __repr__(self): - return f"Error {self.__dict__}" - - def __str__(self): - return str(self.__dict__) diff --git a/descope/flask/__init__.py b/descope/flask/__init__.py deleted file mode 100644 index 35faef507..000000000 --- a/descope/flask/__init__.py +++ /dev/null @@ -1,505 +0,0 @@ -from __future__ import annotations - -import datetime -import uuid -from functools import wraps - -from flask import Response, g, redirect, request - -from .. import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_COOKIE_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - AuthException, - DeliveryMethod, -) -from ..descope_client import DescopeClient -from ..exceptions import ERROR_TYPE_INVALID_ARGUMENT - - -def set_cookie_on_response(response: Response, token: dict, cookie_data: dict): - cookie_domain = cookie_data.get("domain", "") - if cookie_domain == "": - cookie_domain = None - - current_time = datetime.datetime.now() - expire_time = current_time + datetime.timedelta(days=30) - - return response.set_cookie( - key=token.get("drn", ""), - value=token.get("jwt", ""), - max_age=cookie_data.get("maxAge", int(expire_time.timestamp())), - expires=cookie_data.get("exp", expire_time), - path=cookie_data.get("path", "/"), - domain=cookie_domain, - secure=True, # Cookies must be sent over HTTPS only - httponly=True, - samesite="Strict", # "Strict", "Lax", "None" - ) - - -def descope_signup_otp_by_email(descope_client: DescopeClient): - """ - Sign-up new user, using email to verify the OTP - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - email = data.get("email", None) - user = data.get("user", None) - if not email or email == "": - return Response("Invalid Request, missing email", 400) - - try: - descope_client.otp.sign_up(DeliveryMethod.EMAIL, email, user) - except AuthException as e: - return Response(f"Unable to sign-up user, err: {e}", 500) - - return f(*args, **kwargs) - - return decorated - - return decorator - - -def descope_signin_otp_by_email(descope_client: DescopeClient): - """ - Sign-in existing user, using email to verify OTP - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - email = data.get("email", None) - if not email: - return Response("Invalid Request, missing email", 400) - - try: - descope_client.otp.sign_in(DeliveryMethod.EMAIL, email) - except AuthException as e: - return Response(f"Unable to sign-in, err: {e}", 500) - - return f(*args, **kwargs) - - return decorated - - return decorator - - -def descope_validate_auth( - descope_client: DescopeClient, - permissions: list[str] | None = None, - roles: list[str] | None = None, - tenant="", -): - """ - Test if Access Token is valid - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - _permissions = [] if permissions is None else permissions - _roles = [] if roles is None else roles - - cookies = request.cookies.copy() - session_token = cookies.get(SESSION_COOKIE_NAME, None) - refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, None) - try: - jwt_response = descope_client.validate_and_refresh_session(session_token, refresh_token) - - except AuthException: - return Response("Access denied", 401) - - if _permissions: - if tenant: - valid_permissions = descope_client.validate_tenant_permissions(jwt_response, _permissions) - else: - valid_permissions = descope_client.validate_permissions(jwt_response, _permissions) - - if not valid_permissions: - return Response("Access denied", 401) - - if _roles: - if tenant: - valid_roles = descope_client.validate_tenant_roles(jwt_response, _roles) - else: - valid_roles = descope_client.validate_roles(jwt_response, _roles) - - if not valid_roles: - return Response("Access denied", 401) - - # Save the claims on the context execute the original API - g.claims = jwt_response - response = f(*args, **kwargs) - - if jwt_response.get(COOKIE_DATA_NAME, None): - set_cookie_on_response( - response, - jwt_response[SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - return response - - return decorated - - return decorator - - -def descope_verify_code_by_email(descope_client: DescopeClient): - """ - Verify code by email decorator - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - email = data.get("email", None) - code = data.get("code", None) - if not code or not email: - return Response("Unauthorized", 401) - - try: - jwt_response = descope_client.otp.verify_code(DeliveryMethod.EMAIL, email, code) - except AuthException: - return Response("Unauthorized", 401) - - # Save the claims on the context execute the original API - g.claims = jwt_response - response = f(*args, **kwargs) - - set_cookie_on_response( - response, - jwt_response[SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - set_cookie_on_response( - response, - jwt_response[REFRESH_SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - return response - - return decorated - - return decorator - - -def descope_verify_code_by_phone_sms(descope_client: DescopeClient): - """ - Verify code by phone sms decorator - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - phone = data.get("phone", None) - code = data.get("code", None) - if not code or not phone: - return Response("Unauthorized", 401) - - try: - jwt_response = descope_client.otp.verify_code(DeliveryMethod.SMS, phone, code) - except AuthException: - return Response("Unauthorized", 401) - - # Save the claims on the context execute the original API - g.claims = jwt_response - response = f(*args, **kwargs) - - set_cookie_on_response( - response, - jwt_response[SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - set_cookie_on_response( - response, - jwt_response[REFRESH_SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - - return response - - return decorated - - return decorator - - -def descope_verify_code_by_phone_voice_call(descope_client: DescopeClient): - """ - Verify code by phone voice call decorator - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - phone = data.get("phone", None) - code = data.get("code", None) - if not code or not phone: - return Response("Unauthorized", 401) - - try: - jwt_response = descope_client.otp.verify_code(DeliveryMethod.VOICE, phone, code) - except AuthException: - return Response("Unauthorized", 401) - - # Save the claims on the context execute the original API - g.claims = jwt_response - response = f(*args, **kwargs) - - set_cookie_on_response( - response, - jwt_response[SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - set_cookie_on_response( - response, - jwt_response[REFRESH_SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - - return response - - return decorated - - return decorator - - -def descope_verify_code_by_phone_whatsapp(descope_client: DescopeClient): - """ - Verify code by phone whatsapp decorator - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - phone = data.get("phone", None) - code = data.get("code", None) - if not code or not phone: - return Response("Unauthorized", 401) - - try: - jwt_response = descope_client.otp.verify_code(DeliveryMethod.WHATSAPP, phone, code) - except AuthException: - return Response("Unauthorized", 401) - - # Save the claims on the context execute the original API - g.claims = jwt_response - response = f(*args, **kwargs) - - set_cookie_on_response( - response, - jwt_response[SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - set_cookie_on_response( - response, - jwt_response[REFRESH_SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - - return response - - return decorated - - return decorator - - -def descope_signup_magiclink_by_email(descope_client: DescopeClient, uri: str): - """ - Signup new user using magiclink via email - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - email = data.get("email", None) - user = data.get("user", None) - if not email or email == "": - return Response("Bad Request, missing email", 400) - - try: - descope_client.magiclink.sign_up(DeliveryMethod.EMAIL, email, uri, user) - except AuthException as e: - return Response(f"Failed to signup, err: {e}", 500) - - return f(*args, **kwargs) - - return decorated - - return decorator - - -def descope_signin_magiclink_by_email(descope_client: DescopeClient, uri: str): - """ - Signin using magiclink via email - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - data = request.get_json(force=True) - email = data.get("email", None) - if not email: - return Response("Bad Request, missing email", 400) - - try: - descope_client.magiclink.sign_in(DeliveryMethod.EMAIL, email, uri) - except AuthException as e: - return Response(f"Failed to signin, err: {e}", 500) - - return f(*args, **kwargs) - - return decorated - - return decorator - - -def descope_verify_magiclink_token(descope_client: DescopeClient): - """ - Verify magiclink token - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - code = request.args.get("t") - if not code: - return Response("Unauthorized", 401) - - try: - jwt_response = descope_client.magiclink.verify(code) - except AuthException: - return Response("Unauthorized", 401) - - # Save the claims on the context execute the original API - g.claims = jwt_response - response = f(*args, **kwargs) - - set_cookie_on_response( - response, - jwt_response[SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - set_cookie_on_response( - response, - jwt_response[REFRESH_SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - return response - - return decorated - - return decorator - - -def descope_logout(descope_client: DescopeClient): - """ - Logout - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - cookies = request.cookies.copy() - refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME) - cookie_domain = request.headers.get("Host", "") - try: - descope_client.logout(refresh_token) - except AuthException as e: - return Response(f"Logout failed {e}", e.status_code) - - # Execute the original API - response = f(*args, **kwargs) - - # Invalidate all cookies - if cookie_domain: - response.delete_cookie(SESSION_COOKIE_NAME, "/", cookie_domain) - response.delete_cookie(REFRESH_SESSION_COOKIE_NAME, "/", cookie_domain) - return response - - return decorated - - return decorator - - -def descope_oauth(descope_client: DescopeClient): - """ - OAuth login - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - try: - args = request.args - provider = args.get("provider") - redirect_url = descope_client.oauth.start(provider) - except AuthException as e: - return Response(f"OAuth failed {e}", e.status_code) - - # Execute the original API - # (ignore return value as anyway we redirect) - f(*args, **kwargs) - - return redirect(redirect_url["url"], 302) - - return decorated - - return decorator - - -def descope_full_login(project_id: str, flow_id: str, success_redirect_url: str): - """ - Descope Flow login - """ - - def decorator(f): - @wraps(f) - def decorated(*args, **kwargs): - id = f"descope-{uuid.uuid4()}" - if not success_redirect_url: - raise AuthException( - 500, - ERROR_TYPE_INVALID_ARGUMENT, - "Missing success_redirect_url parameter", - ) - - html = f""" - - - - - - - - - - """ - f(*args, **kwargs) - return html - - return decorated - - return decorator diff --git a/descope/http_client.py b/descope/http_client.py deleted file mode 100644 index 1f4cdadf5..000000000 --- a/descope/http_client.py +++ /dev/null @@ -1,198 +0,0 @@ -from __future__ import annotations - -import time -from typing import cast - -import httpx - -from descope._http_client_base import ( - _RETRY_DELAYS_SECONDS, - _RETRY_STATUS_CODES, - DEFAULT_TIMEOUT_SECONDS, - DescopeResponse, - HTTPClientBase, - ThreadLocalLastResponseStore, -) - - -class HTTPClient(HTTPClientBase): - def __init__( - self, - project_id: str, - base_url: str | None = None, - *, - timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - secure: bool = True, - management_key: str | None = None, - verbose: bool = False, - last_response_store: ThreadLocalLastResponseStore | None = None, - ) -> None: - super().__init__( - project_id, - base_url, - timeout_seconds=timeout_seconds, - secure=secure, - management_key=management_key, - verbose=verbose, - ) - self.last_response_store = last_response_store or ThreadLocalLastResponseStore() - - # ------------- public API ------------- - def get( - self, - uri: str, - *, - params=None, - allow_redirects: bool | None = True, - pswd: str | None = None, - ) -> httpx.Response: - response = self._execute_with_retry( - lambda: httpx.get( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - params=params, - follow_redirects=cast(bool, allow_redirects), - verify=self.client_verify, - timeout=self.timeout_seconds, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - def post( - self, - uri: str, - *, - body: dict | list[dict] | list[str] | None = None, - params=None, - pswd: str | None = None, - base_url: str | None = None, - ) -> httpx.Response: - response = self._execute_with_retry( - lambda: httpx.post( - f"{base_url or self.base_url}{uri}", - headers=self._get_default_headers(pswd), - json=body, - follow_redirects=False, - verify=self.client_verify, - params=params, - timeout=self.timeout_seconds, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - def put( - self, - uri: str, - *, - body: dict | list[dict] | list[str] | None = None, - params=None, - pswd: str | None = None, - ) -> httpx.Response: - response = self._execute_with_retry( - lambda: httpx.put( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - json=body, - follow_redirects=False, - verify=self.client_verify, - params=params, - timeout=self.timeout_seconds, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - def patch( - self, - uri: str, - *, - body: dict | list[dict] | list[str] | None, - params=None, - pswd: str | None = None, - ) -> httpx.Response: - response = self._execute_with_retry( - lambda: httpx.patch( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - json=body, - follow_redirects=False, - verify=self.client_verify, - params=params, - timeout=self.timeout_seconds, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - def delete( - self, - uri: str, - *, - params=None, - pswd: str | None = None, - ) -> httpx.Response: - response = self._execute_with_retry( - lambda: httpx.delete( - f"{self.base_url}{uri}", - params=params, - headers=self._get_default_headers(pswd), - follow_redirects=False, - verify=self.client_verify, - timeout=self.timeout_seconds, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - def get_last_response(self) -> DescopeResponse | None: - """ - Get the last HTTP response when verbose mode is enabled. - - Useful for accessing HTTP metadata like headers (e.g., cf-ray), - status codes, and raw response data for debugging. - - This method is thread-safe: each thread will receive its own - last response when using a shared client instance. - - Returns: - DescopeResponse: The last response if verbose mode is enabled, None otherwise. - - Example: - client = DescopeClient(project_id, management_key, verbose=True) - try: - client.mgmt.user.create(login_id="u1") - except AuthException: - resp = client.get_last_response() - if resp: - logger.error(f"cf-ray: {resp.headers.get('cf-ray')}") - """ - return self.last_response_store.get() - - # ------------- helpers ------------- - def _execute_with_retry(self, request_fn) -> httpx.Response: - """Execute request_fn and retry on retryable status codes. - - Retries up to 3 times: first retry after 100ms, subsequent retries after 5s each. - The prior response is closed before each retry to release the connection back to - the pool and avoid connection exhaustion under repeated transient errors. - """ - response = request_fn() - for delay in _RETRY_DELAYS_SECONDS: - if response.status_code not in _RETRY_STATUS_CODES: - break - response.close() - time.sleep(delay) - response = request_fn() - return response diff --git a/descope/http_client_async.py b/descope/http_client_async.py deleted file mode 100644 index b915bc5e0..000000000 --- a/descope/http_client_async.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Awaitable, Callable, cast - -import httpx - -from descope._http_client_base import ( - _RETRY_DELAYS_SECONDS, - _RETRY_STATUS_CODES, - DEFAULT_TIMEOUT_SECONDS, - ContextVarLastResponseStore, - DescopeResponse, - HTTPClientBase, -) - - -class HTTPClientAsync(HTTPClientBase): - def __init__( - self, - project_id: str, - base_url: str | None = None, - *, - timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - secure: bool = True, - management_key: str | None = None, - verbose: bool = False, - last_response_store: ContextVarLastResponseStore | None = None, - ) -> None: - super().__init__( - project_id, - base_url, - timeout_seconds=timeout_seconds, - secure=secure, - management_key=management_key, - verbose=verbose, - ) - self._async_client = httpx.AsyncClient( - verify=self.client_verify, - timeout=self.timeout_seconds, - ) - self.last_response_store = last_response_store or ContextVarLastResponseStore() - # Optional one-shot async hook invoked before the first request goes - # out. Used by ``DescopeClientAsync`` to lazily run the license - # handshake on ``_mgmt_http`` without blocking the event loop in - # ``__init__``. The hook is expected to be idempotent — it will be - # awaited on every request, but should fast-path after the first run. - self._pre_request_hook: Callable[[], Awaitable[None]] | None = None - - async def get( - self, - uri: str, - *, - params=None, - allow_redirects: bool | None = True, - pswd: str | None = None, - ) -> httpx.Response: - response = await self._async_execute_with_retry( - lambda: self._async_client.get( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - params=params, - follow_redirects=cast(bool, allow_redirects), - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - async def post( - self, - uri: str, - *, - body: dict | list[dict] | list[str] | None = None, - params=None, - pswd: str | None = None, - base_url: str | None = None, - ) -> httpx.Response: - response = await self._async_execute_with_retry( - lambda: self._async_client.post( - f"{base_url or self.base_url}{uri}", - headers=self._get_default_headers(pswd), - json=body, - follow_redirects=False, - params=params, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - async def put( - self, - uri: str, - *, - body: dict | list[dict] | list[str] | None = None, - params=None, - pswd: str | None = None, - ) -> httpx.Response: - response = await self._async_execute_with_retry( - lambda: self._async_client.put( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - json=body, - follow_redirects=False, - params=params, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - async def patch( - self, - uri: str, - *, - body: dict | list[dict] | list[str] | None, - params=None, - pswd: str | None = None, - ) -> httpx.Response: - response = await self._async_execute_with_retry( - lambda: self._async_client.patch( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - json=body, - follow_redirects=False, - params=params, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - async def delete( - self, - uri: str, - *, - params=None, - pswd: str | None = None, - ) -> httpx.Response: - response = await self._async_execute_with_retry( - lambda: self._async_client.delete( - f"{self.base_url}{uri}", - params=params, - headers=self._get_default_headers(pswd), - follow_redirects=False, - ) - ) - if self.verbose: - self.last_response_store.set(DescopeResponse(response)) - self._raise_from_response(response) - return response - - def get_last_response(self) -> DescopeResponse | None: - """ - Get the last HTTP response for the current async task when verbose mode is enabled. - - Uses a ContextVar (not threading.local) so each concurrent async task sees its - own last response, even though all tasks share one event-loop thread. - """ - return self.last_response_store.get() - - async def _async_execute_with_retry(self, request_fn) -> httpx.Response: - if self._pre_request_hook is not None: - await self._pre_request_hook() - response = await request_fn() - for delay in _RETRY_DELAYS_SECONDS: - if response.status_code not in _RETRY_STATUS_CODES: - break - await response.aclose() - await asyncio.sleep(delay) - response = await request_fn() - return response - - async def aclose(self) -> None: - await self._async_client.aclose() - - async def __aenter__(self) -> HTTPClientAsync: - return self - - async def __aexit__(self, *args) -> None: - await self.aclose() diff --git a/descope/jwt_common.py b/descope/jwt_common.py deleted file mode 100644 index 92bbfdba5..000000000 --- a/descope/jwt_common.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import warnings -from typing import Callable, Iterable, Optional - -import jwt - -from descope.common import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, -) - - -def adjust_properties(jwt_response: dict, user_jwt: bool) -> dict: - """Normalize top-level fields on a JWT response. - - Copies permissions/roles/tenants from present token claims (session or refresh) - to the top-level and sets projectId and userId/keyId for convenience. - """ - # Save permissions, roles and tenants info from Session token or from refresh token on the json top level - if SESSION_TOKEN_NAME in jwt_response: - jwt_response["permissions"] = jwt_response[SESSION_TOKEN_NAME].get("permissions", []) - jwt_response["roles"] = jwt_response[SESSION_TOKEN_NAME].get("roles", []) - jwt_response["tenants"] = jwt_response[SESSION_TOKEN_NAME].get("tenants", {}) - elif REFRESH_SESSION_TOKEN_NAME in jwt_response: - jwt_response["permissions"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("permissions", []) - jwt_response["roles"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("roles", []) - jwt_response["tenants"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("tenants", {}) - else: - jwt_response["permissions"] = jwt_response.get("permissions", []) - jwt_response["roles"] = jwt_response.get("roles", []) - jwt_response["tenants"] = jwt_response.get("tenants", {}) - - # Save the projectID also in the dict top level - issuer = ( - jwt_response.get(SESSION_TOKEN_NAME, {}).get("iss", None) - or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("iss", None) - or jwt_response.get("iss", "") - ) - jwt_response["projectId"] = issuer.rsplit("/")[-1] # support both url issuer and project ID issuer - - sub = ( - jwt_response.get(SESSION_TOKEN_NAME, {}).get("dsub", None) - or jwt_response.get(SESSION_TOKEN_NAME, {}).get("sub", None) - or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("dsub", None) - or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("sub", None) - or jwt_response.get("sub", "") - ) - if user_jwt: - # Save the userID also in the dict top level - jwt_response["userId"] = sub - else: - # Save the AccessKeyID also in the dict top level - jwt_response["keyId"] = sub - - return jwt_response - - -def decode_token_unverified(token: str, audience: Optional[str | Iterable[str]] = None) -> dict: - """Decode a JWT without verifying signature (used when no validator is provided). - - WARNING: This function does NOT verify JWT signatures and should NEVER be used - for authentication or authorization decisions. It is only intended for testing - or scenarios where token validation happens elsewhere. - - Audience verification is disabled by default since no key is provided. - Returns an empty dict if decoding fails. - """ - warnings.warn( - "decode_token_unverified() does not verify JWT signatures. " - "Do not use this for authentication or authorization. " - "Use proper token validation with signature verification instead.", - UserWarning, - stacklevel=2, - ) - try: - return jwt.decode(token, options={"verify_signature": False, "verify_aud": False}) - except Exception: - return {} - - -def generate_auth_info( - response_body: dict, - refresh_token: Optional[str], - user_jwt: bool, - audience: Optional[str | Iterable[str]] = None, - token_validator: Optional[Callable[[str, Optional[str | Iterable[str]]], dict]] = None, -) -> dict: - """Build the normalized JWT info dict using a provided token validator. - - token_validator should accept (token, audience) and return decoded claims dict. - If not provided, tokens will be decoded without signature verification. - """ - if token_validator is None: - token_validator = decode_token_unverified - - jwt_response: dict = {} - - st_jwt = response_body.get("sessionJwt", "") - if st_jwt: - jwt_response[SESSION_TOKEN_NAME] = token_validator(st_jwt, audience) - - rt_jwt = response_body.get("refreshJwt", "") - if rt_jwt: - jwt_response[REFRESH_SESSION_TOKEN_NAME] = token_validator(rt_jwt, audience) - elif refresh_token: - jwt_response[REFRESH_SESSION_TOKEN_NAME] = token_validator(refresh_token, audience) - - jwt_response = adjust_properties(jwt_response, user_jwt) - - if user_jwt: - jwt_response[COOKIE_DATA_NAME] = { - "exp": response_body.get("cookieExpiration", 0), - "maxAge": response_body.get("cookieMaxAge", 0), - "domain": response_body.get("cookieDomain", ""), - "path": response_body.get("cookiePath", "/"), - } - - return jwt_response - - -def generate_jwt_response( - response_body: dict, - refresh_cookie: Optional[str], - audience: Optional[str | Iterable[str]] = None, - token_validator: Optional[Callable[[str, Optional[str | Iterable[str]]], dict]] = None, -) -> dict: - """Compose the final JWT response body using the provided token validator.""" - jwt_response = generate_auth_info(response_body, refresh_cookie, True, audience, token_validator) - jwt_response["user"] = response_body.get("user", {}) - jwt_response["firstSeen"] = response_body.get("firstSeen", True) - return jwt_response diff --git a/descope/management/_access_key_base.py b/descope/management/_access_key_base.py deleted file mode 100644 index 8c9362f31..000000000 --- a/descope/management/_access_key_base.py +++ /dev/null @@ -1,41 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from typing import List, Optional - -from descope.management.common import AssociatedTenant, associated_tenants_to_dict - - -class AccessKeyBase: - """Shared, I/O-free base for AccessKey management classes. - - Holds only static body composers — no network I/O, no ``__init__``. - The two concrete subclasses add the network layer: - - - ``AccessKey(AccessKeyBase, HTTPBase)`` — sync - - ``AccessKeyAsync(AccessKeyBase, AsyncHTTPBase)`` — async - """ - - @staticmethod - def _compose_create_body( - name: str, - expire_time: int, - role_names: List[str], - key_tenants: List[AssociatedTenant], - user_id: Optional[str] = None, - custom_claims: Optional[dict] = None, - description: Optional[str] = None, - permitted_ips: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - return { - "name": name, - "expireTime": expire_time, - "roleNames": role_names, - "keyTenants": associated_tenants_to_dict(key_tenants), - "userId": user_id, - "customClaims": custom_claims, - "description": description, - "permittedIps": permitted_ips, - "customAttributes": custom_attributes, - } diff --git a/descope/management/_audit_base.py b/descope/management/_audit_base.py deleted file mode 100644 index eefa67109..000000000 --- a/descope/management/_audit_base.py +++ /dev/null @@ -1,31 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from datetime import datetime - - -class AuditBase: - """Shared, I/O-free base for Audit management classes. - - Holds only static converters — no network I/O, no ``__init__``. - The two concrete subclasses add the network layer: - - - ``Audit(AuditBase, HTTPBase)`` — sync - - ``AuditAsync(AuditBase, AsyncHTTPBase)`` — async - """ - - @staticmethod - def _convert_audit_record(a: dict) -> dict: - return { - "projectId": a.get("projectId", ""), - "userId": a.get("userId", ""), - "action": a.get("action", ""), - "occurred": datetime.utcfromtimestamp(float(a.get("occurred", "0")) / 1000), - "device": a.get("device", ""), - "method": a.get("method", ""), - "geo": a.get("geo", ""), - "remoteAddress": a.get("remoteAddress", ""), - "loginIds": a.get("externalIds", []), - "tenants": a.get("tenants", []), - "data": a.get("data", {}), - } diff --git a/descope/management/_engine_base.py b/descope/management/_engine_base.py deleted file mode 100644 index c91f8f9a8..000000000 --- a/descope/management/_engine_base.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - - -class EngineBase: - @staticmethod - def _compose_create_body(name: str) -> dict: - return {"name": name} - - @staticmethod - def _compose_update_body(id: str, name: str) -> dict: - return {"id": id, "name": name} diff --git a/descope/management/_family_base.py b/descope/management/_family_base.py deleted file mode 100644 index ee675bba4..000000000 --- a/descope/management/_family_base.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - - -class FamilyBase: - @staticmethod - def _compose_create_body( - name: str, - id: Optional[str], - custom_attributes: Optional[dict], - photo: Optional[str], - disabled: Optional[bool], - ) -> dict: - body: dict[str, Any] = {"name": name} - if id is not None: - body["familyId"] = id - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if photo is not None: - body["photo"] = photo - if disabled is not None: - body["disabled"] = disabled - return body - - @staticmethod - def _compose_update_body( - id: str, - name: Optional[str], - custom_attributes: Optional[dict], - photo: Optional[str], - disabled: Optional[bool], - ) -> dict: - body: dict[str, Any] = {"id": id} - if name is not None: - body["name"] = name - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if photo is not None: - body["photo"] = photo - if disabled is not None: - body["disabled"] = disabled - return body - - @staticmethod - def _compose_search_body( - ids: Optional[List[str]], - names: Optional[List[str]], - text: Optional[str], - custom_attributes: Optional[dict], - page: Optional[int], - size: Optional[int], - ) -> dict: - body: dict[str, Any] = {} - if ids is not None: - body["familyIds"] = ids - if names is not None: - body["familyNames"] = names - if text is not None: - body["freeText"] = text - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if page is not None: - body["page"] = page - if size is not None: - body["size"] = size - return body - - @staticmethod - def _compose_create_dependent_body( - family_id: str, - login_id: Optional[str], - name: Optional[str], - email: Optional[str], - phone: Optional[str], - given_name: Optional[str], - middle_name: Optional[str], - family_name: Optional[str], - picture: Optional[str], - custom_attributes: Optional[dict], - family_scoped_attributes: Optional[dict], - ) -> dict: - body: dict[str, Any] = {"familyId": family_id} - if login_id is not None: - body["loginId"] = login_id - if name is not None: - body["name"] = name - if email is not None: - body["email"] = email - if phone is not None: - body["phone"] = phone - if given_name is not None: - body["givenName"] = given_name - if middle_name is not None: - body["middleName"] = middle_name - if family_name is not None: - body["familyName"] = family_name - if picture is not None: - body["picture"] = picture - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if family_scoped_attributes is not None: - body["familyScopedAttributes"] = {family_id: family_scoped_attributes} - return body - - @staticmethod - def _compose_impersonate_body( - impersonator_user_id_or_login_id: str, - dependent_login_id: str, - selected_family: Optional[str], - ) -> dict: - body: dict[str, Any] = { - "impersonatorUserIdOrLoginId": impersonator_user_id_or_login_id, - "dependentLoginId": dependent_login_id, - } - if selected_family is not None: - body["selectedFamily"] = selected_family - return body - - @staticmethod - def _compose_stop_impersonation_body( - jwt: str, - custom_claims: Optional[dict], - refresh_duration: Optional[int], - ) -> dict: - body: dict[str, Any] = {"jwt": jwt} - if custom_claims is not None: - body["customClaims"] = custom_claims - if refresh_duration is not None: - body["refreshDuration"] = refresh_duration - return body - - @staticmethod - def _compose_settings_body( - enabled: Optional[bool], - max_family_members: Optional[int], - allow_multiple_families_users: Optional[bool], - ) -> dict: - body: dict[str, Any] = {} - if enabled is not None: - body["enabled"] = enabled - if max_family_members is not None: - body["maxFamilyMembers"] = max_family_members - if allow_multiple_families_users is not None: - body["allowMultipleFamiliesUsers"] = allow_multiple_families_users - return body diff --git a/descope/management/_jwt_base.py b/descope/management/_jwt_base.py deleted file mode 100644 index 3e6f4efac..000000000 --- a/descope/management/_jwt_base.py +++ /dev/null @@ -1,52 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -from descope.management.common import MgmtSignUpOptions, MgmtUserRequest - - -class JWTBase: - """Shared, I/O-free base for JWT management classes. - - Holds only static validation guards and body composers — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: - - - ``JWT(JWTBase, HTTPBase)`` — sync, uses ``self._http`` (``HTTPClient``) - - ``JWTAsync(JWTBase, AsyncHTTPBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) - """ - - @staticmethod - def _validate_jwt(jwt: str) -> None: - if not jwt: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") - - @staticmethod - def _validate_impersonator_id(impersonator_id: str) -> None: - if not impersonator_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "impersonator_id cannot be empty") - - @staticmethod - def _validate_login_id(login_id: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - @staticmethod - def _validate_jwt_required(login_options) -> None: - if not login_options.jwt: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "JWT is required") - - @staticmethod - def _compose_sign_up_body( - login_id: str, - user: MgmtUserRequest, - signup_options: MgmtSignUpOptions, - ) -> dict: - return { - "loginId": login_id, - "user": user.to_dict(), - "emailVerified": user.email_verified, - "phoneVerified": user.phone_verified, - "ssoAppId": user.sso_app_id, - "customClaims": signup_options.custom_claims, - "refreshDuration": signup_options.refresh_duration, - } diff --git a/descope/management/_lists_base.py b/descope/management/_lists_base.py deleted file mode 100644 index 79b30903b..000000000 --- a/descope/management/_lists_base.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - - -class ListsBase: - @staticmethod - def _compose_create_body(name: str, description: Optional[str], list_type: str, data: Any) -> dict: - body = {"name": name, "type": list_type} - if description is not None: - body["description"] = description - if data is not None: - body["data"] = data - return body - - @staticmethod - def _compose_update_body(id: str, name: str, description: Optional[str], list_type: str, data: Any) -> dict: - body = {"id": id, "name": name, "type": list_type} - if description is not None: - body["description"] = description - if data is not None: - body["data"] = data - return body - - @staticmethod - def _compose_delete_body(id: str) -> dict: - return {"id": id} - - @staticmethod - def _compose_import_body(lists: List[dict]) -> dict: - return {"lists": lists} - - @staticmethod - def _compose_ip_body(id: str, ips: List[str]) -> dict: - return {"id": id, "ips": ips} - - @staticmethod - def _compose_check_ip_body(id: str, ip: str) -> dict: - return {"id": id, "ip": ip} - - @staticmethod - def _compose_text_body(id: str, texts: List[str]) -> dict: - return {"id": id, "texts": texts} - - @staticmethod - def _compose_check_text_body(id: str, text: str) -> dict: - return {"id": id, "text": text} - - @staticmethod - def _compose_check_texts_body(id: str, texts: List[str], match_all: bool) -> dict: - return {"id": id, "texts": texts, "matchAll": match_all} - - @staticmethod - def _compose_clear_body(id: str) -> dict: - return {"id": id} diff --git a/descope/management/_outbound_application_base.py b/descope/management/_outbound_application_base.py deleted file mode 100644 index 4dc5ed798..000000000 --- a/descope/management/_outbound_application_base.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - -from descope.management.common import ( - AccessType, - PromptType, - URLParam, - url_params_to_dict, -) - - -class OutboundApplicationBase: - @staticmethod - def _compose_create_update_body( - name: str, - description: Optional[str] = None, - logo: Optional[str] = None, - id: Optional[str] = None, - client_secret: Optional[str] = None, - client_id: Optional[str] = None, - discovery_url: Optional[str] = None, - authorization_url: Optional[str] = None, - authorization_url_params: Optional[List[URLParam]] = None, - token_url: Optional[str] = None, - token_url_params: Optional[List[URLParam]] = None, - revocation_url: Optional[str] = None, - default_scopes: Optional[List[str]] = None, - default_redirect_url: Optional[str] = None, - callback_domain: Optional[str] = None, - pkce: Optional[bool] = None, - access_type: Optional[AccessType] = None, - prompt: Optional[List[PromptType]] = None, - ) -> dict: - body: dict[str, Any] = { - "name": name, - "id": id, - "description": description, - "logo": logo, - } - if client_secret: - body["clientSecret"] = client_secret - if client_id: - body["clientId"] = client_id - if discovery_url: - body["discoveryUrl"] = discovery_url - if authorization_url: - body["authorizationUrl"] = authorization_url - if authorization_url_params is not None: - body["authorizationUrlParams"] = url_params_to_dict(authorization_url_params) - if token_url: - body["tokenUrl"] = token_url - if token_url_params is not None: - body["tokenUrlParams"] = url_params_to_dict(token_url_params) - if revocation_url: - body["revocationUrl"] = revocation_url - if default_scopes is not None: - body["defaultScopes"] = default_scopes - if default_redirect_url: - body["defaultRedirectUrl"] = default_redirect_url - if callback_domain: - body["callbackDomain"] = callback_domain - if pkce is not None: - body["pkce"] = pkce - if access_type: - body["accessType"] = access_type.value - if prompt is not None: - body["prompt"] = [p.value for p in prompt] - return body - - @staticmethod - def _compose_oauth_upload_body( - base: dict, - refresh_token: Optional[str] = None, - access_token: Optional[str] = None, - access_token_expiry: Optional[int] = None, - access_token_type: Optional[str] = None, - scopes: Optional[List[str]] = None, - external_identifier: Optional[str] = None, - id_token: Optional[str] = None, - granted_by: Optional[str] = None, - verify_refresh: Optional[bool] = None, - ) -> dict: - body = dict(base) - if refresh_token is not None: - body["refreshToken"] = refresh_token - if access_token is not None: - body["accessToken"] = access_token - if access_token_expiry is not None: - body["accessTokenExpiry"] = access_token_expiry - if access_token_type is not None: - body["accessTokenType"] = access_token_type - if scopes is not None: - body["scopes"] = scopes - if external_identifier is not None: - body["externalIdentifier"] = external_identifier - if id_token is not None: - body["idToken"] = id_token - if granted_by is not None: - body["grantedBy"] = granted_by - if verify_refresh is not None: - body["verifyRefresh"] = verify_refresh - return body diff --git a/descope/management/_outbound_scim_base.py b/descope/management/_outbound_scim_base.py deleted file mode 100644 index 4a36d692e..000000000 --- a/descope/management/_outbound_scim_base.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from typing import Any, Optional - - -class OutboundSCIMBase: - @staticmethod - def _compose_create_body( - app_id: str, - configuration: Optional[dict] = None, - ) -> dict: - body: dict[str, Any] = { - "appId": app_id, - } - if configuration is not None: - body["configuration"] = configuration - return body - - @staticmethod - def _compose_update_body( - app_id: str, - version: int, - configuration: Optional[dict] = None, - ) -> dict: - body: dict[str, Any] = { - "appId": app_id, - "version": version, - } - if configuration is not None: - body["configuration"] = configuration - return body diff --git a/descope/management/_sso_application_base.py b/descope/management/_sso_application_base.py deleted file mode 100644 index c791e1240..000000000 --- a/descope/management/_sso_application_base.py +++ /dev/null @@ -1,128 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from typing import Any, List, Optional - -from descope.management.common import ( - SAMLIDPAttributeMappingInfo, - SAMLIDPGroupsMappingInfo, - saml_idp_attribute_mapping_info_to_dict, - saml_idp_groups_mapping_info_to_dict, -) - - -class SSOApplicationBase: - """Shared, I/O-free base for SSOApplication management classes. - - Holds only static body composers — no network I/O, no ``__init__``. - The two concrete subclasses add the network layer: - - - ``SSOApplication(SSOApplicationBase, HTTPBase)`` — sync - - ``SSOApplicationAsync(SSOApplicationBase, AsyncHTTPBase)`` — async - """ - - @staticmethod - def _compose_create_update_oidc_body( - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - force_authentication: Optional[bool] = False, - client_type: Optional[str] = None, - ) -> dict: - body: dict[str, Any] = { - "name": name, - "id": id, - "description": description, - "logo": logo, - "enabled": enabled, - "loginPageUrl": login_page_url, - "forceAuthentication": force_authentication, - } - if client_type is not None: - body["clientType"] = client_type - return body - - @staticmethod - def _compose_create_update_saml_body( - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - enabled: Optional[bool] = True, - logo: Optional[str] = None, - use_metadata_info: Optional[bool] = False, - metadata_url: Optional[str] = None, - entity_id: Optional[str] = None, - acs_url: Optional[str] = None, - certificate: Optional[str] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - acs_allowed_callbacks: Optional[List[str]] = None, - subject_name_id_type: Optional[str] = None, - subject_name_id_format: Optional[str] = None, - default_relay_state: Optional[str] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - default_signature_algorithm: Optional[str] = None, - ) -> dict: - body: dict[str, Any] = { - "id": id, - "name": name, - "description": description, - "enabled": enabled, - "logo": logo, - "loginPageUrl": login_page_url, - "useMetadataInfo": use_metadata_info, - "metadataUrl": metadata_url, - "entityId": entity_id, - "acsUrl": acs_url, - "certificate": certificate, - "attributeMapping": saml_idp_attribute_mapping_info_to_dict(attribute_mapping), - "groupsMapping": saml_idp_groups_mapping_info_to_dict(groups_mapping), - "acsAllowedCallbacks": acs_allowed_callbacks, - "subjectNameIdType": subject_name_id_type, - "subjectNameIdFormat": subject_name_id_format, - "defaultRelayState": default_relay_state, - "forceAuthentication": force_authentication, - "logoutRedirectUrl": logout_redirect_url, - "defaultSignatureAlgorithm": default_signature_algorithm, - } - return body - - @staticmethod - def _compose_create_update_wsfed_body( - name: str, - login_page_url: str, - realm: str, - reply_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - reply_allowed_callbacks: Optional[List[str]] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - error_redirect_url: Optional[str] = None, - ) -> dict: - body: dict[str, Any] = { - "id": id, - "name": name, - "description": description, - "enabled": enabled, - "logo": logo, - "loginPageUrl": login_page_url, - "realm": realm, - "replyUrl": reply_url, - "replyAllowedCallbacks": reply_allowed_callbacks if reply_allowed_callbacks else [], - "attributeMapping": saml_idp_attribute_mapping_info_to_dict(attribute_mapping), - "groupsMapping": saml_idp_groups_mapping_info_to_dict(groups_mapping), - "forceAuthentication": force_authentication, - "logoutRedirectUrl": logout_redirect_url, - "errorRedirectUrl": error_redirect_url, - } - return body diff --git a/descope/management/_sso_settings_base.py b/descope/management/_sso_settings_base.py deleted file mode 100644 index 706cb9974..000000000 --- a/descope/management/_sso_settings_base.py +++ /dev/null @@ -1,328 +0,0 @@ -# This is not part of the public API but a code helper -from __future__ import annotations - -from typing import TYPE_CHECKING, Dict, List, Optional - -if TYPE_CHECKING: - from descope.management.sso_settings import ( - AttributeMapping, - FGAGroupMapping, - RoleMapping, - SSOOIDCSettings, - SSOSAMLSettings, - SSOSAMLSettingsByMetadata, - XAAIssuerSettings, - XAAJWTBearerSettings, - XAASettings, - ) - - -class SSOSettingsBase: - """Shared, I/O-free base for SSOSettings management classes. - - Holds only static body composers and dict converters — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: - - - ``SSOSettings(SSOSettingsBase, HTTPBase)`` — sync - - ``SSOSettingsAsync(SSOSettingsBase, AsyncHTTPBase)`` — async - """ - - @staticmethod - def _compose_configure_body( - tenant_id: str, - idp_url: str, - entity_id: str, - idp_cert: str, - redirect_url: str, - domains: Optional[List[str]], - ) -> dict: - return { - "tenantId": tenant_id, - "idpURL": idp_url, - "entityId": entity_id, - "idpCert": idp_cert, - "redirectURL": redirect_url, - "domains": domains, - } - - @staticmethod - def _compose_metadata_body( - tenant_id: str, - idp_metadata_url: str, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ) -> dict: - return { - "tenantId": tenant_id, - "idpMetadataURL": idp_metadata_url, - "redirectURL": redirect_url, - "domains": domains, - } - - @staticmethod - def _compose_mapping_body( - tenant_id: str, - role_mapping: Optional[List[RoleMapping]], - attribute_mapping: Optional[AttributeMapping], - ) -> dict: - return { - "tenantId": tenant_id, - "roleMappings": SSOSettingsBase._role_mapping_to_dict(role_mapping), - "attributeMapping": SSOSettingsBase._attribute_mapping_to_dict(attribute_mapping), - } - - @staticmethod - def _role_mapping_to_dict(role_mapping: Optional[List[RoleMapping]]) -> list: - if role_mapping is None: - role_mapping = [] - role_mapping_list = [] - for mapping in role_mapping: - role_mapping_list.append( - { - "groups": mapping.groups, - "roleName": mapping.role_name, - } - ) - return role_mapping_list - - @staticmethod - def _attribute_mapping_to_dict( - attribute_mapping: Optional[AttributeMapping], - ) -> dict: - if attribute_mapping is None: - raise ValueError("Attribute mapping cannot be None") - return { - "name": attribute_mapping.name, - "email": attribute_mapping.email, - "phoneNumber": attribute_mapping.phone_number, - "group": attribute_mapping.group, - "givenName": attribute_mapping.given_name, - "middleName": attribute_mapping.middle_name, - "familyName": attribute_mapping.family_name, - "picture": attribute_mapping.picture, - "customAttributes": attribute_mapping.custom_attributes, - } - - @staticmethod - def _fga_mappings_to_dict( - fga_mappings: Optional[Dict[str, FGAGroupMapping]], - ) -> Optional[dict]: - if fga_mappings is None: - return None - result: dict = {} - for group_name, mapping in fga_mappings.items(): - relations = [] - if mapping is not None and mapping.relations: - for relation in mapping.relations: - relations.append( - { - "resource": relation.resource, - "relationDefinition": relation.relation_definition, - "namespace": relation.namespace, - } - ) - result[group_name] = {"relations": relations} - return result - - @staticmethod - def _set_replace_default_sso_roles(target: dict, default_sso_roles: Optional[List[str]]) -> None: - # The server cannot tell an empty defaultSSORoles from an omitted one and keeps the stored roles, - # so an explicit empty list also sends replaceDefaultSSORoles to clear them. The flag is sent only - # then, and never as False, so every other request stays readable by servers without the field. - if default_sso_roles is not None and len(default_sso_roles) == 0: - target["replaceDefaultSSORoles"] = True - - @staticmethod - def _compose_configure_oidc_settings_body( - tenant_id: str, - settings: SSOOIDCSettings, - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = { - "loginId": settings.attribute_mapping.login_id, - "name": settings.attribute_mapping.name, - "givenName": settings.attribute_mapping.given_name, - "middleName": settings.attribute_mapping.middle_name, - "familyName": settings.attribute_mapping.family_name, - "email": settings.attribute_mapping.email, - "verifiedEmail": settings.attribute_mapping.verified_email, - "username": settings.attribute_mapping.username, - "phoneNumber": settings.attribute_mapping.phone_number, - "verifiedPhone": settings.attribute_mapping.verified_phone, - "picture": settings.attribute_mapping.picture, - } - - body: dict = { - "tenantId": tenant_id, - "settings": { - "name": settings.name, - "clientId": settings.client_id, - "clientSecret": settings.client_secret, - "redirectUrl": settings.redirect_url, - "authUrl": settings.auth_url, - "tokenUrl": settings.token_url, - "userDataUrl": settings.user_data_url, - "scope": settings.scope, - "JWKsUrl": settings.jwks_url, - "userAttrMapping": attr_mapping, - "manageProviderTokens": settings.manage_provider_tokens, - "callbackDomain": settings.callback_domain, - "prompt": settings.prompt, - "grantType": settings.grant_type, - "issuer": settings.issuer, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), - }, - "domains": domains, - } - # Optional on the server: an omitted usePkce keeps the stored value, so send it only when set. - if settings.use_pkce is not None: - body["settings"]["usePkce"] = settings.use_pkce - return body - - @staticmethod - def _issuer_settings_to_dict( - issuer: Optional[XAAIssuerSettings], - ) -> Optional[dict]: - if issuer is None: - return None - attr_mapping = None - if issuer.attribute_mapping is not None: - attr_mapping = SSOSettingsBase._attribute_mapping_to_dict(issuer.attribute_mapping) - return { - "jwksUri": issuer.jwks_uri, - "signAlgorithm": issuer.sign_algorithm, - "userInfoUri": issuer.user_info_uri, - "externalIdFieldName": issuer.external_id_field_name, - "jitDisabled": issuer.jit_disabled, - "attributeMapping": attr_mapping, - } - - @staticmethod - def _jwt_bearer_settings_to_dict( - settings: Optional[XAAJWTBearerSettings], - ) -> Optional[dict]: - if settings is None: - return None - issuers = None - if settings.issuers is not None: - issuers = { - url: SSOSettingsBase._issuer_settings_to_dict(issuer) for url, issuer in settings.issuers.items() - } - return { - "issuers": issuers, - "jwtBearerGrantTypeAudienceToUse": settings.jwt_bearer_grant_type_audience_to_use, - "jwtBearerGrantTypeScopeToUse": settings.jwt_bearer_grant_type_scope_to_use, - "jwtBearerGrantTypeCustomClaimsToUse": settings.jwt_bearer_grant_type_custom_claims_to_use, - } - - @staticmethod - def _compose_configure_auth_type_body( - tenant_id: str, - auth_type: str, - sso_id: Optional[str], - ) -> dict: - body: dict = { - "tenantId": tenant_id, - "authType": auth_type, - } - if sso_id: - body["ssoId"] = sso_id - return body - - @staticmethod - def _compose_configure_xaa_settings_body( - tenant_id: str, - settings: XAASettings, - sso_id: Optional[str], - ) -> dict: - body: dict = { - "tenantId": tenant_id, - "enabled": settings.enabled, - "settings": SSOSettingsBase._jwt_bearer_settings_to_dict(settings.settings), - "roleMappings": SSOSettingsBase._role_mapping_to_dict(settings.role_mappings), - "defaultSSORoles": settings.default_sso_roles, - "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), - "groupsPriority": settings.groups_priority, - "groupPriorityEnabled": settings.group_priority_enabled, - "allowOverrideRoles": settings.allow_override_roles, - } - if sso_id: - body["ssoId"] = sso_id - if settings.provider_id: - body["providerID"] = settings.provider_id - return body - - @staticmethod - def _compose_configure_saml_settings_body( - tenant_id: str, - settings: SSOSAMLSettings, - redirect_url: Optional[str], - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = SSOSettingsBase._attribute_mapping_to_dict(settings.attribute_mapping) - - body: dict = { - "tenantId": tenant_id, - "settings": { - "idpUrl": settings.idp_url, - "entityId": settings.idp_entity_id, - "idpCert": settings.idp_cert, - "idpAdditionalCerts": settings.idp_additional_certs, - "spACSUrl": settings.sp_acs_url, - "spEntityId": settings.sp_entity_id, - "attributeMapping": attr_mapping, - "roleMappings": SSOSettingsBase._role_mapping_to_dict(settings.role_mappings), - "defaultSSORoles": settings.default_sso_roles, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), - "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, - "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, - # always sent: the server takes the settings object as a full replacement, so omitting the - # flag on an update would silently turn request signing back on - "disableSignRequest": settings.disable_sign_request, - }, - "redirectUrl": redirect_url, - "domains": domains, - } - SSOSettingsBase._set_replace_default_sso_roles(body["settings"], settings.default_sso_roles) - return body - - @staticmethod - def _compose_configure_saml_settings_by_metadata_body( - tenant_id: str, - settings: SSOSAMLSettingsByMetadata, - redirect_url: Optional[str], - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = SSOSettingsBase._attribute_mapping_to_dict(settings.attribute_mapping) - - body: dict = { - "tenantId": tenant_id, - "settings": { - "idpMetadataUrl": settings.idp_metadata_url, - "entityId": settings.idp_entity_id, - "spACSUrl": settings.sp_acs_url, - "spEntityId": settings.sp_entity_id, - "attributeMapping": attr_mapping, - "roleMappings": SSOSettingsBase._role_mapping_to_dict(settings.role_mappings), - "defaultSSORoles": settings.default_sso_roles, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), - "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, - "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, - # always sent: the server takes the settings object as a full replacement, so omitting the - # flag on an update would silently turn request signing back on - "disableSignRequest": settings.disable_sign_request, - }, - "redirectUrl": redirect_url, - "domains": domains, - } - SSOSettingsBase._set_replace_default_sso_roles(body["settings"], settings.default_sso_roles) - return body diff --git a/descope/management/_tenant_base.py b/descope/management/_tenant_base.py deleted file mode 100644 index 340b7e68a..000000000 --- a/descope/management/_tenant_base.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - - -class TenantBase: - @staticmethod - def _compose_create_update_body( - name: str, - id: Optional[str], - self_provisioning_domains: List[str], - custom_attributes: Optional[dict] = None, - enforce_sso: Optional[bool] = False, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - disabled: Optional[bool] = False, - ) -> dict: - body: dict[str, Any] = { - "name": name, - "id": id, - "selfProvisioningDomains": self_provisioning_domains, - "enforceSSO": enforce_sso, - "disabled": disabled, - } - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if enforce_sso_exclusions is not None: - body["enforceSSOExclusions"] = enforce_sso_exclusions - if federated_app_ids is not None: - body["federatedAppIds"] = federated_app_ids - return body - - @staticmethod - def _compose_patch_body( - id: str, - name: Optional[str] = None, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - disabled: Optional[bool] = None, - enforce_sso: Optional[bool] = None, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - role_inheritance: Optional[str] = None, - ) -> dict: - body: dict[str, Any] = {"id": id} - if name is not None: - body["name"] = name - if self_provisioning_domains is not None: - body["selfProvisioningDomains"] = self_provisioning_domains - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if disabled is not None: - body["disabled"] = disabled - if enforce_sso is not None: - body["enforceSSO"] = enforce_sso - if enforce_sso_exclusions is not None: - body["enforceSSOExclusions"] = enforce_sso_exclusions - if federated_app_ids is not None: - body["federatedAppIds"] = federated_app_ids - if role_inheritance is not None: - body["roleInheritance"] = role_inheritance - return body diff --git a/descope/management/_third_party_application_base.py b/descope/management/_third_party_application_base.py deleted file mode 100644 index 37ffcdd8f..000000000 --- a/descope/management/_third_party_application_base.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any, Dict, List, Optional - - -# This is not part of the public API but a code helper -def compose_create_update_body( - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - approved_callback_urls: Optional[List[str]] = None, - permissions_scopes: Optional[List[Dict[str, Any]]] = None, - attributes_scopes: Optional[List[Dict[str, Any]]] = None, - jwt_bearer_settings: Optional[Dict[str, Any]] = None, - custom_attributes: Optional[Dict[str, Any]] = None, - force_pkce: Optional[bool] = None, - default_audience: Optional[str] = None, - client_type: Optional[str] = None, -) -> dict: - body: Dict[str, Any] = { - "name": name, - "loginPageUrl": login_page_url, - } - if id is not None: - body["id"] = id - if description is not None: - body["description"] = description - if logo is not None: - body["logo"] = logo - if approved_callback_urls is not None: - body["approvedCallbackUrls"] = approved_callback_urls - if permissions_scopes is not None: - body["permissionsScopes"] = permissions_scopes - if attributes_scopes is not None: - body["attributesScopes"] = attributes_scopes - if jwt_bearer_settings is not None: - body["jwtBearerSettings"] = jwt_bearer_settings - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if force_pkce is not None: - body["forcePkce"] = force_pkce - if default_audience is not None: - body["defaultAudience"] = default_audience - if client_type is not None: - body["clientType"] = client_type - return body diff --git a/descope/management/_user_base.py b/descope/management/_user_base.py deleted file mode 100644 index e89c92b7e..000000000 --- a/descope/management/_user_base.py +++ /dev/null @@ -1,361 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -from descope.management.common import ( - AssociatedFamily, - AssociatedTenant, - associated_families_to_dict, - associated_tenants_to_dict, -) -from descope.management.user_pwd import UserPassword - -VALID_USER_STATUSES = ["enabled", "disabled", "invited", "expired"] - - -class UserObj: - def __init__( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - password: Optional[UserPassword] = None, - seed: Optional[str] = None, - status: Optional[str] = None, - family_associations: Optional[List[AssociatedFamily]] = None, - ): - self.login_id = login_id - self.email = email - self.phone = phone - self.display_name = display_name - self.given_name = given_name - self.middle_name = middle_name - self.family_name = family_name - self.role_names = role_names - self.user_tenants = user_tenants - self.picture = picture - self.custom_attributes = custom_attributes - self.verified_email = verified_email - self.verified_phone = verified_phone - self.additional_login_ids = additional_login_ids - self.sso_app_ids = sso_app_ids - self.password = password - self.seed = seed - self.status = status - self.family_associations = family_associations - - -class CreateUserObj: - def __init__( - self, - email: Optional[str] = None, - phone: Optional[str] = None, - name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - ): - self.email = email - self.phone = phone - self.name = name - self.given_name = given_name - self.middle_name = middle_name - self.family_name = family_name - - -class UserBase: - @staticmethod - def _validate_status(status: Optional[str], login_id: Optional[str] = None) -> None: - if status is not None and status not in VALID_USER_STATUSES: - suffix = f" for user {login_id}" if login_id is not None else "" - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Invalid status value: {status}{suffix}. Must be one of: {', '.join(VALID_USER_STATUSES)}", - ) - - @staticmethod - def _compose_create_body( - login_id: str, - email: Optional[str], - phone: Optional[str], - display_name: Optional[str], - given_name: Optional[str], - middle_name: Optional[str], - family_name: Optional[str], - role_names: List[str], - user_tenants: List[AssociatedTenant], - invite: bool, - test: bool, - picture: Optional[str], - custom_attributes: Optional[dict], - verified_email: Optional[bool], - verified_phone: Optional[bool], - invite_url: Optional[str], - send_mail: Optional[bool], - send_sms: Optional[bool], - additional_login_ids: Optional[List[str]], - sso_app_ids: Optional[List[str]] = None, - template_id: str = "", - locale: Optional[str] = None, - status: Optional[str] = None, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - body = UserBase._compose_update_body( - login_id=login_id, - email=email, - phone=phone, - display_name=display_name, - given_name=given_name, - middle_name=middle_name, - family_name=family_name, - role_names=role_names, - user_tenants=user_tenants, - test=test, - picture=picture, - custom_attributes=custom_attributes, - additional_login_ids=additional_login_ids, - sso_app_ids=sso_app_ids, - family_associations=family_associations, - ) - body["invite"] = invite - if verified_email is not None: - body["verifiedEmail"] = verified_email - if verified_phone is not None: - body["verifiedPhone"] = verified_phone - if invite_url is not None: - body["inviteUrl"] = invite_url - if send_mail is not None: - body["sendMail"] = send_mail - if send_sms is not None: - body["sendSMS"] = send_sms - if template_id != "": - body["templateId"] = template_id - if locale is not None: - body["locale"] = locale - if status is not None: - body["status"] = status - return body - - @staticmethod - def _compose_create_batch_body( - users: List[UserObj], - invite_url: Optional[str], - send_mail: Optional[bool], - send_sms: Optional[bool], - locale: Optional[str] = None, - template_options: Optional[dict] = None, - template_id: str = "", - ) -> dict: - users_body = [] - for user in users: - role_names = [] if user.role_names is None else user.role_names - user_tenants = [] if user.user_tenants is None else user.user_tenants - sso_app_ids = [] if user.sso_app_ids is None else user.sso_app_ids - password = None if user.password is None else user.password.cleartext - hashed_password = None - if (user.password is not None) and (user.password.hashed is not None): - hashed_password = user.password.hashed.to_dict() - u_body = UserBase._compose_update_body( - login_id=user.login_id, - email=user.email, - phone=user.phone, - display_name=user.display_name, - given_name=user.given_name, - middle_name=user.middle_name, - family_name=user.family_name, - role_names=role_names, - user_tenants=user_tenants, - picture=user.picture, - custom_attributes=user.custom_attributes, - additional_login_ids=user.additional_login_ids, - verified_email=user.verified_email, - verified_phone=user.verified_phone, - test=False, - sso_app_ids=sso_app_ids, - password=password, - hashed_password=hashed_password, - seed=user.seed, - family_associations=user.family_associations, - ) - if user.status is not None: - u_body["status"] = user.status - users_body.append(u_body) - - body = {"users": users_body, "invite": True} - if invite_url is not None: - body["inviteUrl"] = invite_url - if send_mail is not None: - body["sendMail"] = send_mail - if send_sms is not None: - body["sendSMS"] = send_sms - if template_options is not None: - body["templateOptions"] = template_options - if template_id != "": - body["templateId"] = template_id - if locale is not None: - body["locale"] = locale - return body - - @staticmethod - def _compose_update_body( - login_id: str, - email: Optional[str], - phone: Optional[str], - display_name: Optional[str], - given_name: Optional[str], - middle_name: Optional[str], - family_name: Optional[str], - role_names: List[str], - user_tenants: List[AssociatedTenant], - test: bool, - picture: Optional[str], - custom_attributes: Optional[dict], - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - password: Optional[str] = None, - hashed_password: Optional[dict] = None, - seed: Optional[str] = None, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - res = { - "loginId": login_id, - "email": email, - "phone": phone, - "displayName": display_name, - "roleNames": role_names, - "userTenants": associated_tenants_to_dict(user_tenants), - "test": test, - "picture": picture, - "customAttributes": custom_attributes, - "additionalLoginIds": additional_login_ids, - "ssoAppIDs": sso_app_ids, - } - if verified_email is not None: - res["verifiedEmail"] = verified_email - if given_name is not None: - res["givenName"] = given_name - if middle_name is not None: - res["middleName"] = middle_name - if family_name is not None: - res["familyName"] = family_name - if verified_phone is not None: - res["verifiedPhone"] = verified_phone - if password is not None: - res["password"] = password - if hashed_password is not None: - res["hashedPassword"] = hashed_password - if seed is not None: - res["seed"] = seed - if family_associations is not None: - res["familyAssociations"] = associated_families_to_dict(family_associations) - return res - - @staticmethod - def _compose_patch_body( - login_id: str, - email: Optional[str], - phone: Optional[str], - display_name: Optional[str], - given_name: Optional[str], - middle_name: Optional[str], - family_name: Optional[str], - role_names: Optional[List[str]], - user_tenants: Optional[List[AssociatedTenant]], - picture: Optional[str], - custom_attributes: Optional[dict], - verified_email: Optional[bool], - verified_phone: Optional[bool], - sso_app_ids: Optional[List[str]], - status: Optional[str], - test: bool = False, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - res: dict[str, Any] = { - "loginId": login_id, - } - if email is not None: - res["email"] = email - if phone is not None: - res["phone"] = phone - if display_name is not None: - res["displayName"] = display_name - if given_name is not None: - res["givenName"] = given_name - if middle_name is not None: - res["middleName"] = middle_name - if family_name is not None: - res["familyName"] = family_name - if role_names is not None: - res["roleNames"] = role_names - if user_tenants is not None: - res["userTenants"] = associated_tenants_to_dict(user_tenants) - if picture is not None: - res["picture"] = picture - if custom_attributes is not None: - res["customAttributes"] = custom_attributes - if verified_email is not None: - res["verifiedEmail"] = verified_email - if verified_phone is not None: - res["verifiedPhone"] = verified_phone - if sso_app_ids is not None: - res["ssoAppIds"] = sso_app_ids - if status is not None: - res["status"] = status - if test: - res["test"] = test - if family_associations is not None: - res["familyAssociations"] = associated_families_to_dict(family_associations) - return res - - @staticmethod - def _compose_patch_batch_body( - users: List[UserObj], - test: bool = False, - ) -> dict: - users_body = [] - for user in users: - user_body = UserBase._compose_patch_body( - login_id=user.login_id, - email=user.email, - phone=user.phone, - display_name=user.display_name, - given_name=user.given_name, - middle_name=user.middle_name, - family_name=user.family_name, - role_names=user.role_names, - user_tenants=user.user_tenants, - picture=user.picture, - custom_attributes=user.custom_attributes, - verified_email=user.verified_email, - verified_phone=user.verified_phone, - sso_app_ids=user.sso_app_ids, - status=user.status, - test=test, - family_associations=user.family_associations, - ) - users_body.append(user_body) - - return {"users": users_body} - - @staticmethod - def _validate_search_pagination(limit: int, page: int) -> None: - if limit < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") - if page < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") diff --git a/descope/management/access_key.py b/descope/management/access_key.py deleted file mode 100644 index 429354283..000000000 --- a/descope/management/access_key.py +++ /dev/null @@ -1,310 +0,0 @@ -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.management._access_key_base import AccessKeyBase -from descope.management.common import ( - AssociatedTenant, - MgmtV1, -) - - -class AccessKey(AccessKeyBase, HTTPBase): - def create( - self, - name: str, - expire_time: int = 0, - role_names: Optional[List[str]] = None, - key_tenants: Optional[List[AssociatedTenant]] = None, - user_id: Optional[str] = None, - custom_claims: Optional[dict] = None, - description: Optional[str] = None, - permitted_ips: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - """ - Create a new access key. - - Args: - name (str): Access key name. - expire_time (int): Access key expiration. Leave at 0 to make it indefinite. - role_names (List[str]): An optional list of the access key's roles without tenant association. These roles are - mutually exclusive with the `key_tenant` roles, which take precedence over them. - key_tenants (List[AssociatedTenant]): An optional list of the access key's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`, and take precedence over them. - user_id (str): Bind access key to this user id - If user_id is supplied, then authorizations will be ignored, and the access key will be bound to the user's authorization. - custom_claims (dict): Optional, map of claims and their values that will be present in the JWT. - description (str): an optional text the access key can hold. - permitted_ips: (List[str]): An optional list of IP addresses or CIDR ranges that are allowed to use the access key. - custom_attributes (dict): Optional, map of custom attributes and their values that will be associated with the access key. - - Return value (dict): - Return dict in the format - { - "key": {}, - "cleartext": {} - } - Containing the created access key information and its cleartext. The key cleartext will only be returned on creation. - Make sure to save it securely. - - Raise: - AuthException: raised if create operation fails - """ - role_names = [] if role_names is None else role_names - key_tenants = [] if key_tenants is None else key_tenants - - response = self._http.post( - MgmtV1.access_key_create_path, - body=AccessKey._compose_create_body( - name, - expire_time, - role_names, - key_tenants, - user_id, - custom_claims, - description, - permitted_ips, - custom_attributes, - ), - ) - return response.json() - - def load( - self, - id: str, - ) -> dict: - """ - Load an existing access key. - - Args: - id (str): The id of the access key to be loaded. - - Return value (dict): - Return dict in the format - {"key": {}} - Containing the loaded access key information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - uri=MgmtV1.access_key_load_path, - params={"id": id}, - ) - return response.json() - - def search_all_access_keys( - self, - tenant_ids: Optional[List[str]] = None, - bound_user_id: Optional[str] = None, - creating_user: Optional[str] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - """ - Search all access keys. - - Args: - tenant_ids (List[str]): Optional list of tenant IDs to filter by - bound_user_id (str): Optional user ID of bounded user to filter by - creating_user (str): Optional user name of the creator to filter by - custom_attributes (dict): Optional dictionary of custom attributes to filter by - - Return value (dict): - Return dict in the format - {"keys": []} - "keys" contains a list of all of the found users and their information - - Raise: - AuthException: raised if search operation fails - """ - tenant_ids = [] if tenant_ids is None else tenant_ids - - response = self._http.post( - MgmtV1.access_keys_search_path, - body={ - "tenantIds": tenant_ids, - "boundUserId": bound_user_id, - "creatingUser": creating_user, - "customAttributes": custom_attributes, - }, - ) - return response.json() - - def update( - self, - id: str, - name: str, - description: Optional[str] = None, - custom_claims: Optional[dict] = None, - permitted_ips: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ): - """ - Update an existing access key with the given various fields. IMPORTANT: id and name are mandatory fields. - - Args: - id (str): The id of the access key to update. - name (str): The updated access key name. - description (str): The description of the access key to update. If not provided, it will not be overriden. - custom_claims (dict): Optional dictionary of custom claims to update. If not provided, it will not be overridden. - permitted_ips (List[str]): Optional list of permitted IPs to update. If not provided, it will not be overridden. - custom_attributes (dict): Optional dictionary of custom attributes to update. If not provided, it will not be overridden. - - Raise: - AuthException: raised if update operation fails - """ - body: dict[str, str | List[str] | dict] = { - "id": id, - "name": name, - } - if description is not None: - body["description"] = description - if custom_claims is not None: - body["customClaims"] = custom_claims - if permitted_ips is not None: - body["permittedIps"] = permitted_ips - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - self._http.post( - MgmtV1.access_key_update_path, - body=body, - ) - - def deactivate( - self, - id: str, - ): - """ - Deactivate an existing access key. IMPORTANT: This deactivated key will not be usable from this stage. - It will, however, persist, and can be activated again if needed. - - Args: - id (str): The id of the access key to be deactivated. - - Raise: - AuthException: raised if deactivation operation fails - """ - self._http.post( - MgmtV1.access_key_deactivate_path, - body={"id": id}, - ) - - def activate( - self, - id: str, - ): - """ - Activate an existing access key. IMPORTANT: Only deactivated keys can be activated again, - and become usable once more. New access keys are active by default. - - Args: - id (str): The id of the access key to be activate. - - Raise: - AuthException: raised if activation operation fails - """ - self._http.post( - MgmtV1.access_key_activate_path, - body={"id": id}, - ) - - def delete( - self, - id: str, - ): - """ - Delete an existing access key. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The id of the access key to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.access_key_delete_path, - body={"id": id}, - ) - - def rotate( - self, - id: str, - ) -> dict: - """ - Rotate an existing access key. Regenerates the secret while preserving the same access key ID, - name, roles, tenants, expiry, and metadata. - - Args: - id (str): The id of the access key to be rotated. - - Return value (dict): - Return dict in the format - { - "key": {}, - "cleartext": {} - } - The cleartext is the new secret and is only visible once. Save it immediately. - The previous secret stops working as soon as this call returns. - - Raise: - AuthException: raised if rotation operation fails - """ - response = self._http.post( - MgmtV1.access_key_rotate_path, - body={"id": id}, - ) - return response.json() - - def activate_batch( - self, - ids: List[str], - ): - """ - Activate multiple existing access keys in a single request. - - Args: - ids (List[str]): The list of access key IDs to be activated. - - Raise: - AuthException: raised if batch activation operation fails - """ - self._http.post( - MgmtV1.access_key_activate_batch_path, - body={"ids": ids}, - ) - - def deactivate_batch( - self, - ids: List[str], - ): - """ - Deactivate multiple existing access keys in a single request. - - Args: - ids (List[str]): The list of access key IDs to be deactivated. - - Raise: - AuthException: raised if batch deactivation operation fails - """ - self._http.post( - MgmtV1.access_key_deactivate_batch_path, - body={"ids": ids}, - ) - - def delete_batch( - self, - ids: List[str], - ): - """ - Delete multiple existing access keys in a single request. IMPORTANT: This action is irreversible. Use carefully. - - Args: - ids (List[str]): The list of access key IDs to be deleted. - - Raise: - AuthException: raised if batch deletion operation fails - """ - self._http.post( - MgmtV1.access_key_delete_batch_path, - body={"ids": ids}, - ) diff --git a/descope/management/access_key_async.py b/descope/management/access_key_async.py deleted file mode 100644 index e9123673d..000000000 --- a/descope/management/access_key_async.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._access_key_base import AccessKeyBase -from descope.management.common import ( - AssociatedTenant, - MgmtV1, -) - - -class AccessKeyAsync(AccessKeyBase, AsyncHTTPBase): - """Async counterpart of AccessKey — all HTTP calls are coroutines.""" - - async def create( - self, - name: str, - expire_time: int = 0, - role_names: Optional[List[str]] = None, - key_tenants: Optional[List[AssociatedTenant]] = None, - user_id: Optional[str] = None, - custom_claims: Optional[dict] = None, - description: Optional[str] = None, - permitted_ips: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - """ - Create a new access key. - - Args: - name (str): Access key name. - expire_time (int): Access key expiration. Leave at 0 to make it indefinite. - role_names (List[str]): An optional list of the access key's roles without tenant association. These roles are - mutually exclusive with the `key_tenant` roles, which take precedence over them. - key_tenants (List[AssociatedTenant]): An optional list of the access key's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`, and take precedence over them. - user_id (str): Bind access key to this user id - If user_id is supplied, then authorizations will be ignored, and the access key will be bound to the user's authorization. - custom_claims (dict): Optional, map of claims and their values that will be present in the JWT. - description (str): an optional text the access key can hold. - permitted_ips: (List[str]): An optional list of IP addresses or CIDR ranges that are allowed to use the access key. - custom_attributes (dict): Optional, map of custom attributes and their values that will be associated with the access key. - - Return value (dict): - Return dict in the format - { - "key": {}, - "cleartext": {} - } - Containing the created access key information and its cleartext. The key cleartext will only be returned on creation. - Make sure to save it securely. - - Raise: - AuthException: raised if create operation fails - """ - role_names = [] if role_names is None else role_names - key_tenants = [] if key_tenants is None else key_tenants - - response = await self._http.post( - MgmtV1.access_key_create_path, - body=AccessKeyAsync._compose_create_body( - name, - expire_time, - role_names, - key_tenants, - user_id, - custom_claims, - description, - permitted_ips, - custom_attributes, - ), - ) - return response.json() - - async def load( - self, - id: str, - ) -> dict: - """ - Load an existing access key. - - Args: - id (str): The id of the access key to be loaded. - - Return value (dict): - Return dict in the format - {"key": {}} - Containing the loaded access key information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - uri=MgmtV1.access_key_load_path, - params={"id": id}, - ) - return response.json() - - async def search_all_access_keys( - self, - tenant_ids: Optional[List[str]] = None, - bound_user_id: Optional[str] = None, - creating_user: Optional[str] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - """ - Search all access keys. - - Args: - tenant_ids (List[str]): Optional list of tenant IDs to filter by - bound_user_id (str): Optional user ID of bounded user to filter by - creating_user (str): Optional user name of the creator to filter by - custom_attributes (dict): Optional dictionary of custom attributes to filter by - - Return value (dict): - Return dict in the format - {"keys": []} - "keys" contains a list of all of the found users and their information - - Raise: - AuthException: raised if search operation fails - """ - tenant_ids = [] if tenant_ids is None else tenant_ids - - response = await self._http.post( - MgmtV1.access_keys_search_path, - body={ - "tenantIds": tenant_ids, - "boundUserId": bound_user_id, - "creatingUser": creating_user, - "customAttributes": custom_attributes, - }, - ) - return response.json() - - async def update( - self, - id: str, - name: str, - description: Optional[str] = None, - custom_claims: Optional[dict] = None, - permitted_ips: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ): - """ - Update an existing access key with the given various fields. IMPORTANT: id and name are mandatory fields. - - Args: - id (str): The id of the access key to update. - name (str): The updated access key name. - description (str): The description of the access key to update. If not provided, it will not be overriden. - custom_claims (dict): Optional dictionary of custom claims to update. If not provided, it will not be overridden. - permitted_ips (List[str]): Optional list of permitted IPs to update. If not provided, it will not be overridden. - custom_attributes (dict): Optional dictionary of custom attributes to update. If not provided, it will not be overridden. - - Raise: - AuthException: raised if update operation fails - """ - body: dict[str, str | List[str] | dict] = { - "id": id, - "name": name, - } - if description is not None: - body["description"] = description - if custom_claims is not None: - body["customClaims"] = custom_claims - if permitted_ips is not None: - body["permittedIps"] = permitted_ips - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - await self._http.post( - MgmtV1.access_key_update_path, - body=body, - ) - - async def deactivate( - self, - id: str, - ): - """ - Deactivate an existing access key. IMPORTANT: This deactivated key will not be usable from this stage. - It will, however, persist, and can be activated again if needed. - - Args: - id (str): The id of the access key to be deactivated. - - Raise: - AuthException: raised if deactivation operation fails - """ - await self._http.post( - MgmtV1.access_key_deactivate_path, - body={"id": id}, - ) - - async def activate( - self, - id: str, - ): - """ - Activate an existing access key. IMPORTANT: Only deactivated keys can be activated again, - and become usable once more. New access keys are active by default. - - Args: - id (str): The id of the access key to be activate. - - Raise: - AuthException: raised if activation operation fails - """ - await self._http.post( - MgmtV1.access_key_activate_path, - body={"id": id}, - ) - - async def delete( - self, - id: str, - ): - """ - Delete an existing access key. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The id of the access key to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - await self._http.post( - MgmtV1.access_key_delete_path, - body={"id": id}, - ) - - async def rotate( - self, - id: str, - ) -> dict: - """ - Rotate an existing access key. Regenerates the secret while preserving the same access key ID, - name, roles, tenants, expiry, and metadata. - - Args: - id (str): The id of the access key to be rotated. - - Return value (dict): - Return dict in the format - { - "key": {}, - "cleartext": {} - } - The cleartext is the new secret and is only visible once. Save it immediately. - The previous secret stops working as soon as this call returns. - - Raise: - AuthException: raised if rotation operation fails - """ - response = await self._http.post( - MgmtV1.access_key_rotate_path, - body={"id": id}, - ) - return response.json() - - async def activate_batch( - self, - ids: List[str], - ): - """ - Activate multiple existing access keys in a single request. - - Args: - ids (List[str]): The list of access key IDs to be activated. - - Raise: - AuthException: raised if batch activation operation fails - """ - await self._http.post( - MgmtV1.access_key_activate_batch_path, - body={"ids": ids}, - ) - - async def deactivate_batch( - self, - ids: List[str], - ): - """ - Deactivate multiple existing access keys in a single request. - - Args: - ids (List[str]): The list of access key IDs to be deactivated. - - Raise: - AuthException: raised if batch deactivation operation fails - """ - await self._http.post( - MgmtV1.access_key_deactivate_batch_path, - body={"ids": ids}, - ) - - async def delete_batch( - self, - ids: List[str], - ): - """ - Delete multiple existing access keys in a single request. IMPORTANT: This action is irreversible. Use carefully. - - Args: - ids (List[str]): The list of access key IDs to be deleted. - - Raise: - AuthException: raised if batch deletion operation fails - """ - await self._http.post( - MgmtV1.access_key_delete_batch_path, - body={"ids": ids}, - ) diff --git a/descope/management/analytics.py b/descope/management/analytics.py deleted file mode 100644 index 4259aae63..000000000 --- a/descope/management/analytics.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class Analytics(HTTPBase): - def search( - self, - actions: Optional[List[str]] = None, - excluded_actions: Optional[List[str]] = None, - from_ts: Optional[datetime] = None, - to_ts: Optional[datetime] = None, - devices: Optional[List[str]] = None, - methods: Optional[List[str]] = None, - geos: Optional[List[str]] = None, - tenants: Optional[List[str]] = None, - group_by_action: bool = False, - group_by_device: bool = False, - group_by_method: bool = False, - group_by_geo: bool = False, - group_by_tenant: bool = False, - group_by_referrer: bool = False, - group_by_created: Optional[str] = None, - ) -> dict: - """ - Search analytics records according to given filters. - - Args: - actions (List[str]): Optional list of actions to filter by. - excluded_actions (List[str]): Optional list of actions to exclude. - from_ts (datetime): Optional retrieve analytics newer than given time. Limited to no older than 12 months. - to_ts (datetime): Optional retrieve records older than given time. - devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown". - methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password". - geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. - tenants (List[str]): Optional list of tenants to filter by. - group_by_action (bool): Should we group summarized results by action. - group_by_device (bool): Should we group summarized results by device. - group_by_method (bool): Should we group summarized results by method. - group_by_geo (bool): Should we group summarized results by geo. - group_by_tenant (bool): Should we group summarized results by tenant. - group_by_referrer (bool): Should we group summarized results by referrer. - group_by_created (str): Optional how should we group the dates. Possible values are "h" for hour, "d" for day, "w" for week, "m" for month and "q" for quarter. - - Return value (dict): - Return dict in the format {"analytics": [...]} - "analytics" contains a list of analytic records matching the filters. - - Raise: - AuthException: raised if search operation fails - """ - body = { - "actions": actions or [], - "excludedActions": excluded_actions or [], - "devices": devices or [], - "methods": methods or [], - "geos": geos or [], - "tenants": tenants or [], - "groupByAction": group_by_action, - "groupByDevice": group_by_device, - "groupByMethod": group_by_method, - "groupByGeo": group_by_geo, - "groupByTenant": group_by_tenant, - "groupByReferrer": group_by_referrer, - } - if from_ts is not None: - body["from"] = int(from_ts.timestamp() * 1000) - if to_ts is not None: - body["to"] = int(to_ts.timestamp() * 1000) - if group_by_created is not None: - body["groupByCreated"] = group_by_created - - response = self._http.post( - MgmtV1.analytics_search_path, - body=body, - ) - return response.json() diff --git a/descope/management/analytics_async.py b/descope/management/analytics_async.py deleted file mode 100644 index df3d6efe6..000000000 --- a/descope/management/analytics_async.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class AnalyticsAsync(AsyncHTTPBase): - """Async counterpart of Analytics — all HTTP calls are coroutines.""" - - async def search( - self, - actions: Optional[List[str]] = None, - excluded_actions: Optional[List[str]] = None, - from_ts: Optional[datetime] = None, - to_ts: Optional[datetime] = None, - devices: Optional[List[str]] = None, - methods: Optional[List[str]] = None, - geos: Optional[List[str]] = None, - tenants: Optional[List[str]] = None, - group_by_action: bool = False, - group_by_device: bool = False, - group_by_method: bool = False, - group_by_geo: bool = False, - group_by_tenant: bool = False, - group_by_referrer: bool = False, - group_by_created: Optional[str] = None, - ) -> dict: - """ - Search analytics records according to given filters. - - Args: - actions (List[str]): Optional list of actions to filter by. - excluded_actions (List[str]): Optional list of actions to exclude. - from_ts (datetime): Optional retrieve analytics newer than given time. Limited to no older than 12 months. - to_ts (datetime): Optional retrieve records older than given time. - devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown". - methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password". - geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. - tenants (List[str]): Optional list of tenants to filter by. - group_by_action (bool): Should we group summarized results by action. - group_by_device (bool): Should we group summarized results by device. - group_by_method (bool): Should we group summarized results by method. - group_by_geo (bool): Should we group summarized results by geo. - group_by_tenant (bool): Should we group summarized results by tenant. - group_by_referrer (bool): Should we group summarized results by referrer. - group_by_created (str): Optional how should we group the dates. Possible values are "h" for hour, "d" for day, "w" for week, "m" for month and "q" for quarter. - - Return value (dict): - Return dict in the format {"analytics": [...]} - "analytics" contains a list of analytic records matching the filters. - - Raise: - AuthException: raised if search operation fails - """ - body = { - "actions": actions or [], - "excludedActions": excluded_actions or [], - "devices": devices or [], - "methods": methods or [], - "geos": geos or [], - "tenants": tenants or [], - "groupByAction": group_by_action, - "groupByDevice": group_by_device, - "groupByMethod": group_by_method, - "groupByGeo": group_by_geo, - "groupByTenant": group_by_tenant, - "groupByReferrer": group_by_referrer, - } - if from_ts is not None: - body["from"] = int(from_ts.timestamp() * 1000) - if to_ts is not None: - body["to"] = int(to_ts.timestamp() * 1000) - if group_by_created is not None: - body["groupByCreated"] = group_by_created - - response = await self._http.post( - MgmtV1.analytics_search_path, - body=body, - ) - return response.json() diff --git a/descope/management/audit.py b/descope/management/audit.py deleted file mode 100644 index 7cfd02558..000000000 --- a/descope/management/audit.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any, List, Optional - -from descope._http_base import HTTPBase -from descope.management._audit_base import AuditBase -from descope.management.common import MgmtV1 - - -class Audit(AuditBase, HTTPBase): - def search( - self, - user_ids: Optional[List[str]] = None, - actions: Optional[List[str]] = None, - excluded_actions: Optional[List[str]] = None, - devices: Optional[List[str]] = None, - methods: Optional[List[str]] = None, - geos: Optional[List[str]] = None, - remote_addresses: Optional[List[str]] = None, - login_ids: Optional[List[str]] = None, - tenants: Optional[List[str]] = None, - no_tenants: bool = False, - text: Optional[str] = None, - from_ts: Optional[datetime] = None, - to_ts: Optional[datetime] = None, - ) -> dict: - """ - Search the audit trail up to last 30 days based on given parameters - - Args: - user_ids (List[str]): Optional list of user IDs to filter by - actions (List[str]): Optional list of actions to filter by - excluded_actions (List[str]): Optional list of actions to exclude - devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown" - methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password" - geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. - remote_addresses (List[str]): Optional list of remote addresses to filter by - login_ids (List[str]): Optional list of login IDs to filter by - tenants (List[str]): Optional list of tenants to filter by - no_tenants (bool): Should audits without any tenants always be included - text (str): Free text search across all fields - from_ts (datetime): Retrieve records newer than given time but not older than 30 days - to_ts (datetime): Retrieve records older than given time - - Return value (dict): - Return dict in the format - { - "audits": [ - { - "projectId":"", - "userId": "", - "action": "", - "occurred": 0 (unix-time-milli), - "device": "", - "method": "", - "geo": "", - "remoteAddress": "", - "externalIds": [""], - "tenants": [""], - "data": { - "field1": "field1-value", - "more-details": "in-console-examples" - } - } - ] - } - Raise: - AuthException: raised if search operation fails - """ - body: dict[str, Any] = {"noTenants": no_tenants} - if user_ids is not None: - body["userIds"] = user_ids - if actions is not None: - body["actions"] = actions - if excluded_actions is not None: - body["excludedActions"] = excluded_actions - if devices is not None: - body["devices"] = devices - if methods is not None: - body["methods"] = methods - if geos is not None: - body["geos"] = geos - if remote_addresses is not None: - body["remoteAddresses"] = remote_addresses - if login_ids is not None: - body["externalIds"] = login_ids - if tenants is not None: - body["tenants"] = tenants - if text is not None: - body["text"] = text - if from_ts is not None: - body["from"] = int(from_ts.timestamp() * 1000) - if to_ts is not None: - body["to"] = int(to_ts.timestamp() * 1000) - - response = self._http.post(MgmtV1.audit_search, body=body) - return {"audits": list(map(Audit._convert_audit_record, response.json()["audits"]))} - - def create_event( - self, - action: str, - type: str, - actor_id: str, - tenant_id: str, - user_id: Optional[str] = None, - data: Optional[dict] = None, - ): - """ - Create audit event based on given parameters - - Args: - action (str): Audit action - type (str): Audit type (info/warn/error) - actor_id (str): Audit actor id - tenant_id (str): Audit tenant id - user_id (str): Optional, Audit user id - data (dict): Optional, Audit data - - Raise: - AuthException: raised if search operation fails - """ - body: dict[str, Any] = { - "action": action, - "type": type, - "actorId": actor_id, - "tenantId": tenant_id, - } - if user_id is not None: - body["userId"] = user_id - if data is not None: - body["data"] = data - - self._http.post(MgmtV1.audit_create_event, body=body) - - def create_audit_webhook(self, name: str, url: Optional[str] = None, headers: Optional[dict] = None): - """ - Create an audit webhook to receive audit events. - - Args: - name (str): The webhook name. - url (str): Optional webhook URL. - headers (dict): Optional headers to include in webhook requests. - - Raise: - AuthException: raised if create operation fails - """ - body: dict[str, Any] = {"name": name} - if url is not None: - body["url"] = url - if headers is not None: - body["headers"] = headers - - self._http.post(MgmtV1.audit_webhook_set_path, body=body) diff --git a/descope/management/audit_async.py b/descope/management/audit_async.py deleted file mode 100644 index 4d9611b66..000000000 --- a/descope/management/audit_async.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any, List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._audit_base import AuditBase -from descope.management.common import MgmtV1 - - -class AuditAsync(AuditBase, AsyncHTTPBase): - """Async counterpart of Audit — all HTTP calls are coroutines.""" - - async def search( - self, - user_ids: Optional[List[str]] = None, - actions: Optional[List[str]] = None, - excluded_actions: Optional[List[str]] = None, - devices: Optional[List[str]] = None, - methods: Optional[List[str]] = None, - geos: Optional[List[str]] = None, - remote_addresses: Optional[List[str]] = None, - login_ids: Optional[List[str]] = None, - tenants: Optional[List[str]] = None, - no_tenants: bool = False, - text: Optional[str] = None, - from_ts: Optional[datetime] = None, - to_ts: Optional[datetime] = None, - ) -> dict: - """ - Search the audit trail up to last 30 days based on given parameters - - Args: - user_ids (List[str]): Optional list of user IDs to filter by - actions (List[str]): Optional list of actions to filter by - excluded_actions (List[str]): Optional list of actions to exclude - devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown" - methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password" - geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. - remote_addresses (List[str]): Optional list of remote addresses to filter by - login_ids (List[str]): Optional list of login IDs to filter by - tenants (List[str]): Optional list of tenants to filter by - no_tenants (bool): Should audits without any tenants always be included - text (str): Free text search across all fields - from_ts (datetime): Retrieve records newer than given time but not older than 30 days - to_ts (datetime): Retrieve records older than given time - - Return value (dict): - Return dict in the format - { - "audits": [ - { - "projectId":"", - "userId": "", - "action": "", - "occurred": 0 (unix-time-milli), - "device": "", - "method": "", - "geo": "", - "remoteAddress": "", - "externalIds": [""], - "tenants": [""], - "data": { - "field1": "field1-value", - "more-details": "in-console-examples" - } - } - ] - } - Raise: - AuthException: raised if search operation fails - """ - body: dict[str, Any] = {"noTenants": no_tenants} - if user_ids is not None: - body["userIds"] = user_ids - if actions is not None: - body["actions"] = actions - if excluded_actions is not None: - body["excludedActions"] = excluded_actions - if devices is not None: - body["devices"] = devices - if methods is not None: - body["methods"] = methods - if geos is not None: - body["geos"] = geos - if remote_addresses is not None: - body["remoteAddresses"] = remote_addresses - if login_ids is not None: - body["externalIds"] = login_ids - if tenants is not None: - body["tenants"] = tenants - if text is not None: - body["text"] = text - if from_ts is not None: - body["from"] = int(from_ts.timestamp() * 1000) - if to_ts is not None: - body["to"] = int(to_ts.timestamp() * 1000) - - response = await self._http.post(MgmtV1.audit_search, body=body) - return {"audits": list(map(AuditAsync._convert_audit_record, response.json()["audits"]))} - - async def create_event( - self, - action: str, - type: str, - actor_id: str, - tenant_id: str, - user_id: Optional[str] = None, - data: Optional[dict] = None, - ): - """ - Create audit event based on given parameters - - Args: - action (str): Audit action - type (str): Audit type (info/warn/error) - actor_id (str): Audit actor id - tenant_id (str): Audit tenant id - user_id (str): Optional, Audit user id - data (dict): Optional, Audit data - - Raise: - AuthException: raised if search operation fails - """ - body: dict[str, Any] = { - "action": action, - "type": type, - "actorId": actor_id, - "tenantId": tenant_id, - } - if user_id is not None: - body["userId"] = user_id - if data is not None: - body["data"] = data - - await self._http.post(MgmtV1.audit_create_event, body=body) - - async def create_audit_webhook(self, name: str, url: Optional[str] = None, headers: Optional[dict] = None): - """ - Create an audit webhook to receive audit events. - - Args: - name (str): The webhook name. - url (str): Optional webhook URL. - headers (dict): Optional headers to include in webhook requests. - - Raise: - AuthException: raised if create operation fails - """ - body: dict[str, Any] = {"name": name} - if url is not None: - body["url"] = url - if headers is not None: - body["headers"] = headers - - await self._http.post(MgmtV1.audit_webhook_set_path, body=body) diff --git a/descope/management/authz.py b/descope/management/authz.py deleted file mode 100644 index 4c39aaedc..000000000 --- a/descope/management/authz.py +++ /dev/null @@ -1,383 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any, List, Optional - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class Authz(HTTPBase): - def __init__(self, http_client, fga_cache_url: Optional[str] = None): - super().__init__(http_client) - self._fga_cache_url = fga_cache_url - - def save_schema(self, schema: dict, upgrade: bool = False): - """ - Create or update the ReBAC schema. - In case of update, will update only given namespaces and will not delete namespaces unless upgrade flag is true. - Args: - schema (dict): the schema dict with format - { - "name": "name-of-schema", - "namespaces": [ - { - "name": "name-of-namespace", - "relationDefinitions": [ - { - "name": "name-of-relation-definition", - "complexDefinition": { - "nType": "one of child|union|intersect|sub", - "children": "optional list of node children - same format as complexDefinition", - "expression": { - "neType": "one of self|targetSet|relationLeft|relationRight", - "relationDefinition": "name of relation definition for relationLeft and relationRight", - "relationDefinitionNamespace": "the namespace for the rd above", - "targetRelationDefinition": "relation definition for targetSet and relationLeft/right", - "targetRelationDefinitionNamespace": "the namespace for above" - } - } - } - ] - } - ] - } - Schema name can be used for projects to track versioning. - Raise: - AuthException: raised if saving fails - """ - self._http.post( - MgmtV1.authz_schema_save, - body={"schema": schema, "upgrade": upgrade}, - ) - - def delete_schema(self): - """ - Delete the schema for the project which will also delete all relations. - Raise: - AuthException: raised if delete schema fails - """ - self._http.post( - MgmtV1.authz_schema_delete, - ) - - def load_schema(self) -> dict: - """ - Load the schema for the project - Return value (dict): - Return dict in the format of schema as above (see save_schema) - Raise: - AuthException: raised if load schema fails - """ - response = self._http.post( - MgmtV1.authz_schema_load, - ) - return response.json()["schema"] - - def save_namespace(self, namespace: dict, old_name: str = "", schema_name: str = ""): - """ - Create or update the given namespace - Will not delete relation definitions not mentioned in the namespace. - Args: - namespace (dict): namespace in the format as specified above (see save_schema) - old_name (str): is used if we are changing the namespace name - schema_name (str): is optional and can be used to track the current schema version. - Raise: - AuthException: raised if save namespace fails - """ - body: dict[str, Any] = {"namespace": namespace} - if old_name != "": - body["oldName"] = old_name - if schema_name != "": - body["schemaName"] = schema_name - self._http.post( - MgmtV1.authz_ns_save, - body=body, - ) - - def delete_namespace(self, name: str, schema_name: str = ""): - """ - delete_namespace will also delete the relevant relations. - Args: - name (str): namespace name to delete - schema_name (str): is optional and can be used to track the current schema version. - Raise: - AuthException: raised if delete namespace fails - """ - body: dict[str, Any] = {"name": name} - if schema_name != "": - body["schemaName"] = schema_name - self._http.post( - MgmtV1.authz_ns_delete, - body=body, - ) - - def save_relation_definition( - self, - relation_definition: dict, - namespace: str, - old_name: str = "", - schema_name: str = "", - ): - """ - Create or update the given relation definition - Will not delete relation definitions not mentioned in the namespace. - Args: - relation_definition (dict): relation definition in the format as specified above (see save_schema) - namespace (str): the namespace for the relation definition - old_name (str): is used if we are changing the relation definition name - schema_name (str): is optional and can be used to track the current schema version. - Raise: - AuthException: raised if save relation definition fails - """ - body: dict[str, Any] = { - "relationDefinition": relation_definition, - "namespace": namespace, - } - if old_name != "": - body["oldName"] = old_name - if schema_name != "": - body["schemaName"] = schema_name - self._http.post( - MgmtV1.authz_rd_save, - body=body, - ) - - def delete_relation_definition(self, name: str, namespace: str, schema_name: str = ""): - """ - delete_relation_definition will also delete the relevant relations. - Args: - name (str): relation definition name to delete - namespace (str): the namespace for the relation definition - schema_name (str): is optional and can be used to track the current schema version. - Raise: - AuthException: raised if delete namespace fails - """ - body: dict[str, Any] = {"name": name, "namespace": namespace} - if schema_name != "": - body["schemaName"] = schema_name - self._http.post( - MgmtV1.authz_rd_delete, - body=body, - ) - - def create_relations( - self, - relations: List[dict], - ): - """ - Create the given relations based on the existing schema - Args: - relations (List[dict]): the relations to create. Each in the following format: - { - "resource": "id of the resource that has the relation", - "relationDefinition": "the relation definition for the relation", - "namespace": "namespace for the relation definition", - "target": "the target that has the relation - usually users or other resources", - "targetSetResource": "if the target is a group that has another relation", - "targetSetRelationDefinition": "the relation definition for the targetSet group", - "targetSetRelationDefinitionNamespace": "the namespace for the relation definition for the targetSet group", - "query": { - "tenants": ["t1", "t2"], - "roles": ["r1", "r2"], - "text": "full-text-search", - "statuses": ["enabled|disabled|invited|expired"], - "ssoOnly": True|False, - "withTestUser": True|False, - "customAttributes": { - "key": "value", - ... - } - } - } - Each relation should have exactly one of: target, targetSet, query - Regarding query above, it should be specified if the target is a set of users that matches the query - all fields are optional - Raise: - AuthException: raised if create relations fails - """ - self._http.post( - MgmtV1.authz_re_create, - body={"relations": relations}, - ) - - def delete_relations( - self, - relations: List[dict], - ): - """ - Delete the given relations based on the existing schema - Args: - relations (List[dict]): the relations to create. Each in the format as specified above for (create_relations) - Raise: - AuthException: raised if delete relations fails - """ - self._http.post( - MgmtV1.authz_re_delete, - body={"relations": relations}, - ) - - def delete_relations_for_resources( - self, - resources: List[str], - ): - """ - Delete all relations to the given resources - Args: - resources (List[str]): the list of resources to delete any relations for - Raise: - AuthException: raised if delete relations for resources fails - """ - self._http.post( - MgmtV1.authz_re_delete_resources, - body={"resources": resources}, - ) - - def has_relations( - self, - relation_queries: List[dict], - ) -> List[dict]: - """ - Queries the given relations to see if they exist returning true if they do - Args: - relation_queries (List[dict]): List of queries each in the format of: - { - "resource": "resource for the relation query", - "relationDefinition": "the relation definition for the relation query", - "namespace": "namespace for the relation definition", - "target": "the target that has the relation - usually users or other resources" - } - - Return value (List[dict]): - Return List in the format - [ - { - "resource": "resource for the relation query", - "relationDefinition": "the relation definition for the relation query", - "namespace": "namespace for the relation definition", - "target": "the target that has the relation - usually users or other resources", - "hasRelation": True|False - } - ] - Raise: - AuthException: raised if query fails - """ - response = self._http.post( - MgmtV1.authz_re_has_relations, - body={"relationQueries": relation_queries}, - ) - return response.json()["relationQueries"] - - def who_can_access(self, resource: str, relation_definition: str, namespace: str) -> List[dict]: - """ - Finds the list of targets (usually users) who can access the given resource with the given RD - Args: - resource (str): the resource we are checking - relation_definition (str): the RD we are checking - namespace (str): the namespace for the RD - - Return value (List[str]): list of targets (user IDs usually that have the access) - Raise: - AuthException: raised if query fails - """ - response = self._http.post( - MgmtV1.authz_re_who, - body={ - "resource": resource, - "relationDefinition": relation_definition, - "namespace": namespace, - }, - base_url=self._fga_cache_url, - ) - return response.json()["targets"] - - def resource_relations(self, resource: str) -> List[dict]: - """ - Returns the list of all defined relations (not recursive) on the given resource. - Args: - resource (str): the resource we are listing relations for - - Return value (List[dict]): - Return List of relations each in the format of a relation as documented in create_relations - Raise: - AuthException: raised if query fails - """ - response = self._http.post( - MgmtV1.authz_re_resource, - body={"resource": resource}, - ) - return response.json()["relations"] - - def targets_relations(self, targets: List[str]) -> List[dict]: - """ - Returns the list of all defined relations (not recursive) for the given targets. - Args: - targets (List[str]): the list of targets we are returning the relations for - - Return value (List[dict]): - Return List of relations each in the format of a relation as documented in create_relations - Raise: - AuthException: raised if query fails - """ - response = self._http.post( - MgmtV1.authz_re_targets, - body={"targets": targets}, - ) - return response.json()["relations"] - - def what_can_target_access(self, target: str) -> List[dict]: - """ - Returns the list of all relations for the given target including derived relations from the schema tree. - Args: - target (str): the target we are returning the relations for - - Return value (List[dict]): - Return List of relations each in the format of a relation as documented in create_relations - Raise: - AuthException: raised if query fails - """ - response = self._http.post( - MgmtV1.authz_re_target_all, - body={"target": target}, - base_url=self._fga_cache_url, - ) - return response.json()["relations"] - - def what_can_target_access_with_relation(self, target: str, relation_definition: str, namespace: str) -> List[dict]: - """ - Returns the list of all resources that the target has the given relation to including all derived relations - Args: - target (str): the target we are returning the relations for - relation_definition (str): the RD we are checking - namespace (str): the namespace for the RD - - Return value (List[dict]): - Return List of relations each in the format of a relation as documented in create_relations - Raise: - AuthException: raised if query fails - """ - response = self._http.post( - MgmtV1.authz_re_target_with_relation, - body={ - "target": target, - "relationDefinition": relation_definition, - "namespace": namespace, - }, - ) - return response.json()["relations"] - - def get_modified(self, since: Optional[datetime] = None) -> dict: - """ - Get all targets and resources changed since the given date. - Args: - since (datetime): only return changes from this given datetime - - Return value (dict): - Dict including "resources" list of strings, "targets" list of strings and "schemaChanged" bool - Raise: - AuthException: raised if query fails - """ - response = self._http.post( - MgmtV1.authz_get_modified, - body={"since": (int(since.replace(tzinfo=timezone.utc).timestamp() * 1000) if since else 0)}, - ) - return response.json()["relations"] diff --git a/descope/management/authz_async.py b/descope/management/authz_async.py deleted file mode 100644 index de2cf3ddf..000000000 --- a/descope/management/authz_async.py +++ /dev/null @@ -1,387 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any, List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class AuthzAsync(AsyncHTTPBase): - """Async counterpart of Authz — all HTTP calls are coroutines.""" - - def __init__(self, http_client, fga_cache_url: Optional[str] = None): - super().__init__(http_client) - self._fga_cache_url = fga_cache_url - - async def save_schema(self, schema: dict, upgrade: bool = False): - """ - Create or update the ReBAC schema. - In case of update, will update only given namespaces and will not delete namespaces unless upgrade flag is true. - Args: - schema (dict): the schema dict with format - { - "name": "name-of-schema", - "namespaces": [ - { - "name": "name-of-namespace", - "relationDefinitions": [ - { - "name": "name-of-relation-definition", - "complexDefinition": { - "nType": "one of child|union|intersect|sub", - "children": "optional list of node children - same format as complexDefinition", - "expression": { - "neType": "one of self|targetSet|relationLeft|relationRight", - "relationDefinition": "name of relation definition for relationLeft and relationRight", - "relationDefinitionNamespace": "the namespace for the rd above", - "targetRelationDefinition": "relation definition for targetSet and relationLeft/right", - "targetRelationDefinitionNamespace": "the namespace for above" - } - } - } - ] - } - ] - } - Schema name can be used for projects to track versioning. - Raise: - AuthException: raised if saving fails - """ - await self._http.post( - MgmtV1.authz_schema_save, - body={"schema": schema, "upgrade": upgrade}, - ) - - async def delete_schema(self): - """ - Delete the schema for the project which will also delete all relations. - Raise: - AuthException: raised if delete schema fails - """ - await self._http.post( - MgmtV1.authz_schema_delete, - ) - - async def load_schema(self) -> dict: - """ - Load the schema for the project - Return value (dict): - Return dict in the format of schema as above (see save_schema) - Raise: - AuthException: raised if load schema fails - """ - response = await self._http.post( - MgmtV1.authz_schema_load, - ) - return response.json()["schema"] - - async def save_namespace(self, namespace: dict, old_name: str = "", schema_name: str = ""): - """ - Create or update the given namespace - Will not delete relation definitions not mentioned in the namespace. - Args: - namespace (dict): namespace in the format as specified above (see save_schema) - old_name (str): is used if we are changing the namespace name - schema_name (str): is optional and can be used to track the current schema version. - Raise: - AuthException: raised if save namespace fails - """ - body: dict[str, Any] = {"namespace": namespace} - if old_name != "": - body["oldName"] = old_name - if schema_name != "": - body["schemaName"] = schema_name - await self._http.post( - MgmtV1.authz_ns_save, - body=body, - ) - - async def delete_namespace(self, name: str, schema_name: str = ""): - """ - delete_namespace will also delete the relevant relations. - Args: - name (str): namespace name to delete - schema_name (str): is optional and can be used to track the current schema version. - Raise: - AuthException: raised if delete namespace fails - """ - body: dict[str, Any] = {"name": name} - if schema_name != "": - body["schemaName"] = schema_name - await self._http.post( - MgmtV1.authz_ns_delete, - body=body, - ) - - async def save_relation_definition( - self, - relation_definition: dict, - namespace: str, - old_name: str = "", - schema_name: str = "", - ): - """ - Create or update the given relation definition - Will not delete relation definitions not mentioned in the namespace. - Args: - relation_definition (dict): relation definition in the format as specified above (see save_schema) - namespace (str): the namespace for the relation definition - old_name (str): is used if we are changing the relation definition name - schema_name (str): is optional and can be used to track the current schema version. - Raise: - AuthException: raised if save relation definition fails - """ - body: dict[str, Any] = { - "relationDefinition": relation_definition, - "namespace": namespace, - } - if old_name != "": - body["oldName"] = old_name - if schema_name != "": - body["schemaName"] = schema_name - await self._http.post( - MgmtV1.authz_rd_save, - body=body, - ) - - async def delete_relation_definition(self, name: str, namespace: str, schema_name: str = ""): - """ - delete_relation_definition will also delete the relevant relations. - Args: - name (str): relation definition name to delete - namespace (str): the namespace for the relation definition - schema_name (str): is optional and can be used to track the current schema version. - Raise: - AuthException: raised if delete namespace fails - """ - body: dict[str, Any] = {"name": name, "namespace": namespace} - if schema_name != "": - body["schemaName"] = schema_name - await self._http.post( - MgmtV1.authz_rd_delete, - body=body, - ) - - async def create_relations( - self, - relations: List[dict], - ): - """ - Create the given relations based on the existing schema - Args: - relations (List[dict]): the relations to create. Each in the following format: - { - "resource": "id of the resource that has the relation", - "relationDefinition": "the relation definition for the relation", - "namespace": "namespace for the relation definition", - "target": "the target that has the relation - usually users or other resources", - "targetSetResource": "if the target is a group that has another relation", - "targetSetRelationDefinition": "the relation definition for the targetSet group", - "targetSetRelationDefinitionNamespace": "the namespace for the relation definition for the targetSet group", - "query": { - "tenants": ["t1", "t2"], - "roles": ["r1", "r2"], - "text": "full-text-search", - "statuses": ["enabled|disabled|invited|expired"], - "ssoOnly": True|False, - "withTestUser": True|False, - "customAttributes": { - "key": "value", - ... - } - } - } - Each relation should have exactly one of: target, targetSet, query - Regarding query above, it should be specified if the target is a set of users that matches the query - all fields are optional - Raise: - AuthException: raised if create relations fails - """ - await self._http.post( - MgmtV1.authz_re_create, - body={"relations": relations}, - ) - - async def delete_relations( - self, - relations: List[dict], - ): - """ - Delete the given relations based on the existing schema - Args: - relations (List[dict]): the relations to create. Each in the format as specified above for (create_relations) - Raise: - AuthException: raised if delete relations fails - """ - await self._http.post( - MgmtV1.authz_re_delete, - body={"relations": relations}, - ) - - async def delete_relations_for_resources( - self, - resources: List[str], - ): - """ - Delete all relations to the given resources - Args: - resources (List[str]): the list of resources to delete any relations for - Raise: - AuthException: raised if delete relations for resources fails - """ - await self._http.post( - MgmtV1.authz_re_delete_resources, - body={"resources": resources}, - ) - - async def has_relations( - self, - relation_queries: List[dict], - ) -> List[dict]: - """ - Queries the given relations to see if they exist returning true if they do - Args: - relation_queries (List[dict]): List of queries each in the format of: - { - "resource": "resource for the relation query", - "relationDefinition": "the relation definition for the relation query", - "namespace": "namespace for the relation definition", - "target": "the target that has the relation - usually users or other resources" - } - - Return value (List[dict]): - Return List in the format - [ - { - "resource": "resource for the relation query", - "relationDefinition": "the relation definition for the relation query", - "namespace": "namespace for the relation definition", - "target": "the target that has the relation - usually users or other resources", - "hasRelation": True|False - } - ] - Raise: - AuthException: raised if query fails - """ - response = await self._http.post( - MgmtV1.authz_re_has_relations, - body={"relationQueries": relation_queries}, - ) - return response.json()["relationQueries"] - - async def who_can_access(self, resource: str, relation_definition: str, namespace: str) -> List[dict]: - """ - Finds the list of targets (usually users) who can access the given resource with the given RD - Args: - resource (str): the resource we are checking - relation_definition (str): the RD we are checking - namespace (str): the namespace for the RD - - Return value (List[str]): list of targets (user IDs usually that have the access) - Raise: - AuthException: raised if query fails - """ - response = await self._http.post( - MgmtV1.authz_re_who, - body={ - "resource": resource, - "relationDefinition": relation_definition, - "namespace": namespace, - }, - base_url=self._fga_cache_url, - ) - return response.json()["targets"] - - async def resource_relations(self, resource: str) -> List[dict]: - """ - Returns the list of all defined relations (not recursive) on the given resource. - Args: - resource (str): the resource we are listing relations for - - Return value (List[dict]): - Return List of relations each in the format of a relation as documented in create_relations - Raise: - AuthException: raised if query fails - """ - response = await self._http.post( - MgmtV1.authz_re_resource, - body={"resource": resource}, - ) - return response.json()["relations"] - - async def targets_relations(self, targets: List[str]) -> List[dict]: - """ - Returns the list of all defined relations (not recursive) for the given targets. - Args: - targets (List[str]): the list of targets we are returning the relations for - - Return value (List[dict]): - Return List of relations each in the format of a relation as documented in create_relations - Raise: - AuthException: raised if query fails - """ - response = await self._http.post( - MgmtV1.authz_re_targets, - body={"targets": targets}, - ) - return response.json()["relations"] - - async def what_can_target_access(self, target: str) -> List[dict]: - """ - Returns the list of all relations for the given target including derived relations from the schema tree. - Args: - target (str): the target we are returning the relations for - - Return value (List[dict]): - Return List of relations each in the format of a relation as documented in create_relations - Raise: - AuthException: raised if query fails - """ - response = await self._http.post( - MgmtV1.authz_re_target_all, - body={"target": target}, - base_url=self._fga_cache_url, - ) - return response.json()["relations"] - - async def what_can_target_access_with_relation( - self, target: str, relation_definition: str, namespace: str - ) -> List[dict]: - """ - Returns the list of all resources that the target has the given relation to including all derived relations - Args: - target (str): the target we are returning the relations for - relation_definition (str): the RD we are checking - namespace (str): the namespace for the RD - - Return value (List[dict]): - Return List of relations each in the format of a relation as documented in create_relations - Raise: - AuthException: raised if query fails - """ - response = await self._http.post( - MgmtV1.authz_re_target_with_relation, - body={ - "target": target, - "relationDefinition": relation_definition, - "namespace": namespace, - }, - ) - return response.json()["relations"] - - async def get_modified(self, since: Optional[datetime] = None) -> dict: - """ - Get all targets and resources changed since the given date. - Args: - since (datetime): only return changes from this given datetime - - Return value (dict): - Dict including "resources" list of strings, "targets" list of strings and "schemaChanged" bool - Raise: - AuthException: raised if query fails - """ - response = await self._http.post( - MgmtV1.authz_get_modified, - body={"since": (int(since.replace(tzinfo=timezone.utc).timestamp() * 1000) if since else 0)}, - ) - return response.json()["relations"] diff --git a/descope/management/common.py b/descope/management/common.py deleted file mode 100644 index 79261fdcf..000000000 --- a/descope/management/common.py +++ /dev/null @@ -1,971 +0,0 @@ -from enum import Enum -from typing import Any, Dict, List, Optional, Union - - -class SessionExpirationUnit(Enum): - MINUTES = "minutes" - HOURS = "hours" - DAYS = "days" - WEEKS = "weeks" - - -class TenantAuthType(Enum): - NONE = "none" - SAML = "saml" - OIDC = "oidc" - - -class SSOSetupSuiteSettingsDisabledFeatures: - def __init__( - self, - saml: bool = False, - oidc: bool = False, - scim: bool = False, - sso_domains: bool = False, - group_mapping: bool = False, - ): - self.saml = saml - self.oidc = oidc - self.scim = scim - self.sso_domains = sso_domains - self.group_mapping = group_mapping - - def to_dict(self) -> Dict[str, bool]: - return { - "saml": self.saml, - "oidc": self.oidc, - "scim": self.scim, - "ssoDomains": self.sso_domains, - "groupMapping": self.group_mapping, - } - - -class SSOSetupSuiteSettings: - def __init__( - self, - enabled: bool, - style_id: Optional[str] = None, - disabled_features: Optional[SSOSetupSuiteSettingsDisabledFeatures] = None, - ): - self.enabled = enabled - self.style_id = style_id - self.disabled_features = disabled_features - - def to_dict(self) -> Dict[str, Any]: - result: Dict[str, Any] = {"enabled": self.enabled} - if self.style_id is not None: - result["styleId"] = self.style_id - if self.disabled_features is not None: - result["disabledFeatures"] = self.disabled_features.to_dict() - return result - - -class AccessType(Enum): - OFFLINE = "offline" - ONLINE = "online" - - -class PromptType(Enum): - NONE = "none" - LOGIN = "login" - CONSENT = "consent" - SELECT_ACCOUNT = "select_account" - - -class URLParam: - def __init__(self, name: str, value: str): - self.name = name - self.value = value - - def to_dict(self) -> dict: - return {"name": self.name, "value": self.value} - - -def url_params_to_dict(url_params: Optional[List[URLParam]] = None) -> list: - if url_params is None: - return [] - return [param.to_dict() for param in url_params] - - -def role_delete_batch_body( - roles: List[Union[str, dict]], - tenant_id: Optional[str] = None, -) -> dict: - """ - Build the request body for the role batch delete endpoint. - - Accepts role names, or role objects with a "name" key and an optional - "tenantId", and returns the roleNames/tenantId body the endpoint filters on. - An empty list is rejected: the endpoint deletes every role when it receives - no filter. - """ - if not roles: - raise ValueError("roles list cannot be empty") - - role_names: List[str] = [] - tenant_ids = set() - for role in roles: - if isinstance(role, str): - role_names.append(role) - elif isinstance(role, dict) and isinstance(role.get("name"), str): - role_names.append(role["name"]) - if role.get("tenantId") is not None: - tenant_ids.add(role["tenantId"]) - else: - raise ValueError('roles must be role names or dicts with a "name" key') - - if tenant_id is not None: - tenant_ids.add(tenant_id) - - if len(tenant_ids) > 1: - raise ValueError("all roles in a batch delete must belong to the same tenant") - - return {"roleNames": role_names, "tenantId": next(iter(tenant_ids), None)} - - -class MgmtV1: - # tenant - tenant_create_path = "/v1/mgmt/tenant/create" - tenant_update_path = "/v1/mgmt/tenant/update" - tenant_patch_path = "/v1/mgmt/tenant/patch" - tenant_delete_path = "/v1/mgmt/tenant/delete" - tenant_load_path = "/v1/mgmt/tenant" - tenant_settings_path = "/v1/mgmt/tenant/settings" - tenant_load_all_path = "/v1/mgmt/tenant/all" - tenant_search_all_path = "/v1/mgmt/tenant/search" - tenant_update_default_roles_path = "/v1/mgmt/tenant/updateDefaultRoles" - tenant_generate_sso_configuration_link_path = "/v2/mgmt/tenant/adminlinks/sso/generate" - - # family - family_create_path = "/v1/mgmt/family/create" - family_update_path = "/v1/mgmt/family/update" - family_delete_path = "/v1/mgmt/family/delete" - family_search_path = "/v1/mgmt/family/search" - family_dependent_create_path = "/v1/mgmt/family/dependent/create" - family_dependent_delete_path = "/v1/mgmt/family/dependent/delete" - family_impersonate_path = "/v1/mgmt/family/impersonate" - family_stop_impersonation_path = "/v1/mgmt/family/impersonate/stop" - family_settings_path = "/v1/mgmt/family/settings" - family_load_custom_attributes_path = "/v1/mgmt/family/customattributes" - family_create_custom_attributes_path = "/v1/mgmt/family/customattribute/create" - family_delete_custom_attributes_path = "/v1/mgmt/family/customattribute/delete" - - # sso application - sso_application_oidc_create_path = "/v1/mgmt/sso/idp/app/oidc/create" - sso_application_saml_create_path = "/v1/mgmt/sso/idp/app/saml/create" - sso_application_oidc_update_path = "/v1/mgmt/sso/idp/app/oidc/update" - sso_application_saml_update_path = "/v1/mgmt/sso/idp/app/saml/update" - sso_application_delete_path = "/v1/mgmt/sso/idp/app/delete" - sso_application_load_path = "/v1/mgmt/sso/idp/app/load" - sso_application_load_all_path = "/v1/mgmt/sso/idp/apps/load" - - # outbound application - outbound_application_create_path = "/v1/mgmt/outbound/app/create" - outbound_application_update_path = "/v1/mgmt/outbound/app/update" - outbound_application_delete_path = "/v1/mgmt/outbound/app/delete" - outbound_application_load_path = "/v1/mgmt/outbound/app" - outbound_application_load_all_path = "/v1/mgmt/outbound/apps" - outbound_application_fetch_token_by_scopes_path = "/v1/mgmt/outbound/app/user/token" - outbound_application_fetch_token_path = "/v1/mgmt/outbound/app/user/token/latest" - outbound_application_fetch_tenant_token_by_scopes_path = "/v1/mgmt/outbound/app/tenant/token" - outbound_application_fetch_tenant_token_path = "/v1/mgmt/outbound/app/tenant/token/latest" - outbound_application_delete_user_tokens_path = "/v1/mgmt/outbound/user/tokens" - outbound_application_delete_token_path = "/v1/mgmt/outbound/token" - outbound_application_list_apps_with_user_token_path = "/v1/mgmt/outbound/apps-with-user-token" - outbound_application_upload_user_api_key_path = "/v1/mgmt/outbound/app/user/apikey/upload" - outbound_application_upload_tenant_api_key_path = "/v1/mgmt/outbound/app/tenant/apikey/upload" - outbound_application_upload_user_token_path = "/v1/mgmt/outbound/app/user/oauthtoken/upload" - outbound_application_upload_tenant_token_path = "/v1/mgmt/outbound/app/tenant/oauthtoken/upload" - outbound_application_batch_upload_user_tokens_path = "/v1/mgmt/outbound/app/user/oauthtoken/batch/upload" - outbound_application_batch_upload_tenant_tokens_path = "/v1/mgmt/outbound/app/tenant/oauthtoken/batch/upload" - - # outbound scim - outbound_scim_create_path = "/v1/mgmt/outbound/scim/create" - outbound_scim_update_path = "/v1/mgmt/outbound/scim/update" - outbound_scim_delete_path = "/v1/mgmt/outbound/scim/delete" - outbound_scim_load_path = "/v1/mgmt/outbound/scim" - outbound_scim_set_enabled_path = "/v1/mgmt/outbound/scim/enabled/set" - - # engine - engine_create_path = "/v1/mgmt/engine/create" - engine_update_path = "/v1/mgmt/engine/update" - engine_delete_path = "/v1/mgmt/engine/delete" - engine_load_path = "/v1/mgmt/engine/load" - engine_load_all_path = "/v1/mgmt/engines/load" - engine_rotate_secret_path = "/v1/mgmt/engine/rotate" - - # user - user_create_path = "/v1/mgmt/user/create" - test_user_create_path = "/v1/mgmt/user/create/test" - user_create_batch_path = "/v1/mgmt/user/create/batch" - user_update_path = "/v1/mgmt/user/update" - user_patch_path = "/v1/mgmt/user/patch" - user_patch_batch_path = "/v1/mgmt/user/patch/batch" - user_delete_path = "/v1/mgmt/user/delete" - user_logout_path = "/v1/mgmt/user/logout" - user_delete_all_test_users_path = "/v1/mgmt/user/test/delete/all" - user_load_path = "/v1/mgmt/user" - users_load_path = "/v1/mgmt/users/load" - users_search_path = "/v2/mgmt/user/search" - test_users_search_path = "/v2/mgmt/user/search/test" - user_get_provider_token = "/v1/mgmt/user/provider/token" - user_update_status_path = "/v1/mgmt/user/update/status" - user_update_login_id_path = "/v1/mgmt/user/update/loginid" - user_update_email_path = "/v1/mgmt/user/update/email" - user_update_phone_path = "/v1/mgmt/user/update/phone" - user_update_name_path = "/v1/mgmt/user/update/name" - user_update_picture_path = "/v1/mgmt/user/update/picture" - user_update_custom_attribute_path = "/v1/mgmt/user/update/customAttribute" - user_set_role_path = "/v1/mgmt/user/update/role/set" - user_add_role_path = "/v2/mgmt/user/update/role/add" - user_remove_role_path = "/v1/mgmt/user/update/role/remove" - user_add_sso_apps = "/v1/mgmt/user/update/ssoapp/add" - user_set_sso_apps = "/v1/mgmt/user/update/ssoapp/set" - user_remove_sso_apps = "/v1/mgmt/user/update/ssoapp/remove" - user_set_password_path = "/v1/mgmt/user/password/set" # Deprecated - user_set_temporary_password_path = "/v1/mgmt/user/password/set/temporary" - user_set_active_password_path = "/v1/mgmt/user/password/set/active" - user_expire_password_path = "/v1/mgmt/user/password/expire" - user_remove_all_passkeys_path = "/v1/mgmt/user/passkeys/delete" - user_remove_totp_seed_path = "/v1/mgmt/user/totp/delete" - user_remove_recovery_codes_path = "/v1/mgmt/user/recovery-codes/delete" - user_add_tenant_path = "/v1/mgmt/user/update/tenant/add" - user_remove_tenant_path = "/v1/mgmt/user/update/tenant/remove" - user_add_families_path = "/v1/mgmt/user/update/family/add" - user_remove_families_path = "/v1/mgmt/user/update/family/remove" - user_generate_otp_for_test_path = "/v1/mgmt/tests/generate/otp" - user_generate_magic_link_for_test_path = "/v1/mgmt/tests/generate/magiclink" - user_generate_enchanted_link_for_test_path = "/v1/mgmt/tests/generate/enchantedlink" - user_generate_embedded_link_path = "/v1/mgmt/user/signin/embeddedlink" - user_generate_sign_up_embedded_link_path = "/v1/mgmt/user/signup/embeddedlink" - user_history_path = "/v1/mgmt/user/history" - - # access key - access_key_create_path = "/v1/mgmt/accesskey/create" - access_key_load_path = "/v1/mgmt/accesskey" - access_keys_search_path = "/v1/mgmt/accesskey/search" - access_key_update_path = "/v1/mgmt/accesskey/update" - access_key_deactivate_path = "/v1/mgmt/accesskey/deactivate" - access_key_activate_path = "/v1/mgmt/accesskey/activate" - access_key_delete_path = "/v1/mgmt/accesskey/delete" - - # sso - sso_settings_path = "/v1/mgmt/sso/settings" - sso_metadata_path = "/v1/mgmt/sso/metadata" - sso_mapping_path = "/v1/mgmt/sso/mapping" - sso_recalculate_mappings_path = "/v1/mgmt/sso/recalculate-mappings" - sso_load_settings_path = "/v2/mgmt/sso/settings" # v2 only - sso_configure_oidc_settings = "/v1/mgmt/sso/oidc" - sso_configure_saml_settings = "/v1/mgmt/sso/saml" - sso_configure_saml_by_metadata_settings = "/v1/mgmt/sso/saml/metadata" - sso_configure_xaa_settings = "/v1/mgmt/sso/xaa/settings" - sso_load_all_xaa_settings = "/v1/mgmt/sso/xaa/settings/all" - - # jwt - update_jwt_path = "/v1/mgmt/jwt/update" - impersonate_path = "/v1/mgmt/impersonate" - stop_impersonation_path = "/v1/mgmt/stop/impersonation" - mgmt_sign_in_path = "/v1/mgmt/auth/signin" - mgmt_sign_up_path = "/v1/mgmt/auth/signup" - mgmt_sign_up_or_in_path = "/v1/mgmt/auth/signup-in" - anonymous_path = "/v1/mgmt/auth/anonymous" - - # permission - permission_create_path = "/v1/mgmt/permission/create" - permission_create_batch_path = "/v1/mgmt/permission/create/batch" - permission_update_path = "/v1/mgmt/permission/update" - permission_update_batch_path = "/v1/mgmt/permission/update/batch" - permission_delete_path = "/v1/mgmt/permission/delete" - permission_delete_batch_path = "/v1/mgmt/permission/delete/batch" - permission_load_all_path = "/v1/mgmt/permission/all" - - # role - role_create_path = "/v1/mgmt/role/create" - role_create_batch_path = "/v1/mgmt/role/create/batch" - role_update_path = "/v1/mgmt/role/update" - role_update_batch_path = "/v1/mgmt/role/update/batch" - role_delete_path = "/v1/mgmt/role/delete" - role_delete_batch_path = "/v1/mgmt/role/delete/batch" - role_load_all_path = "/v1/mgmt/role/all" - role_search_path = "/v1/mgmt/role/search" - - # flow - flow_list_path = "/v1/mgmt/flow/list" - flow_delete_path = "/v1/mgmt/flow/delete" - flow_import_path = "/v1/mgmt/flow/import" - flow_export_path = "/v1/mgmt/flow/export" - flow_run_path = "/v1/mgmt/flow/run" - flow_async_run_path = "/v1/mgmt/flow/async/run" - flow_async_result_path = "/v1/mgmt/flow/async/result" - - # theme - theme_import_path = "/v1/mgmt/theme/import" - theme_export_path = "/v1/mgmt/theme/export" - - # group - group_load_all_path = "/v1/mgmt/group/all" - group_load_all_for_member_path = "/v1/mgmt/group/member/all" - group_load_all_group_members_path = "/v1/mgmt/group/members" - - # Audit - audit_search = "/v1/mgmt/audit/search" - audit_create_event = "/v1/mgmt/audit/event" - - # Authz ReBAC - authz_schema_save = "/v1/mgmt/authz/schema/save" - authz_schema_delete = "/v1/mgmt/authz/schema/delete" - authz_schema_load = "/v1/mgmt/authz/schema/load" - authz_ns_save = "/v1/mgmt/authz/ns/save" - authz_ns_delete = "/v1/mgmt/authz/ns/delete" - authz_rd_save = "/v1/mgmt/authz/rd/save" - authz_rd_delete = "/v1/mgmt/authz/rd/delete" - authz_re_create = "/v1/mgmt/authz/re/create" - authz_re_delete = "/v1/mgmt/authz/re/delete" - authz_re_delete_resources = "/v1/mgmt/authz/re/deleteresources" - authz_re_has_relations = "/v1/mgmt/authz/re/has" - authz_re_who = "/v1/mgmt/authz/re/who" - authz_re_resource = "/v1/mgmt/authz/re/resource" - authz_re_targets = "/v1/mgmt/authz/re/targets" - authz_re_target_all = "/v1/mgmt/authz/re/targetall" - authz_re_target_with_relation = "/v1/mgmt/authz/re/targetwithrelation" - authz_get_modified = "/v1/mgmt/authz/getmodified" - - # FGA (new style Authz) - fga_save_schema = "/v1/mgmt/fga/schema" - fga_create_relations = "/v1/mgmt/fga/relations" - fga_delete_relations = "/v1/mgmt/fga/relations/delete" - fga_check = "/v1/mgmt/fga/check" - fga_resources_load = "/v1/mgmt/fga/resources/load" - fga_resources_save = "/v1/mgmt/fga/resources/save" - - # Project - project_update_name = "/v1/mgmt/project/update/name" - project_update_tags = "/v1/mgmt/project/update/tags" - project_clone = "/v1/mgmt/project/clone" - project_export = "/v1/mgmt/project/export" - project_import = "/v1/mgmt/project/import" - project_list_projects = "/v1/mgmt/projects/list" - - # Descoper - descoper_create_path = "/v1/mgmt/descoper" - descoper_update_path = "/v1/mgmt/descoper" - descoper_load_path = "/v1/mgmt/descoper" - descoper_delete_path = "/v1/mgmt/descoper" - descoper_list_path = "/v1/mgmt/descoper/list" - - # management key - mgmt_key_create_path = "/v1/mgmt/managementkey" - mgmt_key_update_path = "/v1/mgmt/managementkey" - mgmt_key_load_path = "/v1/mgmt/managementkey" - mgmt_key_delete_path = "/v1/mgmt/managementkey/delete" - mgmt_key_search_path = "/v1/mgmt/managementkey/search" - - # license - license_get_path = "/v1/mgmt/license" - - # access key (batch + rotate) - access_key_activate_batch_path = "/v1/mgmt/accesskey/activate/batch" - access_key_deactivate_batch_path = "/v1/mgmt/accesskey/deactivate/batch" - access_key_delete_batch_path = "/v1/mgmt/accesskey/delete/batch" - access_key_rotate_path = "/v1/mgmt/accesskey/rotate" - - # analytics - analytics_search_path = "/v1/mgmt/analytics/search" - - # connector / audit webhook - audit_webhook_set_path = "/v2/mgmt/connector/audit/web/set" - - # fga (mappable + dryrun) - fga_mappable_resources_path = "/v1/mgmt/fga/mappable/resources" - fga_mappable_schema_path = "/v1/mgmt/fga/mappable/schema" - fga_schema_dryrun_path = "/v1/mgmt/fga/schema/dryrun" - - # jwt impersonate step-up - impersonate_stepup_path = "/v1/mgmt/impersonate/stepup" - - # list (deny / allow lists) - list_path = "/v1/mgmt/list" - list_all_path = "/v1/mgmt/list/all" - list_clear_path = "/v1/mgmt/list/clear" - list_delete_path = "/v1/mgmt/list/delete" - list_import_path = "/v1/mgmt/list/import" - list_ip_add_path = "/v1/mgmt/list/ip/add" - list_ip_check_path = "/v1/mgmt/list/ip/check" - list_ip_remove_path = "/v1/mgmt/list/ip/remove" - list_name_path = "/v1/mgmt/list/name" - list_text_add_path = "/v1/mgmt/list/text/add" - list_text_check_path = "/v1/mgmt/list/text/check" - list_text_remove_path = "/v1/mgmt/list/text/remove" - list_update_path = "/v1/mgmt/list/update" - - # password settings - password_settings_path = "/v1/mgmt/password/settings" - - # project (delete + snapshot) - project_delete_path = "/v1/mgmt/project/delete" - project_snapshot_export_path = "/v1/mgmt/project/snapshot/export" - project_snapshot_import_path = "/v1/mgmt/project/snapshot/import" - project_snapshot_validate_path = "/v1/mgmt/project/snapshot/validate" - - # scope claim mapping - scope_claim_mapping_get_path = "/v1/mgmt/scopeClaimMapping/get" - scope_claim_mapping_set_path = "/v1/mgmt/scopeClaimMapping/set" - scope_claim_mapping_delete_path = "/v1/mgmt/scopeClaimMapping/delete" - - # sso application (rotate / secret / wsfed) - sso_application_rotate_path = "/v1/mgmt/sso/idp/app/rotate" - sso_application_secret_path = "/v1/mgmt/sso/idp/app/secret" - sso_application_wsfed_create_path = "/v1/mgmt/sso/idp/app/wsfed/create" - sso_application_wsfed_update_path = "/v1/mgmt/sso/idp/app/wsfed/update" - - # sso settings (redirect / all / new) - sso_redirect_path = "/v1/mgmt/sso/redirect" - sso_load_all_settings_path = "/v2/mgmt/sso/settings/all" - sso_new_settings_path = "/v1/mgmt/sso/settings/new" - sso_configure_auth_type_path = "/v1/mgmt/sso/settings/authtype" - - # tenant revoke sso config link - tenant_revoke_sso_configuration_link_path = "/v1/mgmt/tenant/adminlinks/sso/revoke" - - # third party application - thirdparty_application_create_path = "/v1/mgmt/thirdparty/app/create" - thirdparty_application_update_path = "/v1/mgmt/thirdparty/app/update" - thirdparty_application_patch_path = "/v1/mgmt/thirdparty/app/patch" - thirdparty_application_delete_path = "/v1/mgmt/thirdparty/app/delete" - thirdparty_application_delete_batch_path = "/v1/mgmt/thirdparty/app/delete/batch" - thirdparty_application_load_path = "/v1/mgmt/thirdparty/app/load" - thirdparty_application_load_all_path = "/v1/mgmt/thirdparty/apps/load" - thirdparty_application_rotate_path = "/v1/mgmt/thirdparty/app/rotate" - thirdparty_application_secret_path = "/v1/mgmt/thirdparty/app/secret" - thirdparty_consents_create_path = "/v1/mgmt/thirdparty/consents/create" - thirdparty_consents_delete_path = "/v1/mgmt/thirdparty/consents/delete" - thirdparty_consents_delete_tenant_path = "/v1/mgmt/thirdparty/consents/delete/tenant" - thirdparty_consents_search_path = "/v1/mgmt/thirdparty/consents/search" - - # user (gaps) - user_create_custom_attribute_path = "/v1/mgmt/user/customattribute/create" - user_delete_custom_attribute_path = "/v1/mgmt/user/customattribute/delete" - user_load_custom_attributes_path = "/v1/mgmt/user/customattributes" - user_load_family_scoped_custom_attributes_path = "/v1/mgmt/user/families/customattributes" - user_create_family_scoped_custom_attributes_path = "/v1/mgmt/user/families/customattribute/create" - user_delete_family_scoped_custom_attributes_path = "/v1/mgmt/user/families/customattribute/delete" - user_delete_batch_path = "/v1/mgmt/user/delete/batch" - user_import_path = "/v1/mgmt/user/import" - user_delete_passkey_path = "/v1/mgmt/user/passkey/delete" - user_list_passkeys_path = "/v1/mgmt/user/passkeys/list" - user_list_trusted_devices_path = "/v1/mgmt/user/trusteddevices/list" - user_remove_trusted_device_path = "/v1/mgmt/user/trusteddevices/remove" - user_update_recovery_email_path = "/v1/mgmt/user/update/recovery/email" - user_update_recovery_phone_path = "/v1/mgmt/user/update/recovery/phone" - - -class MgmtSignUpOptions: - def __init__( - self, - custom_claims: Optional[dict] = None, - refresh_duration: Optional[int] = None, - ): - self.custom_claims = custom_claims - self.refresh_duration = refresh_duration - - -class FlowRunOptions: - """ - Options for running a flow. - """ - - def __init__( - self, - flow_input: Optional[Dict[str, Any]] = None, - preview: Optional[bool] = None, - tenant: Optional[str] = None, - ): - self.flow_input = flow_input - self.preview = preview - self.tenant = tenant - - def to_dict(self) -> Dict[str, Any]: - result: Dict[str, Any] = {} - if self.flow_input is not None: - result["input"] = self.flow_input - if self.preview is not None: - result["preview"] = self.preview - if self.tenant is not None: - result["tenant"] = self.tenant - return result - - @staticmethod - def from_dict(options: Optional[dict]) -> Optional["FlowRunOptions"]: - if options is None: - return None - return FlowRunOptions( - flow_input=options.get("input"), - preview=options.get("preview"), - tenant=options.get("tenant"), - ) - - -class MgmtLoginOptions: - def __init__( - self, - stepup: bool = False, - mfa: bool = False, - revoke_other_sessions: Optional[bool] = None, - custom_claims: Optional[dict] = None, - jwt: Optional[str] = None, - refresh_duration: Optional[int] = None, - ): - self.stepup = stepup - self.custom_claims = custom_claims - self.mfa = mfa - self.revoke_other_sessions = revoke_other_sessions - self.jwt = jwt - self.refresh_duration = refresh_duration - - -def is_jwt_required(lgo: MgmtLoginOptions) -> bool: - return lgo is not None and (lgo.stepup or lgo.mfa) - - -class MgmtUserRequest: - def __init__( - self, - name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - phone: Optional[str] = None, - email: Optional[str] = None, - email_verified: Optional[bool] = None, - phone_verified: Optional[bool] = None, - sso_app_id: Optional[str] = None, - ): - self.name = name - self.given_name = given_name - self.middle_name = middle_name - self.family_name = family_name - self.phone = phone - self.email = email - self.email_verified = email_verified - self.phone_verified = phone_verified - self.sso_app_id = sso_app_id - - def to_dict(self) -> dict: - return { - "name": self.name, - "givenName": self.given_name, - "middleName": self.middle_name, - "familyName": self.family_name, - "phone": self.phone, - "email": self.email, - "emailVerified": self.email_verified, - "phoneVerified": self.phone_verified, - "ssoAppId": self.sso_app_id, - } - - -class AssociatedTenant: - """ - Represents a tenant association for a User or Access Key. The tenant_id is required to denote - which tenant the user or access key belongs to. The role_names array is an optional list of - roles for the user or access key in this specific tenant. - """ - - def __init__(self, tenant_id: str, role_names: Optional[List[str]] = None): - self.tenant_id = tenant_id - self.role_names = [] if role_names is None else role_names - - -def associated_tenants_to_dict(associated_tenants: List[AssociatedTenant]) -> list: - associated_tenant_list = [] - if associated_tenants: - for associated_tenant in associated_tenants: - associated_tenant_list.append( - { - "tenantId": associated_tenant.tenant_id, - "roleNames": associated_tenant.role_names, - } - ) - return associated_tenant_list - - -class AssociatedFamily: - """ - Represents a family association for a User. The family_id is required to denote which family - the user belongs to. The role_names array is an optional list of the user's roles within that - family, and family_scoped_attributes is an optional dict of the user's custom attribute values - scoped to that family. When adding a user to a family they already belong to, omitting - role_names or family_scoped_attributes leaves the existing values unchanged. - """ - - def __init__( - self, - family_id: str, - role_names: Optional[List[str]] = None, - family_scoped_attributes: Optional[dict] = None, - ): - self.family_id = family_id - self.role_names = role_names - self.family_scoped_attributes = family_scoped_attributes - - def to_dict(self) -> dict: - res: Dict[str, Any] = {"familyId": self.family_id} - if self.role_names is not None: - res["roleNames"] = self.role_names - if self.family_scoped_attributes is not None: - res["familyScopedAttributes"] = self.family_scoped_attributes - return res - - -def associated_families_to_dict(associated_families: List[AssociatedFamily]) -> list: - return [associated_family.to_dict() for associated_family in associated_families] - - -class CustomAttributeOption: - """ - Represents a selectable option of a single-select or multi-select custom attribute. - """ - - def __init__(self, value: str, label: str): - self.value = value - self.label = label - - def to_dict(self) -> dict: - return {"value": self.value, "label": self.label} - - -class CustomAttribute: - """ - Represents a custom attribute definition, as used by the family custom attributes - and the family-scoped user custom attributes APIs. - - The type is an integer: 1 = string, 2 = number, 3 = boolean, 4 = single select, - 5 = multi select, 6 = date, 7 = month and day. - """ - - def __init__( - self, - name: str, - type: int, - display_name: Optional[str] = None, - options: Optional[List[CustomAttributeOption]] = None, - default_value: Optional[Any] = None, - view_permissions: Optional[List[str]] = None, - edit_permissions: Optional[List[str]] = None, - ): - self.name = name - self.type = type - self.display_name = display_name - self.options = options - self.default_value = default_value - self.view_permissions = view_permissions - self.edit_permissions = edit_permissions - - def to_dict(self) -> dict: - res: Dict[str, Any] = {"name": self.name, "type": self.type} - if self.display_name is not None: - res["displayName"] = self.display_name - if self.options is not None: - res["options"] = [option.to_dict() for option in self.options] - if self.default_value is not None: - res["defaultValue"] = self.default_value - if self.view_permissions is not None: - res["viewPermissions"] = self.view_permissions - if self.edit_permissions is not None: - res["editPermissions"] = self.edit_permissions - return res - - -def custom_attributes_to_dict(attributes: List[CustomAttribute]) -> list: - return [attribute.to_dict() for attribute in attributes] - - -class SAMLIDPAttributeMappingInfo: - """ - Represents a SAML IDP attribute mapping object. use this class for mapping Descope attribute - to the relevant SAML Assertion attributes matching your expected SP attributes names. - """ - - def __init__(self, name: str, type: str, value: str): - self.name = name - self.type = type - self.value = value - - -def saml_idp_attribute_mapping_info_to_dict( - attributes_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, -) -> list: - attributes_mapping_list = [] - if attributes_mapping: - for attribute_mapping in attributes_mapping: - attributes_mapping_list.append( - { - "name": attribute_mapping.name, - "type": attribute_mapping.type, - "value": attribute_mapping.value, - } - ) - return attributes_mapping_list - - -class SAMLIDPRoleGroupMappingInfo: - """ - Represents a SAML IDP Role Group mapping object. - """ - - def __init__(self, id: str, name: str): - self.id = id - self.name = name - - -def saml_idp_role_group_mapping_info_to_dict( - role_groups_mapping: Optional[List[SAMLIDPRoleGroupMappingInfo]] = None, -) -> list: - role_groups_mapping_list = [] - if role_groups_mapping: - for group_mapping in role_groups_mapping: - role_groups_mapping_list.append( - { - "id": group_mapping.id, - "name": group_mapping.name, - } - ) - return role_groups_mapping_list - - -class SAMLIDPGroupsMappingInfo: - """ - Represents a SAML IDP Descope roles to SP groups mapping object. use this class for mapping Descope roles - to your SP groups. - """ - - def __init__( - self, - name: str, - type: str, - filter_type: str, - value: str, - roles: List[SAMLIDPRoleGroupMappingInfo], - ): - self.name = name - self.type = type - self.filter_type = filter_type - self.value = value - self.roles = roles - - -def saml_idp_groups_mapping_info_to_dict( - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, -) -> list: - groups_mapping_list = [] - if groups_mapping: - for group_mapping in groups_mapping: - groups_mapping_list.append( - { - "name": group_mapping.name, - "type": group_mapping.type, - "filterType": group_mapping.filter_type, - "value": group_mapping.value, - "roles": saml_idp_role_group_mapping_info_to_dict(group_mapping.roles), - } - ) - return groups_mapping_list - - -class Sort: - """ - Represents a sort object. - """ - - def __init__(self, field: str, desc: Optional[bool] = False): - self.field = field - self.desc = desc - - -def sort_to_dict(sort: List[Sort]) -> list: - sort_list = [] - if sort: - for s in sort: - sort_list.append( - { - "field": s.field, - "desc": s.desc, - } - ) - return sort_list - - -class DescoperRole(Enum): - """Represents a Descoper role.""" - - ADMIN = "admin" - DEVELOPER = "developer" - SUPPORT = "support" - AUDITOR = "auditor" - - -class DescoperAttributes: - """ - Represents Descoper attributes, such as name and email/phone. - """ - - def __init__( - self, - display_name: Optional[str] = None, - email: Optional[str] = None, - phone: Optional[str] = None, - ): - self.display_name = display_name - self.email = email - self.phone = phone - - def to_dict(self) -> dict: - return { - "displayName": self.display_name, - "email": self.email, - "phone": self.phone, - } - - -class DescoperTagRole: - """ - Represents a Descoper tags to role mapping. - """ - - def __init__( - self, - tags: Optional[List[str]] = None, - role: Optional[DescoperRole] = None, - ): - self.tags = tags if tags is not None else [] - self.role = role - - def to_dict(self) -> dict: - return { - "tags": self.tags, - "role": self.role.value if self.role else None, - } - - -class DescoperProjectRole: - """ - Represents a Descoper projects to role mapping. - """ - - def __init__( - self, - project_ids: Optional[List[str]] = None, - role: Optional[DescoperRole] = None, - ): - self.project_ids = project_ids if project_ids is not None else [] - self.role = role - - def to_dict(self) -> dict: - return { - "projectIds": self.project_ids, - "role": self.role.value if self.role else None, - } - - -class DescoperRBAC: - """ - Represents Descoper RBAC configuration. - """ - - def __init__( - self, - is_company_admin: bool = False, - tags: Optional[List[DescoperTagRole]] = None, - projects: Optional[List[DescoperProjectRole]] = None, - ): - self.is_company_admin = is_company_admin - self.tags = tags if tags is not None else [] - self.projects = projects if projects is not None else [] - - def to_dict(self) -> dict: - return { - "isCompanyAdmin": self.is_company_admin, - "tags": [t.to_dict() for t in self.tags], - "projects": [p.to_dict() for p in self.projects], - } - - -class DescoperCreate: - """ - Represents a Descoper to be created. - """ - - def __init__( - self, - login_id: str, - attributes: Optional[DescoperAttributes] = None, - send_invite: bool = False, - rbac: Optional[DescoperRBAC] = None, - ): - self.login_id = login_id - self.attributes = attributes - self.send_invite = send_invite - self.rbac = rbac - - def to_dict(self) -> dict: - return { - "loginId": self.login_id, - "attributes": self.attributes.to_dict() if self.attributes else None, - "sendInvite": self.send_invite, - "rbac": self.rbac.to_dict() if self.rbac else None, - } - - -def descopers_to_dict(descopers: List[DescoperCreate]) -> list: - return [d.to_dict() for d in descopers] - - -class MgmtKeyStatus(Enum): - ACTIVE = "active" - INACTIVE = "inactive" - - -class MgmtKeyProjectRole: - def __init__(self, project_ids: List[str], roles: List[str]): - self.project_ids = project_ids - self.roles = roles - - def to_dict(self) -> dict: - return { - "projectIds": self.project_ids, - "roles": self.roles, - } - - -class MgmtKeyTagRole: - def __init__(self, tags: List[str], roles: List[str]): - self.tags = tags - self.roles = roles - - def to_dict(self) -> dict: - return { - "tags": self.tags, - "roles": self.roles, - } - - -class MgmtKeyReBac: - def __init__( - self, - company_roles: Optional[List[str]] = None, - project_roles: Optional[List[MgmtKeyProjectRole]] = None, - tag_roles: Optional[List[MgmtKeyTagRole]] = None, - ): - self.company_roles = company_roles - self.project_roles = project_roles - self.tag_roles = tag_roles - - def to_dict(self) -> dict: - res: dict = {} - if self.company_roles is not None: - res["companyRoles"] = self.company_roles - if self.project_roles is not None: - res["projectRoles"] = [pr.to_dict() for pr in self.project_roles] - if self.tag_roles is not None: - res["tagRoles"] = [tr.to_dict() for tr in self.tag_roles] - return res diff --git a/descope/management/descoper.py b/descope/management/descoper.py deleted file mode 100644 index 05e715a02..000000000 --- a/descope/management/descoper.py +++ /dev/null @@ -1,153 +0,0 @@ -from typing import Any, List, Optional - -from descope._http_base import HTTPBase -from descope.management.common import ( - DescoperAttributes, - DescoperCreate, - DescoperRBAC, - MgmtV1, - descopers_to_dict, -) - - -class Descoper(HTTPBase): - def create( - self, - descopers: List[DescoperCreate], - ) -> dict: - """ - Create new Descopers. - - Args: - descopers (List[DescoperCreate]): List of Descopers to create. - Note that tags are referred to by name, without the company ID prefix. - - Return value (dict): - Return dict in the format - { - "descopers": [...], - "total": - } - - Raise: - AuthException: raised if create operation fails - """ - if not descopers: - raise ValueError("descopers list cannot be empty") - - response = self._http.put( - MgmtV1.descoper_create_path, - body={"descopers": descopers_to_dict(descopers)}, - ) - return response.json() - - def update( - self, - id: str, - attributes: Optional[DescoperAttributes] = None, - rbac: Optional[DescoperRBAC] = None, - ) -> dict: - """ - Update an existing Descoper's RBAC and/or Attributes. - - IMPORTANT: All parameter *fields*, if set, will override whatever values are currently set - in the existing Descoper. Use carefully. - - Args: - id (str): The id of the Descoper to update. - attributes (DescoperAttributes): Optional attributes to update. - rbac (DescoperRBAC): Optional RBAC configuration to update. - - Return value (dict): - Return dict in the format - {"descoper": {...}} - Containing the updated Descoper information. - - Raise: - AuthException: raised if update operation fails - """ - if not id: - raise ValueError("id cannot be empty") - - body: dict[str, Any] = {"id": id} - if attributes is not None: - body["attributes"] = attributes.to_dict() - if rbac is not None: - body["rbac"] = rbac.to_dict() - - response = self._http.patch( - MgmtV1.descoper_update_path, - body=body, - ) - return response.json() - - def load( - self, - id: str, - ) -> dict: - """ - Load an existing Descoper by ID. - - Args: - id (str): The id of the Descoper to load. - - Return value (dict): - Return dict in the format - {"descoper": {...}} - Containing the loaded Descoper information. - - Raise: - AuthException: raised if load operation fails - """ - if not id: - raise ValueError("id cannot be empty") - - response = self._http.get( - uri=MgmtV1.descoper_load_path, - params={"id": id}, - ) - return response.json() - - def delete( - self, - id: str, - ): - """ - Delete an existing Descoper. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The id of the Descoper to delete. - - Raise: - AuthException: raised if delete operation fails - """ - if not id: - raise ValueError("id cannot be empty") - - self._http.delete( - uri=MgmtV1.descoper_delete_path, - params={"id": id}, - ) - - def list( - self, - ) -> dict: - """ - List all Descopers. - - Return value (dict): - Return dict in the format - { - "descopers": [...], - "total": - } - Containing all Descopers and the total count. - - Raise: - AuthException: raised if list operation fails - """ - response = self._http.post( - MgmtV1.descoper_list_path, - body={}, - ) - return response.json() diff --git a/descope/management/descoper_async.py b/descope/management/descoper_async.py deleted file mode 100644 index e0ba8ef1e..000000000 --- a/descope/management/descoper_async.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management.common import ( - DescoperAttributes, - DescoperCreate, - DescoperRBAC, - MgmtV1, - descopers_to_dict, -) - - -class DescoperAsync(AsyncHTTPBase): - """Async counterpart of Descoper — all HTTP calls are coroutines.""" - - async def create( - self, - descopers: List[DescoperCreate], - ) -> dict: - """ - Create new Descopers. - - Args: - descopers (List[DescoperCreate]): List of Descopers to create. - Note that tags are referred to by name, without the company ID prefix. - - Return value (dict): - Return dict in the format - { - "descopers": [...], - "total": - } - - Raise: - AuthException: raised if create operation fails - """ - if not descopers: - raise ValueError("descopers list cannot be empty") - - response = await self._http.put( - MgmtV1.descoper_create_path, - body={"descopers": descopers_to_dict(descopers)}, - ) - return response.json() - - async def update( - self, - id: str, - attributes: Optional[DescoperAttributes] = None, - rbac: Optional[DescoperRBAC] = None, - ) -> dict: - """ - Update an existing Descoper's RBAC and/or Attributes. - - IMPORTANT: All parameter *fields*, if set, will override whatever values are currently set - in the existing Descoper. Use carefully. - - Args: - id (str): The id of the Descoper to update. - attributes (DescoperAttributes): Optional attributes to update. - rbac (DescoperRBAC): Optional RBAC configuration to update. - - Return value (dict): - Return dict in the format - {"descoper": {...}} - Containing the updated Descoper information. - - Raise: - AuthException: raised if update operation fails - """ - if not id: - raise ValueError("id cannot be empty") - - body: dict[str, Any] = {"id": id} - if attributes is not None: - body["attributes"] = attributes.to_dict() - if rbac is not None: - body["rbac"] = rbac.to_dict() - - response = await self._http.patch( - MgmtV1.descoper_update_path, - body=body, - ) - return response.json() - - async def load( - self, - id: str, - ) -> dict: - """ - Load an existing Descoper by ID. - - Args: - id (str): The id of the Descoper to load. - - Return value (dict): - Return dict in the format - {"descoper": {...}} - Containing the loaded Descoper information. - - Raise: - AuthException: raised if load operation fails - """ - if not id: - raise ValueError("id cannot be empty") - - response = await self._http.get( - uri=MgmtV1.descoper_load_path, - params={"id": id}, - ) - return response.json() - - async def delete( - self, - id: str, - ): - """ - Delete an existing Descoper. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The id of the Descoper to delete. - - Raise: - AuthException: raised if delete operation fails - """ - if not id: - raise ValueError("id cannot be empty") - - await self._http.delete( - uri=MgmtV1.descoper_delete_path, - params={"id": id}, - ) - - async def list( - self, - ) -> dict: - """ - List all Descopers. - - Return value (dict): - Return dict in the format - { - "descopers": [...], - "total": - } - Containing all Descopers and the total count. - - Raise: - AuthException: raised if list operation fails - """ - response = await self._http.post( - MgmtV1.descoper_list_path, - body={}, - ) - return response.json() diff --git a/descope/management/engine.py b/descope/management/engine.py deleted file mode 100644 index c09a5441a..000000000 --- a/descope/management/engine.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -from descope._http_base import HTTPBase -from descope.management._engine_base import EngineBase -from descope.management.common import MgmtV1 - - -class Engine(EngineBase, HTTPBase): - def create(self, name: str) -> dict: - """ - Create a new engine with the given name. The engine ID and secret are - generated by Descope and returned in the response. - - Args: - name (str): The engine's name. - - Return value (dict): - Return dict in the format - {"engine": {"id": , "name": , "secret": , ...}} - The secret is returned ONLY on create and rotate_secret; store it securely. - - Raise: - AuthException: raised if create operation fails - """ - response = self._http.post( - MgmtV1.engine_create_path, - body=EngineBase._compose_create_body(name), - ) - return response.json() - - def update(self, id: str, name: str) -> dict: - """ - Update an existing engine's name. - - Args: - id (str): The ID of the engine to update. - name (str): Updated engine name. - - Return value (dict): - Return dict in the format {"engine": {...}} (without the secret). - - Raise: - AuthException: raised if update operation fails - """ - response = self._http.post( - MgmtV1.engine_update_path, - body=EngineBase._compose_update_body(id, name), - ) - return response.json() - - def delete(self, id: str): - """ - Delete an existing engine. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the engine to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post(MgmtV1.engine_delete_path, body={"id": id}) - - def load(self, id: str) -> dict: - """ - Load an engine by ID. The returned engine's secret is always empty. - - Args: - id (str): The ID of the engine to load. - - Return value (dict): - Return dict in the format {"engine": {...}}. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.engine_load_path, params={"id": id}) - return response.json() - - def load_all(self) -> dict: - """ - Load all engines for the project. The returned engines' secrets are always empty. - - Return value (dict): - Return dict in the format {"engines": [{...}, ...]}. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.engine_load_all_path) - return response.json() - - def rotate_secret(self, id: str) -> dict: - """ - Rotate an engine's secret, invalidating the previous one and returning the - new secret in cleartext. Store it securely; it cannot be retrieved again. - - Args: - id (str): The ID of the engine whose secret to rotate. - - Return value (dict): - Return dict in the format {"secret": }. - - Raise: - AuthException: raised if rotate operation fails - """ - response = self._http.post(MgmtV1.engine_rotate_secret_path, body={"id": id}) - return response.json() diff --git a/descope/management/engine_async.py b/descope/management/engine_async.py deleted file mode 100644 index b680eb2d4..000000000 --- a/descope/management/engine_async.py +++ /dev/null @@ -1,109 +0,0 @@ -from __future__ import annotations - -from descope._http_base import AsyncHTTPBase -from descope.management._engine_base import EngineBase -from descope.management.common import MgmtV1 - - -class EngineAsync(EngineBase, AsyncHTTPBase): - """Async counterpart of Engine — all HTTP calls are coroutines.""" - - async def create(self, name: str) -> dict: - """ - Create a new engine with the given name. The engine ID and secret are - generated by Descope and returned in the response. - - Args: - name (str): The engine's name. - - Return value (dict): - Return dict in the format - {"engine": {"id": , "name": , "secret": , ...}} - The secret is returned ONLY on create and rotate_secret; store it securely. - - Raise: - AuthException: raised if create operation fails - """ - response = await self._http.post( - MgmtV1.engine_create_path, - body=EngineBase._compose_create_body(name), - ) - return response.json() - - async def update(self, id: str, name: str) -> dict: - """ - Update an existing engine's name. - - Args: - id (str): The ID of the engine to update. - name (str): Updated engine name. - - Return value (dict): - Return dict in the format {"engine": {...}} (without the secret). - - Raise: - AuthException: raised if update operation fails - """ - response = await self._http.post( - MgmtV1.engine_update_path, - body=EngineBase._compose_update_body(id, name), - ) - return response.json() - - async def delete(self, id: str): - """ - Delete an existing engine. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the engine to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post(MgmtV1.engine_delete_path, body={"id": id}) - - async def load(self, id: str) -> dict: - """ - Load an engine by ID. The returned engine's secret is always empty. - - Args: - id (str): The ID of the engine to load. - - Return value (dict): - Return dict in the format {"engine": {...}}. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.engine_load_path, params={"id": id}) - return response.json() - - async def load_all(self) -> dict: - """ - Load all engines for the project. The returned engines' secrets are always empty. - - Return value (dict): - Return dict in the format {"engines": [{...}, ...]}. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.engine_load_all_path) - return response.json() - - async def rotate_secret(self, id: str) -> dict: - """ - Rotate an engine's secret, invalidating the previous one and returning the - new secret in cleartext. Store it securely; it cannot be retrieved again. - - Args: - id (str): The ID of the engine whose secret to rotate. - - Return value (dict): - Return dict in the format {"secret": }. - - Raise: - AuthException: raised if rotate operation fails - """ - response = await self._http.post(MgmtV1.engine_rotate_secret_path, body={"id": id}) - return response.json() diff --git a/descope/management/family.py b/descope/management/family.py deleted file mode 100644 index d045c7093..000000000 --- a/descope/management/family.py +++ /dev/null @@ -1,354 +0,0 @@ -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.management._family_base import FamilyBase -from descope.management._jwt_base import JWTBase -from descope.management.common import ( - CustomAttribute, - MgmtV1, - custom_attributes_to_dict, -) - - -class Family(FamilyBase, HTTPBase): - def create( - self, - name: str, - id: Optional[str] = None, - custom_attributes: Optional[dict] = None, - photo: Optional[str] = None, - disabled: Optional[bool] = None, - ) -> dict: - """ - Create a new family with the given name. Family IDs are provisioned automatically, but can be - provided explicitly if needed. - - Args: - name (str): The family's name. - id (str): Optional family ID. A random ID is generated when omitted. - custom_attributes (dict): Optional, the family's custom attribute values, keyed by attribute name. - The attributes must first be defined with `create_custom_attributes`. - photo (str): Optional URL of the family's photo. - disabled (bool): Optional, whether the family is disabled. - - Return value (dict): - Return dict in the format - {"family": {"id": , "name": , "customAttributes": {}, "disabled": , "photo": , "createdTime": }} - - Raise: - AuthException: raised if create operation fails - """ - response = self._http.post( - MgmtV1.family_create_path, - body=FamilyBase._compose_create_body(name, id, custom_attributes, photo, disabled), - ) - return response.json() - - def update( - self, - id: str, - name: Optional[str] = None, - custom_attributes: Optional[dict] = None, - photo: Optional[str] = None, - disabled: Optional[bool] = None, - ) -> dict: - """ - Update an existing family. Only the given fields are updated; omitted fields are left unchanged. - custom_attributes replaces all of the family's custom attributes, it is not merged. - - Args: - id (str): The ID of the family to update. - name (str): Optional updated family name. - custom_attributes (dict): Optional, the family's custom attribute values, keyed by attribute name. - Replaces all of the family's custom attributes. - photo (str): Optional URL of the family's photo. - disabled (bool): Optional, whether the family is disabled. - - Return value (dict): - Return dict in the format - {"family": {...}} - Containing the updated family information. - - Raise: - AuthException: raised if update operation fails - """ - response = self._http.post( - MgmtV1.family_update_path, - body=FamilyBase._compose_update_body(id, name, custom_attributes, photo, disabled), - ) - return response.json() - - def delete(self, id: str) -> None: - """ - Delete an existing family. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the family to delete. - - Raise: - AuthException: raised if delete operation fails - """ - self._http.post(MgmtV1.family_delete_path, body={"id": id}) - - def search_all( - self, - ids: Optional[List[str]] = None, - names: Optional[List[str]] = None, - text: Optional[str] = None, - custom_attributes: Optional[dict] = None, - page: Optional[int] = None, - size: Optional[int] = None, - ) -> dict: - """ - Search families. Called with no arguments, returns all families. - - Args: - ids (List[str]): Optional list of family IDs to filter by. - names (List[str]): Optional list of family names to filter by. - text (str): Optional free text search among the families' attributes. - custom_attributes (dict): Optional, search for families with the given custom attribute values. - page (int): Optional pagination control. Pages start at 0. - size (int): Optional page size (up to 1000). - - Return value (dict): - Return dict in the format - {"families": [{...}]} - Containing the found families. - - Raise: - AuthException: raised if search operation fails - """ - response = self._http.post( - MgmtV1.family_search_path, - body=FamilyBase._compose_search_body(ids, names, text, custom_attributes, page, size), - ) - return response.json() - - def create_dependent( - self, - family_id: str, - login_id: Optional[str] = None, - name: Optional[str] = None, - email: Optional[str] = None, - phone: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - family_scoped_attributes: Optional[dict] = None, - ) -> dict: - """ - Create a dependent user in a family. A dependent is a user with no login credentials of their own, - managed by the family's members. - - Args: - family_id (str): The ID of the family to create the dependent in. - login_id (str): Optional login ID. When omitted it is derived from the name. The email and phone - are never used as the login ID, since a dependent may share them with their guardian. - name (str): Optional display name. - email (str): Optional email address. - phone (str): Optional phone number. - given_name (str): Optional given name. - middle_name (str): Optional middle name. - family_name (str): Optional family name. - picture (str): Optional URL of the user's picture. - custom_attributes (dict): Optional, the user's custom attribute values. - family_scoped_attributes (dict): Optional, the dependent's family-scoped custom attribute values - in this family, in the format {: }. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the created dependent user information. - - Raise: - AuthException: raised if create operation fails - """ - response = self._http.post( - MgmtV1.family_dependent_create_path, - body=FamilyBase._compose_create_dependent_body( - family_id, - login_id, - name, - email, - phone, - given_name, - middle_name, - family_name, - picture, - custom_attributes, - family_scoped_attributes, - ), - ) - return response.json() - - def delete_dependent(self, user_id: str) -> None: - """ - Delete a dependent user. The family is inferred from the dependent. Regular (non-dependent) - family members are removed from a family with `user.remove_families`, not deleted. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - user_id (str): The user ID of the dependent to delete. - - Raise: - AuthException: raised if delete operation fails - """ - self._http.post(MgmtV1.family_dependent_delete_path, body={"userId": user_id}) - - def impersonate_dependent( - self, - impersonator_user_id_or_login_id: str, - dependent_login_id: str, - selected_family: Optional[str] = None, - ) -> str: - """ - Impersonate a family dependent. The impersonator must be a member of the dependent's family - and hold the "Family Impersonate Dependents" permission in that family. - - Args: - impersonator_user_id_or_login_id (str): The user ID or login ID of the impersonating family member. - dependent_login_id (str): The login ID of the dependent to impersonate. - selected_family (str): Optional family to scope the impersonated session to. When set, it must be - the dependent's family. - - Return value (str): A JWT of the impersonated dependent - - Raise: - AuthException: raised if impersonation fails - """ - JWTBase._validate_impersonator_id(impersonator_user_id_or_login_id) - JWTBase._validate_login_id(dependent_login_id) - response = self._http.post( - MgmtV1.family_impersonate_path, - body=FamilyBase._compose_impersonate_body( - impersonator_user_id_or_login_id, dependent_login_id, selected_family - ), - ) - return response.json().get("jwt", "") - - def stop_impersonation( - self, - jwt: str, - custom_claims: Optional[dict] = None, - refresh_duration: Optional[int] = None, - ) -> str: - """ - Stop impersonating a family dependent and return to the impersonating member's own session. - - Args: - jwt (str): The impersonation JWT to stop. - custom_claims (dict): Optional custom claims to add to the JWT. - refresh_duration (int): Optional duration in seconds for which the new JWT will be valid. - - Return value (str): A JWT of the impersonating family member - - Raise: - AuthException: raised if the operation fails - """ - JWTBase._validate_jwt(jwt) - response = self._http.post( - MgmtV1.family_stop_impersonation_path, - body=FamilyBase._compose_stop_impersonation_body(jwt, custom_claims, refresh_duration), - ) - return response.json().get("jwt", "") - - def get_settings(self) -> dict: - """ - Load the project's family account settings. - - Return value (dict): - Return dict in the format - {"enabled": , "maxFamilyMembers": , "allowMultipleFamiliesUsers": } - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.family_settings_path) - return response.json() - - def configure_settings( - self, - enabled: Optional[bool] = None, - max_family_members: Optional[int] = None, - allow_multiple_families_users: Optional[bool] = None, - ) -> dict: - """ - Configure the project's family account settings. Omitted fields are left unchanged. - - Args: - enabled (bool): Optional, whether family accounts are enabled for the project. - max_family_members (int): Optional, the maximum number of members in a single family (at least 1). - allow_multiple_families_users (bool): Optional, whether a user may belong to more than one family. - - Return value (dict): - Return dict in the format - {"enabled": , "maxFamilyMembers": , "allowMultipleFamiliesUsers": } - Containing the updated settings. - - Raise: - AuthException: raised if update operation fails - """ - response = self._http.post( - MgmtV1.family_settings_path, - body=FamilyBase._compose_settings_body(enabled, max_family_members, allow_multiple_families_users), - ) - return response.json() - - def load_custom_attributes(self) -> dict: - """ - Load the custom attribute definitions of the family entity itself. These are distinct from the - family-scoped user attributes managed with `user.load_family_scoped_custom_attributes`. - - Return value (dict): - Return dict in the format - {"data": [{"name": , "type": , "displayName": , ...}]} - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.family_load_custom_attributes_path) - return response.json() - - def create_custom_attributes(self, attributes: List[CustomAttribute]) -> dict: - """ - Create custom attribute definitions on the family entity. - - Args: - attributes (List[CustomAttribute]): The custom attribute definitions to create. - - Return value (dict): - Return dict in the format - {"data": [...]} - Containing the updated custom attribute definitions. - - Raise: - AuthException: raised if create operation fails - """ - response = self._http.post( - MgmtV1.family_create_custom_attributes_path, - body={"attributes": custom_attributes_to_dict(attributes)}, - ) - return response.json() - - def delete_custom_attributes(self, names: List[str]) -> dict: - """ - Delete custom attribute definitions from the family entity by name. - - Args: - names (List[str]): The names of the custom attributes to delete. - - Return value (dict): - Return dict in the format - {"data": [...]} - Containing the remaining custom attribute definitions. - - Raise: - AuthException: raised if delete operation fails - """ - response = self._http.post( - MgmtV1.family_delete_custom_attributes_path, - body={"names": names}, - ) - return response.json() diff --git a/descope/management/family_async.py b/descope/management/family_async.py deleted file mode 100644 index 11c484530..000000000 --- a/descope/management/family_async.py +++ /dev/null @@ -1,358 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._family_base import FamilyBase -from descope.management._jwt_base import JWTBase -from descope.management.common import ( - CustomAttribute, - MgmtV1, - custom_attributes_to_dict, -) - - -class FamilyAsync(FamilyBase, AsyncHTTPBase): - """Async counterpart of Family - all HTTP calls are coroutines.""" - - async def create( - self, - name: str, - id: Optional[str] = None, - custom_attributes: Optional[dict] = None, - photo: Optional[str] = None, - disabled: Optional[bool] = None, - ) -> dict: - """ - Create a new family with the given name. Family IDs are provisioned automatically, but can be - provided explicitly if needed. - - Args: - name (str): The family's name. - id (str): Optional family ID. A random ID is generated when omitted. - custom_attributes (dict): Optional, the family's custom attribute values, keyed by attribute name. - The attributes must first be defined with `create_custom_attributes`. - photo (str): Optional URL of the family's photo. - disabled (bool): Optional, whether the family is disabled. - - Return value (dict): - Return dict in the format - {"family": {"id": , "name": , "customAttributes": {}, "disabled": , "photo": , "createdTime": }} - - Raise: - AuthException: raised if create operation fails - """ - response = await self._http.post( - MgmtV1.family_create_path, - body=FamilyBase._compose_create_body(name, id, custom_attributes, photo, disabled), - ) - return response.json() - - async def update( - self, - id: str, - name: Optional[str] = None, - custom_attributes: Optional[dict] = None, - photo: Optional[str] = None, - disabled: Optional[bool] = None, - ) -> dict: - """ - Update an existing family. Only the given fields are updated; omitted fields are left unchanged. - custom_attributes replaces all of the family's custom attributes, it is not merged. - - Args: - id (str): The ID of the family to update. - name (str): Optional updated family name. - custom_attributes (dict): Optional, the family's custom attribute values, keyed by attribute name. - Replaces all of the family's custom attributes. - photo (str): Optional URL of the family's photo. - disabled (bool): Optional, whether the family is disabled. - - Return value (dict): - Return dict in the format - {"family": {...}} - Containing the updated family information. - - Raise: - AuthException: raised if update operation fails - """ - response = await self._http.post( - MgmtV1.family_update_path, - body=FamilyBase._compose_update_body(id, name, custom_attributes, photo, disabled), - ) - return response.json() - - async def delete(self, id: str) -> None: - """ - Delete an existing family. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the family to delete. - - Raise: - AuthException: raised if delete operation fails - """ - await self._http.post(MgmtV1.family_delete_path, body={"id": id}) - - async def search_all( - self, - ids: Optional[List[str]] = None, - names: Optional[List[str]] = None, - text: Optional[str] = None, - custom_attributes: Optional[dict] = None, - page: Optional[int] = None, - size: Optional[int] = None, - ) -> dict: - """ - Search families. Called with no arguments, returns all families. - - Args: - ids (List[str]): Optional list of family IDs to filter by. - names (List[str]): Optional list of family names to filter by. - text (str): Optional free text search among the families' attributes. - custom_attributes (dict): Optional, search for families with the given custom attribute values. - page (int): Optional pagination control. Pages start at 0. - size (int): Optional page size (up to 1000). - - Return value (dict): - Return dict in the format - {"families": [{...}]} - Containing the found families. - - Raise: - AuthException: raised if search operation fails - """ - response = await self._http.post( - MgmtV1.family_search_path, - body=FamilyBase._compose_search_body(ids, names, text, custom_attributes, page, size), - ) - return response.json() - - async def create_dependent( - self, - family_id: str, - login_id: Optional[str] = None, - name: Optional[str] = None, - email: Optional[str] = None, - phone: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - family_scoped_attributes: Optional[dict] = None, - ) -> dict: - """ - Create a dependent user in a family. A dependent is a user with no login credentials of their own, - managed by the family's members. - - Args: - family_id (str): The ID of the family to create the dependent in. - login_id (str): Optional login ID. When omitted it is derived from the name. The email and phone - are never used as the login ID, since a dependent may share them with their guardian. - name (str): Optional display name. - email (str): Optional email address. - phone (str): Optional phone number. - given_name (str): Optional given name. - middle_name (str): Optional middle name. - family_name (str): Optional family name. - picture (str): Optional URL of the user's picture. - custom_attributes (dict): Optional, the user's custom attribute values. - family_scoped_attributes (dict): Optional, the dependent's family-scoped custom attribute values - in this family, in the format {: }. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the created dependent user information. - - Raise: - AuthException: raised if create operation fails - """ - response = await self._http.post( - MgmtV1.family_dependent_create_path, - body=FamilyBase._compose_create_dependent_body( - family_id, - login_id, - name, - email, - phone, - given_name, - middle_name, - family_name, - picture, - custom_attributes, - family_scoped_attributes, - ), - ) - return response.json() - - async def delete_dependent(self, user_id: str) -> None: - """ - Delete a dependent user. The family is inferred from the dependent. Regular (non-dependent) - family members are removed from a family with `user.remove_families`, not deleted. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - user_id (str): The user ID of the dependent to delete. - - Raise: - AuthException: raised if delete operation fails - """ - await self._http.post(MgmtV1.family_dependent_delete_path, body={"userId": user_id}) - - async def impersonate_dependent( - self, - impersonator_user_id_or_login_id: str, - dependent_login_id: str, - selected_family: Optional[str] = None, - ) -> str: - """ - Impersonate a family dependent. The impersonator must be a member of the dependent's family - and hold the "Family Impersonate Dependents" permission in that family. - - Args: - impersonator_user_id_or_login_id (str): The user ID or login ID of the impersonating family member. - dependent_login_id (str): The login ID of the dependent to impersonate. - selected_family (str): Optional family to scope the impersonated session to. When set, it must be - the dependent's family. - - Return value (str): A JWT of the impersonated dependent - - Raise: - AuthException: raised if impersonation fails - """ - JWTBase._validate_impersonator_id(impersonator_user_id_or_login_id) - JWTBase._validate_login_id(dependent_login_id) - response = await self._http.post( - MgmtV1.family_impersonate_path, - body=FamilyBase._compose_impersonate_body( - impersonator_user_id_or_login_id, dependent_login_id, selected_family - ), - ) - return response.json().get("jwt", "") - - async def stop_impersonation( - self, - jwt: str, - custom_claims: Optional[dict] = None, - refresh_duration: Optional[int] = None, - ) -> str: - """ - Stop impersonating a family dependent and return to the impersonating member's own session. - - Args: - jwt (str): The impersonation JWT to stop. - custom_claims (dict): Optional custom claims to add to the JWT. - refresh_duration (int): Optional duration in seconds for which the new JWT will be valid. - - Return value (str): A JWT of the impersonating family member - - Raise: - AuthException: raised if the operation fails - """ - JWTBase._validate_jwt(jwt) - response = await self._http.post( - MgmtV1.family_stop_impersonation_path, - body=FamilyBase._compose_stop_impersonation_body(jwt, custom_claims, refresh_duration), - ) - return response.json().get("jwt", "") - - async def get_settings(self) -> dict: - """ - Load the project's family account settings. - - Return value (dict): - Return dict in the format - {"enabled": , "maxFamilyMembers": , "allowMultipleFamiliesUsers": } - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.family_settings_path) - return response.json() - - async def configure_settings( - self, - enabled: Optional[bool] = None, - max_family_members: Optional[int] = None, - allow_multiple_families_users: Optional[bool] = None, - ) -> dict: - """ - Configure the project's family account settings. Omitted fields are left unchanged. - - Args: - enabled (bool): Optional, whether family accounts are enabled for the project. - max_family_members (int): Optional, the maximum number of members in a single family (at least 1). - allow_multiple_families_users (bool): Optional, whether a user may belong to more than one family. - - Return value (dict): - Return dict in the format - {"enabled": , "maxFamilyMembers": , "allowMultipleFamiliesUsers": } - Containing the updated settings. - - Raise: - AuthException: raised if update operation fails - """ - response = await self._http.post( - MgmtV1.family_settings_path, - body=FamilyBase._compose_settings_body(enabled, max_family_members, allow_multiple_families_users), - ) - return response.json() - - async def load_custom_attributes(self) -> dict: - """ - Load the custom attribute definitions of the family entity itself. These are distinct from the - family-scoped user attributes managed with `user.load_family_scoped_custom_attributes`. - - Return value (dict): - Return dict in the format - {"data": [{"name": , "type": , "displayName": , ...}]} - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.family_load_custom_attributes_path) - return response.json() - - async def create_custom_attributes(self, attributes: List[CustomAttribute]) -> dict: - """ - Create custom attribute definitions on the family entity. - - Args: - attributes (List[CustomAttribute]): The custom attribute definitions to create. - - Return value (dict): - Return dict in the format - {"data": [...]} - Containing the updated custom attribute definitions. - - Raise: - AuthException: raised if create operation fails - """ - response = await self._http.post( - MgmtV1.family_create_custom_attributes_path, - body={"attributes": custom_attributes_to_dict(attributes)}, - ) - return response.json() - - async def delete_custom_attributes(self, names: List[str]) -> dict: - """ - Delete custom attribute definitions from the family entity by name. - - Args: - names (List[str]): The names of the custom attributes to delete. - - Return value (dict): - Return dict in the format - {"data": [...]} - Containing the remaining custom attribute definitions. - - Raise: - AuthException: raised if delete operation fails - """ - response = await self._http.post( - MgmtV1.family_delete_custom_attributes_path, - body={"names": names}, - ) - return response.json() diff --git a/descope/management/fga.py b/descope/management/fga.py deleted file mode 100644 index 1a5a67250..000000000 --- a/descope/management/fga.py +++ /dev/null @@ -1,212 +0,0 @@ -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class FGA(HTTPBase): - def __init__(self, http_client, fga_cache_url: Optional[str] = None): - super().__init__(http_client) - self._fga_cache_url = fga_cache_url - - def save_schema(self, schema: str): - """ - Create or update an FGA schema. - Args: - schema (str): the schema in the AuthZ 1.0 DSL - model AuthZ 1.0 - - type user - - type org - relation member: user - relation parent: org - - type folder - relation parent: folder - relation owner: user | org#member - relation editor: user - relation viewer: user - - permission can_create: owner | parent.owner - permission can_edit: editor | can_create - permission can_view: viewer | can_edit - - type doc - relation parent: folder - relation owner: user | org#member - relation editor: user - relation viewer: user - - permission can_create: owner | parent.owner - permission can_edit: editor | can_create - permission can_view: viewer | can_edit - Raise: - AuthException: raised if saving fails - """ - self._http.post( - MgmtV1.fga_save_schema, - body={"dsl": schema}, - base_url=self._fga_cache_url, - ) - - def create_relations( - self, - relations: List[dict], - ): - """ - Create the given relations based on the existing schema - Args: - relations (List[dict]): the relations to create. Each in the following format: - { - "resource": "id of the resource that has the relation", - "resourceType": "the type of the resource (namespace)", - "relation": "the relation definition for the relation", - "target": "the target that has the relation - usually users or other resources", - "targetType": "the type of the target (namespace) - can also be group#member for target sets" - } - Raise: - AuthException: raised if create relations fails - """ - self._http.post( - MgmtV1.fga_create_relations, - body={"tuples": relations}, - base_url=self._fga_cache_url, - ) - - def delete_relations( - self, - relations: List[dict], - ): - """ - Delete the given relations based on the existing schema - Args: - relations (List[dict]): the relations to create. Each in the format as specified above for (create_relations) - Raise: - AuthException: raised if delete relations fails - """ - self._http.post( - MgmtV1.fga_delete_relations, - body={"tuples": relations}, - base_url=self._fga_cache_url, - ) - - def check( - self, - relations: List[dict], - ) -> List[dict]: - """ - Queries the given relations to see if they exist returning true if they do - Args: - relations (List[dict]): List of relation queries each in the format of: - { - "resource": "id of the resource that has the relation", - "resourceType": "the type of the resource (namespace)", - "relation": "the relation definition for the relation", - "target": "the target that has the relation - usually users or other resources", - "targetType": "the type of the target (namespace)" - } - - Return value (List[dict]): - Return List in the format - [ - { - "allowed": True|False - "relation": { - "resource": "id of the resource that has the relation", - "resourceType": "the type of the resource (namespace)", - "relation": "the relation definition for the relation", - "target": "the target that has the relation - usually users or other resources", - "targetType": "the type of the target (namespace)" - } - } - ] - Raise: - AuthException: raised if query fails - """ - response = self._http.post( - MgmtV1.fga_check, - body={"tuples": relations}, - base_url=self._fga_cache_url, - ) - return list( - map( - lambda tuple: {"relation": tuple["tuple"], "allowed": tuple["allowed"]}, - response.json()["tuples"], - ) - ) - - def load_resources_details(self, resource_identifiers: List[dict]) -> List[dict]: - """ - Load details for the given resource identifiers. - Args: - resource_identifiers (List[dict]): list of dicts each containing 'resourceId' and 'resourceType'. - Returns: - List[dict]: list of resources details as returned by the server. - """ - response = self._http.post( - MgmtV1.fga_resources_load, - body={"resourceIdentifiers": resource_identifiers}, - ) - return response.json().get("resourcesDetails", []) - - def save_resources_details(self, resources_details: List[dict]) -> None: - """ - Save details for the given resources. - Args: - resources_details (List[dict]): list of dicts each containing 'resourceId' and 'resourceType' plus optionally containing metadata fields such as 'displayName'. - """ - self._http.post( - MgmtV1.fga_resources_save, - body={"resourcesDetails": resources_details}, - ) - - def load_mappable_schema(self, tenant_id: str, resources_limit: Optional[int] = None) -> dict: - """ - Load the mappable schema for the given tenant. - Args: - tenant_id (str): The tenant ID for which to load the mappable schema. - resources_limit (int): Optional limit on the number of resources to include. - Returns: - dict: The mappable schema as returned by the server. - Raise: - AuthException: raised if loading the mappable schema fails. - """ - params = {"tenantId": tenant_id} - if resources_limit is not None: - params["resourcesLimit"] = str(resources_limit) - response = self._http.get(MgmtV1.fga_mappable_schema_path, params=params) - return response.json() - - def load_mappable_resources( - self, tenant_id: str, resources_queries: List[dict], resources_limit: Optional[int] = None - ) -> List[dict]: - """ - Search for mappable resources matching the given queries. - Args: - tenant_id (str): The tenant ID for which to search resources. - resources_queries (List[dict]): List of resource queries to search for. - resources_limit (int): Optional limit on the number of resources to return. - Returns: - List[dict]: List of mappable resources matching the queries. - Raise: - AuthException: raised if the search fails. - """ - body = {"tenantId": tenant_id, "resourcesQueries": resources_queries} - if resources_limit is not None: - body["resourcesLimit"] = str(resources_limit) - response = self._http.post(MgmtV1.fga_mappable_resources_path, body=body) - return response.json().get("mappableResources", []) - - def save_schema_dryrun(self, schema: str) -> dict: - """ - Perform a dry run of saving the schema to validate it without applying changes. - Args: - schema (str): the schema in the AuthZ 1.0 DSL. - Returns: - dict: The dry run response containing validation results. - Raise: - AuthException: raised if the dry run fails or schema is invalid. - """ - response = self._http.post(MgmtV1.fga_schema_dryrun_path, body={"dsl": schema}) - return response.json() diff --git a/descope/management/fga_async.py b/descope/management/fga_async.py deleted file mode 100644 index cecaf3a86..000000000 --- a/descope/management/fga_async.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class FGAAsync(AsyncHTTPBase): - """Async counterpart of FGA — all HTTP calls are coroutines.""" - - def __init__(self, http_client, fga_cache_url: Optional[str] = None): - super().__init__(http_client) - self._fga_cache_url = fga_cache_url - - async def save_schema(self, schema: str): - """ - Create or update an FGA schema. - Args: - schema (str): the schema in the AuthZ 1.0 DSL - model AuthZ 1.0 - - type user - - type org - relation member: user - relation parent: org - - type folder - relation parent: folder - relation owner: user | org#member - relation editor: user - relation viewer: user - - permission can_create: owner | parent.owner - permission can_edit: editor | can_create - permission can_view: viewer | can_edit - - type doc - relation parent: folder - relation owner: user | org#member - relation editor: user - relation viewer: user - - permission can_create: owner | parent.owner - permission can_edit: editor | can_create - permission can_view: viewer | can_edit - Raise: - AuthException: raised if saving fails - """ - await self._http.post( - MgmtV1.fga_save_schema, - body={"dsl": schema}, - base_url=self._fga_cache_url, - ) - - async def create_relations( - self, - relations: List[dict], - ): - """ - Create the given relations based on the existing schema - Args: - relations (List[dict]): the relations to create. Each in the following format: - { - "resource": "id of the resource that has the relation", - "resourceType": "the type of the resource (namespace)", - "relation": "the relation definition for the relation", - "target": "the target that has the relation - usually users or other resources", - "targetType": "the type of the target (namespace) - can also be group#member for target sets" - } - Raise: - AuthException: raised if create relations fails - """ - await self._http.post( - MgmtV1.fga_create_relations, - body={"tuples": relations}, - base_url=self._fga_cache_url, - ) - - async def delete_relations( - self, - relations: List[dict], - ): - """ - Delete the given relations based on the existing schema - Args: - relations (List[dict]): the relations to create. Each in the format as specified above for (create_relations) - Raise: - AuthException: raised if delete relations fails - """ - await self._http.post( - MgmtV1.fga_delete_relations, - body={"tuples": relations}, - base_url=self._fga_cache_url, - ) - - async def check( - self, - relations: List[dict], - ) -> List[dict]: - """ - Queries the given relations to see if they exist returning true if they do - Args: - relations (List[dict]): List of relation queries each in the format of: - { - "resource": "id of the resource that has the relation", - "resourceType": "the type of the resource (namespace)", - "relation": "the relation definition for the relation", - "target": "the target that has the relation - usually users or other resources", - "targetType": "the type of the target (namespace)" - } - - Return value (List[dict]): - Return List in the format - [ - { - "allowed": True|False - "relation": { - "resource": "id of the resource that has the relation", - "resourceType": "the type of the resource (namespace)", - "relation": "the relation definition for the relation", - "target": "the target that has the relation - usually users or other resources", - "targetType": "the type of the target (namespace)" - } - } - ] - Raise: - AuthException: raised if query fails - """ - response = await self._http.post( - MgmtV1.fga_check, - body={"tuples": relations}, - base_url=self._fga_cache_url, - ) - return list( - map( - lambda tuple: {"relation": tuple["tuple"], "allowed": tuple["allowed"]}, - response.json()["tuples"], - ) - ) - - async def load_resources_details(self, resource_identifiers: List[dict]) -> List[dict]: - """ - Load details for the given resource identifiers. - Args: - resource_identifiers (List[dict]): list of dicts each containing 'resourceId' and 'resourceType'. - Returns: - List[dict]: list of resources details as returned by the server. - """ - response = await self._http.post( - MgmtV1.fga_resources_load, - body={"resourceIdentifiers": resource_identifiers}, - ) - return response.json().get("resourcesDetails", []) - - async def save_resources_details(self, resources_details: List[dict]) -> None: - """ - Save details for the given resources. - Args: - resources_details (List[dict]): list of dicts each containing 'resourceId' and 'resourceType' plus optionally containing metadata fields such as 'displayName'. - """ - await self._http.post( - MgmtV1.fga_resources_save, - body={"resourcesDetails": resources_details}, - ) - - async def load_mappable_schema(self, tenant_id: str, resources_limit: Optional[int] = None) -> dict: - """ - Load the mappable schema for the given tenant. - Args: - tenant_id (str): The tenant ID for which to load the mappable schema. - resources_limit (int): Optional limit on the number of resources to include. - Returns: - dict: The mappable schema as returned by the server. - Raise: - AuthException: raised if loading the mappable schema fails. - """ - params = {"tenantId": tenant_id} - if resources_limit is not None: - params["resourcesLimit"] = str(resources_limit) - response = await self._http.get(MgmtV1.fga_mappable_schema_path, params=params) - return response.json() - - async def load_mappable_resources( - self, tenant_id: str, resources_queries: List[dict], resources_limit: Optional[int] = None - ) -> List[dict]: - """ - Search for mappable resources matching the given queries. - Args: - tenant_id (str): The tenant ID for which to search resources. - resources_queries (List[dict]): List of resource queries to search for. - resources_limit (int): Optional limit on the number of resources to return. - Returns: - List[dict]: List of mappable resources matching the queries. - Raise: - AuthException: raised if the search fails. - """ - body = {"tenantId": tenant_id, "resourcesQueries": resources_queries} - if resources_limit is not None: - body["resourcesLimit"] = str(resources_limit) - response = await self._http.post(MgmtV1.fga_mappable_resources_path, body=body) - return response.json().get("mappableResources", []) - - async def save_schema_dryrun(self, schema: str) -> dict: - """ - Perform a dry run of saving the schema to validate it without applying changes. - Args: - schema (str): the schema in the AuthZ 1.0 DSL. - Returns: - dict: The dry run response containing validation results. - Raise: - AuthException: raised if the dry run fails or schema is invalid. - """ - response = await self._http.post(MgmtV1.fga_schema_dryrun_path, body={"dsl": schema}) - return response.json() diff --git a/descope/management/flow.py b/descope/management/flow.py deleted file mode 100644 index 7b76fb6db..000000000 --- a/descope/management/flow.py +++ /dev/null @@ -1,242 +0,0 @@ -from typing import List, Optional, Union - -from descope._http_base import HTTPBase -from descope.management.common import FlowRunOptions, MgmtV1 - - -class Flow(HTTPBase): - def list_flows( - self, - ) -> dict: - """ - List all project flows - - Return value (dict): - Return dict in the format - { "flows": [{"id": "", "name": "", "description": "", "disabled": False}], total: number} - - Raise: - AuthException: raised if list operation fails - """ - response = self._http.post(MgmtV1.flow_list_path) - return response.json() - - def delete_flows( - self, - flow_ids: List[str], - ) -> dict: - """ - Delete flows by the given ids - - Args: - flow_ids (List[str]): list of flow IDs to delete. - - Raise: - AuthException: raised if delete operation fails - """ - response = self._http.post( - MgmtV1.flow_delete_path, - body={ - "ids": flow_ids, - }, - ) - return response.json() - - def export_flow( - self, - flow_id: str, - ) -> dict: - """ - Export the given flow id flow and screens. - - Args: - flow_id (str): the flow id to export. - - Return value (dict): - Return dict in the format - { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}}, screens: [{ "id": "", "inputs": [], "interactions": [] }] } - - Raise: - AuthException: raised if export operation fails - """ - response = self._http.post( - MgmtV1.flow_export_path, - body={ - "flowId": flow_id, - }, - ) - return response.json() - - def import_flow( - self, - flow_id: str, - flow: dict, - screens: List[dict], - ) -> dict: - """ - Import the given flow and screens to the flow id. - Imoprtant: This will override the current project flow by the given id, treat with caution. - - Args: - flow_id (str): the flow id to import to. - flow (dict): the flow to import. dict in the format - { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}} - screens (List[dict]): the flow screens to import. list of dictss in the format: - { "id": "", "inputs": [], "interactions": [] } - - Return value (dict): - Return dict in the format - { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}}, screens: [{ "id": "", "inputs": [], "interactions": [] }] } - - Raise: - AuthException: raised if import operation fails - """ - response = self._http.post( - MgmtV1.flow_import_path, - body={ - "flowId": flow_id, - "flow": flow, - "screens": screens, - }, - ) - return response.json() - - def export_theme( - self, - ) -> dict: - """ - Export the current project theme. - - Return value (dict): - Return dict in the format - {"id": "", "cssTemplate": {} } - - Raise: - AuthException: raised if export operation fails - """ - response = self._http.post( - MgmtV1.theme_export_path, - body={}, - ) - return response.json() - - def import_theme( - self, - theme: dict, - ) -> dict: - """ - Import the given theme as the current project theme. - Imoprtant: This will override the current project theme, treat with caution. - - Args: - theme (Theme): the theme to import. dict in the format - {"id": "", "cssTemplate": {} } - - Return value (dict): - Return dict in the format - {"id": "", "cssTemplate": {} } - - Raise: - AuthException: raised if import operation fails - """ - response = self._http.post( - MgmtV1.theme_import_path, - body={ - "theme": theme, - }, - ) - return response.json() - - def run_flow( - self, - flow_id: str, - options: Optional[Union[FlowRunOptions, dict]] = None, - ) -> dict: - """ - Run a flow with the given flow id and options. - - Args: - flow_id (str): the flow id to run. - options (Optional[Union[FlowRunOptions, dict]]): optional flow run options containing: - - input: optional input data to pass to the flow. - - preview: optional flag to run the flow in preview mode. - - tenant: optional tenant ID to run the flow for. - - Return value (dict): - Return dict with the flow execution result. - - Raise: - AuthException: raised if run operation fails - """ - body: dict = {"flowId": flow_id} - - if options is not None: - if isinstance(options, dict): - options = FlowRunOptions.from_dict(options) - if options is not None: - body.update(options.to_dict()) - - response = self._http.post( - MgmtV1.flow_run_path, - body=body, - ) - return response.json() - - def run_flow_async( - self, - flow_id: str, - options: Optional[Union[FlowRunOptions, dict]] = None, - ) -> dict: - """ - Run a flow asynchronously with the given flow id and options. - - Args: - flow_id (str): the flow id to run. - options (Optional[Union[FlowRunOptions, dict]]): optional flow run options containing: - - input: optional input data to pass to the flow. - - preview: optional flag to run the flow in preview mode. - - tenant: optional tenant ID to run the flow for. - - Return value (dict): - Return dict with the async flow execution result. - use the get_flow_async_result() method with this result's executionId - to get the actual flow's result. - - Raise: - AuthException: raised if run operation fails - """ - body: dict = {"flowId": flow_id} - - if options is not None: - if isinstance(options, dict): - options = FlowRunOptions.from_dict(options) - if options is not None: - body.update(options.to_dict()) - - response = self._http.post( - MgmtV1.flow_async_run_path, - body=body, - ) - return response.json() - - def get_flow_async_result( - self, - execution_id: str, - ) -> dict: - """ - Get the result of an async flow execution. - - Args: - execution_id (str): the execution id returned from run_flow_async. - - Return value (dict): - Return dict with the async flow execution result. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.flow_async_result_path, - body={"executionId": execution_id}, - ) - return response.json() diff --git a/descope/management/flow_async.py b/descope/management/flow_async.py deleted file mode 100644 index 826b562f9..000000000 --- a/descope/management/flow_async.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional, Union - -from descope._http_base import AsyncHTTPBase -from descope.management.common import FlowRunOptions, MgmtV1 - - -class FlowAsync(AsyncHTTPBase): - """Async counterpart of Flow — all HTTP calls are coroutines.""" - - async def list_flows( - self, - ) -> dict: - """ - List all project flows - - Return value (dict): - Return dict in the format - { "flows": [{"id": "", "name": "", "description": "", "disabled": False}], total: number} - - Raise: - AuthException: raised if list operation fails - """ - response = await self._http.post(MgmtV1.flow_list_path) - return response.json() - - async def delete_flows( - self, - flow_ids: List[str], - ) -> dict: - """ - Delete flows by the given ids - - Args: - flow_ids (List[str]): list of flow IDs to delete. - - Raise: - AuthException: raised if delete operation fails - """ - response = await self._http.post( - MgmtV1.flow_delete_path, - body={ - "ids": flow_ids, - }, - ) - return response.json() - - async def export_flow( - self, - flow_id: str, - ) -> dict: - """ - Export the given flow id flow and screens. - - Args: - flow_id (str): the flow id to export. - - Return value (dict): - Return dict in the format - { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}}, screens: [{ "id": "", "inputs": [], "interactions": [] }] } - - Raise: - AuthException: raised if export operation fails - """ - response = await self._http.post( - MgmtV1.flow_export_path, - body={ - "flowId": flow_id, - }, - ) - return response.json() - - async def import_flow( - self, - flow_id: str, - flow: dict, - screens: List[dict], - ) -> dict: - """ - Import the given flow and screens to the flow id. - Imoprtant: This will override the current project flow by the given id, treat with caution. - - Args: - flow_id (str): the flow id to import to. - flow (dict): the flow to import. dict in the format - { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}} - screens (List[dict]): the flow screens to import. list of dictss in the format: - { "id": "", "inputs": [], "interactions": [] } - - Return value (dict): - Return dict in the format - { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}}, screens: [{ "id": "", "inputs": [], "interactions": [] }] } - - Raise: - AuthException: raised if import operation fails - """ - response = await self._http.post( - MgmtV1.flow_import_path, - body={ - "flowId": flow_id, - "flow": flow, - "screens": screens, - }, - ) - return response.json() - - async def export_theme( - self, - ) -> dict: - """ - Export the current project theme. - - Return value (dict): - Return dict in the format - {"id": "", "cssTemplate": {} } - - Raise: - AuthException: raised if export operation fails - """ - response = await self._http.post( - MgmtV1.theme_export_path, - body={}, - ) - return response.json() - - async def import_theme( - self, - theme: dict, - ) -> dict: - """ - Import the given theme as the current project theme. - Imoprtant: This will override the current project theme, treat with caution. - - Args: - theme (Theme): the theme to import. dict in the format - {"id": "", "cssTemplate": {} } - - Return value (dict): - Return dict in the format - {"id": "", "cssTemplate": {} } - - Raise: - AuthException: raised if import operation fails - """ - response = await self._http.post( - MgmtV1.theme_import_path, - body={ - "theme": theme, - }, - ) - return response.json() - - async def run_flow( - self, - flow_id: str, - options: Optional[Union[FlowRunOptions, dict]] = None, - ) -> dict: - """ - Run a flow with the given flow id and options. - - Args: - flow_id (str): the flow id to run. - options (Optional[Union[FlowRunOptions, dict]]): optional flow run options containing: - - input: optional input data to pass to the flow. - - preview: optional flag to run the flow in preview mode. - - tenant: optional tenant ID to run the flow for. - - Return value (dict): - Return dict with the flow execution result. - - Raise: - AuthException: raised if run operation fails - """ - body: dict = {"flowId": flow_id} - - if options is not None: - if isinstance(options, dict): - options = FlowRunOptions.from_dict(options) - if options is not None: - body.update(options.to_dict()) - - response = await self._http.post( - MgmtV1.flow_run_path, - body=body, - ) - return response.json() - - async def run_flow_async( - self, - flow_id: str, - options: Optional[Union[FlowRunOptions, dict]] = None, - ) -> dict: - """ - Run a flow asynchronously with the given flow id and options. - - Args: - flow_id (str): the flow id to run. - options (Optional[Union[FlowRunOptions, dict]]): optional flow run options containing: - - input: optional input data to pass to the flow. - - preview: optional flag to run the flow in preview mode. - - tenant: optional tenant ID to run the flow for. - - Return value (dict): - Return dict with the async flow execution result. - use the get_flow_async_result() method with this result's executionId - to get the actual flow's result. - - Raise: - AuthException: raised if run operation fails - """ - body: dict = {"flowId": flow_id} - - if options is not None: - if isinstance(options, dict): - options = FlowRunOptions.from_dict(options) - if options is not None: - body.update(options.to_dict()) - - response = await self._http.post( - MgmtV1.flow_async_run_path, - body=body, - ) - return response.json() - - async def get_flow_async_result( - self, - execution_id: str, - ) -> dict: - """ - Get the result of an async flow execution. - - Args: - execution_id (str): the execution id returned from run_flow_async. - - Return value (dict): - Return dict with the async flow execution result. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.flow_async_result_path, - body={"executionId": execution_id}, - ) - return response.json() diff --git a/descope/management/group.py b/descope/management/group.py deleted file mode 100644 index 2691445b9..000000000 --- a/descope/management/group.py +++ /dev/null @@ -1,159 +0,0 @@ -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class Group(HTTPBase): - def load_all_groups( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ) -> dict: - """ - Load all groups for a specific tenant id. - - Args: - tenant_id (str): Tenant ID to load groups from. - sso_id (str): Optional SSO configuration id (ssoId) to load only groups that came from - that SSO configuration. Use the reserved id "default_ssoid" for the tenant's default - SSO configuration. When omitted, all the tenant's groups are returned. - - Return value (dict): - Return dict in the format - [ - { - "id": , - "display": , - "source": <"scim" or "jit">, - "ssoId": , - "members":[ - { - "loginId": , - "userId": , - "display": - } - ] - } - ] - Containing the loaded groups information. - - Raise: - AuthException: raised if load operation fails - """ - body = { - "tenantId": tenant_id, - } - if sso_id is not None: - body["ssoId"] = sso_id - response = self._http.post( - MgmtV1.group_load_all_path, - body=body, - ) - return response.json() - - def load_all_groups_for_members( - self, - tenant_id: str, - user_ids: Optional[List[str]] = None, - login_ids: Optional[List[str]] = None, - sso_id: Optional[str] = None, - ) -> dict: - """ - Load all groups for the provided user IDs or login IDs. - - Args: - tenant_id (str): Tenant ID to load groups from. - user_ids (List[str]): Optional List of user IDs, with the format of "U2J5ES9S8TkvCgOvcrkpzUgVTEBM" (example), which can be found on the user's JWT. - login_ids (List[str]): Optional List of login IDs, how the users identify when logging in. - sso_id (str): Optional SSO configuration id (ssoId) to load only groups that came from - that SSO configuration. Use the reserved id "default_ssoid" for the tenant's default - SSO configuration. When omitted, all matching groups are returned. - - Return value (dict): - Return dict in the format - [ - { - "id": , - "display": , - "source": <"scim" or "jit">, - "ssoId": , - "members":[ - { - "loginId": , - "userId": , - "display": - } - ] - } - ] - Containing the loaded groups information. - - Raise: - AuthException: raised if load operation fails - """ - user_ids = [] if user_ids is None else user_ids - login_ids = [] if login_ids is None else login_ids - - body = { - "tenantId": tenant_id, - "loginIds": login_ids, - "userIds": user_ids, - } - if sso_id is not None: - body["ssoId"] = sso_id - response = self._http.post( - MgmtV1.group_load_all_for_member_path, - body=body, - ) - return response.json() - - def load_all_group_members( - self, - tenant_id: str, - group_id: str, - sso_id: Optional[str] = None, - ) -> dict: - """ - Load all members of the provided group id. - - Args: - tenant_id (str): Tenant ID to load groups from. - group_id (str): Group ID to load members for. - sso_id (str): Optional SSO configuration id (ssoId): return the group only if it came - from that SSO configuration. Use the reserved id "default_ssoid" for the tenant's - default SSO configuration. - - Return value (dict): - Return dict in the format - [ - { - "id": , - "display": , - "source": <"scim" or "jit">, - "ssoId": , - "members":[ - { - "loginId": , - "userId": , - "display": - } - ] - } - ] - Containing the loaded groups information. - - Raise: - AuthException: raised if load operation fails - """ - body = { - "tenantId": tenant_id, - "groupId": group_id, - } - if sso_id is not None: - body["ssoId"] = sso_id - response = self._http.post( - MgmtV1.group_load_all_group_members_path, - body=body, - ) - return response.json() diff --git a/descope/management/group_async.py b/descope/management/group_async.py deleted file mode 100644 index 34e05bde0..000000000 --- a/descope/management/group_async.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class GroupAsync(AsyncHTTPBase): - """Async counterpart of Group — all HTTP calls are coroutines.""" - - async def load_all_groups( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ) -> dict: - """ - Load all groups for a specific tenant id. - - Args: - tenant_id (str): Tenant ID to load groups from. - sso_id (str): Optional SSO configuration id (ssoId) to load only groups that came from - that SSO configuration. Use the reserved id "default_ssoid" for the tenant's default - SSO configuration. When omitted, all the tenant's groups are returned. - - Return value (dict): - Return dict in the format - [ - { - "id": , - "display": , - "source": <"scim" or "jit">, - "ssoId": , - "members":[ - { - "loginId": , - "userId": , - "display": - } - ] - } - ] - Containing the loaded groups information. - - Raise: - AuthException: raised if load operation fails - """ - body = { - "tenantId": tenant_id, - } - if sso_id is not None: - body["ssoId"] = sso_id - response = await self._http.post( - MgmtV1.group_load_all_path, - body=body, - ) - return response.json() - - async def load_all_groups_for_members( - self, - tenant_id: str, - user_ids: Optional[List[str]] = None, - login_ids: Optional[List[str]] = None, - sso_id: Optional[str] = None, - ) -> dict: - """ - Load all groups for the provided user IDs or login IDs. - - Args: - tenant_id (str): Tenant ID to load groups from. - user_ids (List[str]): Optional List of user IDs, with the format of "U2J5ES9S8TkvCgOvcrkpzUgVTEBM" (example), which can be found on the user's JWT. - login_ids (List[str]): Optional List of login IDs, how the users identify when logging in. - sso_id (str): Optional SSO configuration id (ssoId) to load only groups that came from - that SSO configuration. Use the reserved id "default_ssoid" for the tenant's default - SSO configuration. When omitted, all matching groups are returned. - - Return value (dict): - Return dict in the format - [ - { - "id": , - "display": , - "source": <"scim" or "jit">, - "ssoId": , - "members":[ - { - "loginId": , - "userId": , - "display": - } - ] - } - ] - Containing the loaded groups information. - - Raise: - AuthException: raised if load operation fails - """ - user_ids = [] if user_ids is None else user_ids - login_ids = [] if login_ids is None else login_ids - - body = { - "tenantId": tenant_id, - "loginIds": login_ids, - "userIds": user_ids, - } - if sso_id is not None: - body["ssoId"] = sso_id - response = await self._http.post( - MgmtV1.group_load_all_for_member_path, - body=body, - ) - return response.json() - - async def load_all_group_members( - self, - tenant_id: str, - group_id: str, - sso_id: Optional[str] = None, - ) -> dict: - """ - Load all members of the provided group id. - - Args: - tenant_id (str): Tenant ID to load groups from. - group_id (str): Group ID to load members for. - sso_id (str): Optional SSO configuration id (ssoId): return the group only if it came - from that SSO configuration. Use the reserved id "default_ssoid" for the tenant's - default SSO configuration. - - Return value (dict): - Return dict in the format - [ - { - "id": , - "display": , - "source": <"scim" or "jit">, - "ssoId": , - "members":[ - { - "loginId": , - "userId": , - "display": - } - ] - } - ] - Containing the loaded groups information. - - Raise: - AuthException: raised if load operation fails - """ - body = { - "tenantId": tenant_id, - "groupId": group_id, - } - if sso_id is not None: - body["ssoId"] = sso_id - response = await self._http.post( - MgmtV1.group_load_all_group_members_path, - body=body, - ) - return response.json() diff --git a/descope/management/jwt.py b/descope/management/jwt.py deleted file mode 100644 index c0d9032b4..000000000 --- a/descope/management/jwt.py +++ /dev/null @@ -1,285 +0,0 @@ -from typing import Optional - -from descope._http_base import HTTPBase -from descope.auth import Auth -from descope.jwt_common import generate_jwt_response -from descope.management._jwt_base import JWTBase -from descope.management.common import ( - MgmtLoginOptions, - MgmtSignUpOptions, - MgmtUserRequest, - MgmtV1, - is_jwt_required, -) - - -class JWT(JWTBase, HTTPBase): - _auth: Auth - - def __init__(self, http_client, auth: Auth): - super().__init__(http_client) - self._auth = auth - - def update_jwt(self, jwt: str, custom_claims: dict, refresh_duration: int = 0) -> str: - """ - Given a valid JWT, update it with custom claims, and update its authz claims as well - - Args: - token (str): valid jwt. - custom_claims (dict): Custom claims to add to JWT, system claims will be filtered out - refresh_duration (int): duration in seconds for which the new JWT will be valid - - Return value (str): the newly updated JWT - - Raise: - AuthException: raised if update failed - """ - self._validate_jwt(jwt) - response = self._http.post( - MgmtV1.update_jwt_path, - body={ - "jwt": jwt, - "customClaims": custom_claims, - "refreshDuration": refresh_duration, - }, - params=None, - ) - return response.json().get("jwt", "") - - def impersonate( - self, - impersonator_id: str, - login_id: str, - validate_consent: bool, - custom_claims: Optional[dict] = None, - tenant_id: Optional[str] = None, - refresh_duration: Optional[int] = None, - stepup: Optional[bool] = None, - ) -> str: - """ - Impersonate to another user - - Args: - impersonator_id (str): login id / user id of impersonator, must have "impersonation" permission. - login_id (str): login id of the user whom to which to impersonate to. - validate_consent (bool): Indicate whether to allow impersonation in any case or only if a consent to this operation was granted. - customClaims dict: Custom claims to add to JWT - tenant_id (str): tenant id to set on DCT claim. - refresh_duration (int): duration in seconds for which the new JWT will be valid - stepup (bool): Whether to generate a stepup token for the impersonated user. - - Return value (str): A JWT of the impersonated user - - Raise: - AuthException: raised if update failed - """ - self._validate_impersonator_id(impersonator_id) - self._validate_login_id(login_id) - response = self._http.post( - MgmtV1.impersonate_path, - body={ - "loginId": login_id, - "impersonatorId": impersonator_id, - "validateConsent": validate_consent, - "customClaims": custom_claims, - "selectedTenant": tenant_id, - "refreshDuration": refresh_duration, - "stepup": stepup, - }, - params=None, - ) - return response.json().get("jwt", "") - - def impersonate_stepup( - self, - impersonator_id: str, - login_id: str, - validate_consent: bool, - custom_claims: Optional[dict] = None, - tenant_id: Optional[str] = None, - refresh_duration: Optional[int] = None, - ) -> str: - """ - Impersonate to another user with step-up authentication - - Args: - impersonator_id (str): login id / user id of impersonator, must have "impersonation" permission. - login_id (str): login id of the user whom to which to impersonate to. - validate_consent (bool): Indicate whether to allow impersonation in any case or only if a consent to this operation was granted. - customClaims dict: Custom claims to add to JWT - tenant_id (str): tenant id to set on DCT claim. - refresh_duration (int): duration in seconds for which the new JWT will be valid - - Return value (str): A JWT of the impersonated user with step-up - - Raise: - AuthException: raised if update failed - """ - self._validate_impersonator_id(impersonator_id) - self._validate_login_id(login_id) - response = self._http.post( - MgmtV1.impersonate_stepup_path, - body={ - "loginId": login_id, - "impersonatorId": impersonator_id, - "validateConsent": validate_consent, - "customClaims": custom_claims, - "selectedTenant": tenant_id, - "refreshDuration": refresh_duration, - }, - params=None, - ) - return response.json().get("jwt", "") - - def stop_impersonation( - self, - jwt: str, - custom_claims: Optional[dict] = None, - tenant_id: Optional[str] = None, - refresh_duration: Optional[int] = None, - ) -> str: - """ - Stop impersonation and return to the original user - Args: - jwt (str): The impersonation jwt to stop. - customClaims dict: Custom claims to add to JWT - tenant_id (str): tenant id to set on DCT claim. - refresh_duration (int): duration in seconds for which the new JWT will be valid - - Return value (str): A JWT of the actor - - Raise: - AuthException: raised if update failed - """ - self._validate_jwt(jwt) - response = self._http.post( - MgmtV1.stop_impersonation_path, - body={ - "jwt": jwt, - "customClaims": custom_claims, - "selectedTenant": tenant_id, - "refreshDuration": refresh_duration, - }, - params=None, - ) - return response.json().get("jwt", "") - - def sign_in(self, login_id: str, login_options: Optional[MgmtLoginOptions] = None) -> dict: - """ - Generate a JWT for a user, simulating a signin request. - - Args: - login_id (str): login id of the user. - login_options (MgmtLoginOptions): options for the login request. - """ - - self._validate_login_id(login_id) - - if login_options is None: - login_options = MgmtLoginOptions() - - if is_jwt_required(login_options): - self._validate_jwt_required(login_options) - - response = self._http.post( - MgmtV1.mgmt_sign_in_path, - body={ - "loginId": login_id, - "stepup": login_options.stepup, - "mfa": login_options.mfa, - "revokeOtherSessions": login_options.revoke_other_sessions, - "customClaims": login_options.custom_claims, - "jwt": login_options.jwt, - "refreshDuration": login_options.refresh_duration, - }, - params=None, - ) - resp = response.json() - jwt_response = generate_jwt_response(resp, None, None, self._auth.validate_token) - return jwt_response - - def sign_up( - self, - login_id: str, - user: Optional[MgmtUserRequest] = None, - signup_options: Optional[MgmtSignUpOptions] = None, - ) -> dict: - """ - Generate a JWT for a user, simulating a signup request. - - Args: - login_id (str): login id of the user. - user (MgmtUserRequest): user details. - signup_options (MgmtSignUpOptions): signup options. - """ - - return self._sign_up_internal(login_id, MgmtV1.mgmt_sign_up_path, user, signup_options) - - def sign_up_or_in( - self, - login_id: str, - user: Optional[MgmtUserRequest] = None, - signup_options: Optional[MgmtSignUpOptions] = None, - ) -> dict: - """ - Generate a JWT for a user, simulating a signup or in request. - - Args: - login_id (str): login id of the user. - user (MgmtUserRequest): user details. - signup_options (MgmtSignUpOptions): signup options. - """ - return self._sign_up_internal(login_id, MgmtV1.mgmt_sign_up_or_in_path, user, signup_options) - - def _sign_up_internal( - self, - login_id: str, - endpoint: str, - user: Optional[MgmtUserRequest] = None, - signup_options: Optional[MgmtSignUpOptions] = None, - ) -> dict: - if user is None: - user = MgmtUserRequest() - - self._validate_login_id(login_id) - - if signup_options is None: - signup_options = MgmtSignUpOptions() - - response = self._http.post( - endpoint, - body=self._compose_sign_up_body(login_id, user, signup_options), - params=None, - ) - resp = response.json() - jwt_response = generate_jwt_response(resp, None, None, self._auth.validate_token) - return jwt_response - - def anonymous( - self, - custom_claims: Optional[dict] = None, - tenant_id: Optional[str] = None, - refresh_duration: Optional[int] = None, - ) -> dict: - """ - Generate a JWT for an anonymous user. - - Args: - custom_claims dict: Custom claims to add to JWT - tenant_id (str): tenant id to set on DCT claim. - """ - - response = self._http.post( - MgmtV1.anonymous_path, - body={ - "customClaims": custom_claims, - "selectedTenant": tenant_id, - "refreshDuration": refresh_duration, - }, - params=None, - ) - resp = response.json() - jwt_response = generate_jwt_response(resp, None, None, self._auth.validate_token) - del jwt_response["firstSeen"] - del jwt_response["user"] - return jwt_response diff --git a/descope/management/jwt_async.py b/descope/management/jwt_async.py deleted file mode 100644 index c2bb29edc..000000000 --- a/descope/management/jwt_async.py +++ /dev/null @@ -1,283 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._http_base import AsyncHTTPBase -from descope.auth_async import AuthAsync -from descope.management._jwt_base import JWTBase -from descope.management.common import ( - MgmtLoginOptions, - MgmtSignUpOptions, - MgmtUserRequest, - MgmtV1, - is_jwt_required, -) - - -class JWTAsync(JWTBase, AsyncHTTPBase): - """Async counterpart of JWT — all HTTP calls are coroutines.""" - - _auth: AuthAsync - - def __init__(self, http_client, auth: AuthAsync): - super().__init__(http_client) - self._auth = auth - - async def update_jwt(self, jwt: str, custom_claims: dict, refresh_duration: int = 0) -> str: - """ - Given a valid JWT, update it with custom claims, and update its authz claims as well - - Args: - token (str): valid jwt. - custom_claims (dict): Custom claims to add to JWT, system claims will be filtered out - refresh_duration (int): duration in seconds for which the new JWT will be valid - - Return value (str): the newly updated JWT - - Raise: - AuthException: raised if update failed - """ - self._validate_jwt(jwt) - response = await self._http.post( - MgmtV1.update_jwt_path, - body={ - "jwt": jwt, - "customClaims": custom_claims, - "refreshDuration": refresh_duration, - }, - params=None, - ) - return response.json().get("jwt", "") - - async def impersonate( - self, - impersonator_id: str, - login_id: str, - validate_consent: bool, - custom_claims: Optional[dict] = None, - tenant_id: Optional[str] = None, - refresh_duration: Optional[int] = None, - stepup: Optional[bool] = None, - ) -> str: - """ - Impersonate to another user - - Args: - impersonator_id (str): login id / user id of impersonator, must have "impersonation" permission. - login_id (str): login id of the user whom to which to impersonate to. - validate_consent (bool): Indicate whether to allow impersonation in any case or only if a consent to this operation was granted. - customClaims dict: Custom claims to add to JWT - tenant_id (str): tenant id to set on DCT claim. - refresh_duration (int): duration in seconds for which the new JWT will be valid - stepup (bool): Whether to generate a stepup token for the impersonated user. - - Return value (str): A JWT of the impersonated user - - Raise: - AuthException: raised if update failed - """ - self._validate_impersonator_id(impersonator_id) - self._validate_login_id(login_id) - response = await self._http.post( - MgmtV1.impersonate_path, - body={ - "loginId": login_id, - "impersonatorId": impersonator_id, - "validateConsent": validate_consent, - "customClaims": custom_claims, - "selectedTenant": tenant_id, - "refreshDuration": refresh_duration, - "stepup": stepup, - }, - params=None, - ) - return response.json().get("jwt", "") - - async def impersonate_stepup( - self, - impersonator_id: str, - login_id: str, - validate_consent: bool, - custom_claims: Optional[dict] = None, - tenant_id: Optional[str] = None, - refresh_duration: Optional[int] = None, - ) -> str: - """ - Impersonate to another user with step-up authentication - - Args: - impersonator_id (str): login id / user id of impersonator, must have "impersonation" permission. - login_id (str): login id of the user whom to which to impersonate to. - validate_consent (bool): Indicate whether to allow impersonation in any case or only if a consent to this operation was granted. - customClaims dict: Custom claims to add to JWT - tenant_id (str): tenant id to set on DCT claim. - refresh_duration (int): duration in seconds for which the new JWT will be valid - - Return value (str): A JWT of the impersonated user with step-up - - Raise: - AuthException: raised if update failed - """ - self._validate_impersonator_id(impersonator_id) - self._validate_login_id(login_id) - response = await self._http.post( - MgmtV1.impersonate_stepup_path, - body={ - "loginId": login_id, - "impersonatorId": impersonator_id, - "validateConsent": validate_consent, - "customClaims": custom_claims, - "selectedTenant": tenant_id, - "refreshDuration": refresh_duration, - }, - params=None, - ) - return response.json().get("jwt", "") - - async def stop_impersonation( - self, - jwt: str, - custom_claims: Optional[dict] = None, - tenant_id: Optional[str] = None, - refresh_duration: Optional[int] = None, - ) -> str: - """ - Stop impersonation and return to the original user - Args: - jwt (str): The impersonation jwt to stop. - customClaims dict: Custom claims to add to JWT - tenant_id (str): tenant id to set on DCT claim. - refresh_duration (int): duration in seconds for which the new JWT will be valid - - Return value (str): A JWT of the actor - - Raise: - AuthException: raised if update failed - """ - self._validate_jwt(jwt) - response = await self._http.post( - MgmtV1.stop_impersonation_path, - body={ - "jwt": jwt, - "customClaims": custom_claims, - "selectedTenant": tenant_id, - "refreshDuration": refresh_duration, - }, - params=None, - ) - return response.json().get("jwt", "") - - async def sign_in(self, login_id: str, login_options: Optional[MgmtLoginOptions] = None) -> dict: - """ - Generate a JWT for a user, simulating a signin request. - - Args: - login_id (str): login id of the user. - login_options (MgmtLoginOptions): options for the login request. - """ - - self._validate_login_id(login_id) - - if login_options is None: - login_options = MgmtLoginOptions() - - if is_jwt_required(login_options): - self._validate_jwt_required(login_options) - - response = await self._http.post( - MgmtV1.mgmt_sign_in_path, - body={ - "loginId": login_id, - "stepup": login_options.stepup, - "mfa": login_options.mfa, - "revokeOtherSessions": login_options.revoke_other_sessions, - "customClaims": login_options.custom_claims, - "jwt": login_options.jwt, - "refreshDuration": login_options.refresh_duration, - }, - params=None, - ) - return await self._auth.prepare_jwt_response(response.json(), None, None) - - async def sign_up( - self, - login_id: str, - user: Optional[MgmtUserRequest] = None, - signup_options: Optional[MgmtSignUpOptions] = None, - ) -> dict: - """ - Generate a JWT for a user, simulating a signup request. - - Args: - login_id (str): login id of the user. - user (MgmtUserRequest): user details. - signup_options (MgmtSignUpOptions): signup options. - """ - - return await self._sign_up_internal(login_id, MgmtV1.mgmt_sign_up_path, user, signup_options) - - async def sign_up_or_in( - self, - login_id: str, - user: Optional[MgmtUserRequest] = None, - signup_options: Optional[MgmtSignUpOptions] = None, - ) -> dict: - """ - Generate a JWT for a user, simulating a signup or in request. - - Args: - login_id (str): login id of the user. - user (MgmtUserRequest): user details. - signup_options (MgmtSignUpOptions): signup options. - """ - return await self._sign_up_internal(login_id, MgmtV1.mgmt_sign_up_or_in_path, user, signup_options) - - async def _sign_up_internal( - self, - login_id: str, - endpoint: str, - user: Optional[MgmtUserRequest] = None, - signup_options: Optional[MgmtSignUpOptions] = None, - ) -> dict: - if user is None: - user = MgmtUserRequest() - - self._validate_login_id(login_id) - - if signup_options is None: - signup_options = MgmtSignUpOptions() - - response = await self._http.post( - endpoint, - body=self._compose_sign_up_body(login_id, user, signup_options), - params=None, - ) - return await self._auth.prepare_jwt_response(response.json(), None, None) - - async def anonymous( - self, - custom_claims: Optional[dict] = None, - tenant_id: Optional[str] = None, - refresh_duration: Optional[int] = None, - ) -> dict: - """ - Generate a JWT for an anonymous user. - - Args: - custom_claims dict: Custom claims to add to JWT - tenant_id (str): tenant id to set on DCT claim. - """ - - response = await self._http.post( - MgmtV1.anonymous_path, - body={ - "customClaims": custom_claims, - "selectedTenant": tenant_id, - "refreshDuration": refresh_duration, - }, - params=None, - ) - jwt_response = await self._auth.prepare_jwt_response(response.json(), None, None) - del jwt_response["firstSeen"] - del jwt_response["user"] - return jwt_response diff --git a/descope/management/license.py b/descope/management/license.py deleted file mode 100644 index 1ae34bb2f..000000000 --- a/descope/management/license.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class License(HTTPBase): - def get(self) -> dict: - """ - Fetch the rate limit tier for the project's company license. - - Returns a dict with a ``rateLimitTier`` field whose value is one of - ``tier1`` (free), ``tier2`` (pro), ``tier3`` (growth), or ``tier4`` - (enterprise). The SDK sends this value in the ``x-descope-license`` - header on every management request so Cloudflare can apply the right - rate limit bucket. - """ - response = self._http.get(MgmtV1.license_get_path) - return response.json() diff --git a/descope/management/license_async.py b/descope/management/license_async.py deleted file mode 100644 index 388315257..000000000 --- a/descope/management/license_async.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class LicenseAsync(AsyncHTTPBase): - """Async counterpart of License — all HTTP calls are coroutines.""" - - async def get(self) -> dict: - """ - Fetch the rate limit tier for the project's company license. - - Returns a dict with a ``rateLimitTier`` field whose value is one of - ``tier1`` (free), ``tier2`` (pro), ``tier3`` (growth), or ``tier4`` - (enterprise). The SDK sends this value in the ``x-descope-license`` - header on every management request so Cloudflare can apply the right - rate limit bucket. - """ - response = await self._http.get(MgmtV1.license_get_path) - return response.json() diff --git a/descope/management/lists.py b/descope/management/lists.py deleted file mode 100644 index 921bfb86c..000000000 --- a/descope/management/lists.py +++ /dev/null @@ -1,270 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - -from descope._http_base import HTTPBase -from descope.management._lists_base import ListsBase -from descope.management.common import MgmtV1 - - -class Lists(ListsBase, HTTPBase): - def create( - self, - name: str, - list_type: str, - description: Optional[str] = None, - data: Any = None, - ) -> dict: - """ - Create a new list with the given name and type. - - Args: - name (str): The list's name (required, must be unique). - list_type (str): The list type - "texts", "ips", or "json" (required). - description (str, optional): Optional list description. - data (Any, optional): The list data - format depends on type: - - For "texts" and "ips": list of strings - - For "json": dict - - Return value (dict): - Return dict in the format {"list": {...}}. - - Raise: - AuthException: raised if create operation fails - """ - response = self._http.post( - MgmtV1.list_path, - body=ListsBase._compose_create_body(name, description, list_type, data), - ) - return response.json() - - def update( - self, - id: str, - name: str, - list_type: str, - description: Optional[str] = None, - data: Any = None, - ) -> dict: - """ - Update an existing list. All parameters are required and will override - whatever value is currently set in the existing list. Use carefully. - - Args: - id (str): The ID of the list to update. - name (str): Updated list name. - list_type (str): The list type - "texts", "ips", or "json". - description (str, optional): Updated description. - data (Any, optional): Updated list data. - - Return value (dict): - Return dict in the format {"list": {...}}. - - Raise: - AuthException: raised if update operation fails - """ - response = self._http.post( - MgmtV1.list_update_path, - body=ListsBase._compose_update_body(id, name, description, list_type, data), - ) - return response.json() - - def delete(self, id: str): - """ - Delete an existing list. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the list to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post(MgmtV1.list_delete_path, body=ListsBase._compose_delete_body(id)) - - def load(self, id: str) -> dict: - """ - Load a list by ID. - - Args: - id (str): The ID of the list to load. - - Return value (dict): - Return dict in the format {"list": {...}}. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.list_path, params={"id": id}) - return response.json() - - def load_by_name(self, name: str) -> dict: - """ - Load a list by name. - - Args: - name (str): The name of the list to load. - - Return value (dict): - Return dict in the format {"list": {...}}. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.list_name_path, params={"name": name}) - return response.json() - - def load_all(self) -> dict: - """ - Load all lists in the project. - - Return value (dict): - Return dict in the format {"lists": [{...}, ...]}. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.list_all_path) - return response.json() - - def import_lists(self, lists: List[dict]): - """ - Import multiple lists into the project. This will create or update - lists based on their ID. - - Args: - lists (List[dict]): List of list objects to import. - - Raise: - AuthException: raised if import operation fails - """ - self._http.post(MgmtV1.list_import_path, body=ListsBase._compose_import_body(lists)) - - def add_ips(self, id: str, ips: List[str]): - """ - Add IP addresses to an IP list. The list must be of type "ips". - Duplicate IPs are automatically ignored. The order of existing IPs is - preserved and new IPs are appended. - - Args: - id (str): The ID of the IP list. - ips (List[str]): List of IP addresses to add. - - Raise: - AuthException: raised if operation fails - """ - self._http.post(MgmtV1.list_ip_add_path, body=ListsBase._compose_ip_body(id, ips)) - - def remove_ips(self, id: str, ips: List[str]): - """ - Remove IP addresses from an IP list. The list must be of type "ips". - Non-existent IPs are silently ignored. - - Args: - id (str): The ID of the IP list. - ips (List[str]): List of IP addresses to remove. - - Raise: - AuthException: raised if operation fails - """ - self._http.post(MgmtV1.list_ip_remove_path, body=ListsBase._compose_ip_body(id, ips)) - - def check_ip(self, id: str, ip: str) -> bool: - """ - Check if an IP address exists in an IP list. The list must be of type "ips". - - Args: - id (str): The ID of the IP list. - ip (str): The IP address to check. - - Return value (bool): - True if the IP exists in the list, False otherwise. - - Raise: - AuthException: raised if operation fails - """ - response = self._http.post(MgmtV1.list_ip_check_path, body=ListsBase._compose_check_ip_body(id, ip)) - result = response.json() - return result.get("exists", False) - - def add_texts(self, id: str, texts: List[str]): - """ - Add text items to a text list. The list must be of type "texts". - Duplicate texts are automatically ignored. The order of existing texts is - preserved and new texts are appended. - - Args: - id (str): The ID of the text list. - texts (List[str]): List of text items to add. - - Raise: - AuthException: raised if operation fails - """ - self._http.post(MgmtV1.list_text_add_path, body=ListsBase._compose_text_body(id, texts)) - - def remove_texts(self, id: str, texts: List[str]): - """ - Remove text items from a text list. The list must be of type "texts". - Non-existent texts are silently ignored. - - Args: - id (str): The ID of the text list. - texts (List[str]): List of text items to remove. - - Raise: - AuthException: raised if operation fails - """ - self._http.post(MgmtV1.list_text_remove_path, body=ListsBase._compose_text_body(id, texts)) - - def check_text(self, id: str, text: str) -> bool: - """ - Check if a text exists in a text list. The list must be of type "texts". - - Args: - id (str): The ID of the text list. - text (str): The text to check. - - Return value (bool): - True if the text exists in the list, False otherwise. - - Raise: - AuthException: raised if operation fails - """ - response = self._http.post(MgmtV1.list_text_check_path, body=ListsBase._compose_check_text_body(id, text)) - result = response.json() - return result.get("exists", False) - - def check_texts(self, id: str, texts: List[str], match_all: bool = False) -> bool: - """ - Check if texts exist in a text list in a single call. The list must be of type "texts". - - Args: - id (str): The ID of the text list. - texts (List[str]): The texts to check (up to 100, non-empty). - match_all (bool): Optional, when True all texts must exist in the list. - Defaults to False, meaning at least one of the texts must exist. - - Return value (bool): - True if any of the texts exists in the list (or all of them when match_all is True), False otherwise. - - Raise: - AuthException: raised if operation fails - """ - response = self._http.post( - MgmtV1.list_text_check_path, body=ListsBase._compose_check_texts_body(id, texts, match_all) - ) - result = response.json() - return result.get("exists", False) - - def clear(self, id: str): - """ - Clear all data from a list. The list metadata (name, description, type) is - preserved. For "json" type lists, sets data to empty object. For "texts" and - "ips" type lists, sets data to empty array. - - Args: - id (str): The ID of the list to clear. - - Raise: - AuthException: raised if operation fails - """ - self._http.post(MgmtV1.list_clear_path, body=ListsBase._compose_clear_body(id)) diff --git a/descope/management/lists_async.py b/descope/management/lists_async.py deleted file mode 100644 index 15befe3f2..000000000 --- a/descope/management/lists_async.py +++ /dev/null @@ -1,272 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._lists_base import ListsBase -from descope.management.common import MgmtV1 - - -class ListsAsync(ListsBase, AsyncHTTPBase): - """Async counterpart of Lists — all HTTP calls are coroutines.""" - - async def create( - self, - name: str, - list_type: str, - description: Optional[str] = None, - data: Any = None, - ) -> dict: - """ - Create a new list with the given name and type. - - Args: - name (str): The list's name (required, must be unique). - list_type (str): The list type - "texts", "ips", or "json" (required). - description (str, optional): Optional list description. - data (Any, optional): The list data - format depends on type: - - For "texts" and "ips": list of strings - - For "json": dict - - Return value (dict): - Return dict in the format {"list": {...}}. - - Raise: - AuthException: raised if create operation fails - """ - response = await self._http.post( - MgmtV1.list_path, - body=ListsBase._compose_create_body(name, description, list_type, data), - ) - return response.json() - - async def update( - self, - id: str, - name: str, - list_type: str, - description: Optional[str] = None, - data: Any = None, - ) -> dict: - """ - Update an existing list. All parameters are required and will override - whatever value is currently set in the existing list. Use carefully. - - Args: - id (str): The ID of the list to update. - name (str): Updated list name. - list_type (str): The list type - "texts", "ips", or "json". - description (str, optional): Updated description. - data (Any, optional): Updated list data. - - Return value (dict): - Return dict in the format {"list": {...}}. - - Raise: - AuthException: raised if update operation fails - """ - response = await self._http.post( - MgmtV1.list_update_path, - body=ListsBase._compose_update_body(id, name, description, list_type, data), - ) - return response.json() - - async def delete(self, id: str): - """ - Delete an existing list. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the list to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post(MgmtV1.list_delete_path, body=ListsBase._compose_delete_body(id)) - - async def load(self, id: str) -> dict: - """ - Load a list by ID. - - Args: - id (str): The ID of the list to load. - - Return value (dict): - Return dict in the format {"list": {...}}. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.list_path, params={"id": id}) - return response.json() - - async def load_by_name(self, name: str) -> dict: - """ - Load a list by name. - - Args: - name (str): The name of the list to load. - - Return value (dict): - Return dict in the format {"list": {...}}. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.list_name_path, params={"name": name}) - return response.json() - - async def load_all(self) -> dict: - """ - Load all lists in the project. - - Return value (dict): - Return dict in the format {"lists": [{...}, ...]}. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.list_all_path) - return response.json() - - async def import_lists(self, lists: List[dict]): - """ - Import multiple lists into the project. This will create or update - lists based on their ID. - - Args: - lists (List[dict]): List of list objects to import. - - Raise: - AuthException: raised if import operation fails - """ - await self._http.post(MgmtV1.list_import_path, body=ListsBase._compose_import_body(lists)) - - async def add_ips(self, id: str, ips: List[str]): - """ - Add IP addresses to an IP list. The list must be of type "ips". - Duplicate IPs are automatically ignored. The order of existing IPs is - preserved and new IPs are appended. - - Args: - id (str): The ID of the IP list. - ips (List[str]): List of IP addresses to add. - - Raise: - AuthException: raised if operation fails - """ - await self._http.post(MgmtV1.list_ip_add_path, body=ListsBase._compose_ip_body(id, ips)) - - async def remove_ips(self, id: str, ips: List[str]): - """ - Remove IP addresses from an IP list. The list must be of type "ips". - Non-existent IPs are silently ignored. - - Args: - id (str): The ID of the IP list. - ips (List[str]): List of IP addresses to remove. - - Raise: - AuthException: raised if operation fails - """ - await self._http.post(MgmtV1.list_ip_remove_path, body=ListsBase._compose_ip_body(id, ips)) - - async def check_ip(self, id: str, ip: str) -> bool: - """ - Check if an IP address exists in an IP list. The list must be of type "ips". - - Args: - id (str): The ID of the IP list. - ip (str): The IP address to check. - - Return value (bool): - True if the IP exists in the list, False otherwise. - - Raise: - AuthException: raised if operation fails - """ - response = await self._http.post(MgmtV1.list_ip_check_path, body=ListsBase._compose_check_ip_body(id, ip)) - result = response.json() - return result.get("exists", False) - - async def add_texts(self, id: str, texts: List[str]): - """ - Add text items to a text list. The list must be of type "texts". - Duplicate texts are automatically ignored. The order of existing texts is - preserved and new texts are appended. - - Args: - id (str): The ID of the text list. - texts (List[str]): List of text items to add. - - Raise: - AuthException: raised if operation fails - """ - await self._http.post(MgmtV1.list_text_add_path, body=ListsBase._compose_text_body(id, texts)) - - async def remove_texts(self, id: str, texts: List[str]): - """ - Remove text items from a text list. The list must be of type "texts". - Non-existent texts are silently ignored. - - Args: - id (str): The ID of the text list. - texts (List[str]): List of text items to remove. - - Raise: - AuthException: raised if operation fails - """ - await self._http.post(MgmtV1.list_text_remove_path, body=ListsBase._compose_text_body(id, texts)) - - async def check_text(self, id: str, text: str) -> bool: - """ - Check if a text exists in a text list. The list must be of type "texts". - - Args: - id (str): The ID of the text list. - text (str): The text to check. - - Return value (bool): - True if the text exists in the list, False otherwise. - - Raise: - AuthException: raised if operation fails - """ - response = await self._http.post(MgmtV1.list_text_check_path, body=ListsBase._compose_check_text_body(id, text)) - result = response.json() - return result.get("exists", False) - - async def check_texts(self, id: str, texts: List[str], match_all: bool = False) -> bool: - """ - Check if texts exist in a text list in a single call. The list must be of type "texts". - - Args: - id (str): The ID of the text list. - texts (List[str]): The texts to check (up to 100, non-empty). - match_all (bool): Optional, when True all texts must exist in the list. - Defaults to False, meaning at least one of the texts must exist. - - Return value (bool): - True if any of the texts exists in the list (or all of them when match_all is True), False otherwise. - - Raise: - AuthException: raised if operation fails - """ - response = await self._http.post( - MgmtV1.list_text_check_path, body=ListsBase._compose_check_texts_body(id, texts, match_all) - ) - result = response.json() - return result.get("exists", False) - - async def clear(self, id: str): - """ - Clear all data from a list. The list metadata (name, description, type) is - preserved. For "json" type lists, sets data to empty object. For "texts" and - "ips" type lists, sets data to empty array. - - Args: - id (str): The ID of the list to clear. - - Raise: - AuthException: raised if operation fails - """ - await self._http.post(MgmtV1.list_clear_path, body=ListsBase._compose_clear_body(id)) diff --git a/descope/management/management_key.py b/descope/management/management_key.py deleted file mode 100644 index 2b9f219b9..000000000 --- a/descope/management/management_key.py +++ /dev/null @@ -1,182 +0,0 @@ -from typing import Any, List, Optional - -from descope._http_base import HTTPBase -from descope.management.common import MgmtKeyReBac, MgmtKeyStatus, MgmtV1 - - -class ManagementKey(HTTPBase): - def create( - self, - name: str, - rebac: MgmtKeyReBac, - description: Optional[str] = None, - expires_in: int = 0, - permitted_ips: Optional[List[str]] = None, - ) -> dict: - """ - Create a new management key. - - Args: - name (str): The name of the management key. - rebac (MgmtKeyReBac): RBAC configuration for the key. - description (str): Optional description for the management key. - expires_in (int): Expiration time in seconds (0 for no expiration). - permitted_ips (List[str]): Optional list of IP addresses or CIDR ranges that are allowed to use this key. - - Return value (dict): - Return dict in the format - { - "key": {...}, - "cleartext": "..." - } - - Raise: - AuthException: raised if create operation fails - """ - if not name: - raise ValueError("name cannot be empty") - if rebac is None: - raise ValueError("rebac cannot be empty") - - body: dict[str, Any] = { - "name": name, - "description": description, - "expiresIn": expires_in, - "permittedIps": permitted_ips if permitted_ips is not None else [], - "reBac": rebac.to_dict(), - } - - response = self._http.put( - MgmtV1.mgmt_key_create_path, - body=body, - ) - return response.json() - - def update( - self, - id: str, - name: str, - description: str, - permitted_ips: List[str], - status: MgmtKeyStatus, - rebac: Optional[MgmtKeyReBac] = None, - ) -> dict: - """ - Update an existing management key. - - IMPORTANT: All parameters will override whatever values are currently set - in the existing management key, except for rebac, which is only replaced - when provided. Use carefully. - - Args: - id (str): The id of the management key to update. - name (str): The updated name. - description (str): Updated description. - permitted_ips (List[str]): Updated list of IP addresses or CIDR ranges. - status (MgmtKeyStatus): Updated status. - rebac (MgmtKeyReBac): Optional RBAC configuration that replaces the key's current roles. - If not provided, the key keeps its current roles. - - Return value (dict): - Return dict in the format - {"key": {...}} - Containing the updated management key information. - - Raise: - AuthException: raised if update operation fails - """ - if not id: - raise ValueError("id cannot be empty") - if not name: - raise ValueError("name cannot be empty") - if status is None: - raise ValueError("status cannot be empty") - - body: dict[str, Any] = { - "id": id, - "name": name, - "description": description, - "permittedIps": permitted_ips if permitted_ips is not None else [], - "status": status.value, - } - if rebac is not None: - body["reBac"] = rebac.to_dict() - - response = self._http.patch( - MgmtV1.mgmt_key_update_path, - body=body, - ) - return response.json() - - def load( - self, - id: str, - ) -> dict: - """ - Get a management key by ID. - - Args: - id (str): The id of the management key to load. - - Return value (dict): - Return dict in the format - {"key": {...}} - Containing the loaded management key information. - - Raise: - AuthException: raised if load operation fails - """ - if not id: - raise ValueError("id cannot be empty") - - response = self._http.get( - uri=MgmtV1.mgmt_key_load_path, - params={"id": id}, - ) - return response.json() - - def delete( - self, - ids: List[str], - ) -> dict: - """ - Delete existing management keys. IMPORTANT: This action is irreversible. Use carefully. - - Args: - ids (List[str]): The ids of the management keys to delete. - - Return value (dict): - Return dict in the format - {"total": } - Containing the number of keys deleted. - - Raise: - AuthException: raised if delete operation fails - """ - if not ids: - raise ValueError("ids list cannot be empty") - - response = self._http.post( - uri=MgmtV1.mgmt_key_delete_path, - body={"ids": ids}, - ) - return response.json() - - def search(self) -> dict: - """ - Search for management keys. - - Return value (dict): - Return dict in the format - { - "keys": [...] - } - Containing the found management keys. - - Raise: - AuthException: raised if search operation fails - """ - response = self._http.get( - MgmtV1.mgmt_key_search_path, - ) - return response.json() diff --git a/descope/management/management_key_async.py b/descope/management/management_key_async.py deleted file mode 100644 index e8b0e023f..000000000 --- a/descope/management/management_key_async.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtKeyReBac, MgmtKeyStatus, MgmtV1 - - -class ManagementKeyAsync(AsyncHTTPBase): - """Async counterpart of ManagementKey — all HTTP calls are coroutines.""" - - async def create( - self, - name: str, - rebac: MgmtKeyReBac, - description: Optional[str] = None, - expires_in: int = 0, - permitted_ips: Optional[List[str]] = None, - ) -> dict: - """ - Create a new management key. - - Args: - name (str): The name of the management key. - rebac (MgmtKeyReBac): RBAC configuration for the key. - description (str): Optional description for the management key. - expires_in (int): Expiration time in seconds (0 for no expiration). - permitted_ips (List[str]): Optional list of IP addresses or CIDR ranges that are allowed to use this key. - - Return value (dict): - Return dict in the format - { - "key": {...}, - "cleartext": "..." - } - - Raise: - AuthException: raised if create operation fails - """ - if not name: - raise ValueError("name cannot be empty") - if rebac is None: - raise ValueError("rebac cannot be empty") - - body: dict[str, Any] = { - "name": name, - "description": description, - "expiresIn": expires_in, - "permittedIps": permitted_ips if permitted_ips is not None else [], - "reBac": rebac.to_dict(), - } - - response = await self._http.put( - MgmtV1.mgmt_key_create_path, - body=body, - ) - return response.json() - - async def update( - self, - id: str, - name: str, - description: str, - permitted_ips: List[str], - status: MgmtKeyStatus, - rebac: Optional[MgmtKeyReBac] = None, - ) -> dict: - """ - Update an existing management key. - - IMPORTANT: All parameters will override whatever values are currently set - in the existing management key, except for rebac, which is only replaced - when provided. Use carefully. - - Args: - id (str): The id of the management key to update. - name (str): The updated name. - description (str): Updated description. - permitted_ips (List[str]): Updated list of IP addresses or CIDR ranges. - status (MgmtKeyStatus): Updated status. - rebac (MgmtKeyReBac): Optional RBAC configuration that replaces the key's current roles. - If not provided, the key keeps its current roles. - - Return value (dict): - Return dict in the format - {"key": {...}} - Containing the updated management key information. - - Raise: - AuthException: raised if update operation fails - """ - if not id: - raise ValueError("id cannot be empty") - if not name: - raise ValueError("name cannot be empty") - if status is None: - raise ValueError("status cannot be empty") - - body: dict[str, Any] = { - "id": id, - "name": name, - "description": description, - "permittedIps": permitted_ips if permitted_ips is not None else [], - "status": status.value, - } - if rebac is not None: - body["reBac"] = rebac.to_dict() - - response = await self._http.patch( - MgmtV1.mgmt_key_update_path, - body=body, - ) - return response.json() - - async def load( - self, - id: str, - ) -> dict: - """ - Get a management key by ID. - - Args: - id (str): The id of the management key to load. - - Return value (dict): - Return dict in the format - {"key": {...}} - Containing the loaded management key information. - - Raise: - AuthException: raised if load operation fails - """ - if not id: - raise ValueError("id cannot be empty") - - response = await self._http.get( - uri=MgmtV1.mgmt_key_load_path, - params={"id": id}, - ) - return response.json() - - async def delete( - self, - ids: List[str], - ) -> dict: - """ - Delete existing management keys. IMPORTANT: This action is irreversible. Use carefully. - - Args: - ids (List[str]): The ids of the management keys to delete. - - Return value (dict): - Return dict in the format - {"total": } - Containing the number of keys deleted. - - Raise: - AuthException: raised if delete operation fails - """ - if not ids: - raise ValueError("ids list cannot be empty") - - response = await self._http.post( - uri=MgmtV1.mgmt_key_delete_path, - body={"ids": ids}, - ) - return response.json() - - async def search(self) -> dict: - """ - Search for management keys. - - Return value (dict): - Return dict in the format - { - "keys": [...] - } - Containing the found management keys. - - Raise: - AuthException: raised if search operation fails - """ - response = await self._http.get( - MgmtV1.mgmt_key_search_path, - ) - return response.json() diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py deleted file mode 100644 index 032df78ac..000000000 --- a/descope/management/outbound_application.py +++ /dev/null @@ -1,881 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 -from descope.http_client import HTTPClient -from descope.management._outbound_application_base import OutboundApplicationBase -from descope.management.common import ( - AccessType, - MgmtV1, - PromptType, - URLParam, -) - - -class _OutboundApplicationTokenFetcher: - """Internal helper class for shared token fetching logic.""" - - @staticmethod - def fetch_token_by_scopes( - *, - http: HTTPClient, - token: Optional[str] = None, - app_id: str, - user_id: str, - scopes: List[str], - options: Optional[dict] = None, - tenant_id: Optional[str] = None, - ) -> dict: - """Internal implementation for fetching token by scopes.""" - uri = MgmtV1.outbound_application_fetch_token_by_scopes_path - response = http.post( - uri, - body={ - "appId": app_id, - "userId": user_id, - "scopes": scopes, - "options": options, - "tenantId": tenant_id, - }, - pswd=token, - ) - return response.json() - - @staticmethod - def fetch_token( - *, - http: HTTPClient, - token: Optional[str] = None, - app_id: str, - user_id: str, - tenant_id: Optional[str] = None, - options: Optional[dict] = None, - ) -> dict: - """Internal implementation for fetching token.""" - uri = MgmtV1.outbound_application_fetch_token_path - response = http.post( - uri, - body={ - "appId": app_id, - "userId": user_id, - "tenantId": tenant_id, - "options": options, - }, - pswd=token, - ) - return response.json() - - @staticmethod - def fetch_tenant_token_by_scopes( - *, - http: HTTPClient, - token: Optional[str] = None, - app_id: str, - tenant_id: str, - scopes: List[str], - options: Optional[dict] = None, - ) -> dict: - """Internal implementation for fetching tenant token by scopes.""" - uri = MgmtV1.outbound_application_fetch_tenant_token_by_scopes_path - response = http.post( - uri, - body={ - "appId": app_id, - "tenantId": tenant_id, - "scopes": scopes, - "options": options, - }, - pswd=token, - ) - return response.json() - - @staticmethod - def fetch_tenant_token( - *, - http: HTTPClient, - token: Optional[str] = None, - app_id: str, - tenant_id: str, - options: Optional[dict] = None, - ) -> dict: - """Internal implementation for fetching tenant token.""" - uri = MgmtV1.outbound_application_fetch_tenant_token_path - response = http.post( - uri, - body={ - "appId": app_id, - "tenantId": tenant_id, - "options": options, - }, - pswd=token, - ) - return response.json() - - -class OutboundApplication(OutboundApplicationBase, HTTPBase): - def create_application( - self, - name: str, - description: Optional[str] = None, - logo: Optional[str] = None, - id: Optional[str] = None, - client_secret: Optional[str] = None, - client_id: Optional[str] = None, - discovery_url: Optional[str] = None, - authorization_url: Optional[str] = None, - authorization_url_params: Optional[List[URLParam]] = None, - token_url: Optional[str] = None, - token_url_params: Optional[List[URLParam]] = None, - revocation_url: Optional[str] = None, - default_scopes: Optional[List[str]] = None, - default_redirect_url: Optional[str] = None, - callback_domain: Optional[str] = None, - pkce: Optional[bool] = None, - access_type: Optional[AccessType] = None, - prompt: Optional[List[PromptType]] = None, - ) -> dict: - """ - Create a new outbound application with the given name. Outbound application IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The outbound application's name. - description (str): Optional outbound application description. - logo (str): Optional outbound application logo. - id (str): Optional outbound application ID. - client_secret (str): Optional client secret for the application. - client_id (str): Optional client ID for the application. - discovery_url (str): Optional OAuth discovery URL. - authorization_url (str): Optional OAuth authorization URL. - authorization_url_params (List[URLParam]): Optional authorization URL parameters. - token_url (str): Optional OAuth token URL. - token_url_params (List[URLParam]): Optional token URL parameters. - revocation_url (str): Optional OAuth token revocation URL. - default_scopes (List[str]): Optional default OAuth scopes. - default_redirect_url (str): Optional default redirect URL. - callback_domain (str): Optional callback domain. - pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. - access_type (AccessType): Optional OAuth access type. - prompt (List[PromptType]): Optional OAuth prompt parameters. - - Return value (dict): - Return dict in the format - {"app": {"id": , "name": , "description": , "logo": }} - - Raise: - AuthException: raised if create operation fails - """ - uri = MgmtV1.outbound_application_create_path - response = self._http.post( - uri, - body=OutboundApplicationBase._compose_create_update_body( - name, - description, - logo, - id, - client_secret, - client_id, - discovery_url, - authorization_url, - authorization_url_params, - token_url, - token_url_params, - revocation_url, - default_scopes, - default_redirect_url, - callback_domain, - pkce, - access_type, - prompt, - ), - ) - return response.json() - - def update_application( - self, - id: str, - name: str, - description: Optional[str] = None, - logo: Optional[str] = None, - client_secret: Optional[str] = None, - client_id: Optional[str] = None, - discovery_url: Optional[str] = None, - authorization_url: Optional[str] = None, - authorization_url_params: Optional[List[URLParam]] = None, - token_url: Optional[str] = None, - token_url_params: Optional[List[URLParam]] = None, - revocation_url: Optional[str] = None, - default_scopes: Optional[List[str]] = None, - default_redirect_url: Optional[str] = None, - callback_domain: Optional[str] = None, - pkce: Optional[bool] = None, - access_type: Optional[AccessType] = None, - prompt: Optional[List[PromptType]] = None, - ) -> dict: - """ - Update an existing outbound application with the given parameters. IMPORTANT: All parameters are used as overrides - to the existing outbound application. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the outbound application to update. - name (str): Updated outbound application name. - description (str): Optional outbound application description. - logo (str): Optional outbound application logo. - client_secret (str): Optional client secret for the application. - client_id (str): Optional client ID for the application. - discovery_url (str): Optional OAuth discovery URL. - authorization_url (str): Optional OAuth authorization URL. - authorization_url_params (List[URLParam]): Optional authorization URL parameters. - token_url (str): Optional OAuth token URL. - token_url_params (List[URLParam]): Optional token URL parameters. - revocation_url (str): Optional OAuth token revocation URL. - default_scopes (List[str]): Optional default OAuth scopes. - default_redirect_url (str): Optional default redirect URL. - callback_domain (str): Optional callback domain. - pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. - access_type (AccessType): Optional OAuth access type. - prompt (List[PromptType]): Optional OAuth prompt parameters. - - Return value (dict): - Return dict in the format - {"app": {"id": , "name": , "description": , "logo": }} - - Raise: - AuthException: raised if update operation fails - """ - uri = MgmtV1.outbound_application_update_path - response = self._http.post( - uri, - body={ - "app": OutboundApplicationBase._compose_create_update_body( - name, - description, - logo, - id, - client_secret, - client_id, - discovery_url, - authorization_url, - authorization_url_params, - token_url, - token_url_params, - revocation_url, - default_scopes, - default_redirect_url, - callback_domain, - pkce, - access_type, - prompt, - ) - }, - ) - return response.json() - - def delete_application( - self, - id: str, - ): - """ - Delete an existing outbound application. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the outbound application that's to be deleted. - - Raise: - AuthException: raised if deletion operation fails - """ - uri = MgmtV1.outbound_application_delete_path - self._http.post(uri, body={"id": id}) - - def load_application( - self, - id: str, - ) -> dict: - """ - Load outbound application by id. - - Args: - id (str): The ID of the outbound application to load. - - Return value (dict): - Return dict in the format - {"app": {"id": , "name": , "description": , "logo": }} - Containing the loaded outbound application information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(f"{MgmtV1.outbound_application_load_path}/{id}") - return response.json() - - def load_all_applications(self) -> dict: - """ - Load all outbound applications. - - Return value (dict): - Return dict in the format - {"apps": [{"id": , "name": , "description": , "logo": }, ...]} - Containing the loaded outbound applications information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.outbound_application_load_all_path) - return response.json() - - def fetch_token_by_scopes( - self, - app_id: str, - user_id: str, - scopes: List[str], - options: Optional[dict] = None, - tenant_id: Optional[str] = None, - ) -> dict: - """ - Fetch an outbound application token for a user with specific scopes. - - Args: - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - scopes (List[str]): List of scopes to include in the token. - options (dict): Optional token options. - tenant_id (str): Optional tenant ID. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - return _OutboundApplicationTokenFetcher.fetch_token_by_scopes( - http=self._http, - app_id=app_id, - user_id=user_id, - scopes=scopes, - options=options, - tenant_id=tenant_id, - ) - - def fetch_token( - self, - app_id: str, - user_id: str, - tenant_id: Optional[str] = None, - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a user. - - Args: - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - tenant_id (str): Optional tenant ID. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - return _OutboundApplicationTokenFetcher.fetch_token( - http=self._http, - app_id=app_id, - user_id=user_id, - tenant_id=tenant_id, - options=options, - ) - - def fetch_tenant_token_by_scopes( - self, - app_id: str, - tenant_id: str, - scopes: List[str], - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a tenant with specific scopes. - - Args: - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - scopes (List[str]): List of scopes to include in the token. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - return _OutboundApplicationTokenFetcher.fetch_tenant_token_by_scopes( - http=self._http, - app_id=app_id, - tenant_id=tenant_id, - scopes=scopes, - options=options, - ) - - def fetch_tenant_token( - self, - app_id: str, - tenant_id: str, - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a tenant. - - Args: - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - return _OutboundApplicationTokenFetcher.fetch_tenant_token( - http=self._http, - app_id=app_id, - tenant_id=tenant_id, - options=options, - ) - - def delete_user_tokens( - self, - app_id: Optional[str] = None, - user_id: Optional[str] = None, - ): - """ - Delete outbound application tokens by app ID and/or user ID. - At least one of app_id or user_id must be provided. - - Args: - app_id (str): Optional ID of the outbound application. - user_id (str): Optional ID of the user. - - Raise: - AuthException: raised if delete operation fails - """ - params = {} - if app_id: - params["appId"] = app_id - if user_id: - params["userId"] = user_id - uri = MgmtV1.outbound_application_delete_user_tokens_path - self._http.delete(uri, params=params) - - def delete_token( - self, - token_id: str, - ): - """ - Delete an outbound application token by its ID. - - Args: - token_id (str): The ID of the token to delete. - - Raise: - AuthException: raised if delete operation fails - """ - uri = MgmtV1.outbound_application_delete_token_path - self._http.delete(uri, params={"id": token_id}) - - def list_apps_with_user_token( - self, - user_id: str, - tenant_id: Optional[str] = None, - ) -> dict: - """ - List the IDs of the outbound applications the given user currently holds a valid token for. - Use this for connection-status views instead of calling fetch_token once per application. - - Args: - user_id (str): The ID of the user. - tenant_id (str): Optional tenant ID to scope the lookup to. - - Return value (dict): - Return dict in the format - {"appIds": [, ...]} - - Raise: - AuthException: raised if the operation fails - """ - params = {"userId": user_id} - if tenant_id: - params["tenantId"] = tenant_id - response = self._http.get( - MgmtV1.outbound_application_list_apps_with_user_token_path, - params=params, - ) - return response.json() - - def upload_user_api_key( - self, - app_id: str, - user_id: str, - api_key: str, - tenant_id: Optional[str] = None, - ) -> dict: - """ - Upload (set) a static API key for a user on an apikey-type outbound application. - - Args: - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - api_key (str): The API key to store for the user. - tenant_id (str): Optional tenant ID, used to associate different keys for the - same user in different tenants. - - Raise: - AuthException: raised if the operation fails - """ - body: dict = {"appId": app_id, "userId": user_id, "apiKey": api_key} - if tenant_id is not None: - body["tenantId"] = tenant_id - response = self._http.post( - MgmtV1.outbound_application_upload_user_api_key_path, - body=body, - ) - return response.json() - - def upload_tenant_api_key( - self, - app_id: str, - tenant_id: str, - api_key: str, - ) -> dict: - """ - Upload (set) a static API key for a tenant on an apikey-type outbound application. - - Args: - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - api_key (str): The API key to store for the tenant. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.outbound_application_upload_tenant_api_key_path, - body={"appId": app_id, "tenantId": tenant_id, "apiKey": api_key}, - ) - return response.json() - - def upload_user_token( - self, - app_id: str, - user_id: str, - tenant_id: Optional[str] = None, - refresh_token: Optional[str] = None, - access_token: Optional[str] = None, - access_token_expiry: Optional[int] = None, - access_token_type: Optional[str] = None, - scopes: Optional[List[str]] = None, - external_identifier: Optional[str] = None, - id_token: Optional[str] = None, - verify_refresh: Optional[bool] = None, - granted_by: Optional[str] = None, - ) -> dict: - """ - Upload (migrate) an existing OAuth token for a user on an oauth-type outbound application, - without requiring the user to re-run the OAuth flow. At least one of refresh_token or - access_token must be provided. - - Args: - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - tenant_id (str): Optional tenant ID. - refresh_token (str): Optional refresh token. - access_token (str): Optional access token. - access_token_expiry (int): Optional access token expiry, in epoch seconds. - access_token_type (str): Optional token type (defaults to "Bearer"). - scopes (List[str]): Optional scopes the token was granted for. - external_identifier (str): Optional external identifier. - id_token (str): Optional id token. - verify_refresh (bool): If True, verify the refresh token against the provider before - persisting; nothing is written if verification fails. - granted_by (str): Optional attribution for who granted the token. - - Raise: - AuthException: raised if the operation fails - """ - base: dict = {"appId": app_id, "userId": user_id} - if tenant_id is not None: - base["tenantId"] = tenant_id - body = OutboundApplicationBase._compose_oauth_upload_body( - base, - refresh_token, - access_token, - access_token_expiry, - access_token_type, - scopes, - external_identifier, - id_token, - granted_by, - verify_refresh, - ) - response = self._http.post( - MgmtV1.outbound_application_upload_user_token_path, - body=body, - ) - return response.json() - - def upload_tenant_token( - self, - app_id: str, - tenant_id: str, - refresh_token: Optional[str] = None, - access_token: Optional[str] = None, - access_token_expiry: Optional[int] = None, - access_token_type: Optional[str] = None, - scopes: Optional[List[str]] = None, - external_identifier: Optional[str] = None, - id_token: Optional[str] = None, - verify_refresh: Optional[bool] = None, - granted_by: Optional[str] = None, - ) -> dict: - """ - Upload (migrate) an existing OAuth token for a tenant on an oauth-type outbound application. - At least one of refresh_token or access_token must be provided. - - Args: - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - (remaining args): see upload_user_token. - - Raise: - AuthException: raised if the operation fails - """ - base: dict = {"appId": app_id, "tenantId": tenant_id} - body = OutboundApplicationBase._compose_oauth_upload_body( - base, - refresh_token, - access_token, - access_token_expiry, - access_token_type, - scopes, - external_identifier, - id_token, - granted_by, - verify_refresh, - ) - response = self._http.post( - MgmtV1.outbound_application_upload_tenant_token_path, - body=body, - ) - return response.json() - - def batch_upload_user_tokens(self, tokens: List[dict]) -> dict: - """ - Batch upload (migrate) existing OAuth tokens for users. All-or-nothing: if any item fails - per-item validation, the returned failures are populated and no tokens are committed. - - Args: - tokens (List[dict]): A list of token dicts, each with at least appId and userId plus one of - refreshToken / accessToken. See upload_user_token for the full set of fields - (verifyRefresh is not supported for batch uploads). - - Return value (dict): - Return dict in the format - {"failures": [{"appId": , "userId": , "errorCode": , "reason": }, ...]} - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.outbound_application_batch_upload_user_tokens_path, - body={"tokens": tokens}, - ) - return response.json() - - def batch_upload_tenant_tokens(self, tokens: List[dict]) -> dict: - """ - Batch upload (migrate) existing OAuth tokens for tenants. All-or-nothing: if any item fails - per-item validation, the returned failures are populated and no tokens are committed. - - Args: - tokens (List[dict]): A list of token dicts, each with at least appId and tenantId plus one of - refreshToken / accessToken. - - Return value (dict): - Return dict in the format - {"failures": [{"appId": , "tenantId": , "errorCode": , "reason": }, ...]} - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.outbound_application_batch_upload_tenant_tokens_path, - body={"tokens": tokens}, - ) - return response.json() - - -class OutboundApplicationByToken(HTTPBase): - def __init__(self, http_client: HTTPClient): - # This class expects the token to be passed for each call - no_key_client = HTTPClient( - project_id=http_client.project_id, - base_url=http_client.base_url, - timeout_seconds=http_client.timeout_seconds, - secure=http_client.secure, - management_key=None, # Override the management key for this client - verbose=http_client.verbose, - last_response_store=http_client.last_response_store, - ) - super().__init__(no_key_client) - - # Methods for fetching outbound application tokens using an inbound application token - # that includes the "outbound.token.fetch" scope (no management key required) - - def _check_inbound_app_token(self, token: str): - """Check if inbound app token is available for the given property.""" - if not token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "Inbound app token is required for perform this functionality", - ) - - def fetch_token_by_scopes( - self, - token: str, - app_id: str, - user_id: str, - scopes: List[str], - options: Optional[dict] = None, - tenant_id: Optional[str] = None, - ) -> dict: - """ - Fetch an outbound application token for a user with specific scopes. - - Args: - token (str): The Inbound Application token to use for authentication. - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - scopes (List[str]): List of scopes to include in the token. - options (dict): Optional token options. - tenant_id (str): Optional tenant ID. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - self._check_inbound_app_token(token) - return _OutboundApplicationTokenFetcher.fetch_token_by_scopes( - http=self._http, - token=token, - app_id=app_id, - user_id=user_id, - scopes=scopes, - options=options, - tenant_id=tenant_id, - ) - - def fetch_token( - self, - token: str, - app_id: str, - user_id: str, - tenant_id: Optional[str] = None, - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a user. - - Args: - token (str): The Inbound Application token to use for authentication. - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - tenant_id (str): Optional tenant ID. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - self._check_inbound_app_token(token) - return _OutboundApplicationTokenFetcher.fetch_token( - http=self._http, - token=token, - app_id=app_id, - user_id=user_id, - tenant_id=tenant_id, - options=options, - ) - - def fetch_tenant_token_by_scopes( - self, - token: str, - app_id: str, - tenant_id: str, - scopes: List[str], - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a tenant with specific scopes. - - Args: - token (str): The Inbound Application token to use for authentication. - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - scopes (List[str]): List of scopes to include in the token. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - self._check_inbound_app_token(token) - return _OutboundApplicationTokenFetcher.fetch_tenant_token_by_scopes( - http=self._http, - token=token, - app_id=app_id, - tenant_id=tenant_id, - scopes=scopes, - options=options, - ) - - def fetch_tenant_token(self, token: str, app_id: str, tenant_id: str, options: Optional[dict] = None) -> dict: - """ - Fetch an outbound application token for a tenant. - - Args: - token (str): The Inbound Application token to use for authentication. - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - self._check_inbound_app_token(token) - return _OutboundApplicationTokenFetcher.fetch_tenant_token( - http=self._http, - token=token, - app_id=app_id, - tenant_id=tenant_id, - options=options, - ) diff --git a/descope/management/outbound_application_async.py b/descope/management/outbound_application_async.py deleted file mode 100644 index b5f9b78e2..000000000 --- a/descope/management/outbound_application_async.py +++ /dev/null @@ -1,891 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 -from descope.http_client_async import HTTPClientAsync -from descope.management._outbound_application_base import OutboundApplicationBase -from descope.management.common import ( - AccessType, - MgmtV1, - PromptType, - URLParam, -) - - -class _OutboundApplicationTokenFetcherAsync: - """Internal async helper class for shared token fetching logic.""" - - @staticmethod - async def fetch_token_by_scopes( - *, - http: HTTPClientAsync, - token: Optional[str] = None, - app_id: str, - user_id: str, - scopes: List[str], - options: Optional[dict] = None, - tenant_id: Optional[str] = None, - ) -> dict: - """Internal async implementation for fetching token by scopes.""" - uri = MgmtV1.outbound_application_fetch_token_by_scopes_path - response = await http.post( - uri, - body={ - "appId": app_id, - "userId": user_id, - "scopes": scopes, - "options": options, - "tenantId": tenant_id, - }, - pswd=token, - ) - return response.json() - - @staticmethod - async def fetch_token( - *, - http: HTTPClientAsync, - token: Optional[str] = None, - app_id: str, - user_id: str, - tenant_id: Optional[str] = None, - options: Optional[dict] = None, - ) -> dict: - """Internal async implementation for fetching token.""" - uri = MgmtV1.outbound_application_fetch_token_path - response = await http.post( - uri, - body={ - "appId": app_id, - "userId": user_id, - "tenantId": tenant_id, - "options": options, - }, - pswd=token, - ) - return response.json() - - @staticmethod - async def fetch_tenant_token_by_scopes( - *, - http: HTTPClientAsync, - token: Optional[str] = None, - app_id: str, - tenant_id: str, - scopes: List[str], - options: Optional[dict] = None, - ) -> dict: - """Internal async implementation for fetching tenant token by scopes.""" - uri = MgmtV1.outbound_application_fetch_tenant_token_by_scopes_path - response = await http.post( - uri, - body={ - "appId": app_id, - "tenantId": tenant_id, - "scopes": scopes, - "options": options, - }, - pswd=token, - ) - return response.json() - - @staticmethod - async def fetch_tenant_token( - *, - http: HTTPClientAsync, - token: Optional[str] = None, - app_id: str, - tenant_id: str, - options: Optional[dict] = None, - ) -> dict: - """Internal async implementation for fetching tenant token.""" - uri = MgmtV1.outbound_application_fetch_tenant_token_path - response = await http.post( - uri, - body={ - "appId": app_id, - "tenantId": tenant_id, - "options": options, - }, - pswd=token, - ) - return response.json() - - -class OutboundApplicationAsync(OutboundApplicationBase, AsyncHTTPBase): - async def create_application( - self, - name: str, - description: Optional[str] = None, - logo: Optional[str] = None, - id: Optional[str] = None, - client_secret: Optional[str] = None, - client_id: Optional[str] = None, - discovery_url: Optional[str] = None, - authorization_url: Optional[str] = None, - authorization_url_params: Optional[List[URLParam]] = None, - token_url: Optional[str] = None, - token_url_params: Optional[List[URLParam]] = None, - revocation_url: Optional[str] = None, - default_scopes: Optional[List[str]] = None, - default_redirect_url: Optional[str] = None, - callback_domain: Optional[str] = None, - pkce: Optional[bool] = None, - access_type: Optional[AccessType] = None, - prompt: Optional[List[PromptType]] = None, - ) -> dict: - """ - Create a new outbound application with the given name. Outbound application IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The outbound application's name. - description (str): Optional outbound application description. - logo (str): Optional outbound application logo. - id (str): Optional outbound application ID. - client_secret (str): Optional client secret for the application. - client_id (str): Optional client ID for the application. - discovery_url (str): Optional OAuth discovery URL. - authorization_url (str): Optional OAuth authorization URL. - authorization_url_params (List[URLParam]): Optional authorization URL parameters. - token_url (str): Optional OAuth token URL. - token_url_params (List[URLParam]): Optional token URL parameters. - revocation_url (str): Optional OAuth token revocation URL. - default_scopes (List[str]): Optional default OAuth scopes. - default_redirect_url (str): Optional default redirect URL. - callback_domain (str): Optional callback domain. - pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. - access_type (AccessType): Optional OAuth access type. - prompt (List[PromptType]): Optional OAuth prompt parameters. - - Return value (dict): - Return dict in the format - {"app": {"id": , "name": , "description": , "logo": }} - - Raise: - AuthException: raised if create operation fails - """ - uri = MgmtV1.outbound_application_create_path - response = await self._http.post( - uri, - body=OutboundApplicationBase._compose_create_update_body( - name, - description, - logo, - id, - client_secret, - client_id, - discovery_url, - authorization_url, - authorization_url_params, - token_url, - token_url_params, - revocation_url, - default_scopes, - default_redirect_url, - callback_domain, - pkce, - access_type, - prompt, - ), - ) - return response.json() - - async def update_application( - self, - id: str, - name: str, - description: Optional[str] = None, - logo: Optional[str] = None, - client_secret: Optional[str] = None, - client_id: Optional[str] = None, - discovery_url: Optional[str] = None, - authorization_url: Optional[str] = None, - authorization_url_params: Optional[List[URLParam]] = None, - token_url: Optional[str] = None, - token_url_params: Optional[List[URLParam]] = None, - revocation_url: Optional[str] = None, - default_scopes: Optional[List[str]] = None, - default_redirect_url: Optional[str] = None, - callback_domain: Optional[str] = None, - pkce: Optional[bool] = None, - access_type: Optional[AccessType] = None, - prompt: Optional[List[PromptType]] = None, - ) -> dict: - """ - Update an existing outbound application with the given parameters. IMPORTANT: All parameters are used as overrides - to the existing outbound application. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the outbound application to update. - name (str): Updated outbound application name. - description (str): Optional outbound application description. - logo (str): Optional outbound application logo. - client_secret (str): Optional client secret for the application. - client_id (str): Optional client ID for the application. - discovery_url (str): Optional OAuth discovery URL. - authorization_url (str): Optional OAuth authorization URL. - authorization_url_params (List[URLParam]): Optional authorization URL parameters. - token_url (str): Optional OAuth token URL. - token_url_params (List[URLParam]): Optional token URL parameters. - revocation_url (str): Optional OAuth token revocation URL. - default_scopes (List[str]): Optional default OAuth scopes. - default_redirect_url (str): Optional default redirect URL. - callback_domain (str): Optional callback domain. - pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. - access_type (AccessType): Optional OAuth access type. - prompt (List[PromptType]): Optional OAuth prompt parameters. - - Return value (dict): - Return dict in the format - {"app": {"id": , "name": , "description": , "logo": }} - - Raise: - AuthException: raised if update operation fails - """ - uri = MgmtV1.outbound_application_update_path - response = await self._http.post( - uri, - body={ - "app": OutboundApplicationBase._compose_create_update_body( - name, - description, - logo, - id, - client_secret, - client_id, - discovery_url, - authorization_url, - authorization_url_params, - token_url, - token_url_params, - revocation_url, - default_scopes, - default_redirect_url, - callback_domain, - pkce, - access_type, - prompt, - ) - }, - ) - return response.json() - - async def delete_application( - self, - id: str, - ): - """ - Delete an existing outbound application. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the outbound application that's to be deleted. - - Raise: - AuthException: raised if deletion operation fails - """ - uri = MgmtV1.outbound_application_delete_path - await self._http.post(uri, body={"id": id}) - - async def load_application( - self, - id: str, - ) -> dict: - """ - Load outbound application by id. - - Args: - id (str): The ID of the outbound application to load. - - Return value (dict): - Return dict in the format - {"app": {"id": , "name": , "description": , "logo": }} - Containing the loaded outbound application information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(f"{MgmtV1.outbound_application_load_path}/{id}") - return response.json() - - async def load_all_applications(self) -> dict: - """ - Load all outbound applications. - - Return value (dict): - Return dict in the format - {"apps": [{"id": , "name": , "description": , "logo": }, ...]} - Containing the loaded outbound applications information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.outbound_application_load_all_path) - return response.json() - - async def fetch_token_by_scopes( - self, - app_id: str, - user_id: str, - scopes: List[str], - options: Optional[dict] = None, - tenant_id: Optional[str] = None, - ) -> dict: - """ - Fetch an outbound application token for a user with specific scopes. - - Args: - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - scopes (List[str]): List of scopes to include in the token. - options (dict): Optional token options. - tenant_id (str): Optional tenant ID. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - return await _OutboundApplicationTokenFetcherAsync.fetch_token_by_scopes( - http=self._http, - app_id=app_id, - user_id=user_id, - scopes=scopes, - options=options, - tenant_id=tenant_id, - ) - - async def fetch_token( - self, - app_id: str, - user_id: str, - tenant_id: Optional[str] = None, - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a user. - - Args: - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - tenant_id (str): Optional tenant ID. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - return await _OutboundApplicationTokenFetcherAsync.fetch_token( - http=self._http, - app_id=app_id, - user_id=user_id, - tenant_id=tenant_id, - options=options, - ) - - async def fetch_tenant_token_by_scopes( - self, - app_id: str, - tenant_id: str, - scopes: List[str], - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a tenant with specific scopes. - - Args: - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - scopes (List[str]): List of scopes to include in the token. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token_by_scopes( - http=self._http, - app_id=app_id, - tenant_id=tenant_id, - scopes=scopes, - options=options, - ) - - async def fetch_tenant_token( - self, - app_id: str, - tenant_id: str, - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a tenant. - - Args: - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token( - http=self._http, - app_id=app_id, - tenant_id=tenant_id, - options=options, - ) - - async def delete_user_tokens( - self, - app_id: Optional[str] = None, - user_id: Optional[str] = None, - ): - """ - Delete outbound application tokens by app ID and/or user ID. - At least one of app_id or user_id must be provided. - - Args: - app_id (str): Optional ID of the outbound application. - user_id (str): Optional ID of the user. - - Raise: - AuthException: raised if delete operation fails - """ - params = {} - if app_id: - params["appId"] = app_id - if user_id: - params["userId"] = user_id - uri = MgmtV1.outbound_application_delete_user_tokens_path - await self._http.delete(uri, params=params) - - async def delete_token( - self, - token_id: str, - ): - """ - Delete an outbound application token by its ID. - - Args: - token_id (str): The ID of the token to delete. - - Raise: - AuthException: raised if delete operation fails - """ - uri = MgmtV1.outbound_application_delete_token_path - await self._http.delete(uri, params={"id": token_id}) - - async def list_apps_with_user_token( - self, - user_id: str, - tenant_id: Optional[str] = None, - ) -> dict: - """ - List the IDs of the outbound applications the given user currently holds a valid token for. - Use this for connection-status views instead of calling fetch_token once per application. - - Args: - user_id (str): The ID of the user. - tenant_id (str): Optional tenant ID to scope the lookup to. - - Return value (dict): - Return dict in the format - {"appIds": [, ...]} - - Raise: - AuthException: raised if the operation fails - """ - params = {"userId": user_id} - if tenant_id: - params["tenantId"] = tenant_id - response = await self._http.get( - MgmtV1.outbound_application_list_apps_with_user_token_path, - params=params, - ) - return response.json() - - async def upload_user_api_key( - self, - app_id: str, - user_id: str, - api_key: str, - tenant_id: Optional[str] = None, - ) -> dict: - """ - Upload (set) a static API key for a user on an apikey-type outbound application. - - Args: - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - api_key (str): The API key to store for the user. - tenant_id (str): Optional tenant ID, used to associate different keys for the - same user in different tenants. - - Raise: - AuthException: raised if the operation fails - """ - body: dict = {"appId": app_id, "userId": user_id, "apiKey": api_key} - if tenant_id is not None: - body["tenantId"] = tenant_id - response = await self._http.post( - MgmtV1.outbound_application_upload_user_api_key_path, - body=body, - ) - return response.json() - - async def upload_tenant_api_key( - self, - app_id: str, - tenant_id: str, - api_key: str, - ) -> dict: - """ - Upload (set) a static API key for a tenant on an apikey-type outbound application. - - Args: - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - api_key (str): The API key to store for the tenant. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.outbound_application_upload_tenant_api_key_path, - body={"appId": app_id, "tenantId": tenant_id, "apiKey": api_key}, - ) - return response.json() - - async def upload_user_token( - self, - app_id: str, - user_id: str, - tenant_id: Optional[str] = None, - refresh_token: Optional[str] = None, - access_token: Optional[str] = None, - access_token_expiry: Optional[int] = None, - access_token_type: Optional[str] = None, - scopes: Optional[List[str]] = None, - external_identifier: Optional[str] = None, - id_token: Optional[str] = None, - verify_refresh: Optional[bool] = None, - granted_by: Optional[str] = None, - ) -> dict: - """ - Upload (migrate) an existing OAuth token for a user on an oauth-type outbound application, - without requiring the user to re-run the OAuth flow. At least one of refresh_token or - access_token must be provided. - - Args: - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - tenant_id (str): Optional tenant ID. - refresh_token (str): Optional refresh token. - access_token (str): Optional access token. - access_token_expiry (int): Optional access token expiry, in epoch seconds. - access_token_type (str): Optional token type (defaults to "Bearer"). - scopes (List[str]): Optional scopes the token was granted for. - external_identifier (str): Optional external identifier. - id_token (str): Optional id token. - verify_refresh (bool): If True, verify the refresh token against the provider before - persisting; nothing is written if verification fails. - granted_by (str): Optional attribution for who granted the token. - - Raise: - AuthException: raised if the operation fails - """ - base: dict = {"appId": app_id, "userId": user_id} - if tenant_id is not None: - base["tenantId"] = tenant_id - body = OutboundApplicationBase._compose_oauth_upload_body( - base, - refresh_token, - access_token, - access_token_expiry, - access_token_type, - scopes, - external_identifier, - id_token, - granted_by, - verify_refresh, - ) - response = await self._http.post( - MgmtV1.outbound_application_upload_user_token_path, - body=body, - ) - return response.json() - - async def upload_tenant_token( - self, - app_id: str, - tenant_id: str, - refresh_token: Optional[str] = None, - access_token: Optional[str] = None, - access_token_expiry: Optional[int] = None, - access_token_type: Optional[str] = None, - scopes: Optional[List[str]] = None, - external_identifier: Optional[str] = None, - id_token: Optional[str] = None, - verify_refresh: Optional[bool] = None, - granted_by: Optional[str] = None, - ) -> dict: - """ - Upload (migrate) an existing OAuth token for a tenant on an oauth-type outbound application. - At least one of refresh_token or access_token must be provided. - - Args: - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - (remaining args): see upload_user_token. - - Raise: - AuthException: raised if the operation fails - """ - base: dict = {"appId": app_id, "tenantId": tenant_id} - body = OutboundApplicationBase._compose_oauth_upload_body( - base, - refresh_token, - access_token, - access_token_expiry, - access_token_type, - scopes, - external_identifier, - id_token, - granted_by, - verify_refresh, - ) - response = await self._http.post( - MgmtV1.outbound_application_upload_tenant_token_path, - body=body, - ) - return response.json() - - async def batch_upload_user_tokens(self, tokens: List[dict]) -> dict: - """ - Batch upload (migrate) existing OAuth tokens for users. All-or-nothing: if any item fails - per-item validation, the returned failures are populated and no tokens are committed. - - Args: - tokens (List[dict]): A list of token dicts, each with at least appId and userId plus one of - refreshToken / accessToken. See upload_user_token for the full set of fields - (verifyRefresh is not supported for batch uploads). - - Return value (dict): - Return dict in the format - {"failures": [{"appId": , "userId": , "errorCode": , "reason": }, ...]} - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.outbound_application_batch_upload_user_tokens_path, - body={"tokens": tokens}, - ) - return response.json() - - async def batch_upload_tenant_tokens(self, tokens: List[dict]) -> dict: - """ - Batch upload (migrate) existing OAuth tokens for tenants. All-or-nothing: if any item fails - per-item validation, the returned failures are populated and no tokens are committed. - - Args: - tokens (List[dict]): A list of token dicts, each with at least appId and tenantId plus one of - refreshToken / accessToken. - - Return value (dict): - Return dict in the format - {"failures": [{"appId": , "tenantId": , "errorCode": , "reason": }, ...]} - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.outbound_application_batch_upload_tenant_tokens_path, - body={"tokens": tokens}, - ) - return response.json() - - -class OutboundApplicationByTokenAsync(AsyncHTTPBase): - def __init__(self, http_client: HTTPClientAsync): - # This class expects the token to be passed for each call - no_key_client = HTTPClientAsync( - project_id=http_client.project_id, - base_url=http_client.base_url, - timeout_seconds=http_client.timeout_seconds, - secure=http_client.secure, - management_key=None, # Override the management key for this client - verbose=http_client.verbose, - last_response_store=http_client.last_response_store, - ) - super().__init__(no_key_client) - - async def aclose(self) -> None: - """Close the dedicated no-management-key HTTP client this instance owns.""" - await self._http.aclose() - - # Methods for fetching outbound application tokens using an inbound application token - # that includes the "outbound.token.fetch" scope (no management key required) - - def _check_inbound_app_token(self, token: str): - """Check if inbound app token is available for the given property.""" - if not token: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "Inbound app token is required for perform this functionality", - ) - - async def fetch_token_by_scopes( - self, - token: str, - app_id: str, - user_id: str, - scopes: List[str], - options: Optional[dict] = None, - tenant_id: Optional[str] = None, - ) -> dict: - """ - Fetch an outbound application token for a user with specific scopes. - - Args: - token (str): The Inbound Application token to use for authentication. - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - scopes (List[str]): List of scopes to include in the token. - options (dict): Optional token options. - tenant_id (str): Optional tenant ID. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - self._check_inbound_app_token(token) - return await _OutboundApplicationTokenFetcherAsync.fetch_token_by_scopes( - http=self._http, - token=token, - app_id=app_id, - user_id=user_id, - scopes=scopes, - options=options, - tenant_id=tenant_id, - ) - - async def fetch_token( - self, - token: str, - app_id: str, - user_id: str, - tenant_id: Optional[str] = None, - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a user. - - Args: - token (str): The Inbound Application token to use for authentication. - app_id (str): The ID of the outbound application. - user_id (str): The ID of the user. - tenant_id (str): Optional tenant ID. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - self._check_inbound_app_token(token) - return await _OutboundApplicationTokenFetcherAsync.fetch_token( - http=self._http, - token=token, - app_id=app_id, - user_id=user_id, - tenant_id=tenant_id, - options=options, - ) - - async def fetch_tenant_token_by_scopes( - self, - token: str, - app_id: str, - tenant_id: str, - scopes: List[str], - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a tenant with specific scopes. - - Args: - token (str): The Inbound Application token to use for authentication. - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - scopes (List[str]): List of scopes to include in the token. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - self._check_inbound_app_token(token) - return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token_by_scopes( - http=self._http, - token=token, - app_id=app_id, - tenant_id=tenant_id, - scopes=scopes, - options=options, - ) - - async def fetch_tenant_token( - self, - token: str, - app_id: str, - tenant_id: str, - options: Optional[dict] = None, - ) -> dict: - """ - Fetch an outbound application token for a tenant. - - Args: - token (str): The Inbound Application token to use for authentication. - app_id (str): The ID of the outbound application. - tenant_id (str): The ID of the tenant. - options (dict): Optional token options. - - Return value (dict): - Return dict in the format - {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} - - Raise: - AuthException: raised if fetch operation fails - """ - self._check_inbound_app_token(token) - return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token( - http=self._http, - token=token, - app_id=app_id, - tenant_id=tenant_id, - options=options, - ) diff --git a/descope/management/outbound_scim.py b/descope/management/outbound_scim.py deleted file mode 100644 index 62841c4ba..000000000 --- a/descope/management/outbound_scim.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._http_base import HTTPBase -from descope.management._outbound_scim_base import OutboundSCIMBase -from descope.management.common import MgmtV1 - - -class OutboundSCIM(OutboundSCIMBase, HTTPBase): - def create_configuration( - self, - app_id: str, - configuration: Optional[dict] = None, - ) -> dict: - """ - Create a new outbound SCIM configuration on the federated SSO application - identified by app_id. The connector name is derived server-side from the app. - - Args: - app_id (str): The federated SSO application id this SCIM configuration binds to. - configuration (dict): Optional provider-specific SCIM configuration dictionary. - - Return value (dict): - Return dict in the format - {"configuration": {"appId": , "configuration": {...}, - "enabled": , "lastExportTime": , - "lastProcessingTime": , "failures": , - "version": }} - - Raise: - AuthException: raised if create operation fails - """ - response = self._http.post( - MgmtV1.outbound_scim_create_path, - body=OutboundSCIMBase._compose_create_body(app_id, configuration), - ) - return response.json() - - def update_configuration( - self, - app_id: str, - version: int, - configuration: Optional[dict] = None, - ) -> dict: - """ - Update the outbound SCIM configuration attached to the given federated SSO app. - The version must match the currently stored version — otherwise the update is - rejected as a conflict. - - Args: - app_id (str): The federated SSO application id. - version (int): The currently stored version, used for optimistic concurrency. - configuration (dict): Optional updated provider-specific SCIM configuration. - - Return value (dict): - Return dict in the format - {"configuration": {"appId": , ...}} - - Raise: - AuthException: raised if update operation fails - """ - response = self._http.post( - MgmtV1.outbound_scim_update_path, - body=OutboundSCIMBase._compose_update_body(app_id, version, configuration), - ) - return response.json() - - def delete_configuration( - self, - app_id: str, - ): - """ - Delete the outbound SCIM configuration attached to the given federated SSO app. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - app_id (str): The federated SSO application id. - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post(MgmtV1.outbound_scim_delete_path, body={"appId": app_id}) - - def load_configuration( - self, - app_id: str, - ) -> dict: - """ - Load the outbound SCIM configuration attached to the given federated SSO app. - - Args: - app_id (str): The federated SSO application id. - - Return value (dict): - Return dict in the format - {"configuration": {"appId": , "configuration": {...}, - "enabled": , "lastExportTime": , - "lastProcessingTime": , "failures": , - "version": }} - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(f"{MgmtV1.outbound_scim_load_path}/{app_id}") - return response.json() - - def set_enabled( - self, - app_id: str, - enabled: bool, - ) -> dict: - """ - Enable or disable the outbound SCIM configuration attached to the given - federated SSO app. - - Args: - app_id (str): The federated SSO application id. - enabled (bool): Whether the SCIM configuration should be enabled. - - Return value (dict): - Return dict in the format - {"configuration": {"appId": , "enabled": , ...}} - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.outbound_scim_set_enabled_path, - body={"appId": app_id, "enabled": enabled}, - ) - return response.json() diff --git a/descope/management/outbound_scim_async.py b/descope/management/outbound_scim_async.py deleted file mode 100644 index 211bf19ba..000000000 --- a/descope/management/outbound_scim_async.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._outbound_scim_base import OutboundSCIMBase -from descope.management.common import MgmtV1 - - -class OutboundSCIMAsync(OutboundSCIMBase, AsyncHTTPBase): - """Async counterpart of OutboundSCIM — all HTTP calls are coroutines.""" - - async def create_configuration( - self, - app_id: str, - configuration: Optional[dict] = None, - ) -> dict: - """ - Create a new outbound SCIM configuration on the federated SSO application - identified by app_id. The connector name is derived server-side from the app. - - Args: - app_id (str): The federated SSO application id this SCIM configuration binds to. - configuration (dict): Optional provider-specific SCIM configuration dictionary. - - Return value (dict): - Return dict in the format - {"configuration": {"appId": , "configuration": {...}, - "enabled": , "lastExportTime": , - "lastProcessingTime": , "failures": , - "version": }} - - Raise: - AuthException: raised if create operation fails - """ - response = await self._http.post( - MgmtV1.outbound_scim_create_path, - body=OutboundSCIMBase._compose_create_body(app_id, configuration), - ) - return response.json() - - async def update_configuration( - self, - app_id: str, - version: int, - configuration: Optional[dict] = None, - ) -> dict: - """ - Update the outbound SCIM configuration attached to the given federated SSO app. - The version must match the currently stored version — otherwise the update is - rejected as a conflict. - - Args: - app_id (str): The federated SSO application id. - version (int): The currently stored version, used for optimistic concurrency. - configuration (dict): Optional updated provider-specific SCIM configuration. - - Return value (dict): - Return dict in the format - {"configuration": {"appId": , ...}} - - Raise: - AuthException: raised if update operation fails - """ - response = await self._http.post( - MgmtV1.outbound_scim_update_path, - body=OutboundSCIMBase._compose_update_body(app_id, version, configuration), - ) - return response.json() - - async def delete_configuration( - self, - app_id: str, - ): - """ - Delete the outbound SCIM configuration attached to the given federated SSO app. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - app_id (str): The federated SSO application id. - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post(MgmtV1.outbound_scim_delete_path, body={"appId": app_id}) - - async def load_configuration( - self, - app_id: str, - ) -> dict: - """ - Load the outbound SCIM configuration attached to the given federated SSO app. - - Args: - app_id (str): The federated SSO application id. - - Return value (dict): - Return dict in the format - {"configuration": {"appId": , "configuration": {...}, - "enabled": , "lastExportTime": , - "lastProcessingTime": , "failures": , - "version": }} - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(f"{MgmtV1.outbound_scim_load_path}/{app_id}") - return response.json() - - async def set_enabled( - self, - app_id: str, - enabled: bool, - ) -> dict: - """ - Enable or disable the outbound SCIM configuration attached to the given - federated SSO app. - - Args: - app_id (str): The federated SSO application id. - enabled (bool): Whether the SCIM configuration should be enabled. - - Return value (dict): - Return dict in the format - {"configuration": {"appId": , "enabled": , ...}} - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.outbound_scim_set_enabled_path, - body={"appId": app_id, "enabled": enabled}, - ) - return response.json() diff --git a/descope/management/password.py b/descope/management/password.py deleted file mode 100644 index 0fbcdeb70..000000000 --- a/descope/management/password.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class Password(HTTPBase): - def get_settings(self, tenant_id: str = "") -> dict: - """ - Get password settings for the project or a specific tenant. - - Args: - tenant_id (str): Optional tenant ID. If empty, returns project-level settings. - - Return value (dict): - Return dict in the format - { - "enabled": bool, - "minLength": int, - "lowercase": bool, - "uppercase": bool, - "number": bool, - "nonAlphanumeric": bool, - "expiration": bool, - "expirationWeeks": int, - "reuse": bool, - "reuseAmount": int, - "lock": bool, - "lockAttempts": int - } - - Raise: - AuthException: raised if get operation fails - """ - params = {"tenantId": tenant_id} - response = self._http.get(MgmtV1.password_settings_path, params=params) - return response.json() - - def configure_settings(self, tenant_id: str, settings: dict): - """ - Configure password settings for the project or a specific tenant. - - Args: - tenant_id (str): Tenant ID. Empty string for project-level settings. - settings (dict): Password settings dict with keys: - - enabled (bool): Whether password authentication is enabled - - minLength (int): Minimum password length - - lowercase (bool): Require lowercase characters - - uppercase (bool): Require uppercase characters - - number (bool): Require numeric characters - - nonAlphanumeric (bool): Require non-alphanumeric characters - - expiration (bool): Enable password expiration - - expirationWeeks (int): Number of weeks until password expires - - reuse (bool): Enable password reuse prevention - - reuseAmount (int): Number of previous passwords to prevent reuse - - lock (bool): Enable account locking after failed attempts - - lockAttempts (int): Number of failed attempts before locking - - Raise: - AuthException: raised if configure operation fails - """ - body = {"tenantId": tenant_id, **settings} - self._http.post(MgmtV1.password_settings_path, body=body) diff --git a/descope/management/password_async.py b/descope/management/password_async.py deleted file mode 100644 index 4e36f45f0..000000000 --- a/descope/management/password_async.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class PasswordAsync(AsyncHTTPBase): - """Async counterpart of Password — all HTTP calls are coroutines.""" - - async def get_settings(self, tenant_id: str = "") -> dict: - """ - Get password settings for the project or a specific tenant. - - Args: - tenant_id (str): Optional tenant ID. If empty, returns project-level settings. - - Return value (dict): - Return dict in the format - { - "enabled": bool, - "minLength": int, - "lowercase": bool, - "uppercase": bool, - "number": bool, - "nonAlphanumeric": bool, - "expiration": bool, - "expirationWeeks": int, - "reuse": bool, - "reuseAmount": int, - "lock": bool, - "lockAttempts": int - } - - Raise: - AuthException: raised if get operation fails - """ - params = {"tenantId": tenant_id} - response = await self._http.get(MgmtV1.password_settings_path, params=params) - return response.json() - - async def configure_settings(self, tenant_id: str, settings: dict): - """ - Configure password settings for the project or a specific tenant. - - Args: - tenant_id (str): Tenant ID. Empty string for project-level settings. - settings (dict): Password settings dict with keys: - - enabled (bool): Whether password authentication is enabled - - minLength (int): Minimum password length - - lowercase (bool): Require lowercase characters - - uppercase (bool): Require uppercase characters - - number (bool): Require numeric characters - - nonAlphanumeric (bool): Require non-alphanumeric characters - - expiration (bool): Enable password expiration - - expirationWeeks (int): Number of weeks until password expires - - reuse (bool): Enable password reuse prevention - - reuseAmount (int): Number of previous passwords to prevent reuse - - lock (bool): Enable account locking after failed attempts - - lockAttempts (int): Number of failed attempts before locking - - Raise: - AuthException: raised if configure operation fails - """ - body = {"tenantId": tenant_id, **settings} - await self._http.post(MgmtV1.password_settings_path, body=body) diff --git a/descope/management/permission.py b/descope/management/permission.py deleted file mode 100644 index 7ba253d36..000000000 --- a/descope/management/permission.py +++ /dev/null @@ -1,206 +0,0 @@ -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class Permission(HTTPBase): - def create( - self, - name: str, - description: Optional[str] = None, - ): - """ - Create a new permission. - - Args: - name (str): permission name. - description (str): Optional description to briefly explain what this permission allows. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.permission_create_path, - body={"name": name, "description": description}, - ) - - def create_batch( - self, - permissions: List[dict], - ): - """ - Create a batch of permissions in a single atomic transaction. - - Args: - permissions (List[dict]): List of permission objects, each with: - - name (str): permission name. - - description (str): Optional description. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.permission_create_batch_path, - body={"permissions": permissions}, - ) - - def update_batch( - self, - permissions: List[dict], - ): - """ - Update a batch of permissions in a single atomic transaction. - - Args: - permissions (List[dict]): List of permission objects, each with: - - name (str): current permission name (or id (str): permission ID, e.g. PERM...). - - newName (str): new permission name. - - description (str): Optional new description. - - Raise: - AuthException: raised if update operation fails - """ - self._http.post( - MgmtV1.permission_update_batch_path, - body={"permissions": permissions}, - ) - - def delete_batch( - self, - names: List[str], - ): - """ - Delete a batch of permissions in a single atomic transaction. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - names (List[str]): List of permission names to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post( - MgmtV1.permission_delete_batch_path, - body={"names": names}, - ) - - def delete_batch_by_ids( - self, - ids: List[str], - ): - """ - Delete a batch of permissions by their IDs in a single atomic transaction. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - ids (List[str]): List of permission IDs to delete (e.g. PERM...). - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post( - MgmtV1.permission_delete_batch_path, - body={"ids": ids}, - ) - - def update( - self, - name: str, - new_name: str, - description: Optional[str] = None, - ): - """ - Update an existing permission with the given various fields. IMPORTANT: All parameters are used as overrides - to the existing permission. Empty fields will override populated fields. Use carefully. - - Args: - name (str): permission name. - new_name (str): permission updated name. - description (str): Optional description to briefly explain what this permission allows. - - Raise: - AuthException: raised if update operation fails - """ - self._http.post( - MgmtV1.permission_update_path, - body={"name": name, "newName": new_name, "description": description}, - ) - - def update_by_id( - self, - id: str, - new_name: str, - description: Optional[str] = None, - ): - """ - Update an existing permission identified by its ID. IMPORTANT: All parameters are used as overrides - to the existing permission. Empty fields will override populated fields. Use carefully. - - Args: - id (str): permission ID (e.g. PERM...). - new_name (str): permission updated name. - description (str): Optional description to briefly explain what this permission allows. - - Raise: - AuthException: raised if update operation fails - """ - self._http.post( - MgmtV1.permission_update_path, - body={"id": id, "newName": new_name, "description": description}, - ) - - def delete( - self, - name: str, - ): - """ - Delete an existing permission. IMPORTANT: This action is irreversible. Use carefully. - - Args: - name (str): The name of the permission to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.permission_delete_path, - body={"name": name}, - ) - - def delete_by_id( - self, - id: str, - ): - """ - Delete an existing permission by its ID. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the permission to be deleted (e.g. PERM...). - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post( - MgmtV1.permission_delete_path, - body={"id": id}, - ) - - def load_all( - self, - ) -> dict: - """ - Load all permissions. - - Return value (dict): - Return dict in the format - {"permissions": [{"name": , "description": , "systemDefault":}]} - Containing the loaded permission information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - MgmtV1.permission_load_all_path, - ) - return response.json() diff --git a/descope/management/permission_async.py b/descope/management/permission_async.py deleted file mode 100644 index daa1946f1..000000000 --- a/descope/management/permission_async.py +++ /dev/null @@ -1,210 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class PermissionAsync(AsyncHTTPBase): - """Async counterpart of Permission — all HTTP calls are coroutines.""" - - async def create( - self, - name: str, - description: Optional[str] = None, - ): - """ - Create a new permission. - - Args: - name (str): permission name. - description (str): Optional description to briefly explain what this permission allows. - - Raise: - AuthException: raised if creation operation fails - """ - await self._http.post( - MgmtV1.permission_create_path, - body={"name": name, "description": description}, - ) - - async def create_batch( - self, - permissions: List[dict], - ): - """ - Create a batch of permissions in a single atomic transaction. - - Args: - permissions (List[dict]): List of permission objects, each with: - - name (str): permission name. - - description (str): Optional description. - - Raise: - AuthException: raised if creation operation fails - """ - await self._http.post( - MgmtV1.permission_create_batch_path, - body={"permissions": permissions}, - ) - - async def update_batch( - self, - permissions: List[dict], - ): - """ - Update a batch of permissions in a single atomic transaction. - - Args: - permissions (List[dict]): List of permission objects, each with: - - name (str): current permission name (or id (str): permission ID, e.g. PERM...). - - newName (str): new permission name. - - description (str): Optional new description. - - Raise: - AuthException: raised if update operation fails - """ - await self._http.post( - MgmtV1.permission_update_batch_path, - body={"permissions": permissions}, - ) - - async def delete_batch( - self, - names: List[str], - ): - """ - Delete a batch of permissions in a single atomic transaction. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - names (List[str]): List of permission names to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post( - MgmtV1.permission_delete_batch_path, - body={"names": names}, - ) - - async def delete_batch_by_ids( - self, - ids: List[str], - ): - """ - Delete a batch of permissions by their IDs in a single atomic transaction. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - ids (List[str]): List of permission IDs to delete (e.g. PERM...). - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post( - MgmtV1.permission_delete_batch_path, - body={"ids": ids}, - ) - - async def update( - self, - name: str, - new_name: str, - description: Optional[str] = None, - ): - """ - Update an existing permission with the given various fields. IMPORTANT: All parameters are used as overrides - to the existing permission. Empty fields will override populated fields. Use carefully. - - Args: - name (str): permission name. - new_name (str): permission updated name. - description (str): Optional description to briefly explain what this permission allows. - - Raise: - AuthException: raised if update operation fails - """ - await self._http.post( - MgmtV1.permission_update_path, - body={"name": name, "newName": new_name, "description": description}, - ) - - async def update_by_id( - self, - id: str, - new_name: str, - description: Optional[str] = None, - ): - """ - Update an existing permission identified by its ID. IMPORTANT: All parameters are used as overrides - to the existing permission. Empty fields will override populated fields. Use carefully. - - Args: - id (str): permission ID (e.g. PERM...). - new_name (str): permission updated name. - description (str): Optional description to briefly explain what this permission allows. - - Raise: - AuthException: raised if update operation fails - """ - await self._http.post( - MgmtV1.permission_update_path, - body={"id": id, "newName": new_name, "description": description}, - ) - - async def delete( - self, - name: str, - ): - """ - Delete an existing permission. IMPORTANT: This action is irreversible. Use carefully. - - Args: - name (str): The name of the permission to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - await self._http.post( - MgmtV1.permission_delete_path, - body={"name": name}, - ) - - async def delete_by_id( - self, - id: str, - ): - """ - Delete an existing permission by its ID. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the permission to be deleted (e.g. PERM...). - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post( - MgmtV1.permission_delete_path, - body={"id": id}, - ) - - async def load_all( - self, - ) -> dict: - """ - Load all permissions. - - Return value (dict): - Return dict in the format - {"permissions": [{"name": , "description": , "systemDefault":}]} - Containing the loaded permission information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - MgmtV1.permission_load_all_path, - ) - return response.json() diff --git a/descope/management/project.py b/descope/management/project.py deleted file mode 100644 index 1057acec3..000000000 --- a/descope/management/project.py +++ /dev/null @@ -1,255 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class Project(HTTPBase): - def update_name( - self, - name: str, - ): - """ - Update the current project name. - - Args: - name (str): The new name for the project. - Raise: - AuthException: raised if operation fails - """ - self._http.post( - MgmtV1.project_update_name, - body={ - "name": name, - }, - ) - - def update_tags( - self, - tags: List[str], - ): - """ - Update the current project tags. - - Args: - tags (List[str]): Array of free text tags. - Raise: - AuthException: raised if operation fails - """ - self._http.post( - MgmtV1.project_update_tags, - body={ - "tags": tags, - }, - ) - - def list_projects( - self, - ) -> dict: - """ - List of all the projects in the company. - - Return value (dict): - Return dict in the format - {"projects": []} - "projects" contains a list of all of the projects and their information - - Raise: - AuthException: raised if operation fails - """ - response = self._http.post( - MgmtV1.project_list_projects, - body={}, - ) - resp = response.json() - - projects = resp["projects"] - # Apply the function to the projects list - formatted_projects = self.remove_tag_field(projects) - - # Return the same structure with 'tag' removed - result = {"projects": formatted_projects} - return result - - def clone( - self, - name: str, - environment: Optional[str] = None, - tags: Optional[List[str]] = None, - ): - """ - Clone the current project, including its settings and configurations. - - This action is supported only with a pro license or above. - - Users, tenants and access keys are not cloned. - - Args: - name (str): The new name for the project. - environment (str): Optional state for the project. Currently, only the "production" tag is supported. - tags(list[str]): Optional free text tags. - - Return value (dict): - Return dict Containing the new project details (name, id, environment and tag). - - Raise: - AuthException: raised if clone operation fails - """ - response = self._http.post( - MgmtV1.project_clone, - body={ - "name": name, - "environment": environment, - "tags": tags, - }, - ) - return response.json() - - def export_project( - self, - ): - """ - Exports all settings and configurations for a project and returns the - raw JSON files response as a dictionary. - - This action is supported only with a pro license or above. - - Users, tenants and access keys are not cloned. - - Secrets, keys and tokens are not stripped from the exported data. - - Return value (dict): - Return dict Containing the exported JSON files payload. - - Raise: - AuthException: raised if export operation fails - """ - response = self._http.post( - MgmtV1.project_export, - body={}, - ) - return response.json()["files"] - - def import_project( - self, - files: dict, - ): - """ - Imports all settings and configurations for a project overriding any current - configuration. - - This action is supported only with a pro license or above. - - Secrets, keys and tokens are not overwritten unless overwritten in the input. - - Args: - files (dict): The raw JSON dictionary of files, in the same format as the one - returned by calls to export. - - Raise: - AuthException: raised if import operation fails - """ - self._http.post( - MgmtV1.project_import, - body={ - "files": files, - }, - ) - return - - def delete(self): - """ - Delete the current project. - IMPORTANT: This action is irreversible. Use carefully. - - Raise: - AuthException: raised if delete operation fails - """ - self._http.post( - MgmtV1.project_delete_path, - body={}, - ) - - def export_snapshot( - self, - format: Optional[str] = None, - ) -> dict: - """ - Exports a snapshot of all the settings and configurations for a project and returns - the raw JSON files response as a dictionary. - - Args: - format (str): Optional format for the snapshot export. - - Return value (dict): - Return dict containing the exported snapshot data. - - Raise: - AuthException: raised if export operation fails - """ - body = {} - if format: - body["format"] = format - response = self._http.post( - MgmtV1.project_snapshot_export_path, - body=body, - ) - return response.json() - - def import_snapshot( - self, - files: dict, - input_secrets: Optional[dict] = None, - excludes: Optional[List[str]] = None, - ): - """ - Imports a snapshot of all settings and configurations into a project, overriding any - current configuration. - - Args: - files (dict): The raw JSON dictionary of files, in the same format as the one - returned by calls to export_snapshot. - input_secrets (dict): Optional secrets that need to be provided for the import. - excludes (List[str]): Optional list of items to exclude from the import. - - Raise: - AuthException: raised if import operation fails - """ - body: dict = {"files": files} - if input_secrets is not None: - body["inputSecrets"] = input_secrets - if excludes is not None: - body["excludes"] = excludes - self._http.post( - MgmtV1.project_snapshot_import_path, - body=body, - ) - - def validate_snapshot( - self, - files: dict, - input_secrets: Optional[dict] = None, - ) -> dict: - """ - Validates a snapshot by performing an import dry run and reporting any validation - failures or missing data. This should be called right before import_snapshot to - minimize the risk of the import failing. - - Args: - files (dict): The raw JSON dictionary of files to validate. - input_secrets (dict): Optional secrets to provide for validation. - - Return value (dict): - Return dict containing validation results, including 'ok' boolean and any 'failures' - or 'missingSecrets' if validation fails. - - Raise: - AuthException: raised if validation operation fails - """ - body: dict = {"files": files} - if input_secrets is not None: - body["inputSecrets"] = input_secrets - response = self._http.post( - MgmtV1.project_snapshot_validate_path, - body=body, - ) - return response.json() - - # Function to remove 'tag' field from each project - def remove_tag_field(self, projects): - return [{k: v for k, v in project.items() if k != "tag"} for project in projects] diff --git a/descope/management/project_async.py b/descope/management/project_async.py deleted file mode 100644 index bf66b977c..000000000 --- a/descope/management/project_async.py +++ /dev/null @@ -1,257 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class ProjectAsync(AsyncHTTPBase): - """Async counterpart of Project — all HTTP calls are coroutines.""" - - async def update_name( - self, - name: str, - ): - """ - Update the current project name. - - Args: - name (str): The new name for the project. - Raise: - AuthException: raised if operation fails - """ - await self._http.post( - MgmtV1.project_update_name, - body={ - "name": name, - }, - ) - - async def update_tags( - self, - tags: List[str], - ): - """ - Update the current project tags. - - Args: - tags (List[str]): Array of free text tags. - Raise: - AuthException: raised if operation fails - """ - await self._http.post( - MgmtV1.project_update_tags, - body={ - "tags": tags, - }, - ) - - async def list_projects( - self, - ) -> dict: - """ - List of all the projects in the company. - - Return value (dict): - Return dict in the format - {"projects": []} - "projects" contains a list of all of the projects and their information - - Raise: - AuthException: raised if operation fails - """ - response = await self._http.post( - MgmtV1.project_list_projects, - body={}, - ) - resp = response.json() - - projects = resp["projects"] - # Apply the function to the projects list - formatted_projects = self.remove_tag_field(projects) - - # Return the same structure with 'tag' removed - result = {"projects": formatted_projects} - return result - - async def clone( - self, - name: str, - environment: Optional[str] = None, - tags: Optional[List[str]] = None, - ): - """ - Clone the current project, including its settings and configurations. - - This action is supported only with a pro license or above. - - Users, tenants and access keys are not cloned. - - Args: - name (str): The new name for the project. - environment (str): Optional state for the project. Currently, only the "production" tag is supported. - tags(list[str]): Optional free text tags. - - Return value (dict): - Return dict Containing the new project details (name, id, environment and tag). - - Raise: - AuthException: raised if clone operation fails - """ - response = await self._http.post( - MgmtV1.project_clone, - body={ - "name": name, - "environment": environment, - "tags": tags, - }, - ) - return response.json() - - async def export_project( - self, - ): - """ - Exports all settings and configurations for a project and returns the - raw JSON files response as a dictionary. - - This action is supported only with a pro license or above. - - Users, tenants and access keys are not cloned. - - Secrets, keys and tokens are not stripped from the exported data. - - Return value (dict): - Return dict Containing the exported JSON files payload. - - Raise: - AuthException: raised if export operation fails - """ - response = await self._http.post( - MgmtV1.project_export, - body={}, - ) - return response.json()["files"] - - async def import_project( - self, - files: dict, - ): - """ - Imports all settings and configurations for a project overriding any current - configuration. - - This action is supported only with a pro license or above. - - Secrets, keys and tokens are not overwritten unless overwritten in the input. - - Args: - files (dict): The raw JSON dictionary of files, in the same format as the one - returned by calls to export. - - Raise: - AuthException: raised if import operation fails - """ - await self._http.post( - MgmtV1.project_import, - body={ - "files": files, - }, - ) - return - - async def delete(self): - """ - Delete the current project. - IMPORTANT: This action is irreversible. Use carefully. - - Raise: - AuthException: raised if delete operation fails - """ - await self._http.post( - MgmtV1.project_delete_path, - body={}, - ) - - async def export_snapshot( - self, - format: Optional[str] = None, - ) -> dict: - """ - Exports a snapshot of all the settings and configurations for a project and returns - the raw JSON files response as a dictionary. - - Args: - format (str): Optional format for the snapshot export. - - Return value (dict): - Return dict containing the exported snapshot data. - - Raise: - AuthException: raised if export operation fails - """ - body = {} - if format: - body["format"] = format - response = await self._http.post( - MgmtV1.project_snapshot_export_path, - body=body, - ) - return response.json() - - async def import_snapshot( - self, - files: dict, - input_secrets: Optional[dict] = None, - excludes: Optional[List[str]] = None, - ): - """ - Imports a snapshot of all settings and configurations into a project, overriding any - current configuration. - - Args: - files (dict): The raw JSON dictionary of files, in the same format as the one - returned by calls to export_snapshot. - input_secrets (dict): Optional secrets that need to be provided for the import. - excludes (List[str]): Optional list of items to exclude from the import. - - Raise: - AuthException: raised if import operation fails - """ - body: dict = {"files": files} - if input_secrets is not None: - body["inputSecrets"] = input_secrets - if excludes is not None: - body["excludes"] = excludes - await self._http.post( - MgmtV1.project_snapshot_import_path, - body=body, - ) - - async def validate_snapshot( - self, - files: dict, - input_secrets: Optional[dict] = None, - ) -> dict: - """ - Validates a snapshot by performing an import dry run and reporting any validation - failures or missing data. This should be called right before import_snapshot to - minimize the risk of the import failing. - - Args: - files (dict): The raw JSON dictionary of files to validate. - input_secrets (dict): Optional secrets to provide for validation. - - Return value (dict): - Return dict containing validation results, including 'ok' boolean and any 'failures' - or 'missingSecrets' if validation fails. - - Raise: - AuthException: raised if validation operation fails - """ - body: dict = {"files": files} - if input_secrets is not None: - body["inputSecrets"] = input_secrets - response = await self._http.post( - MgmtV1.project_snapshot_validate_path, - body=body, - ) - return response.json() - - # Function to remove 'tag' field from each project - def remove_tag_field(self, projects): - return [{k: v for k, v in project.items() if k != "tag"} for project in projects] diff --git a/descope/management/role.py b/descope/management/role.py deleted file mode 100644 index 0f5108b01..000000000 --- a/descope/management/role.py +++ /dev/null @@ -1,329 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional, Union - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1, role_delete_batch_body - - -class Role(HTTPBase): - def create( - self, - name: str, - description: Optional[str] = None, - permission_names: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - default: Optional[bool] = None, - private: Optional[bool] = None, - ): - """ - Create a new role. - - Args: - name (str): role name. - description (str): Optional description to briefly explain what this role allows. - permission_names (List[str]): Optional list of names of permissions this role grants. - tenant_id (str): Optional tenant ID to create the role in. - default (bool): Optional marks this role as default role. - private (bool): Optional marks this role as private role. - - Raise: - AuthException: raised if creation operation fails - """ - permission_names = [] if permission_names is None else permission_names - - self._http.post( - MgmtV1.role_create_path, - body={ - "name": name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - }, - ) - - def create_batch( - self, - roles: List[dict], - ): - """ - Create a batch of roles in a single atomic transaction. - - Args: - roles (List[dict]): List of role objects, each with: - - name (str): role name. - - description (str): Optional description. - - permissionNames (List[str]): Optional list of permission names. - - tenantId (str): Optional tenant ID. - - default (bool): Optional default flag. - - private (bool): Optional private flag. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.role_create_batch_path, - body={"roles": roles}, - ) - - def update_batch( - self, - roles: List[dict], - ): - """ - Update a batch of roles in a single atomic transaction. - - Args: - roles (List[dict]): List of role objects, each with: - - name (str): current role name (or id (str): role ID, e.g. ROL...). - - newName (str): new role name. - - description (str): Optional new description. - - permissionNames (List[str]): Optional list of permission names. - - tenantId (str): Optional tenant ID. - - default (bool): Optional default flag. - - private (bool): Optional private flag. - - Raise: - AuthException: raised if update operation fails - """ - self._http.post( - MgmtV1.role_update_batch_path, - body={"roles": roles}, - ) - - def delete_batch( - self, - roles: List[Union[str, dict]], - tenant_id: Optional[str] = None, - ): - """ - Delete a batch of roles in a single atomic transaction. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - roles (List[Union[str, dict]]): List of role names to delete, or role objects each with: - - name (str): role name. - - tenantId (str): Optional tenant ID. - tenant_id (str): Optional tenant ID the roles belong to. - - Raise: - ValueError: raised if roles is empty, holds an entry without a name, - or spans more than one tenant - AuthException: raised if deletion operation fails - """ - self._http.post( - MgmtV1.role_delete_batch_path, - body=role_delete_batch_body(roles, tenant_id), - ) - - def delete_batch_by_ids( - self, - role_ids: List[str], - tenant_id: Optional[str] = None, - ): - """ - Delete a batch of roles by their IDs in a single atomic transaction. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - role_ids (List[str]): List of role IDs to delete (e.g. ROL...). - tenant_id (str): Optional tenant ID the roles belong to. - - Raise: - ValueError: raised if role_ids is empty - AuthException: raised if deletion operation fails - """ - if not role_ids: - raise ValueError("role_ids list cannot be empty") - - self._http.post( - MgmtV1.role_delete_batch_path, - body={"roleIds": role_ids, "tenantId": tenant_id}, - ) - - def update( - self, - name: str, - new_name: str, - description: Optional[str] = None, - permission_names: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - default: Optional[bool] = None, - private: Optional[bool] = None, - ): - """ - Update an existing role with the given various fields. IMPORTANT: All parameters are used as overrides - to the existing role. Empty fields will override populated fields. Use carefully. - - Args: - name (str): role name. - new_name (str): role updated name. - description (str): Optional description to briefly explain what this role allows. - permission_names (List[str]): Optional list of names of permissions this role grants. - tenant_id (str): Optional tenant ID to update the role in. - default (bool): Optional marks this role as default role. - private (bool): Optional marks this role as private role. - - Raise: - AuthException: raised if update operation fails - """ - permission_names = [] if permission_names is None else permission_names - self._http.post( - MgmtV1.role_update_path, - body={ - "name": name, - "newName": new_name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - }, - ) - - def update_by_id( - self, - id: str, - new_name: str, - description: Optional[str] = None, - permission_names: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - default: Optional[bool] = None, - private: Optional[bool] = None, - ): - """ - Update an existing role identified by its ID. IMPORTANT: All parameters are used as overrides - to the existing role. Empty fields will override populated fields. Use carefully. - - Args: - id (str): role ID (e.g. ROL...). - new_name (str): role updated name. - description (str): Optional description to briefly explain what this role allows. - permission_names (List[str]): Optional list of names of permissions this role grants. - tenant_id (str): Optional tenant ID to update the role in. - default (bool): Optional marks this role as default role. - private (bool): Optional marks this role as private role. - - Raise: - AuthException: raised if update operation fails - """ - permission_names = [] if permission_names is None else permission_names - self._http.post( - MgmtV1.role_update_path, - body={ - "id": id, - "newName": new_name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - }, - ) - - def delete( - self, - name: str, - tenant_id: Optional[str] = None, - ): - """ - Delete an existing role. IMPORTANT: This action is irreversible. Use carefully. - - Args: - name (str): The name of the role to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.role_delete_path, - body={"name": name, "tenantId": tenant_id}, - ) - - def delete_by_id( - self, - id: str, - tenant_id: Optional[str] = None, - ): - """ - Delete an existing role by its ID. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the role to be deleted (e.g. ROL...). - tenant_id (str): Optional tenant ID the role belongs to. - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post( - MgmtV1.role_delete_path, - body={"id": id, "tenantId": tenant_id}, - ) - - def load_all( - self, - ) -> dict: - """ - Load all roles. - - Return value (dict): - Return dict in the format - {"roles": [{"name": , "description": , "permissionNames":[]}] } - Containing the loaded role information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - MgmtV1.role_load_all_path, - ) - return response.json() - - def search( - self, - tenant_ids: Optional[List[str]] = None, - role_names: Optional[List[str]] = None, - role_name_like: Optional[str] = None, - permission_names: Optional[List[str]] = None, - include_project_roles: Optional[bool] = None, - role_ids: Optional[List[str]] = None, - ) -> dict: - """ - Search roles based on the given filters. - - Args: - tenant_ids (List[str]): List of tenant ids to filter by - role_names (List[str]): Only return matching roles to the given names - role_name_like (str): Return roles that contain the given string ignoring case - permission_names (List[str]): Only return roles that have the given permissions - role_ids (List[str]): Only return roles matching the given IDs (e.g. ROL...) - - Return value (dict): - Return dict in the format - {"roles": [{"id": , "name": , "description": , "permissionNames":[]}] } - Containing the loaded role information. - - Raise: - AuthException: raised if load operation fails - """ - body: dict[str, str | bool | List[str]] = {} - if tenant_ids is not None: - body["tenantIds"] = tenant_ids - if role_names is not None: - body["roleNames"] = role_names - if role_name_like is not None: - body["roleNameLike"] = role_name_like - if permission_names is not None: - body["permissionNames"] = permission_names - if include_project_roles is not None: - body["includeProjectRoles"] = include_project_roles - if role_ids is not None: - body["roleIds"] = role_ids - - response = self._http.post( - MgmtV1.role_search_path, - body=body, - ) - return response.json() diff --git a/descope/management/role_async.py b/descope/management/role_async.py deleted file mode 100644 index 4733ed340..000000000 --- a/descope/management/role_async.py +++ /dev/null @@ -1,331 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional, Union - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1, role_delete_batch_body - - -class RoleAsync(AsyncHTTPBase): - """Async counterpart of Role — all HTTP calls are coroutines.""" - - async def create( - self, - name: str, - description: Optional[str] = None, - permission_names: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - default: Optional[bool] = None, - private: Optional[bool] = None, - ): - """ - Create a new role. - - Args: - name (str): role name. - description (str): Optional description to briefly explain what this role allows. - permission_names (List[str]): Optional list of names of permissions this role grants. - tenant_id (str): Optional tenant ID to create the role in. - default (bool): Optional marks this role as default role. - private (bool): Optional marks this role as private role. - - Raise: - AuthException: raised if creation operation fails - """ - permission_names = [] if permission_names is None else permission_names - - await self._http.post( - MgmtV1.role_create_path, - body={ - "name": name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - }, - ) - - async def create_batch( - self, - roles: List[dict], - ): - """ - Create a batch of roles in a single atomic transaction. - - Args: - roles (List[dict]): List of role objects, each with: - - name (str): role name. - - description (str): Optional description. - - permissionNames (List[str]): Optional list of permission names. - - tenantId (str): Optional tenant ID. - - default (bool): Optional default flag. - - private (bool): Optional private flag. - - Raise: - AuthException: raised if creation operation fails - """ - await self._http.post( - MgmtV1.role_create_batch_path, - body={"roles": roles}, - ) - - async def update_batch( - self, - roles: List[dict], - ): - """ - Update a batch of roles in a single atomic transaction. - - Args: - roles (List[dict]): List of role objects, each with: - - name (str): current role name (or id (str): role ID, e.g. ROL...). - - newName (str): new role name. - - description (str): Optional new description. - - permissionNames (List[str]): Optional list of permission names. - - tenantId (str): Optional tenant ID. - - default (bool): Optional default flag. - - private (bool): Optional private flag. - - Raise: - AuthException: raised if update operation fails - """ - await self._http.post( - MgmtV1.role_update_batch_path, - body={"roles": roles}, - ) - - async def delete_batch( - self, - roles: List[Union[str, dict]], - tenant_id: Optional[str] = None, - ): - """ - Delete a batch of roles in a single atomic transaction. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - roles (List[Union[str, dict]]): List of role names to delete, or role objects each with: - - name (str): role name. - - tenantId (str): Optional tenant ID. - tenant_id (str): Optional tenant ID the roles belong to. - - Raise: - ValueError: raised if roles is empty, holds an entry without a name, - or spans more than one tenant - AuthException: raised if deletion operation fails - """ - await self._http.post( - MgmtV1.role_delete_batch_path, - body=role_delete_batch_body(roles, tenant_id), - ) - - async def delete_batch_by_ids( - self, - role_ids: List[str], - tenant_id: Optional[str] = None, - ): - """ - Delete a batch of roles by their IDs in a single atomic transaction. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - role_ids (List[str]): List of role IDs to delete (e.g. ROL...). - tenant_id (str): Optional tenant ID the roles belong to. - - Raise: - ValueError: raised if role_ids is empty - AuthException: raised if deletion operation fails - """ - if not role_ids: - raise ValueError("role_ids list cannot be empty") - - await self._http.post( - MgmtV1.role_delete_batch_path, - body={"roleIds": role_ids, "tenantId": tenant_id}, - ) - - async def update( - self, - name: str, - new_name: str, - description: Optional[str] = None, - permission_names: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - default: Optional[bool] = None, - private: Optional[bool] = None, - ): - """ - Update an existing role with the given various fields. IMPORTANT: All parameters are used as overrides - to the existing role. Empty fields will override populated fields. Use carefully. - - Args: - name (str): role name. - new_name (str): role updated name. - description (str): Optional description to briefly explain what this role allows. - permission_names (List[str]): Optional list of names of permissions this role grants. - tenant_id (str): Optional tenant ID to update the role in. - default (bool): Optional marks this role as default role. - private (bool): Optional marks this role as private role. - - Raise: - AuthException: raised if update operation fails - """ - permission_names = [] if permission_names is None else permission_names - await self._http.post( - MgmtV1.role_update_path, - body={ - "name": name, - "newName": new_name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - }, - ) - - async def update_by_id( - self, - id: str, - new_name: str, - description: Optional[str] = None, - permission_names: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - default: Optional[bool] = None, - private: Optional[bool] = None, - ): - """ - Update an existing role identified by its ID. IMPORTANT: All parameters are used as overrides - to the existing role. Empty fields will override populated fields. Use carefully. - - Args: - id (str): role ID (e.g. ROL...). - new_name (str): role updated name. - description (str): Optional description to briefly explain what this role allows. - permission_names (List[str]): Optional list of names of permissions this role grants. - tenant_id (str): Optional tenant ID to update the role in. - default (bool): Optional marks this role as default role. - private (bool): Optional marks this role as private role. - - Raise: - AuthException: raised if update operation fails - """ - permission_names = [] if permission_names is None else permission_names - await self._http.post( - MgmtV1.role_update_path, - body={ - "id": id, - "newName": new_name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - }, - ) - - async def delete( - self, - name: str, - tenant_id: Optional[str] = None, - ): - """ - Delete an existing role. IMPORTANT: This action is irreversible. Use carefully. - - Args: - name (str): The name of the role to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - await self._http.post( - MgmtV1.role_delete_path, - body={"name": name, "tenantId": tenant_id}, - ) - - async def delete_by_id( - self, - id: str, - tenant_id: Optional[str] = None, - ): - """ - Delete an existing role by its ID. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the role to be deleted (e.g. ROL...). - tenant_id (str): Optional tenant ID the role belongs to. - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post( - MgmtV1.role_delete_path, - body={"id": id, "tenantId": tenant_id}, - ) - - async def load_all( - self, - ) -> dict: - """ - Load all roles. - - Return value (dict): - Return dict in the format - {"roles": [{"name": , "description": , "permissionNames":[]}] } - Containing the loaded role information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - MgmtV1.role_load_all_path, - ) - return response.json() - - async def search( - self, - tenant_ids: Optional[List[str]] = None, - role_names: Optional[List[str]] = None, - role_name_like: Optional[str] = None, - permission_names: Optional[List[str]] = None, - include_project_roles: Optional[bool] = None, - role_ids: Optional[List[str]] = None, - ) -> dict: - """ - Search roles based on the given filters. - - Args: - tenant_ids (List[str]): List of tenant ids to filter by - role_names (List[str]): Only return matching roles to the given names - role_name_like (str): Return roles that contain the given string ignoring case - permission_names (List[str]): Only return roles that have the given permissions - role_ids (List[str]): Only return roles matching the given IDs (e.g. ROL...) - - Return value (dict): - Return dict in the format - {"roles": [{"id": , "name": , "description": , "permissionNames":[]}] } - Containing the loaded role information. - - Raise: - AuthException: raised if load operation fails - """ - body: dict[str, str | bool | List[str]] = {} - if tenant_ids is not None: - body["tenantIds"] = tenant_ids - if role_names is not None: - body["roleNames"] = role_names - if role_name_like is not None: - body["roleNameLike"] = role_name_like - if permission_names is not None: - body["permissionNames"] = permission_names - if include_project_roles is not None: - body["includeProjectRoles"] = include_project_roles - if role_ids is not None: - body["roleIds"] = role_ids - - response = await self._http.post( - MgmtV1.role_search_path, - body=body, - ) - return response.json() diff --git a/descope/management/scope_claim_mapping.py b/descope/management/scope_claim_mapping.py deleted file mode 100644 index 91bffef88..000000000 --- a/descope/management/scope_claim_mapping.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from typing import List - -from descope._http_base import HTTPBase -from descope.management.common import MgmtV1 - - -class ScopeClaimMapping(HTTPBase): - def get(self) -> dict: - """ - Get the scope claim mappings for the project. - - Return value (dict): - Return dict in the format {"mappings": [...]} - "mappings" contains a list of scope claim mapping entries. - Each entry has: scope (str), claims (dict), description (str). - - Raise: - AuthException: raised if get operation fails - """ - response = self._http.post( - MgmtV1.scope_claim_mapping_get_path, - body={}, - ) - return response.json() - - def set( - self, - mappings: List[dict], - ): - """ - Set the scope claim mappings for the project. - This will replace all existing mappings. - - Args: - mappings (List[dict]): List of scope claim mapping entries. - Each entry should have: scope (str), claims (dict), description (str). - - Raise: - AuthException: raised if set operation fails - """ - self._http.post( - MgmtV1.scope_claim_mapping_set_path, - body={"mappings": mappings}, - ) - - def delete(self): - """ - Delete all scope claim mappings for the project. - IMPORTANT: This action is irreversible. Use carefully. - - Raise: - AuthException: raised if delete operation fails - """ - self._http.post( - MgmtV1.scope_claim_mapping_delete_path, - body={}, - ) diff --git a/descope/management/scope_claim_mapping_async.py b/descope/management/scope_claim_mapping_async.py deleted file mode 100644 index 34b0a4c52..000000000 --- a/descope/management/scope_claim_mapping_async.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from typing import List - -from descope._http_base import AsyncHTTPBase -from descope.management.common import MgmtV1 - - -class ScopeClaimMappingAsync(AsyncHTTPBase): - """Async counterpart of ScopeClaimMapping — all HTTP calls are coroutines.""" - - async def get(self) -> dict: - """ - Get the scope claim mappings for the project. - - Return value (dict): - Return dict in the format {"mappings": [...]} - "mappings" contains a list of scope claim mapping entries. - Each entry has: scope (str), claims (dict), description (str). - - Raise: - AuthException: raised if get operation fails - """ - response = await self._http.post( - MgmtV1.scope_claim_mapping_get_path, - body={}, - ) - return response.json() - - async def set( - self, - mappings: List[dict], - ): - """ - Set the scope claim mappings for the project. - This will replace all existing mappings. - - Args: - mappings (List[dict]): List of scope claim mapping entries. - Each entry should have: scope (str), claims (dict), description (str). - - Raise: - AuthException: raised if set operation fails - """ - await self._http.post( - MgmtV1.scope_claim_mapping_set_path, - body={"mappings": mappings}, - ) - - async def delete(self): - """ - Delete all scope claim mappings for the project. - IMPORTANT: This action is irreversible. Use carefully. - - Raise: - AuthException: raised if delete operation fails - """ - await self._http.post( - MgmtV1.scope_claim_mapping_delete_path, - body={}, - ) diff --git a/descope/management/sso_application.py b/descope/management/sso_application.py deleted file mode 100644 index 66097c96b..000000000 --- a/descope/management/sso_application.py +++ /dev/null @@ -1,539 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import HTTPBase -from descope.management._sso_application_base import SSOApplicationBase -from descope.management.common import ( - MgmtV1, - SAMLIDPAttributeMappingInfo, - SAMLIDPGroupsMappingInfo, -) - - -class SSOApplication(SSOApplicationBase, HTTPBase): - def create_oidc_application( - self, - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - force_authentication: Optional[bool] = False, - client_type: Optional[str] = None, - ) -> dict: - """ - Create a new OIDC sso application with the given name. SSO application IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The sso application's name. - login_page_url (str): The URL where login page is hosted. - id (str): Optional sso application ID. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional (default True) does the sso application will be enabled or disabled. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - client_type (str): Optional OAuth client type, "confidential" or "public". If omitted, the account's - default client type is used; the created app's client type shows which one it got. - - Return value (dict): - Return dict in the format - {"id": } - - Raise: - AuthException: raised if create operation fails - """ - uri = MgmtV1.sso_application_oidc_create_path - response = self._http.post( - uri, - body=SSOApplication._compose_create_update_oidc_body( - name, - login_page_url, - id, - description, - logo, - enabled, - force_authentication, - client_type, - ), - ) - return response.json() - - def create_saml_application( - self, - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - use_metadata_info: Optional[bool] = False, - metadata_url: Optional[str] = None, - entity_id: Optional[str] = None, - acs_url: Optional[str] = None, - certificate: Optional[str] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - acs_allowed_callbacks: Optional[List[str]] = None, - subject_name_id_type: Optional[str] = None, - subject_name_id_format: Optional[str] = None, - default_relay_state: Optional[str] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - default_signature_algorithm: Optional[str] = None, - ) -> dict: - """ - Create a new SAML sso application with the given name. SSO application IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The sso application's name. - login_page_url (str): The URL where login page is hosted. - id (str): Optional sso application ID. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional set the sso application as enabled or disabled. - use_metadata_info (bool): Optional determine if SP info should be automatically fetched from metadata_url or by specified it by the entity_id, acs_url, certificate parameters. - metadata_url (str): Optional SP metadata url which include all the SP SAML info. - entity_id (str): Optional SP entity id. - acs_url (str): Optional SP ACS (saml callback) url. - certificate (str): Optional SP certificate, relevant only when SAML request must be signed. - attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. - groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. - acs_allowed_callbacks (List[str]): Optional list of urls wildcards strings represents the allowed ACS urls that will be accepted while arriving on the SAML request as SP callback urls. - subject_name_id_type (str): Optional define the SAML Assertion subject name type, leave empty for using Descope user-id or set to "email"/"phone". - subject_name_id_format (str): Optional define the SAML Assertion subject name format, leave empty for using "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified". - default_relay_state (str): Optional define the default relay state. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. - default_signature_algorithm (str): Optional signature algorithm for SAML responses. Use "sha256" to opt in to SHA-256. Leave empty for the default (SHA-1). Only applies to IdP-initiated flows. - - Return value (dict): - Return dict in the format - {"id": } - - Raise: - AuthException: raised if create operation fails - """ - - if use_metadata_info: - if not metadata_url: - raise Exception("metadata_url argument must be set") - else: - if not entity_id or not acs_url or not certificate: - raise Exception("entity_id, acs_url, certificate arguments must be set") - - attribute_mapping = [] if attribute_mapping is None else attribute_mapping - - groups_mapping = [] if groups_mapping is None else groups_mapping - - acs_allowed_callbacks = [] if acs_allowed_callbacks is None else acs_allowed_callbacks - - uri = MgmtV1.sso_application_saml_create_path - response = self._http.post( - uri, - body=SSOApplication._compose_create_update_saml_body( - name, - login_page_url, - id, - description, - enabled, - logo, - use_metadata_info, - metadata_url, - entity_id, - acs_url, - certificate, - attribute_mapping, - groups_mapping, - acs_allowed_callbacks, - subject_name_id_type, - subject_name_id_format, - default_relay_state, - force_authentication, - logout_redirect_url, - default_signature_algorithm, - ), - ) - return response.json() - - def update_oidc_application( - self, - id: str, - name: str, - login_page_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - force_authentication: Optional[bool] = False, - client_type: Optional[str] = None, - ): - """ - Update an existing OIDC sso application with the given parameters. IMPORTANT: All parameters are used as overrides - to the existing sso application. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the sso application to update. - name (str): Updated sso application name - login_page_url (str): The URL where login page is hosted. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional (default True) does the sso application will be enabled or disabled. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - client_type (str): Optional OAuth client type, "confidential" or "public". Update overrides all fields, - so pass the app's current client type. - - Raise: - AuthException: raised if update operation fails - """ - - uri = MgmtV1.sso_application_oidc_update_path - self._http.post( - uri, - body=SSOApplication._compose_create_update_oidc_body( - name, - login_page_url, - id, - description, - logo, - enabled, - force_authentication, - client_type, - ), - ) - - def update_saml_application( - self, - id: str, - name: str, - login_page_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - use_metadata_info: Optional[bool] = False, - metadata_url: Optional[str] = None, - entity_id: Optional[str] = None, - acs_url: Optional[str] = None, - certificate: Optional[str] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - acs_allowed_callbacks: Optional[List[str]] = None, - subject_name_id_type: Optional[str] = None, - subject_name_id_format: Optional[str] = None, - default_relay_state: Optional[str] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - default_signature_algorithm: Optional[str] = None, - ): - """ - Update an existing SAML sso application with the given parameters. IMPORTANT: All parameters are used as overrides - to the existing sso application. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the sso application to update. - name (str): Updated sso application name - login_page_url (str): The URL where login page is hosted. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional (default True) does the sso application will be enabled or disabled. - use_metadata_info (bool): Optional determine if SP info should be automatically fetched from metadata_url or by specified it by the entity_id, acs_url, certificate parameters. - metadata_url (str): Optional SP metadata url which include all the SP SAML info. - entity_id (str): Optional SP entity id. - acs_url (str): Optional SP ACS (saml callback) url. - certificate (str): Optional SP certificate, relevant only when SAML request must be signed. - attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. - groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. - acs_allowed_callbacks (List[str]): Optional list of urls wildcards strings represents the allowed ACS urls that will be accepted while arriving on the SAML request as SP callback urls. - subject_name_id_type (str): Optional define the SAML Assertion subject name type, leave empty for using Descope user-id or set to "email"/"phone". - subject_name_id_format (str): Optional define the SAML Assertion subject name format, leave empty for using "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified". - default_relay_state (str): Optional define the default relay state. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. - default_signature_algorithm (str): Optional signature algorithm for SAML responses. Use "sha256" to opt in to SHA-256. Leave empty for the default (SHA-1). Only applies to IdP-initiated flows. - - Raise: - AuthException: raised if update operation fails - """ - - if use_metadata_info: - if not metadata_url: - raise Exception("metadata_url argument must be set") - else: - if not entity_id or not acs_url or not certificate: - raise Exception("entity_id, acs_url, certificate arguments must be set") - - attribute_mapping = [] if attribute_mapping is None else attribute_mapping - - groups_mapping = [] if groups_mapping is None else groups_mapping - - acs_allowed_callbacks = [] if acs_allowed_callbacks is None else acs_allowed_callbacks - - uri = MgmtV1.sso_application_saml_update_path - self._http.post( - uri, - body=SSOApplication._compose_create_update_saml_body( - name, - login_page_url, - id, - description, - enabled, - logo, - use_metadata_info, - metadata_url, - entity_id, - acs_url, - certificate, - attribute_mapping, - groups_mapping, - acs_allowed_callbacks, - subject_name_id_type, - subject_name_id_format, - default_relay_state, - force_authentication, - logout_redirect_url, - default_signature_algorithm, - ), - ) - - def delete( - self, - id: str, - ): - """ - Delete an existing sso application. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the sso application that's to be deleted. - - Raise: - AuthException: raised if deletion operation fails - """ - uri = MgmtV1.sso_application_delete_path - # Using adapter's do_post which already includes management key in Authorization header - self._http.post(uri, body={"id": id}) - - def load( - self, - id: str, - ) -> dict: - """ - Load sso application by id. - - Args: - id (str): The ID of the sso application to load. - - Return value (dict): - Return dict in the format - {"id":"","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}} - Containing the loaded sso application information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.sso_application_load_path, params={"id": id}) - return response.json() - - def load_all( - self, - ) -> dict: - """ - Load all sso applications. - - Return value (dict): - Return dict in the format - { - "apps": [ - {"id":"app1","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}}, - {"id":"app2","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}} - ] - } - Containing the loaded sso applications information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.sso_application_load_all_path) - return response.json() - - def create_wsfed_application( - self, - name: str, - login_page_url: str, - realm: str, - reply_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - reply_allowed_callbacks: Optional[List[str]] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - error_redirect_url: Optional[str] = None, - ) -> dict: - """ - Create a new WS-Federation sso application with the given name. SSO application IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The sso application's name. - login_page_url (str): The URL where login page is hosted. - realm (str): WS-Federation realm identifier. - reply_url (str): WS-Federation reply URL. - id (str): Optional sso application ID. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional set the sso application as enabled or disabled. - reply_allowed_callbacks (List[str]): Optional list of allowed callback URLs. - attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. - groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. - error_redirect_url (str): Optional Target URL to which the user will be redirected upon error. - - Return value (dict): - Return dict in the format - {"id": } - - Raise: - AuthException: raised if create operation fails - """ - - reply_allowed_callbacks = [] if reply_allowed_callbacks is None else reply_allowed_callbacks - attribute_mapping = [] if attribute_mapping is None else attribute_mapping - groups_mapping = [] if groups_mapping is None else groups_mapping - - uri = MgmtV1.sso_application_wsfed_create_path - response = self._http.post( - uri, - body=SSOApplication._compose_create_update_wsfed_body( - name, - login_page_url, - realm, - reply_url, - id, - description, - logo, - enabled, - reply_allowed_callbacks, - attribute_mapping, - groups_mapping, - force_authentication, - logout_redirect_url, - error_redirect_url, - ), - ) - return response.json() - - def update_wsfed_application( - self, - id: str, - name: str, - login_page_url: str, - realm: str, - reply_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - reply_allowed_callbacks: Optional[List[str]] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - error_redirect_url: Optional[str] = None, - ): - """ - Update an existing WS-Federation sso application with the given parameters. IMPORTANT: All parameters are used as overrides - to the existing sso application. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the sso application to update. - name (str): Updated sso application name - login_page_url (str): The URL where login page is hosted. - realm (str): WS-Federation realm identifier. - reply_url (str): WS-Federation reply URL. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional set the sso application as enabled or disabled. - reply_allowed_callbacks (List[str]): Optional list of allowed callback URLs. - attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. - groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. - error_redirect_url (str): Optional Target URL to which the user will be redirected upon error. - - Raise: - AuthException: raised if update operation fails - """ - - reply_allowed_callbacks = [] if reply_allowed_callbacks is None else reply_allowed_callbacks - attribute_mapping = [] if attribute_mapping is None else attribute_mapping - groups_mapping = [] if groups_mapping is None else groups_mapping - - uri = MgmtV1.sso_application_wsfed_update_path - self._http.post( - uri, - body=SSOApplication._compose_create_update_wsfed_body( - name, - login_page_url, - realm, - reply_url, - id, - description, - logo, - enabled, - reply_allowed_callbacks, - attribute_mapping, - groups_mapping, - force_authentication, - logout_redirect_url, - error_redirect_url, - ), - ) - - def get_application_secret( - self, - id: str, - ) -> str: - """ - Get the cleartext secret for an SSO application. - - Args: - id (str): The ID of the sso application. - - Return value (str): - Returns the cleartext secret as a string. - - Raise: - AuthException: raised if get operation fails - """ - response = self._http.get( - MgmtV1.sso_application_secret_path, - params={"id": id}, - ) - return response.json().get("cleartext", "") - - def rotate_application_secret( - self, - id: str, - ) -> str: - """ - Rotate the secret for an SSO application. - - Args: - id (str): The ID of the sso application. - - Return value (str): - Returns the new cleartext secret as a string. - - Raise: - AuthException: raised if rotate operation fails - """ - response = self._http.post( - MgmtV1.sso_application_rotate_path, - body={"id": id}, - ) - return response.json().get("cleartext", "") diff --git a/descope/management/sso_application_async.py b/descope/management/sso_application_async.py deleted file mode 100644 index 0970df735..000000000 --- a/descope/management/sso_application_async.py +++ /dev/null @@ -1,541 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._sso_application_base import SSOApplicationBase -from descope.management.common import ( - MgmtV1, - SAMLIDPAttributeMappingInfo, - SAMLIDPGroupsMappingInfo, -) - - -class SSOApplicationAsync(SSOApplicationBase, AsyncHTTPBase): - """Async counterpart of SSOApplication — all HTTP calls are coroutines.""" - - async def create_oidc_application( - self, - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - force_authentication: Optional[bool] = False, - client_type: Optional[str] = None, - ) -> dict: - """ - Create a new OIDC sso application with the given name. SSO application IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The sso application's name. - login_page_url (str): The URL where login page is hosted. - id (str): Optional sso application ID. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional (default True) does the sso application will be enabled or disabled. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - client_type (str): Optional OAuth client type, "confidential" or "public". If omitted, the account's - default client type is used; the created app's client type shows which one it got. - - Return value (dict): - Return dict in the format - {"id": } - - Raise: - AuthException: raised if create operation fails - """ - uri = MgmtV1.sso_application_oidc_create_path - response = await self._http.post( - uri, - body=SSOApplicationAsync._compose_create_update_oidc_body( - name, - login_page_url, - id, - description, - logo, - enabled, - force_authentication, - client_type, - ), - ) - return response.json() - - async def create_saml_application( - self, - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - use_metadata_info: Optional[bool] = False, - metadata_url: Optional[str] = None, - entity_id: Optional[str] = None, - acs_url: Optional[str] = None, - certificate: Optional[str] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - acs_allowed_callbacks: Optional[List[str]] = None, - subject_name_id_type: Optional[str] = None, - subject_name_id_format: Optional[str] = None, - default_relay_state: Optional[str] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - default_signature_algorithm: Optional[str] = None, - ) -> dict: - """ - Create a new SAML sso application with the given name. SSO application IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The sso application's name. - login_page_url (str): The URL where login page is hosted. - id (str): Optional sso application ID. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional set the sso application as enabled or disabled. - use_metadata_info (bool): Optional determine if SP info should be automatically fetched from metadata_url or by specified it by the entity_id, acs_url, certificate parameters. - metadata_url (str): Optional SP metadata url which include all the SP SAML info. - entity_id (str): Optional SP entity id. - acs_url (str): Optional SP ACS (saml callback) url. - certificate (str): Optional SP certificate, relevant only when SAML request must be signed. - attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. - groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. - acs_allowed_callbacks (List[str]): Optional list of urls wildcards strings represents the allowed ACS urls that will be accepted while arriving on the SAML request as SP callback urls. - subject_name_id_type (str): Optional define the SAML Assertion subject name type, leave empty for using Descope user-id or set to "email"/"phone". - subject_name_id_format (str): Optional define the SAML Assertion subject name format, leave empty for using "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified". - default_relay_state (str): Optional define the default relay state. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. - default_signature_algorithm (str): Optional signature algorithm for SAML responses. Use "sha256" to opt in to SHA-256. Leave empty for the default (SHA-1). Only applies to IdP-initiated flows. - - Return value (dict): - Return dict in the format - {"id": } - - Raise: - AuthException: raised if create operation fails - """ - - if use_metadata_info: - if not metadata_url: - raise Exception("metadata_url argument must be set") - else: - if not entity_id or not acs_url or not certificate: - raise Exception("entity_id, acs_url, certificate arguments must be set") - - attribute_mapping = [] if attribute_mapping is None else attribute_mapping - - groups_mapping = [] if groups_mapping is None else groups_mapping - - acs_allowed_callbacks = [] if acs_allowed_callbacks is None else acs_allowed_callbacks - - uri = MgmtV1.sso_application_saml_create_path - response = await self._http.post( - uri, - body=SSOApplicationAsync._compose_create_update_saml_body( - name, - login_page_url, - id, - description, - enabled, - logo, - use_metadata_info, - metadata_url, - entity_id, - acs_url, - certificate, - attribute_mapping, - groups_mapping, - acs_allowed_callbacks, - subject_name_id_type, - subject_name_id_format, - default_relay_state, - force_authentication, - logout_redirect_url, - default_signature_algorithm, - ), - ) - return response.json() - - async def update_oidc_application( - self, - id: str, - name: str, - login_page_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - force_authentication: Optional[bool] = False, - client_type: Optional[str] = None, - ): - """ - Update an existing OIDC sso application with the given parameters. IMPORTANT: All parameters are used as overrides - to the existing sso application. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the sso application to update. - name (str): Updated sso application name - login_page_url (str): The URL where login page is hosted. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional (default True) does the sso application will be enabled or disabled. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - client_type (str): Optional OAuth client type, "confidential" or "public". Update overrides all fields, - so pass the app's current client type. - - Raise: - AuthException: raised if update operation fails - """ - - uri = MgmtV1.sso_application_oidc_update_path - await self._http.post( - uri, - body=SSOApplicationAsync._compose_create_update_oidc_body( - name, - login_page_url, - id, - description, - logo, - enabled, - force_authentication, - client_type, - ), - ) - - async def update_saml_application( - self, - id: str, - name: str, - login_page_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - use_metadata_info: Optional[bool] = False, - metadata_url: Optional[str] = None, - entity_id: Optional[str] = None, - acs_url: Optional[str] = None, - certificate: Optional[str] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - acs_allowed_callbacks: Optional[List[str]] = None, - subject_name_id_type: Optional[str] = None, - subject_name_id_format: Optional[str] = None, - default_relay_state: Optional[str] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - default_signature_algorithm: Optional[str] = None, - ): - """ - Update an existing SAML sso application with the given parameters. IMPORTANT: All parameters are used as overrides - to the existing sso application. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the sso application to update. - name (str): Updated sso application name - login_page_url (str): The URL where login page is hosted. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional (default True) does the sso application will be enabled or disabled. - use_metadata_info (bool): Optional determine if SP info should be automatically fetched from metadata_url or by specified it by the entity_id, acs_url, certificate parameters. - metadata_url (str): Optional SP metadata url which include all the SP SAML info. - entity_id (str): Optional SP entity id. - acs_url (str): Optional SP ACS (saml callback) url. - certificate (str): Optional SP certificate, relevant only when SAML request must be signed. - attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. - groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. - acs_allowed_callbacks (List[str]): Optional list of urls wildcards strings represents the allowed ACS urls that will be accepted while arriving on the SAML request as SP callback urls. - subject_name_id_type (str): Optional define the SAML Assertion subject name type, leave empty for using Descope user-id or set to "email"/"phone". - subject_name_id_format (str): Optional define the SAML Assertion subject name format, leave empty for using "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified". - default_relay_state (str): Optional define the default relay state. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. - default_signature_algorithm (str): Optional signature algorithm for SAML responses. Use "sha256" to opt in to SHA-256. Leave empty for the default (SHA-1). Only applies to IdP-initiated flows. - - Raise: - AuthException: raised if update operation fails - """ - - if use_metadata_info: - if not metadata_url: - raise Exception("metadata_url argument must be set") - else: - if not entity_id or not acs_url or not certificate: - raise Exception("entity_id, acs_url, certificate arguments must be set") - - attribute_mapping = [] if attribute_mapping is None else attribute_mapping - - groups_mapping = [] if groups_mapping is None else groups_mapping - - acs_allowed_callbacks = [] if acs_allowed_callbacks is None else acs_allowed_callbacks - - uri = MgmtV1.sso_application_saml_update_path - await self._http.post( - uri, - body=SSOApplicationAsync._compose_create_update_saml_body( - name, - login_page_url, - id, - description, - enabled, - logo, - use_metadata_info, - metadata_url, - entity_id, - acs_url, - certificate, - attribute_mapping, - groups_mapping, - acs_allowed_callbacks, - subject_name_id_type, - subject_name_id_format, - default_relay_state, - force_authentication, - logout_redirect_url, - default_signature_algorithm, - ), - ) - - async def delete( - self, - id: str, - ): - """ - Delete an existing sso application. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the sso application that's to be deleted. - - Raise: - AuthException: raised if deletion operation fails - """ - uri = MgmtV1.sso_application_delete_path - # Using adapter's do_post which already includes management key in Authorization header - await self._http.post(uri, body={"id": id}) - - async def load( - self, - id: str, - ) -> dict: - """ - Load sso application by id. - - Args: - id (str): The ID of the sso application to load. - - Return value (dict): - Return dict in the format - {"id":"","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}} - Containing the loaded sso application information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.sso_application_load_path, params={"id": id}) - return response.json() - - async def load_all( - self, - ) -> dict: - """ - Load all sso applications. - - Return value (dict): - Return dict in the format - { - "apps": [ - {"id":"app1","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}}, - {"id":"app2","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}} - ] - } - Containing the loaded sso applications information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.sso_application_load_all_path) - return response.json() - - async def create_wsfed_application( - self, - name: str, - login_page_url: str, - realm: str, - reply_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - reply_allowed_callbacks: Optional[List[str]] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - error_redirect_url: Optional[str] = None, - ) -> dict: - """ - Create a new WS-Federation sso application with the given name. SSO application IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The sso application's name. - login_page_url (str): The URL where login page is hosted. - realm (str): WS-Federation realm identifier. - reply_url (str): WS-Federation reply URL. - id (str): Optional sso application ID. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional set the sso application as enabled or disabled. - reply_allowed_callbacks (List[str]): Optional list of allowed callback URLs. - attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. - groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. - error_redirect_url (str): Optional Target URL to which the user will be redirected upon error. - - Return value (dict): - Return dict in the format - {"id": } - - Raise: - AuthException: raised if create operation fails - """ - - reply_allowed_callbacks = [] if reply_allowed_callbacks is None else reply_allowed_callbacks - attribute_mapping = [] if attribute_mapping is None else attribute_mapping - groups_mapping = [] if groups_mapping is None else groups_mapping - - uri = MgmtV1.sso_application_wsfed_create_path - response = await self._http.post( - uri, - body=SSOApplicationAsync._compose_create_update_wsfed_body( - name, - login_page_url, - realm, - reply_url, - id, - description, - logo, - enabled, - reply_allowed_callbacks, - attribute_mapping, - groups_mapping, - force_authentication, - logout_redirect_url, - error_redirect_url, - ), - ) - return response.json() - - async def update_wsfed_application( - self, - id: str, - name: str, - login_page_url: str, - realm: str, - reply_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - reply_allowed_callbacks: Optional[List[str]] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - error_redirect_url: Optional[str] = None, - ): - """ - Update an existing WS-Federation sso application with the given parameters. IMPORTANT: All parameters are used as overrides - to the existing sso application. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the sso application to update. - name (str): Updated sso application name - login_page_url (str): The URL where login page is hosted. - realm (str): WS-Federation realm identifier. - reply_url (str): WS-Federation reply URL. - description (str): Optional sso application description. - logo (str): Optional sso application logo. - enabled (bool): Optional set the sso application as enabled or disabled. - reply_allowed_callbacks (List[str]): Optional list of allowed callback URLs. - attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. - groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. - force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. - logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. - error_redirect_url (str): Optional Target URL to which the user will be redirected upon error. - - Raise: - AuthException: raised if update operation fails - """ - - reply_allowed_callbacks = [] if reply_allowed_callbacks is None else reply_allowed_callbacks - attribute_mapping = [] if attribute_mapping is None else attribute_mapping - groups_mapping = [] if groups_mapping is None else groups_mapping - - uri = MgmtV1.sso_application_wsfed_update_path - await self._http.post( - uri, - body=SSOApplicationAsync._compose_create_update_wsfed_body( - name, - login_page_url, - realm, - reply_url, - id, - description, - logo, - enabled, - reply_allowed_callbacks, - attribute_mapping, - groups_mapping, - force_authentication, - logout_redirect_url, - error_redirect_url, - ), - ) - - async def get_application_secret( - self, - id: str, - ) -> str: - """ - Get the cleartext secret for an SSO application. - - Args: - id (str): The ID of the sso application. - - Return value (str): - Returns the cleartext secret as a string. - - Raise: - AuthException: raised if get operation fails - """ - response = await self._http.get( - MgmtV1.sso_application_secret_path, - params={"id": id}, - ) - return response.json().get("cleartext", "") - - async def rotate_application_secret( - self, - id: str, - ) -> str: - """ - Rotate the secret for an SSO application. - - Args: - id (str): The ID of the sso application. - - Return value (str): - Returns the new cleartext secret as a string. - - Raise: - AuthException: raised if rotate operation fails - """ - response = await self._http.post( - MgmtV1.sso_application_rotate_path, - body={"id": id}, - ) - return response.json().get("cleartext", "") diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py deleted file mode 100644 index 79109d213..000000000 --- a/descope/management/sso_settings.py +++ /dev/null @@ -1,771 +0,0 @@ -from typing import Dict, List, Optional - -from descope._http_base import HTTPBase -from descope.management._sso_settings_base import SSOSettingsBase -from descope.management.common import MgmtV1 - - -class RoleMapping: - """Map IDP group names to the Descope role name""" - - def __init__(self, groups: List[str], role_name: str): - self.groups = groups - self.role_name = role_name - - -class FGAGroupMappingRelation: - """A single FGA relation that maps an IDP group to an FGA resource/relation.""" - - def __init__(self, resource: str, relation_definition: str, namespace: str): - self.resource = resource - self.relation_definition = relation_definition - self.namespace = namespace - - -class FGAGroupMapping: - """A list of FGA relations to apply for an IDP group.""" - - def __init__(self, relations: Optional[List[FGAGroupMappingRelation]] = None): - self.relations = relations - - -class AttributeMapping: - """Map Descope user attributes to IDP user attributes""" - - def __init__( - self, - name: Optional[str] = None, - email: Optional[str] = None, - phone_number: Optional[str] = None, - group: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - ): - self.name = name - self.email = email - self.phone_number = phone_number - self.group = group - self.given_name = given_name - self.middle_name = middle_name - self.family_name = family_name - self.picture = picture - self.custom_attributes = custom_attributes - - -class XAAIssuerSettings: - """ - Cross-App Access (XAA / ID-JAG) trusted-issuer settings, including per-issuer JIT provisioning and - user attribute mapping (parity with the SSO login JIT). Group-to-role mapping reuses the tenant's - shared SSO group mapping. Describes the shape of each entry under the trust config's ``issuers`` map, - as returned by the dedicated Load XAA settings API (``load_xaa_settings`` / ``load_all_xaa_settings``). - """ - - def __init__( - self, - jwks_uri: Optional[str] = None, - sign_algorithm: Optional[str] = None, - user_info_uri: Optional[str] = None, - external_id_field_name: Optional[str] = None, - jit_disabled: Optional[bool] = None, - attribute_mapping: Optional[AttributeMapping] = None, - ): - self.jwks_uri = jwks_uri - self.sign_algorithm = sign_algorithm - self.user_info_uri = user_info_uri - self.external_id_field_name = external_id_field_name - self.jit_disabled = jit_disabled - self.attribute_mapping = attribute_mapping - - -class XAAJWTBearerSettings: - """ - A tenant's Cross-App Access (XAA / ID-JAG) trust config: the set of trusted issuers keyed by issuer - URL, plus the jwt-bearer grant configuration. Used as the ``settings`` field of the XAA configure - request, and returned under ``settings`` by the dedicated Load XAA settings API - (``load_xaa_settings`` / ``load_all_xaa_settings``). - """ - - def __init__( - self, - issuers: Optional[Dict[str, XAAIssuerSettings]] = None, - jwt_bearer_grant_type_audience_to_use: Optional[str] = None, - jwt_bearer_grant_type_scope_to_use: Optional[str] = None, - jwt_bearer_grant_type_custom_claims_to_use: Optional[str] = None, - ): - self.issuers = issuers - self.jwt_bearer_grant_type_audience_to_use = jwt_bearer_grant_type_audience_to_use - self.jwt_bearer_grant_type_scope_to_use = jwt_bearer_grant_type_scope_to_use - self.jwt_bearer_grant_type_custom_claims_to_use = jwt_bearer_grant_type_custom_claims_to_use - - -class XAASettings: - """ - Cross-App Access (XAA / ID-JAG) write payload for a single SSO configuration of a tenant. - - ``settings`` holds the trusted issuers (keyed by issuer URL) and jwt-bearer grant configuration; the - remaining fields are the config-level shared group/role mapping, which is shared across - SAML / OIDC / SCIM / XAA for the sso_id (NOT a per-issuer mapping). Role references are by name and - resolved to role ids server-side; these reuse the same types the SSO SAML settings use. - """ - - def __init__( - self, - enabled: Optional[bool] = None, - settings: Optional[XAAJWTBearerSettings] = None, - role_mappings: Optional[List[RoleMapping]] = None, - default_sso_roles: Optional[List[str]] = None, - fga_mappings: Optional[Dict[str, FGAGroupMapping]] = None, # map of IDP group name -> FGA relations - groups_priority: Optional[List[str]] = None, # list of group names in priority order (first = highest priority) - group_priority_enabled: Optional[bool] = None, - allow_override_roles: Optional[bool] = None, - provider_id: Optional[str] = None, # selected IdP provider template id (mirrors SSO SAML providerID) - ): - self.enabled = enabled - self.settings = settings - self.role_mappings = role_mappings - self.default_sso_roles = default_sso_roles - self.fga_mappings = fga_mappings - self.groups_priority = groups_priority - self.group_priority_enabled = group_priority_enabled - self.allow_override_roles = allow_override_roles - self.provider_id = provider_id - - -class OIDCAttributeMapping: - """ - Represents tenant OIDC attribute mapping. - """ - - def __init__( - self, - login_id: str, - name: str, - given_name: str, - middle_name: str, - family_name: str, - email: str, - verified_email: str, - username: str, - phone_number: str, - verified_phone: str, - picture: str, - ): - self.login_id = login_id - self.name = name - self.given_name = given_name - self.middle_name = middle_name - self.family_name = family_name - self.email = email - self.verified_email = verified_email - self.username = username - self.phone_number = phone_number - self.verified_phone = verified_phone - self.picture = picture - - -class SSOOIDCSettings: - """ - Represents tenant OIDC settings. - """ - - def __init__( - self, - name: str, - client_id: str, - client_secret: Optional[str] = None, - redirect_url: Optional[str] = None, - auth_url: Optional[str] = None, - token_url: Optional[str] = None, - user_data_url: Optional[str] = None, - scope: Optional[List[str]] = None, - jwks_url: Optional[str] = None, - attribute_mapping: Optional[OIDCAttributeMapping] = None, - manage_provider_tokens: Optional[bool] = False, - callback_domain: Optional[str] = None, - prompt: Optional[List[str]] = None, - grant_type: Optional[str] = None, - issuer: Optional[str] = None, - groups_priority: Optional[List[str]] = None, # list of group names in priority order (first = highest priority) - fga_mappings: Optional[Dict[str, FGAGroupMapping]] = None, # map of IDP group name -> FGA relations - # Send a PKCE (S256) code challenge to the IdP and the code verifier on the code exchange. - # None keeps the stored value, False turns it off. Not allowed with the implicit grant type. - # Appended last to preserve positional compatibility for existing callers. - use_pkce: Optional[bool] = None, - ): - self.name = name - self.client_id = client_id - self.client_secret = client_secret - self.redirect_url = redirect_url - self.auth_url = auth_url - self.token_url = token_url - self.user_data_url = user_data_url - self.scope = scope - self.jwks_url = jwks_url - self.attribute_mapping = attribute_mapping - self.manage_provider_tokens = manage_provider_tokens - self.callback_domain = callback_domain - self.prompt = prompt - self.grant_type = grant_type - self.issuer = issuer - self.groups_priority = groups_priority - self.fga_mappings = fga_mappings - self.use_pkce = use_pkce - - -class SSOSAMLSettings: - """ - Represents tenant SAML settings (manually configuration). - """ - - def __init__( - self, - idp_url: str, - idp_entity_id: str, - idp_cert: str, - attribute_mapping: Optional[AttributeMapping] = None, - role_mappings: Optional[List[RoleMapping]] = None, - default_sso_roles: Optional[List[str]] = None, # an empty list clears the default SSO roles, None keeps them - idp_additional_certs: Optional[List[str]] = None, - groups_priority: Optional[List[str]] = None, # list of group names in priority order (first = highest priority) - fga_mappings: Optional[Dict[str, FGAGroupMapping]] = None, # map of IDP group name -> FGA relations - config_fga_tenant_id_resource_prefix: Optional[str] = None, - config_fga_tenant_id_resource_suffix: Optional[str] = None, - # NOTICE - the following fields should be overridden only in case of SSO migration, otherwise, do not modify these fields - sp_acs_url: Optional[str] = None, - sp_entity_id: Optional[str] = None, - # Leave the SAML AuthnRequest Descope sends to the IdP unsigned. Set it only for IdPs that reject a - # signed request because their trusted provider entry holds no signing certificate for Descope. - # Appended last to preserve positional compatibility for existing callers. - disable_sign_request: bool = False, - ): - self.idp_url = idp_url - self.idp_entity_id = idp_entity_id - self.idp_cert = idp_cert - self.attribute_mapping = attribute_mapping - self.role_mappings = role_mappings - self.default_sso_roles = default_sso_roles - self.idp_additional_certs = idp_additional_certs - self.sp_acs_url = sp_acs_url - self.sp_entity_id = sp_entity_id - self.groups_priority = groups_priority - self.fga_mappings = fga_mappings - self.config_fga_tenant_id_resource_prefix = config_fga_tenant_id_resource_prefix - self.config_fga_tenant_id_resource_suffix = config_fga_tenant_id_resource_suffix - self.disable_sign_request = disable_sign_request - - -class SSOSAMLSettingsByMetadata: - """ - Represents tenant SAML settings (automatically (by metadata xml) configuration). - """ - - def __init__( - self, - idp_metadata_url: str, - attribute_mapping: Optional[AttributeMapping] = None, - role_mappings: Optional[List[RoleMapping]] = None, - default_sso_roles: Optional[List[str]] = None, # an empty list clears the default SSO roles, None keeps them - groups_priority: Optional[List[str]] = None, # list of group names in priority order (first = highest priority) - fga_mappings: Optional[Dict[str, FGAGroupMapping]] = None, # map of IDP group name -> FGA relations - config_fga_tenant_id_resource_prefix: Optional[str] = None, - config_fga_tenant_id_resource_suffix: Optional[str] = None, - # NOTICE - the following fields should be overridden only in case of SSO migration, otherwise, do not modify these fields - sp_acs_url: Optional[str] = None, - sp_entity_id: Optional[str] = None, - # IdP entity ID - set so IdP-initiated login can resolve the tenant by the SAML response issuer. - # Appended last to preserve positional compatibility for existing callers. - idp_entity_id: Optional[str] = None, - # Leave the SAML AuthnRequest Descope sends to the IdP unsigned. Set it only for IdPs that reject a - # signed request because their trusted provider entry holds no signing certificate for Descope. - disable_sign_request: bool = False, - ): - self.idp_metadata_url = idp_metadata_url - self.idp_entity_id = idp_entity_id - self.attribute_mapping = attribute_mapping - self.role_mappings = role_mappings - self.default_sso_roles = default_sso_roles - self.sp_acs_url = sp_acs_url - self.sp_entity_id = sp_entity_id - self.groups_priority = groups_priority - self.fga_mappings = fga_mappings - self.config_fga_tenant_id_resource_prefix = config_fga_tenant_id_resource_prefix - self.config_fga_tenant_id_resource_suffix = config_fga_tenant_id_resource_suffix - self.disable_sign_request = disable_sign_request - - -class SSOSettings(SSOSettingsBase, HTTPBase): - def load_settings( - self, - tenant_id: str, - ) -> dict: - """ - Load SSO setting for the provided tenant_id. - - Args: - tenant_id (str): The tenant ID of the desired SSO Settings - - Return value (dict): - Containing the loaded SSO settings information. - Return dict in the format: - {"tenant": {"id": "T2AAAA", "name": "myTenantName", "selfProvisioningDomains": [], "customAttributes": {}, "authType": "saml", "domains": ["lulu", "kuku"]}, "saml": {"idpEntityId": "", "idpSSOUrl": "", "idpCertificate": "", "idpAdditionalCertificates": [], "idpMetadataUrl": "https://dummy.com/metadata", "spEntityId": "", "spACSUrl": "", "spCertificate": "", "attributeMapping": {"name": "name", "email": "email", "username": "", "phoneNumber": "phone", "group": "", "givenName": "", "middleName": "", "familyName": "", "picture": "", "customAttributes": {}}, "groupsMapping": [], "redirectUrl": "", "lastSuccessTestTime": 0}, "oidc": {"name": "", "clientId": "", "clientSecret": "", "redirectUrl": "", "authUrl": "", "tokenUrl": "", "userDataUrl": "", "scope": [], "JWKsUrl": "", "userAttrMapping": {"loginId": "sub", "username": "", "name": "name", "email": "email", "phoneNumber": "phone_number", "verifiedEmail": "email_verified", "verifiedPhone": "phone_number_verified", "picture": "picture", "givenName": "given_name", "middleName": "middle_name", "familyName": "family_name"}, "manageProviderTokens": False, "callbackDomain": "", "prompt": [], "grantType": "authorization_code", "issuer": "", "lastSuccessTestTime": 0}} - - The "lastSuccessTestTime" field (epoch seconds, read-only) reports the last successful SSO test login on the configuration; a non-zero value means the configuration was tested successfully. - - Raise: - AuthException: raised if load configuration operation fails - """ - response = self._http.get( - uri=MgmtV1.sso_load_settings_path, - params={"tenantId": tenant_id}, - ) - return response.json() - - def recalculate_sso_mappings( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ): - """ - Recalculate SSO group to role mappings for all users in a tenant. - - This method triggers a recalculation of user roles based on the current SSO group mappings. - It will update the roles for all users in the tenant who have SSO group mappings. - - Args: - tenant_id (str): The tenant ID (required) - sso_id (str): Optional, specify to recalculate mappings for a specific SSO configuration - - Raise: - AuthException: raised if recalculation operation fails - """ - body = {"tenantId": tenant_id} - if sso_id: - body["ssoId"] = sso_id - - self._http.post( - uri=MgmtV1.sso_recalculate_mappings_path, - body=body, - ) - - def configure_sso_redirect_url( - self, - tenant_id: str, - saml_redirect_url: Optional[str] = None, - oauth_redirect_url: Optional[str] = None, - sso_id: Optional[str] = None, - ): - """ - Configure SSO redirect URLs for a tenant. - This will override the existing redirect URLs for the tenant and will not affect any other SSO setting. - - Args: - tenant_id (str): The tenant ID to be configured - saml_redirect_url (str): Optional SAML redirect URL - oauth_redirect_url (str): Optional OAuth redirect URL - sso_id (str): Optional SSO identifier for multi-SSO configurations - - Raise: - AuthException: raised if configuration operation fails - """ - body = {"tenantId": tenant_id} - if saml_redirect_url is not None: - body["samlRedirectUrl"] = saml_redirect_url - if oauth_redirect_url is not None: - body["oauthRedirectUrl"] = oauth_redirect_url - if sso_id: - body["ssoId"] = sso_id - - self._http.post( - uri=MgmtV1.sso_redirect_path, - body=body, - ) - - def load_all_settings( - self, - tenant_id: str, - ) -> dict: - """ - Load all SSO settings for the provided tenant_id (for multi-SSO usage). - - Args: - tenant_id (str): The tenant ID of the desired SSO Settings - - Return value (dict): - Containing all loaded SSO settings information. - - Raise: - AuthException: raised if load configuration operation fails - """ - response = self._http.get( - uri=MgmtV1.sso_load_all_settings_path, - params={"tenantId": tenant_id}, - ) - return response.json() - - def new_settings( - self, - tenant_id: str, - display_name: str, - sso_id: Optional[str] = None, - ) -> dict: - """ - Create new SSO settings for a tenant. - - Args: - tenant_id (str): The tenant ID to create settings for - display_name (str): Display name for the SSO settings - sso_id (str): Optional SSO identifier - - Return value (dict): - Containing the created SSO settings information. - - Raise: - AuthException: raised if creation operation fails - """ - body = { - "tenantId": tenant_id, - "displayName": display_name, - } - if sso_id: - body["ssoId"] = sso_id - - response = self._http.post( - uri=MgmtV1.sso_new_settings_path, - body=body, - ) - return response.json() - - def delete_settings( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ): - """ - Delete SSO setting for the provided tenant_id. - - Args: - tenant_id (str): The tenant ID of the desired SSO Settings to delete - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Raise: - AuthException: raised if delete operation fails - """ - params = {"tenantId": tenant_id} - if sso_id: - params["ssoId"] = sso_id - - self._http.delete( - MgmtV1.sso_settings_path, - params=params, - ) - - def configure_auth_type( - self, - tenant_id: str, - auth_type: str, - sso_id: Optional[str] = None, - ): - """ - Set the authentication type of a single SSO configuration, leaving its stored SAML/OIDC - settings, mappings and domains untouched. - - Args: - tenant_id (str): The tenant ID the configuration belongs to - auth_type (str): "none" disables the configuration without deleting it, "saml" or "oidc" - enable it on that protocol with its stored settings, so re-enabling needs no payload. - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Raise: - AuthException: raised if configuration operation fails - """ - self._http.post( - MgmtV1.sso_configure_auth_type_path, - body=SSOSettings._compose_configure_auth_type_body(tenant_id, auth_type, sso_id), - ) - - def configure_oidc_settings( - self, - tenant_id: str, - settings: SSOOIDCSettings, - domains: Optional[List[str]] = None, - ): - """ - Configure SSO OIDC settings for a tenant. - - Args: - tenant_id (str): The tenant ID to be configured - settings (SSOOIDCSettings): The OIDC settings to be configured for this tenant (all settings parameters are required). - domains (List[str]): Optional,domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - - self._http.post( - MgmtV1.sso_configure_oidc_settings, - body=SSOSettings._compose_configure_oidc_settings_body(tenant_id, settings, domains), - ) - - def configure_saml_settings( - self, - tenant_id: str, - settings: SSOSAMLSettings, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ): - """ - Configure SSO SAML settings for a tenant. - - Args: - tenant_id (str): The tenant ID to be configured - settings (SSOSAMLSettings): The SAML settings to be configured for this tenant (all settings parameters are required). - settings.default_sso_roles: use an empty list to clear the tenant's default SSO roles, or None to keep the stored ones. - redirect_url (str): Optional,the Redirect URL to use after successful authentication, or empty string to reset it (if not given it has to be set when starting an SSO authentication via the request). - domains (List[str]): Optional, domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - - self._http.post( - MgmtV1.sso_configure_saml_settings, - body=SSOSettings._compose_configure_saml_settings_body(tenant_id, settings, redirect_url, domains), - ) - - def configure_saml_settings_by_metadata( - self, - tenant_id: str, - settings: SSOSAMLSettingsByMetadata, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ): - """ - Configure SSO SAML settings for a tenant by fetching them from an IDP metadata URL. - - Args: - tenant_id (str): The tenant ID to be configured - settings (SSOSAMLSettingsByMetadata): The SAML settings to be configured for this tenant (all settings parameters are required). - settings.default_sso_roles: use an empty list to clear the tenant's default SSO roles, or None to keep the stored ones. - redirect_url (str): Optional, the Redirect URL to use after successful authentication, or empty string to reset it (if not given it has to be set when starting an SSO authentication via the request). - domains (List[str]): Optional, domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - - self._http.post( - MgmtV1.sso_configure_saml_by_metadata_settings, - body=SSOSettings._compose_configure_saml_settings_by_metadata_body( - tenant_id, settings, redirect_url, domains - ), - ) - - def configure_xaa_settings( - self, - tenant_id: str, - settings: XAASettings, - sso_id: Optional[str] = None, - ): - """ - Configure Cross-App Access (XAA / ID-JAG) trust settings for a single SSO configuration of a tenant. - - Args: - tenant_id (str): The tenant ID to be configured - settings (XAASettings): The trusted issuers + grant configuration together with the config-level - shared group/role mapping. The shared mapping is shared across SAML / OIDC / SCIM / XAA for the sso_id. - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Raise: - AuthException: raised if configuration operation fails - """ - self._http.post( - MgmtV1.sso_configure_xaa_settings, - body=SSOSettings._compose_configure_xaa_settings_body(tenant_id, settings, sso_id), - ) - - def load_xaa_settings( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ) -> dict: - """ - Load the Cross-App Access (XAA / ID-JAG) trust settings for a single SSO configuration of a tenant. - - Args: - tenant_id (str): The tenant ID of the desired XAA settings - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Return value (dict): - Containing the loaded XAA settings (ssoId, enabled, settings, groupsMapping, defaultSSORoles, - fgaMappings, groupsPriority, groupPriorityEnabled, allowOverrideRoles, audience). - audience is read-only: the project-level audience a requesting application must present in its - ID-JAG token. It carries no tenant segment - it equals the issuer the project publishes - so the - identity provider must send the tenant id in the token's aud_tenant claim. - - Raise: - AuthException: raised if load operation fails - """ - params = {"tenantId": tenant_id} - if sso_id: - params["ssoId"] = sso_id - response = self._http.get( - uri=MgmtV1.sso_configure_xaa_settings, - params=params, - ) - return response.json() - - def load_all_xaa_settings( - self, - tenant_id: str, - ) -> dict: - """ - Load the Cross-App Access (XAA / ID-JAG) trust settings for every SSO configuration of a tenant. - - Args: - tenant_id (str): The tenant ID of the desired XAA settings - - Return value (dict): - Containing all loaded XAA settings under the ``XAASettings`` key. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - uri=MgmtV1.sso_load_all_xaa_settings, - params={"tenantId": tenant_id}, - ) - return response.json() - - def delete_xaa_settings( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ): - """ - Delete the Cross-App Access (XAA / ID-JAG) trust settings of a single SSO configuration of a tenant. - - Args: - tenant_id (str): The tenant ID of the desired XAA settings to delete - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Raise: - AuthException: raised if delete operation fails - """ - params = {"tenantId": tenant_id} - if sso_id: - params["ssoId"] = sso_id - self._http.delete( - MgmtV1.sso_configure_xaa_settings, - params=params, - ) - - # DEPRECATED - def get_settings( - self, - tenant_id: str, - ) -> dict: - """ - DEPRECATED (use load_settings(..) function instead) - - Get SSO setting for the provided tenant_id. - - Args: - tenant_id (str): The tenant ID of the desired SSO Settings - - Return value (dict): - Containing the loaded SSO settings information. - - Raise: - AuthException: raised if configuration operation fails - """ - response = self._http.get( - uri=MgmtV1.sso_settings_path, - params={"tenantId": tenant_id}, - ) - return response.json() - - # DEPRECATED - def configure( - self, - tenant_id: str, - idp_url: str, - entity_id: str, - idp_cert: str, - redirect_url: str, - domains: Optional[List[str]] = None, - ) -> None: - """ - DEPRECATED (use configure_saml_settings(..) function instead) - - Configure SSO setting for a tenant manually. Alternatively, `configure_via_metadata` can be used instead. - - Args: - tenant_id (str): The tenant ID to be configured - idp_url (str): The URL for the identity provider. - entity_id (str): The entity ID (in the IDP). - idp_cert (str): The certificate provided by the IDP. - redirect_url (str): The Redirect URL to use after successful authentication, or empty string to reset it. - domain (List[str]): domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - self._http.post( - MgmtV1.sso_settings_path, - body=SSOSettings._compose_configure_body(tenant_id, idp_url, entity_id, idp_cert, redirect_url, domains), - ) - - # DEPRECATED - def configure_via_metadata( - self, - tenant_id: str, - idp_metadata_url: str, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ): - """ - DEPRECATED (use configure_saml_settings_by_metadata(..) function instead) - - Configure SSO setting for am IDP metadata URL. Alternatively, `configure` can be used instead. - - Args: - tenant_id (str): The tenant ID to be configured - idp_metadata_url (str): The URL to fetch SSO settings from. - redirect_url (str): The Redirect URL to use after successful authentication, or empty string to reset it. - domains (List[str]): domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - self._http.post( - MgmtV1.sso_metadata_path, - body=SSOSettings._compose_metadata_body(tenant_id, idp_metadata_url, redirect_url, domains), - ) - - # DEPRECATED - def mapping( - self, - tenant_id: str, - role_mappings: Optional[List[RoleMapping]] = None, - attribute_mapping: Optional[AttributeMapping] = None, - ): - """ - DEPRECATED (use configure_saml_settings(..) or configure_saml_settings_by_metadata(..) functions instead) - - Configure SSO role mapping from the IDP groups to the Descope roles. - - Args: - tenant_id (str): The tenant ID to be configured - role_mappings (List[RoleMapping]): A mapping between IDP groups and Descope roles. - attribute_mapping (AttributeMapping): A mapping between IDP user attributes and descope attributes. - - Raise: - AuthException: raised if configuration operation fails - """ - self._http.post( - MgmtV1.sso_mapping_path, - body=SSOSettings._compose_mapping_body(tenant_id, role_mappings, attribute_mapping), - ) diff --git a/descope/management/sso_settings_async.py b/descope/management/sso_settings_async.py deleted file mode 100644 index 84c4fdb37..000000000 --- a/descope/management/sso_settings_async.py +++ /dev/null @@ -1,491 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._sso_settings_base import SSOSettingsBase -from descope.management.common import MgmtV1 -from descope.management.sso_settings import ( - AttributeMapping, - RoleMapping, - SSOOIDCSettings, - SSOSAMLSettings, - SSOSAMLSettingsByMetadata, - XAASettings, -) - - -class SSOSettingsAsync(SSOSettingsBase, AsyncHTTPBase): - """Async counterpart of SSOSettings — all HTTP calls are coroutines.""" - - async def load_settings( - self, - tenant_id: str, - ) -> dict: - """ - Load SSO setting for the provided tenant_id. - - Args: - tenant_id (str): The tenant ID of the desired SSO Settings - - Return value (dict): - Containing the loaded SSO settings information. - Return dict in the format: - {"tenant": {"id": "T2AAAA", "name": "myTenantName", "selfProvisioningDomains": [], "customAttributes": {}, "authType": "saml", "domains": ["lulu", "kuku"]}, "saml": {"idpEntityId": "", "idpSSOUrl": "", "idpCertificate": "", "idpAdditionalCertificates": [], "idpMetadataUrl": "https://dummy.com/metadata", "spEntityId": "", "spACSUrl": "", "spCertificate": "", "attributeMapping": {"name": "name", "email": "email", "username": "", "phoneNumber": "phone", "group": "", "givenName": "", "middleName": "", "familyName": "", "picture": "", "customAttributes": {}}, "groupsMapping": [], "redirectUrl": ""}, "oidc": {"name": "", "clientId": "", "clientSecret": "", "redirectUrl": "", "authUrl": "", "tokenUrl": "", "userDataUrl": "", "scope": [], "JWKsUrl": "", "userAttrMapping": {"loginId": "sub", "username": "", "name": "name", "email": "email", "phoneNumber": "phone_number", "verifiedEmail": "email_verified", "verifiedPhone": "phone_number_verified", "picture": "picture", "givenName": "given_name", "middleName": "middle_name", "familyName": "family_name"}, "manageProviderTokens": False, "callbackDomain": "", "prompt": [], "grantType": "authorization_code", "issuer": ""}} - - Raise: - AuthException: raised if load configuration operation fails - """ - response = await self._http.get( - uri=MgmtV1.sso_load_settings_path, - params={"tenantId": tenant_id}, - ) - return response.json() - - async def recalculate_sso_mappings( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ): - """ - Recalculate SSO group to role mappings for all users in a tenant. - - This method triggers a recalculation of user roles based on the current SSO group mappings. - It will update the roles for all users in the tenant who have SSO group mappings. - - Args: - tenant_id (str): The tenant ID (required) - sso_id (str): Optional, specify to recalculate mappings for a specific SSO configuration - - Raise: - AuthException: raised if recalculation operation fails - """ - body = {"tenantId": tenant_id} - if sso_id: - body["ssoId"] = sso_id - - await self._http.post( - uri=MgmtV1.sso_recalculate_mappings_path, - body=body, - ) - - async def configure_sso_redirect_url( - self, - tenant_id: str, - saml_redirect_url: Optional[str] = None, - oauth_redirect_url: Optional[str] = None, - sso_id: Optional[str] = None, - ): - """ - Configure SSO redirect URLs for a tenant. - This will override the existing redirect URLs for the tenant and will not affect any other SSO setting. - - Args: - tenant_id (str): The tenant ID to be configured - saml_redirect_url (str): Optional SAML redirect URL - oauth_redirect_url (str): Optional OAuth redirect URL - sso_id (str): Optional SSO identifier for multi-SSO configurations - - Raise: - AuthException: raised if configuration operation fails - """ - body = {"tenantId": tenant_id} - if saml_redirect_url is not None: - body["samlRedirectUrl"] = saml_redirect_url - if oauth_redirect_url is not None: - body["oauthRedirectUrl"] = oauth_redirect_url - if sso_id: - body["ssoId"] = sso_id - - await self._http.post( - uri=MgmtV1.sso_redirect_path, - body=body, - ) - - async def load_all_settings( - self, - tenant_id: str, - ) -> dict: - """ - Load all SSO settings for the provided tenant_id (for multi-SSO usage). - - Args: - tenant_id (str): The tenant ID of the desired SSO Settings - - Return value (dict): - Containing all loaded SSO settings information. - - Raise: - AuthException: raised if load configuration operation fails - """ - response = await self._http.get( - uri=MgmtV1.sso_load_all_settings_path, - params={"tenantId": tenant_id}, - ) - return response.json() - - async def new_settings( - self, - tenant_id: str, - display_name: str, - sso_id: Optional[str] = None, - ) -> dict: - """ - Create new SSO settings for a tenant. - - Args: - tenant_id (str): The tenant ID to create settings for - display_name (str): Display name for the SSO settings - sso_id (str): Optional SSO identifier - - Return value (dict): - Containing the created SSO settings information. - - Raise: - AuthException: raised if creation operation fails - """ - body = { - "tenantId": tenant_id, - "displayName": display_name, - } - if sso_id: - body["ssoId"] = sso_id - - response = await self._http.post( - uri=MgmtV1.sso_new_settings_path, - body=body, - ) - return response.json() - - async def delete_settings( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ): - """ - Delete SSO setting for the provided tenant_id. - - Args: - tenant_id (str): The tenant ID of the desired SSO Settings to delete - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Raise: - AuthException: raised if delete operation fails - """ - params = {"tenantId": tenant_id} - if sso_id: - params["ssoId"] = sso_id - - await self._http.delete( - MgmtV1.sso_settings_path, - params=params, - ) - - async def configure_oidc_settings( - self, - tenant_id: str, - settings: SSOOIDCSettings, - domains: Optional[List[str]] = None, - ): - """ - Configure SSO OIDC settings for a tenant. - - Args: - tenant_id (str): The tenant ID to be configured - settings (SSOOIDCSettings): The OIDC settings to be configured for this tenant (all settings parameters are required). - domains (List[str]): Optional,domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - - await self._http.post( - MgmtV1.sso_configure_oidc_settings, - body=SSOSettingsAsync._compose_configure_oidc_settings_body(tenant_id, settings, domains), - ) - - async def configure_saml_settings( - self, - tenant_id: str, - settings: SSOSAMLSettings, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ): - """ - Configure SSO SAML settings for a tenant. - - Args: - tenant_id (str): The tenant ID to be configured - settings (SSOSAMLSettings): The SAML settings to be configured for this tenant (all settings parameters are required). - settings.default_sso_roles: use an empty list to clear the tenant's default SSO roles, or None to keep the stored ones. - redirect_url (str): Optional,the Redirect URL to use after successful authentication, or empty string to reset it (if not given it has to be set when starting an SSO authentication via the request). - domains (List[str]): Optional, domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - - await self._http.post( - MgmtV1.sso_configure_saml_settings, - body=SSOSettingsAsync._compose_configure_saml_settings_body(tenant_id, settings, redirect_url, domains), - ) - - async def configure_saml_settings_by_metadata( - self, - tenant_id: str, - settings: SSOSAMLSettingsByMetadata, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ): - """ - Configure SSO SAML settings for a tenant by fetching them from an IDP metadata URL. - - Args: - tenant_id (str): The tenant ID to be configured - settings (SSOSAMLSettingsByMetadata): The SAML settings to be configured for this tenant (all settings parameters are required). - settings.default_sso_roles: use an empty list to clear the tenant's default SSO roles, or None to keep the stored ones. - redirect_url (str): Optional, the Redirect URL to use after successful authentication, or empty string to reset it (if not given it has to be set when starting an SSO authentication via the request). - domains (List[str]): Optional, domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - - await self._http.post( - MgmtV1.sso_configure_saml_by_metadata_settings, - body=SSOSettingsAsync._compose_configure_saml_settings_by_metadata_body( - tenant_id, settings, redirect_url, domains - ), - ) - - async def configure_auth_type( - self, - tenant_id: str, - auth_type: str, - sso_id: Optional[str] = None, - ): - """ - Set the authentication type of a single SSO configuration, leaving its stored SAML/OIDC - settings, mappings and domains untouched. - - Args: - tenant_id (str): The tenant ID the configuration belongs to - auth_type (str): "none" disables the configuration without deleting it, "saml" or "oidc" - enable it on that protocol with its stored settings, so re-enabling needs no payload. - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Raise: - AuthException: raised if configuration operation fails - """ - await self._http.post( - MgmtV1.sso_configure_auth_type_path, - body=SSOSettingsAsync._compose_configure_auth_type_body(tenant_id, auth_type, sso_id), - ) - - async def configure_xaa_settings( - self, - tenant_id: str, - settings: XAASettings, - sso_id: Optional[str] = None, - ): - """ - Configure Cross-App Access (XAA / ID-JAG) trust settings for a single SSO configuration of a tenant. - - Args: - tenant_id (str): The tenant ID to be configured - settings (XAASettings): The trusted issuers + grant configuration together with the config-level - shared group/role mapping. The shared mapping is shared across SAML / OIDC / SCIM / XAA for the sso_id. - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Raise: - AuthException: raised if configuration operation fails - """ - await self._http.post( - MgmtV1.sso_configure_xaa_settings, - body=SSOSettingsAsync._compose_configure_xaa_settings_body(tenant_id, settings, sso_id), - ) - - async def load_xaa_settings( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ) -> dict: - """ - Load the Cross-App Access (XAA / ID-JAG) trust settings for a single SSO configuration of a tenant. - - Args: - tenant_id (str): The tenant ID of the desired XAA settings - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Return value (dict): - Containing the loaded XAA settings, including the read-only project-level audience a requesting - application must present in its ID-JAG token. The audience carries no tenant segment - it equals - the issuer the project publishes - so the identity provider must send the tenant id in the - token's aud_tenant claim. - - Raise: - AuthException: raised if load operation fails - """ - params = {"tenantId": tenant_id} - if sso_id: - params["ssoId"] = sso_id - response = await self._http.get( - uri=MgmtV1.sso_configure_xaa_settings, - params=params, - ) - return response.json() - - async def load_all_xaa_settings( - self, - tenant_id: str, - ) -> dict: - """ - Load the Cross-App Access (XAA / ID-JAG) trust settings for every SSO configuration of a tenant. - - Args: - tenant_id (str): The tenant ID of the desired XAA settings - - Return value (dict): - Containing all loaded XAA settings under the ``XAASettings`` key. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - uri=MgmtV1.sso_load_all_xaa_settings, - params={"tenantId": tenant_id}, - ) - return response.json() - - async def delete_xaa_settings( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ): - """ - Delete the Cross-App Access (XAA / ID-JAG) trust settings of a single SSO configuration of a tenant. - - Args: - tenant_id (str): The tenant ID of the desired XAA settings to delete - sso_id (str): Optional, the SSO configuration id (for multi-SSO). Omit for the default SSO configuration. - - Raise: - AuthException: raised if delete operation fails - """ - params = {"tenantId": tenant_id} - if sso_id: - params["ssoId"] = sso_id - await self._http.delete( - MgmtV1.sso_configure_xaa_settings, - params=params, - ) - - # DEPRECATED - async def get_settings( - self, - tenant_id: str, - ) -> dict: - """ - DEPRECATED (use load_settings(..) function instead) - - Get SSO setting for the provided tenant_id. - - Args: - tenant_id (str): The tenant ID of the desired SSO Settings - - Return value (dict): - Containing the loaded SSO settings information. - - Raise: - AuthException: raised if configuration operation fails - """ - response = await self._http.get( - uri=MgmtV1.sso_settings_path, - params={"tenantId": tenant_id}, - ) - return response.json() - - # DEPRECATED - async def configure( - self, - tenant_id: str, - idp_url: str, - entity_id: str, - idp_cert: str, - redirect_url: str, - domains: Optional[List[str]] = None, - ) -> None: - """ - DEPRECATED (use configure_saml_settings(..) function instead) - - Configure SSO setting for a tenant manually. Alternatively, `configure_via_metadata` can be used instead. - - Args: - tenant_id (str): The tenant ID to be configured - idp_url (str): The URL for the identity provider. - entity_id (str): The entity ID (in the IDP). - idp_cert (str): The certificate provided by the IDP. - redirect_url (str): The Redirect URL to use after successful authentication, or empty string to reset it. - domain (List[str]): domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - await self._http.post( - MgmtV1.sso_settings_path, - body=SSOSettingsAsync._compose_configure_body( - tenant_id, idp_url, entity_id, idp_cert, redirect_url, domains - ), - ) - - # DEPRECATED - async def configure_via_metadata( - self, - tenant_id: str, - idp_metadata_url: str, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ): - """ - DEPRECATED (use configure_saml_settings_by_metadata(..) function instead) - - Configure SSO setting for am IDP metadata URL. Alternatively, `configure` can be used instead. - - Args: - tenant_id (str): The tenant ID to be configured - idp_metadata_url (str): The URL to fetch SSO settings from. - redirect_url (str): The Redirect URL to use after successful authentication, or empty string to reset it. - domains (List[str]): domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. - - Raise: - AuthException: raised if configuration operation fails - """ - await self._http.post( - MgmtV1.sso_metadata_path, - body=SSOSettingsAsync._compose_metadata_body(tenant_id, idp_metadata_url, redirect_url, domains), - ) - - # DEPRECATED - async def mapping( - self, - tenant_id: str, - role_mappings: Optional[List[RoleMapping]] = None, - attribute_mapping: Optional[AttributeMapping] = None, - ): - """ - DEPRECATED (use configure_saml_settings(..) or configure_saml_settings_by_metadata(..) functions instead) - - Configure SSO role mapping from the IDP groups to the Descope roles. - - Args: - tenant_id (str): The tenant ID to be configured - role_mappings (List[RoleMapping]): A mapping between IDP groups and Descope roles. - attribute_mapping (AttributeMapping): A mapping between IDP user attributes and descope attributes. - - Raise: - AuthException: raised if configuration operation fails - """ - await self._http.post( - MgmtV1.sso_mapping_path, - body=SSOSettingsAsync._compose_mapping_body(tenant_id, role_mappings, attribute_mapping), - ) diff --git a/descope/management/tenant.py b/descope/management/tenant.py deleted file mode 100644 index bb1f2f8e1..000000000 --- a/descope/management/tenant.py +++ /dev/null @@ -1,440 +0,0 @@ -from typing import Any, List, Optional - -from descope._http_base import HTTPBase -from descope.management._tenant_base import TenantBase -from descope.management.common import ( - MgmtV1, - SessionExpirationUnit, - SSOSetupSuiteSettings, - TenantAuthType, -) - - -class Tenant(TenantBase, HTTPBase): - def create( - self, - name: str, - id: Optional[str] = None, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - enforce_sso: Optional[bool] = False, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - disabled: Optional[bool] = False, - ) -> dict: - """ - Create a new tenant with the given name. Tenant IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The tenant's name - id (str): Optional tenant ID. - self_provisioning_domains (List[str]): An optional list of domain that are associated with this tenant. - Users authenticating from these domains will be associated with this tenant. - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso - enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement - federated_app_ids (List[str]): Optional, list of federated application IDs - disabled (bool): Optional, login to the tenant will be disabled - - Return value (dict): - Return dict in the format - {"id": } - - Raise: - AuthException: raised if creation operation fails - """ - self_provisioning_domains = [] if self_provisioning_domains is None else self_provisioning_domains - - response = self._http.post( - MgmtV1.tenant_create_path, - body=TenantBase._compose_create_update_body( - name, - id, - self_provisioning_domains, - custom_attributes, - enforce_sso, - enforce_sso_exclusions, - federated_app_ids, - disabled, - ), - ) - return response.json() - - def update( - self, - id: str, - name: str, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - enforce_sso: Optional[bool] = False, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - disabled: Optional[bool] = False, - ): - """ - Update an existing tenant with the given name and domains. IMPORTANT: All parameters are used as overrides - to the existing tenant. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the tenant to update. - name (str): Updated tenant name - self_provisioning_domains (List[str]): An optional list of domain that are associated with this tenant. - Users authenticating from these domains will be associated with this tenant. - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso - enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement - federated_app_ids (List[str]): Optional, list of federated application IDs - disabled (bool): Optional, login to the tenant will be disabled - - Raise: - AuthException: raised if creation operation fails - """ - self_provisioning_domains = [] if self_provisioning_domains is None else self_provisioning_domains - - self._http.post( - MgmtV1.tenant_update_path, - body=TenantBase._compose_create_update_body( - name, - id, - self_provisioning_domains, - custom_attributes, - enforce_sso, - enforce_sso_exclusions, - federated_app_ids, - disabled, - ), - ) - - def patch_tenant( - self, - id: str, - name: Optional[str] = None, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - disabled: Optional[bool] = None, - enforce_sso: Optional[bool] = None, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - role_inheritance: Optional[str] = None, - ) -> None: - """ - Patch an existing tenant. Only the given fields will be updated; omitted fields are left unchanged. - - Args: - id (str): The ID of the tenant to patch. - name (str): Optional updated tenant name. - self_provisioning_domains (List[str]): Optional list of domains associated with this tenant. - Users authenticating from these domains will be associated with this tenant. - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - disabled (bool): Optional, login to the tenant will be disabled - enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso - enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement - federated_app_ids (List[str]): Optional, list of federated application IDs - role_inheritance (str): Optional, role inheritance setting for the tenant - - Raise: - AuthException: raised if patch operation fails - """ - self._http.patch( - MgmtV1.tenant_patch_path, - body=TenantBase._compose_patch_body( - id, - name, - self_provisioning_domains, - custom_attributes, - disabled, - enforce_sso, - enforce_sso_exclusions, - federated_app_ids, - role_inheritance, - ), - ) - - def update_settings( - self, - id: str, - self_provisioning_domains: List[str], - domains: Optional[List[str]] = None, - auth_type: Optional[TenantAuthType] = None, - session_settings_enabled: Optional[bool] = None, - refresh_token_expiration: Optional[int] = None, - refresh_token_expiration_unit: Optional[SessionExpirationUnit] = None, - session_token_expiration: Optional[int] = None, - session_token_expiration_unit: Optional[SessionExpirationUnit] = None, - stepup_token_expiration: Optional[int] = None, - stepup_token_expiration_unit: Optional[SessionExpirationUnit] = None, - enable_inactivity: Optional[bool] = None, - inactivity_time: Optional[int] = None, - inactivity_time_unit: Optional[SessionExpirationUnit] = None, - JITDisabled: Optional[bool] = None, - sso_setup_suite_settings: Optional[SSOSetupSuiteSettings] = None, - enforce_sso: Optional[bool] = None, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - ): - """ - Update an existing tenant's session settings. - - Args: - id (str): The ID of the tenant to update. - self_provisioning_domains (List[str]): Domains for self-provisioning. - domains (Optional[List[str]]): List of domains associated with the tenant. - auth_type (Optional[TenantAuthType]): Authentication type for the tenant. - session_settings_enabled (Optional[bool]): Whether session settings are enabled. - refresh_token_expiration (Optional[int]): Expiration time for refresh tokens. - refresh_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for refresh token expiration. - session_token_expiration (Optional[int]): Expiration time for session tokens. - session_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for session token expiration. - stepup_token_expiration (Optional[int]): Expiration time for step-up tokens. - stepup_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for step-up token expiration. - enable_inactivity (Optional[bool]): Whether inactivity timeout is enabled. - inactivity_time (Optional[int]): Inactivity timeout duration. - inactivity_time_unit (Optional[SessionExpirationUnit]): Unit for inactivity timeout. - JITDisabled (Optional[bool]): Whether JIT is disabled. - sso_setup_suite_settings (Optional[SSOSetupSuiteSettings]): SSO Setup Suite configuration. - enforce_sso (Optional[bool]): Whether to enforce SSO for the tenant. - enforce_sso_exclusions (Optional[List[str]]): List of user IDs excluded from SSO enforcement. - federated_app_ids (Optional[List[str]]): List of federated application IDs. - - Raise: - AuthException: raised if update operation fails - """ - body: dict[str, Any] = { - "tenantId": id, - "selfProvisioningDomains": self_provisioning_domains, - "domains": domains, - "authType": auth_type, - "enabled": session_settings_enabled, - "refreshTokenExpiration": refresh_token_expiration, - "refreshTokenExpirationUnit": refresh_token_expiration_unit, - "sessionTokenExpiration": session_token_expiration, - "sessionTokenExpirationUnit": session_token_expiration_unit, - "stepupTokenExpiration": stepup_token_expiration, - "stepupTokenExpirationUnit": stepup_token_expiration_unit, - "enableInactivity": enable_inactivity, - "inactivityTime": inactivity_time, - "inactivityTimeUnit": inactivity_time_unit, - "JITDisabled": JITDisabled, - "ssoSetupSuiteSettings": (sso_setup_suite_settings.to_dict() if sso_setup_suite_settings else None), - "enforceSSO": enforce_sso, - "enforceSSOExclusions": enforce_sso_exclusions, - "federatedAppIds": federated_app_ids, - } - - body = {k: v for k, v in body.items() if v is not None} - - self._http.post(MgmtV1.tenant_settings_path, body=body, params=None) - - def update_default_roles( - self, - tenant_id: str, - role_names: List[str], - ) -> None: - """ - Set which project default roles apply to new users in this tenant. - - Args: - tenant_id (str): The ID of the tenant to update. - role_names (List[str]): List of role names to set as tenant default roles. - - Raise: - AuthException: raised if update operation fails - """ - self._http.post( - MgmtV1.tenant_update_default_roles_path, - body={"id": tenant_id, "defaultRoles": role_names}, - ) - - def delete( - self, - id: str, - cascade: bool = False, - ): - """ - Delete an existing tenant. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the tenant that's to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.tenant_delete_path, - body={"id": id, "cascade": cascade}, - ) - - def load( - self, - id: str, - ) -> dict: - """ - Load tenant by id. - - Args: - id (str): The ID of the tenant to load. - - Return value (dict): - Return dict in the format - {"id": , "name": , "selfProvisioningDomains": [], "customAttributes: {}, "createdTime": } - Containing the loaded tenant information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - MgmtV1.tenant_load_path, - params={"id": id}, - ) - return response.json() - - def load_settings( - self, - id: str, - ) -> dict: - """ - Load tenant session settings by id. - - Args: - id (str): The ID of the tenant to load session settings for. - - Return value (dict): - Return dict in the format - { "domains":, "selfProvisioningDomains":, "authType":, - "enabled":, "refreshTokenExpiration":, "refreshTokenExpirationUnit":, - "sessionTokenExpiration":, "sessionTokenExpirationUnit":, - "stepupTokenExpiration":, "stepupTokenExpirationUnit":, - "enableInactivity":, "inactivityTime":, "inactivityTimeUnit":, - "JITDisabled":, "ssoSetupSuiteSettings": } - Containing the loaded tenant session settings. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - MgmtV1.tenant_settings_path, - params={"id": id}, - ) - return response.json() - - def load_all( - self, - ) -> dict: - """ - Load all tenants. - - Return value (dict): - Return dict in the format - {"tenants": [{"id": , "name": , "selfProvisioningDomains": [], customAttributes: {}, "createdTime": }]} - Containing the loaded tenant information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - MgmtV1.tenant_load_all_path, - ) - return response.json() - - def search_all( - self, - ids: Optional[List[str]] = None, - names: Optional[List[str]] = None, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - """ - Search all tenants. - - Args: - ids (List[str]): Optional list of tenant IDs to filter by - names (List[str]): Optional list of names to filter by - self_provisioning_domains (List[str]): Optional list of self provisioning domains to filter by - custom_attributes (dict): Optional search for a attribute with a given value - - Return value (dict): - Return dict in the format - {"tenants": [{"id": , "name": , "selfProvisioningDomains": [], customAttributes:{}}]} - Containing the loaded tenant information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.post( - MgmtV1.tenant_search_all_path, - body={ - "tenantIds": ids, - "tenantNames": names, - "tenantSelfProvisioningDomains": self_provisioning_domains, - "customAttributes": custom_attributes, - }, - ) - return response.json() - - def generate_sso_configuration_link( - self, - tenant_id: str, - expire_time: Optional[int] = None, - email: Optional[str] = None, - sso_id: Optional[str] = None, - actor_id: Optional[str] = None, - ) -> str: - """ - Generate a tenant admin self-service link for SSO configuration. - - Args: - tenant_id (str): Tenant ID to generate the link for. - expire_time (int): Optional expiration duration in seconds. For a link valid for 6 hours, use 21600. - email (str): Optional email address associated with the admin. - sso_id (str): Optional SSO identifier for the tenant. - actor_id (str): Optional id recorded as the audit actor for actions performed inside the - SSO Setup Suite (instead of the temporary user). It is used as-is for audit attribution - and is not validated. - - Return value (str): - Returns the admin SSO configuration link as a string. - - Raise: - AuthException: raised if generation operation fails - """ - body: dict[str, Any] = {"tenantId": tenant_id} - if expire_time is not None: - body["expireTime"] = expire_time - if email is not None: - body["email"] = email - if sso_id is not None: - body["ssoId"] = sso_id - if actor_id is not None: - body["actorId"] = actor_id - - response = self._http.post( - MgmtV1.tenant_generate_sso_configuration_link_path, - body=body, - ) - result = response.json() - return result.get("adminSSOConfigurationLink", "") - - def revoke_sso_configuration_link( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ): - """ - Revoke a tenant admin self-service link for SSO configuration. - - Args: - tenant_id (str): Tenant ID to revoke the link for. - sso_id (str): Optional SSO identifier for the tenant. - - Raise: - AuthException: raised if revoke operation fails - """ - body: dict[str, Any] = {"tenantId": tenant_id} - if sso_id is not None: - body["ssoId"] = sso_id - - self._http.post( - MgmtV1.tenant_revoke_sso_configuration_link_path, - body=body, - ) diff --git a/descope/management/tenant_async.py b/descope/management/tenant_async.py deleted file mode 100644 index 5817332c5..000000000 --- a/descope/management/tenant_async.py +++ /dev/null @@ -1,444 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._tenant_base import TenantBase -from descope.management.common import ( - MgmtV1, - SessionExpirationUnit, - SSOSetupSuiteSettings, - TenantAuthType, -) - - -class TenantAsync(TenantBase, AsyncHTTPBase): - """Async counterpart of Tenant — all HTTP calls are coroutines.""" - - async def create( - self, - name: str, - id: Optional[str] = None, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - enforce_sso: Optional[bool] = False, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - disabled: Optional[bool] = False, - ) -> dict: - """ - Create a new tenant with the given name. Tenant IDs are provisioned automatically, but can be provided - explicitly if needed. Both the name and ID must be unique per project. - - Args: - name (str): The tenant's name - id (str): Optional tenant ID. - self_provisioning_domains (List[str]): An optional list of domain that are associated with this tenant. - Users authenticating from these domains will be associated with this tenant. - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso - enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement - federated_app_ids (List[str]): Optional, list of federated application IDs - disabled (bool): Optional, login to the tenant will be disabled - - Return value (dict): - Return dict in the format - {"id": } - - Raise: - AuthException: raised if creation operation fails - """ - self_provisioning_domains = [] if self_provisioning_domains is None else self_provisioning_domains - - response = await self._http.post( - MgmtV1.tenant_create_path, - body=TenantBase._compose_create_update_body( - name, - id, - self_provisioning_domains, - custom_attributes, - enforce_sso, - enforce_sso_exclusions, - federated_app_ids, - disabled, - ), - ) - return response.json() - - async def update( - self, - id: str, - name: str, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - enforce_sso: Optional[bool] = False, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - disabled: Optional[bool] = False, - ): - """ - Update an existing tenant with the given name and domains. IMPORTANT: All parameters are used as overrides - to the existing tenant. Empty fields will override populated fields. Use carefully. - - Args: - id (str): The ID of the tenant to update. - name (str): Updated tenant name - self_provisioning_domains (List[str]): An optional list of domain that are associated with this tenant. - Users authenticating from these domains will be associated with this tenant. - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso - enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement - federated_app_ids (List[str]): Optional, list of federated application IDs - disabled (bool): Optional, login to the tenant will be disabled - - Raise: - AuthException: raised if creation operation fails - """ - self_provisioning_domains = [] if self_provisioning_domains is None else self_provisioning_domains - - await self._http.post( - MgmtV1.tenant_update_path, - body=TenantBase._compose_create_update_body( - name, - id, - self_provisioning_domains, - custom_attributes, - enforce_sso, - enforce_sso_exclusions, - federated_app_ids, - disabled, - ), - ) - - async def patch_tenant( - self, - id: str, - name: Optional[str] = None, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - disabled: Optional[bool] = None, - enforce_sso: Optional[bool] = None, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - role_inheritance: Optional[str] = None, - ) -> None: - """ - Patch an existing tenant. Only the given fields will be updated; omitted fields are left unchanged. - - Args: - id (str): The ID of the tenant to patch. - name (str): Optional updated tenant name. - self_provisioning_domains (List[str]): Optional list of domains associated with this tenant. - Users authenticating from these domains will be associated with this tenant. - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - disabled (bool): Optional, login to the tenant will be disabled - enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso - enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement - federated_app_ids (List[str]): Optional, list of federated application IDs - role_inheritance (str): Optional, role inheritance setting for the tenant - - Raise: - AuthException: raised if patch operation fails - """ - await self._http.patch( - MgmtV1.tenant_patch_path, - body=TenantBase._compose_patch_body( - id, - name, - self_provisioning_domains, - custom_attributes, - disabled, - enforce_sso, - enforce_sso_exclusions, - federated_app_ids, - role_inheritance, - ), - ) - - async def update_settings( - self, - id: str, - self_provisioning_domains: List[str], - domains: Optional[List[str]] = None, - auth_type: Optional[TenantAuthType] = None, - session_settings_enabled: Optional[bool] = None, - refresh_token_expiration: Optional[int] = None, - refresh_token_expiration_unit: Optional[SessionExpirationUnit] = None, - session_token_expiration: Optional[int] = None, - session_token_expiration_unit: Optional[SessionExpirationUnit] = None, - stepup_token_expiration: Optional[int] = None, - stepup_token_expiration_unit: Optional[SessionExpirationUnit] = None, - enable_inactivity: Optional[bool] = None, - inactivity_time: Optional[int] = None, - inactivity_time_unit: Optional[SessionExpirationUnit] = None, - JITDisabled: Optional[bool] = None, - sso_setup_suite_settings: Optional[SSOSetupSuiteSettings] = None, - enforce_sso: Optional[bool] = None, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - ): - """ - Update an existing tenant's session settings. - - Args: - id (str): The ID of the tenant to update. - self_provisioning_domains (List[str]): Domains for self-provisioning. - domains (Optional[List[str]]): List of domains associated with the tenant. - auth_type (Optional[TenantAuthType]): Authentication type for the tenant. - session_settings_enabled (Optional[bool]): Whether session settings are enabled. - refresh_token_expiration (Optional[int]): Expiration time for refresh tokens. - refresh_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for refresh token expiration. - session_token_expiration (Optional[int]): Expiration time for session tokens. - session_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for session token expiration. - stepup_token_expiration (Optional[int]): Expiration time for step-up tokens. - stepup_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for step-up token expiration. - enable_inactivity (Optional[bool]): Whether inactivity timeout is enabled. - inactivity_time (Optional[int]): Inactivity timeout duration. - inactivity_time_unit (Optional[SessionExpirationUnit]): Unit for inactivity timeout. - JITDisabled (Optional[bool]): Whether JIT is disabled. - sso_setup_suite_settings (Optional[SSOSetupSuiteSettings]): SSO Setup Suite configuration. - enforce_sso (Optional[bool]): Whether to enforce SSO for the tenant. - enforce_sso_exclusions (Optional[List[str]]): List of user IDs excluded from SSO enforcement. - federated_app_ids (Optional[List[str]]): List of federated application IDs. - - Raise: - AuthException: raised if update operation fails - """ - body: dict[str, Any] = { - "tenantId": id, - "selfProvisioningDomains": self_provisioning_domains, - "domains": domains, - "authType": auth_type, - "enabled": session_settings_enabled, - "refreshTokenExpiration": refresh_token_expiration, - "refreshTokenExpirationUnit": refresh_token_expiration_unit, - "sessionTokenExpiration": session_token_expiration, - "sessionTokenExpirationUnit": session_token_expiration_unit, - "stepupTokenExpiration": stepup_token_expiration, - "stepupTokenExpirationUnit": stepup_token_expiration_unit, - "enableInactivity": enable_inactivity, - "inactivityTime": inactivity_time, - "inactivityTimeUnit": inactivity_time_unit, - "JITDisabled": JITDisabled, - "ssoSetupSuiteSettings": (sso_setup_suite_settings.to_dict() if sso_setup_suite_settings else None), - "enforceSSO": enforce_sso, - "enforceSSOExclusions": enforce_sso_exclusions, - "federatedAppIds": federated_app_ids, - } - - body = {k: v for k, v in body.items() if v is not None} - - await self._http.post(MgmtV1.tenant_settings_path, body=body, params=None) - - async def update_default_roles( - self, - tenant_id: str, - role_names: List[str], - ) -> None: - """ - Set which project default roles apply to new users in this tenant. - - Args: - tenant_id (str): The ID of the tenant to update. - role_names (List[str]): List of role names to set as tenant default roles. - - Raise: - AuthException: raised if update operation fails - """ - await self._http.post( - MgmtV1.tenant_update_default_roles_path, - body={"id": tenant_id, "defaultRoles": role_names}, - ) - - async def delete( - self, - id: str, - cascade: bool = False, - ): - """ - Delete an existing tenant. IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the tenant that's to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - await self._http.post( - MgmtV1.tenant_delete_path, - body={"id": id, "cascade": cascade}, - ) - - async def load( - self, - id: str, - ) -> dict: - """ - Load tenant by id. - - Args: - id (str): The ID of the tenant to load. - - Return value (dict): - Return dict in the format - {"id": , "name": , "selfProvisioningDomains": [], "customAttributes: {}, "createdTime": } - Containing the loaded tenant information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - MgmtV1.tenant_load_path, - params={"id": id}, - ) - return response.json() - - async def load_settings( - self, - id: str, - ) -> dict: - """ - Load tenant session settings by id. - - Args: - id (str): The ID of the tenant to load session settings for. - - Return value (dict): - Return dict in the format - { "domains":, "selfProvisioningDomains":, "authType":, - "enabled":, "refreshTokenExpiration":, "refreshTokenExpirationUnit":, - "sessionTokenExpiration":, "sessionTokenExpirationUnit":, - "stepupTokenExpiration":, "stepupTokenExpirationUnit":, - "enableInactivity":, "inactivityTime":, "inactivityTimeUnit":, - "JITDisabled":, "ssoSetupSuiteSettings": } - Containing the loaded tenant session settings. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - MgmtV1.tenant_settings_path, - params={"id": id}, - ) - return response.json() - - async def load_all( - self, - ) -> dict: - """ - Load all tenants. - - Return value (dict): - Return dict in the format - {"tenants": [{"id": , "name": , "selfProvisioningDomains": [], customAttributes: {}, "createdTime": }]} - Containing the loaded tenant information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - MgmtV1.tenant_load_all_path, - ) - return response.json() - - async def search_all( - self, - ids: Optional[List[str]] = None, - names: Optional[List[str]] = None, - self_provisioning_domains: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - """ - Search all tenants. - - Args: - ids (List[str]): Optional list of tenant IDs to filter by - names (List[str]): Optional list of names to filter by - self_provisioning_domains (List[str]): Optional list of self provisioning domains to filter by - custom_attributes (dict): Optional search for a attribute with a given value - - Return value (dict): - Return dict in the format - {"tenants": [{"id": , "name": , "selfProvisioningDomains": [], customAttributes:{}}]} - Containing the loaded tenant information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.post( - MgmtV1.tenant_search_all_path, - body={ - "tenantIds": ids, - "tenantNames": names, - "tenantSelfProvisioningDomains": self_provisioning_domains, - "customAttributes": custom_attributes, - }, - ) - return response.json() - - async def generate_sso_configuration_link( - self, - tenant_id: str, - expire_time: Optional[int] = None, - email: Optional[str] = None, - sso_id: Optional[str] = None, - actor_id: Optional[str] = None, - ) -> str: - """ - Generate a tenant admin self-service link for SSO configuration. - - Args: - tenant_id (str): Tenant ID to generate the link for. - expire_time (int): Optional expiration duration in seconds. For a link valid for 6 hours, use 21600. - email (str): Optional email address associated with the admin. - sso_id (str): Optional SSO identifier for the tenant. - actor_id (str): Optional id recorded as the audit actor for actions performed inside the - SSO Setup Suite (instead of the temporary user). It is used as-is for audit attribution - and is not validated. - - Return value (str): - Returns the admin SSO configuration link as a string. - - Raise: - AuthException: raised if generation operation fails - """ - body: dict[str, Any] = {"tenantId": tenant_id} - if expire_time is not None: - body["expireTime"] = expire_time - if email is not None: - body["email"] = email - if sso_id is not None: - body["ssoId"] = sso_id - if actor_id is not None: - body["actorId"] = actor_id - - response = await self._http.post( - MgmtV1.tenant_generate_sso_configuration_link_path, - body=body, - ) - result = response.json() - return result.get("adminSSOConfigurationLink", "") - - async def revoke_sso_configuration_link( - self, - tenant_id: str, - sso_id: Optional[str] = None, - ): - """ - Revoke a tenant admin self-service link for SSO configuration. - - Args: - tenant_id (str): Tenant ID to revoke the link for. - sso_id (str): Optional SSO identifier for the tenant. - - Raise: - AuthException: raised if revoke operation fails - """ - body: dict[str, Any] = {"tenantId": tenant_id} - if sso_id is not None: - body["ssoId"] = sso_id - - await self._http.post( - MgmtV1.tenant_revoke_sso_configuration_link_path, - body=body, - ) diff --git a/descope/management/third_party_application.py b/descope/management/third_party_application.py deleted file mode 100644 index 5970cacef..000000000 --- a/descope/management/third_party_application.py +++ /dev/null @@ -1,411 +0,0 @@ -from typing import Any, Dict, List, Optional - -from descope._http_base import HTTPBase -from descope.management._third_party_application_base import compose_create_update_body -from descope.management.common import MgmtV1 - - -class ThirdPartyApplication(HTTPBase): - def create( - self, - name: str, - login_page_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - approved_callback_urls: Optional[List[str]] = None, - permissions_scopes: Optional[List[Dict[str, Any]]] = None, - attributes_scopes: Optional[List[Dict[str, Any]]] = None, - jwt_bearer_settings: Optional[Dict[str, Any]] = None, - custom_attributes: Optional[Dict[str, Any]] = None, - force_pkce: Optional[bool] = None, - default_audience: Optional[str] = None, - client_type: Optional[str] = None, - ) -> dict: - """ - Create a new third party application with the given parameters. - - Args: - name (str): The third party application's name (must be unique per project). - login_page_url (str): The URL where login page is hosted. - description (str): Optional third party application description. - logo (str): Optional third party application logo. - approved_callback_urls (List[str]): Optional list of approved callback URLs. - permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. - Each scope is a dict with keys: name, description, values. - attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. - Each scope is a dict with keys: name, description, values. - jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings which used to validate external token. - Dict with key 'issuers' mapping to issuer settings. - custom_attributes (Dict[str, Any]): Optional custom attributes. - force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. - default_audience (str): Optional default aud of issued tokens: "projectId", "clientId", or "" (both). - client_type (str): Optional OAuth client type, "confidential" or "public". If omitted, the account's - default client type is used; the created app's client type shows which one it got. - - Return value (dict): - Return dict in the format - {"id": "", "cleartext": ""} - Containing the created third party application ID and secret. - - Raise: - AuthException: raised if creation operation fails - """ - body = compose_create_update_body( - name=name, - login_page_url=login_page_url, - description=description, - logo=logo, - approved_callback_urls=approved_callback_urls, - permissions_scopes=permissions_scopes, - attributes_scopes=attributes_scopes, - jwt_bearer_settings=jwt_bearer_settings, - custom_attributes=custom_attributes, - force_pkce=force_pkce, - default_audience=default_audience, - client_type=client_type, - ) - response = self._http.post(MgmtV1.thirdparty_application_create_path, body=body) - return response.json() - - def update( - self, - id: str, - name: str, - login_page_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - approved_callback_urls: Optional[List[str]] = None, - permissions_scopes: Optional[List[Dict[str, Any]]] = None, - attributes_scopes: Optional[List[Dict[str, Any]]] = None, - jwt_bearer_settings: Optional[Dict[str, Any]] = None, - custom_attributes: Optional[Dict[str, Any]] = None, - force_pkce: Optional[bool] = None, - default_audience: Optional[str] = None, - client_type: Optional[str] = None, - ): - """ - Update an existing third party application with the given parameters. - IMPORTANT: All parameters are required and will override whatever value is currently - set in the existing third party application. Use carefully. - - Args: - id (str): The ID of the third party application to update. - name (str): Updated third party application name. - login_page_url (str): The URL where login page is hosted. - description (str): Optional third party application description. - logo (str): Optional third party application logo. - approved_callback_urls (List[str]): Optional list of approved callback URLs. - permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. - attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. - jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings. - custom_attributes (Dict[str, Any]): Optional custom attributes. - force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. - default_audience (str): Optional default aud of issued tokens. - client_type (str): Optional OAuth client type, "confidential" or "public". Update overrides all fields, - so pass the app's current client type. - - Raise: - AuthException: raised if update operation fails - """ - body = compose_create_update_body( - name=name, - login_page_url=login_page_url, - id=id, - description=description, - logo=logo, - approved_callback_urls=approved_callback_urls, - permissions_scopes=permissions_scopes, - attributes_scopes=attributes_scopes, - jwt_bearer_settings=jwt_bearer_settings, - custom_attributes=custom_attributes, - force_pkce=force_pkce, - default_audience=default_audience, - client_type=client_type, - ) - self._http.post(MgmtV1.thirdparty_application_update_path, body=body) - - def patch( - self, - id: str, - name: Optional[str] = None, - login_page_url: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - approved_callback_urls: Optional[List[str]] = None, - permissions_scopes: Optional[List[Dict[str, Any]]] = None, - attributes_scopes: Optional[List[Dict[str, Any]]] = None, - jwt_bearer_settings: Optional[Dict[str, Any]] = None, - custom_attributes: Optional[Dict[str, Any]] = None, - force_pkce: Optional[bool] = None, - default_audience: Optional[str] = None, - client_type: Optional[str] = None, - ): - """ - Patch an existing third party application with the given parameters. - Only provided fields will be updated. - - Args: - id (str): The ID of the third party application to patch (required). - name (str): Optional updated third party application name. - login_page_url (str): Optional URL where login page is hosted. - description (str): Optional third party application description. - logo (str): Optional third party application logo. - approved_callback_urls (List[str]): Optional list of approved callback URLs. - permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. - attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. - jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings. - custom_attributes (Dict[str, Any]): Optional custom attributes. - force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. - default_audience (str): Optional default aud of issued tokens. - client_type (str): Optional OAuth client type, "confidential" or "public". Left unchanged when omitted. - - Raise: - AuthException: raised if patch operation fails - """ - body: Dict[str, Any] = {"id": id} - if name is not None: - body["name"] = name - if login_page_url is not None: - body["loginPageUrl"] = login_page_url - if description is not None: - body["description"] = description - if logo is not None: - body["logo"] = logo - if approved_callback_urls is not None: - body["approvedCallbackUrls"] = approved_callback_urls - if permissions_scopes is not None: - body["permissionsScopes"] = permissions_scopes - if attributes_scopes is not None: - body["attributesScopes"] = attributes_scopes - if jwt_bearer_settings is not None: - body["jwtBearerSettings"] = jwt_bearer_settings - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if force_pkce is not None: - body["forcePkce"] = force_pkce - if default_audience is not None: - body["defaultAudience"] = default_audience - if client_type is not None: - body["clientType"] = client_type - self._http.post(MgmtV1.thirdparty_application_patch_path, body=body) - - def delete(self, id: str): - """ - Delete an existing third party application. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the third party application to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post(MgmtV1.thirdparty_application_delete_path, body={"id": id}) - - def delete_batch(self, ids: List[str]): - """ - Delete multiple third party applications by id in a single request. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - ids (List[str]): List of third party application IDs to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post(MgmtV1.thirdparty_application_delete_batch_path, body={"ids": ids}) - - def load(self, id: str) -> dict: - """ - Load third party application by id. - - Args: - id (str): The ID of the third party application to load. - - Return value (dict): - Return dict containing the loaded third party application information with keys: - id, name, description, logo, loginPageUrl, clientId, approvedCallbackUrls, - permissionsScopes, attributesScopes, jwtBearerSettings, customAttributes, - forcePkce, defaultAudience. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.thirdparty_application_load_path, params={"id": id}) - return response.json() - - def load_all(self) -> dict: - """ - Load all third party applications. - - Return value (dict): - Return dict in the format - {"apps": []} - Containing all third party applications. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get(MgmtV1.thirdparty_application_load_all_path) - return response.json() - - def rotate_secret(self, id: str) -> dict: - """ - Rotate the application secret for a third party application by the application id. - - Args: - id (str): The ID of the third party application. - - Return value (dict): - Return dict in the format - {"cleartext": ""} - Containing the new application secret. - - Raise: - AuthException: raised if rotation operation fails - """ - response = self._http.post(MgmtV1.thirdparty_application_rotate_path, body={"id": id}) - return response.json() - - def get_secret(self, id: str) -> dict: - """ - Get the application secret for a third party application by the application id. - - Args: - id (str): The ID of the third party application. - - Return value (dict): - Return dict in the format - {"cleartext": ""} - Containing the application secret. - - Raise: - AuthException: raised if get operation fails - """ - response = self._http.get(MgmtV1.thirdparty_application_secret_path, params={"id": id}) - return response.json() - - def create_consent( - self, - app_id: str, - user_id: str, - scopes: List[str], - tenant_id: Optional[str] = None, - expire_time: Optional[int] = None, - ) -> dict: - """ - Create a user consent for a third party application, e.g. to migrate consents a user - already granted in another system so the user is not asked to consent again. - The scopes are merged into the user's existing consent for the same application and tenant. - Consents of agentic applications cannot be created. - - Args: - app_id (str): The third party application ID. - user_id (str): The ID of the user who granted the consent. - scopes (List[str]): The consented scopes. Each scope must be defined by the application, - and the application's mandatory scopes must be included (all of its scopes when it - does not allow partial scopes). - tenant_id (str): Optional tenant the consent is bound to. The user must be a member of this tenant. - expire_time (int): Optional expiration time of the consent, in seconds since the epoch. - 0 means the consent never expires. - - Return value (dict): - Return dict in the format - {"consent": {"id": "", "appId": "", "userId": "", "scopes": [], "grantedBy": "", - "createdTime": 0, "tenantId": "", "ExpireTime": 0, ...}} - Containing the created (or merged) consent. - - Raise: - AuthException: raised if creation operation fails - """ - body: Dict[str, Any] = {"appId": app_id, "userId": user_id, "scopes": scopes} - if tenant_id is not None: - body["tenantId"] = tenant_id - if expire_time is not None: - body["expireTime"] = expire_time - response = self._http.post(MgmtV1.thirdparty_consents_create_path, body=body) - return response.json() - - def delete_consents( - self, - consent_ids: Optional[List[str]] = None, - app_id: Optional[str] = None, - user_ids: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - ): - """ - Delete consents for third party applications. - - Args: - consent_ids (List[str]): Optional list of consent IDs to delete. - app_id (str): Optional application ID to filter by. - user_ids (List[str]): Optional list of user IDs to filter by. - tenant_id (str): Optional tenant ID to filter by. - - Raise: - AuthException: raised if deletion operation fails - """ - body: Dict[str, Any] = {} - if consent_ids is not None: - body["consentIds"] = consent_ids - if app_id is not None: - body["appId"] = app_id - if user_ids is not None: - body["userIds"] = user_ids - if tenant_id is not None: - body["tenantId"] = tenant_id - self._http.post(MgmtV1.thirdparty_consents_delete_path, body=body) - - def delete_tenant_consents(self, tenant_id: str): - """ - Delete all consents for a specific tenant. - - Args: - tenant_id (str): The tenant ID. - - Raise: - AuthException: raised if deletion operation fails - """ - self._http.post(MgmtV1.thirdparty_consents_delete_tenant_path, body={"tenantId": tenant_id}) - - def search_consents( - self, - app_id: Optional[str] = None, - user_id: Optional[str] = None, - consent_id: Optional[str] = None, - page: Optional[int] = None, - limit: Optional[int] = None, - tenant_id: Optional[str] = None, - ) -> dict: - """ - Search consents for third party applications. - - Args: - app_id (str): Optional application ID to filter by. - user_id (str): Optional user ID to filter by. - consent_id (str): Optional consent ID to filter by. - page (int): Optional page number for pagination. - limit (int): Optional limit for pagination. - tenant_id (str): Optional tenant ID to filter by. - - Return value (dict): - Return dict containing the search results with consent information. - - Raise: - AuthException: raised if search operation fails - """ - body: Dict[str, Any] = {} - if app_id is not None: - body["appId"] = app_id - if user_id is not None: - body["userId"] = user_id - if consent_id is not None: - body["consentId"] = consent_id - if page is not None: - body["page"] = page - if limit is not None: - body["limit"] = limit - if tenant_id is not None: - body["tenantId"] = tenant_id - response = self._http.post(MgmtV1.thirdparty_consents_search_path, body=body) - return response.json() diff --git a/descope/management/third_party_application_async.py b/descope/management/third_party_application_async.py deleted file mode 100644 index 741052fa0..000000000 --- a/descope/management/third_party_application_async.py +++ /dev/null @@ -1,413 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, List, Optional - -from descope._http_base import AsyncHTTPBase -from descope.management._third_party_application_base import compose_create_update_body -from descope.management.common import MgmtV1 - - -class ThirdPartyApplicationAsync(AsyncHTTPBase): - async def create( - self, - name: str, - login_page_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - approved_callback_urls: Optional[List[str]] = None, - permissions_scopes: Optional[List[Dict[str, Any]]] = None, - attributes_scopes: Optional[List[Dict[str, Any]]] = None, - jwt_bearer_settings: Optional[Dict[str, Any]] = None, - custom_attributes: Optional[Dict[str, Any]] = None, - force_pkce: Optional[bool] = None, - default_audience: Optional[str] = None, - client_type: Optional[str] = None, - ) -> dict: - """ - Create a new third party application with the given parameters. - - Args: - name (str): The third party application's name (must be unique per project). - login_page_url (str): The URL where login page is hosted. - description (str): Optional third party application description. - logo (str): Optional third party application logo. - approved_callback_urls (List[str]): Optional list of approved callback URLs. - permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. - Each scope is a dict with keys: name, description, values. - attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. - Each scope is a dict with keys: name, description, values. - jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings which used to validate external token. - Dict with key 'issuers' mapping to issuer settings. - custom_attributes (Dict[str, Any]): Optional custom attributes. - force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. - default_audience (str): Optional default aud of issued tokens: "projectId", "clientId", or "" (both). - client_type (str): Optional OAuth client type, "confidential" or "public". If omitted, the account's - default client type is used; the created app's client type shows which one it got. - - Return value (dict): - Return dict in the format - {"id": "", "cleartext": ""} - Containing the created third party application ID and secret. - - Raise: - AuthException: raised if creation operation fails - """ - body = compose_create_update_body( - name=name, - login_page_url=login_page_url, - description=description, - logo=logo, - approved_callback_urls=approved_callback_urls, - permissions_scopes=permissions_scopes, - attributes_scopes=attributes_scopes, - jwt_bearer_settings=jwt_bearer_settings, - custom_attributes=custom_attributes, - force_pkce=force_pkce, - default_audience=default_audience, - client_type=client_type, - ) - response = await self._http.post(MgmtV1.thirdparty_application_create_path, body=body) - return response.json() - - async def update( - self, - id: str, - name: str, - login_page_url: str, - description: Optional[str] = None, - logo: Optional[str] = None, - approved_callback_urls: Optional[List[str]] = None, - permissions_scopes: Optional[List[Dict[str, Any]]] = None, - attributes_scopes: Optional[List[Dict[str, Any]]] = None, - jwt_bearer_settings: Optional[Dict[str, Any]] = None, - custom_attributes: Optional[Dict[str, Any]] = None, - force_pkce: Optional[bool] = None, - default_audience: Optional[str] = None, - client_type: Optional[str] = None, - ): - """ - Update an existing third party application with the given parameters. - IMPORTANT: All parameters are required and will override whatever value is currently - set in the existing third party application. Use carefully. - - Args: - id (str): The ID of the third party application to update. - name (str): Updated third party application name. - login_page_url (str): The URL where login page is hosted. - description (str): Optional third party application description. - logo (str): Optional third party application logo. - approved_callback_urls (List[str]): Optional list of approved callback URLs. - permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. - attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. - jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings. - custom_attributes (Dict[str, Any]): Optional custom attributes. - force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. - default_audience (str): Optional default aud of issued tokens. - client_type (str): Optional OAuth client type, "confidential" or "public". Update overrides all fields, - so pass the app's current client type. - - Raise: - AuthException: raised if update operation fails - """ - body = compose_create_update_body( - name=name, - login_page_url=login_page_url, - id=id, - description=description, - logo=logo, - approved_callback_urls=approved_callback_urls, - permissions_scopes=permissions_scopes, - attributes_scopes=attributes_scopes, - jwt_bearer_settings=jwt_bearer_settings, - custom_attributes=custom_attributes, - force_pkce=force_pkce, - default_audience=default_audience, - client_type=client_type, - ) - await self._http.post(MgmtV1.thirdparty_application_update_path, body=body) - - async def patch( - self, - id: str, - name: Optional[str] = None, - login_page_url: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - approved_callback_urls: Optional[List[str]] = None, - permissions_scopes: Optional[List[Dict[str, Any]]] = None, - attributes_scopes: Optional[List[Dict[str, Any]]] = None, - jwt_bearer_settings: Optional[Dict[str, Any]] = None, - custom_attributes: Optional[Dict[str, Any]] = None, - force_pkce: Optional[bool] = None, - default_audience: Optional[str] = None, - client_type: Optional[str] = None, - ): - """ - Patch an existing third party application with the given parameters. - Only provided fields will be updated. - - Args: - id (str): The ID of the third party application to patch (required). - name (str): Optional updated third party application name. - login_page_url (str): Optional URL where login page is hosted. - description (str): Optional third party application description. - logo (str): Optional third party application logo. - approved_callback_urls (List[str]): Optional list of approved callback URLs. - permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. - attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. - jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings. - custom_attributes (Dict[str, Any]): Optional custom attributes. - force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. - default_audience (str): Optional default aud of issued tokens. - client_type (str): Optional OAuth client type, "confidential" or "public". Left unchanged when omitted. - - Raise: - AuthException: raised if patch operation fails - """ - body: Dict[str, Any] = {"id": id} - if name is not None: - body["name"] = name - if login_page_url is not None: - body["loginPageUrl"] = login_page_url - if description is not None: - body["description"] = description - if logo is not None: - body["logo"] = logo - if approved_callback_urls is not None: - body["approvedCallbackUrls"] = approved_callback_urls - if permissions_scopes is not None: - body["permissionsScopes"] = permissions_scopes - if attributes_scopes is not None: - body["attributesScopes"] = attributes_scopes - if jwt_bearer_settings is not None: - body["jwtBearerSettings"] = jwt_bearer_settings - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if force_pkce is not None: - body["forcePkce"] = force_pkce - if default_audience is not None: - body["defaultAudience"] = default_audience - if client_type is not None: - body["clientType"] = client_type - await self._http.post(MgmtV1.thirdparty_application_patch_path, body=body) - - async def delete(self, id: str): - """ - Delete an existing third party application. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - id (str): The ID of the third party application to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post(MgmtV1.thirdparty_application_delete_path, body={"id": id}) - - async def delete_batch(self, ids: List[str]): - """ - Delete multiple third party applications by id in a single request. - IMPORTANT: This action is irreversible. Use carefully. - - Args: - ids (List[str]): List of third party application IDs to delete. - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post(MgmtV1.thirdparty_application_delete_batch_path, body={"ids": ids}) - - async def load(self, id: str) -> dict: - """ - Load third party application by id. - - Args: - id (str): The ID of the third party application to load. - - Return value (dict): - Return dict containing the loaded third party application information with keys: - id, name, description, logo, loginPageUrl, clientId, approvedCallbackUrls, - permissionsScopes, attributesScopes, jwtBearerSettings, customAttributes, - forcePkce, defaultAudience. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.thirdparty_application_load_path, params={"id": id}) - return response.json() - - async def load_all(self) -> dict: - """ - Load all third party applications. - - Return value (dict): - Return dict in the format - {"apps": []} - Containing all third party applications. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get(MgmtV1.thirdparty_application_load_all_path) - return response.json() - - async def rotate_secret(self, id: str) -> dict: - """ - Rotate the application secret for a third party application by the application id. - - Args: - id (str): The ID of the third party application. - - Return value (dict): - Return dict in the format - {"cleartext": ""} - Containing the new application secret. - - Raise: - AuthException: raised if rotation operation fails - """ - response = await self._http.post(MgmtV1.thirdparty_application_rotate_path, body={"id": id}) - return response.json() - - async def get_secret(self, id: str) -> dict: - """ - Get the application secret for a third party application by the application id. - - Args: - id (str): The ID of the third party application. - - Return value (dict): - Return dict in the format - {"cleartext": ""} - Containing the application secret. - - Raise: - AuthException: raised if get operation fails - """ - response = await self._http.get(MgmtV1.thirdparty_application_secret_path, params={"id": id}) - return response.json() - - async def create_consent( - self, - app_id: str, - user_id: str, - scopes: List[str], - tenant_id: Optional[str] = None, - expire_time: Optional[int] = None, - ) -> dict: - """ - Create a user consent for a third party application, e.g. to migrate consents a user - already granted in another system so the user is not asked to consent again. - The scopes are merged into the user's existing consent for the same application and tenant. - Consents of agentic applications cannot be created. - - Args: - app_id (str): The third party application ID. - user_id (str): The ID of the user who granted the consent. - scopes (List[str]): The consented scopes. Each scope must be defined by the application, - and the application's mandatory scopes must be included (all of its scopes when it - does not allow partial scopes). - tenant_id (str): Optional tenant the consent is bound to. The user must be a member of this tenant. - expire_time (int): Optional expiration time of the consent, in seconds since the epoch. - 0 means the consent never expires. - - Return value (dict): - Return dict in the format - {"consent": {"id": "", "appId": "", "userId": "", "scopes": [], "grantedBy": "", - "createdTime": 0, "tenantId": "", "ExpireTime": 0, ...}} - Containing the created (or merged) consent. - - Raise: - AuthException: raised if creation operation fails - """ - body: Dict[str, Any] = {"appId": app_id, "userId": user_id, "scopes": scopes} - if tenant_id is not None: - body["tenantId"] = tenant_id - if expire_time is not None: - body["expireTime"] = expire_time - response = await self._http.post(MgmtV1.thirdparty_consents_create_path, body=body) - return response.json() - - async def delete_consents( - self, - consent_ids: Optional[List[str]] = None, - app_id: Optional[str] = None, - user_ids: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - ): - """ - Delete consents for third party applications. - - Args: - consent_ids (List[str]): Optional list of consent IDs to delete. - app_id (str): Optional application ID to filter by. - user_ids (List[str]): Optional list of user IDs to filter by. - tenant_id (str): Optional tenant ID to filter by. - - Raise: - AuthException: raised if deletion operation fails - """ - body: Dict[str, Any] = {} - if consent_ids is not None: - body["consentIds"] = consent_ids - if app_id is not None: - body["appId"] = app_id - if user_ids is not None: - body["userIds"] = user_ids - if tenant_id is not None: - body["tenantId"] = tenant_id - await self._http.post(MgmtV1.thirdparty_consents_delete_path, body=body) - - async def delete_tenant_consents(self, tenant_id: str): - """ - Delete all consents for a specific tenant. - - Args: - tenant_id (str): The tenant ID. - - Raise: - AuthException: raised if deletion operation fails - """ - await self._http.post(MgmtV1.thirdparty_consents_delete_tenant_path, body={"tenantId": tenant_id}) - - async def search_consents( - self, - app_id: Optional[str] = None, - user_id: Optional[str] = None, - consent_id: Optional[str] = None, - page: Optional[int] = None, - limit: Optional[int] = None, - tenant_id: Optional[str] = None, - ) -> dict: - """ - Search consents for third party applications. - - Args: - app_id (str): Optional application ID to filter by. - user_id (str): Optional user ID to filter by. - consent_id (str): Optional consent ID to filter by. - page (int): Optional page number for pagination. - limit (int): Optional limit for pagination. - tenant_id (str): Optional tenant ID to filter by. - - Return value (dict): - Return dict containing the search results with consent information. - - Raise: - AuthException: raised if search operation fails - """ - body: Dict[str, Any] = {} - if app_id is not None: - body["appId"] = app_id - if user_id is not None: - body["userId"] = user_id - if consent_id is not None: - body["consentId"] = consent_id - if page is not None: - body["page"] = page - if limit is not None: - body["limit"] = limit - if tenant_id is not None: - body["tenantId"] = tenant_id - response = await self._http.post(MgmtV1.thirdparty_consents_search_path, body=body) - return response.json() diff --git a/descope/management/user.py b/descope/management/user.py deleted file mode 100644 index 0742c66fc..000000000 --- a/descope/management/user.py +++ /dev/null @@ -1,2249 +0,0 @@ -import base64 -from typing import List, Optional, Union - -from descope._http_base import HTTPBase -from descope.common import DeliveryMethod, LoginOptions, get_method_string -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -from descope.management._user_base import CreateUserObj, UserBase, UserObj -from descope.management.common import ( - AssociatedFamily, - AssociatedTenant, - CustomAttribute, - MgmtV1, - Sort, - associated_families_to_dict, - custom_attributes_to_dict, - sort_to_dict, -) - - -class User(UserBase, HTTPBase): - def create( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - invite_url: Optional[str] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - status: Optional[str] = None, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Create a new user. Users can have any number of optional fields, including email, phone number and authorization. - - Args: - login_id (str): user login ID. - email (str): Optional user email address. - phone (str): Optional user phone number. - display_name (str): Optional user display name. - role_names (List[str]): An optional list of the user's roles without tenant association. These roles are - mutually exclusive with the `user_tenant` roles. - user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`. - picture (str): Optional url for user picture - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. - status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". - family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and - family-scoped attribute values per family. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the created user information. - - Raise: - AuthException: raised if create operation fails - """ - UserBase._validate_status(status) - role_names = [] if role_names is None else role_names - user_tenants = [] if user_tenants is None else user_tenants - - response = self._http.post( - MgmtV1.user_create_path, - body=UserBase._compose_create_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - False, - False, - picture, - custom_attributes, - verified_email, - verified_phone, - invite_url, - None, - None, - additional_login_ids, - sso_app_ids, - status=status, - family_associations=family_associations, - ), - ) - return response.json() - - def create_test_user( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - invite_url: Optional[str] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - status: Optional[str] = None, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Create a new test user. - The login_id is required and will determine what the user will use to sign in. - Make sure the login id is unique for test. All other fields are optional. - - Args: - login_id (str): user login ID. - email (str): Optional user email address. - phone (str): Optional user phone number. - display_name (str): Optional user display name. - role_names (List[str]): An optional list of the user's roles without tenant association. These roles are - mutually exclusive with the `user_tenant` roles. - user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`. - picture (str): Optional url for user picture - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. - status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". - family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and - family-scoped attribute values per family. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the created test user information. - - Raise: - AuthException: raised if create operation fails - """ - UserBase._validate_status(status) - role_names = [] if role_names is None else role_names - user_tenants = [] if user_tenants is None else user_tenants - - response = self._http.post( - MgmtV1.test_user_create_path, - body=UserBase._compose_create_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - False, - True, - picture, - custom_attributes, - verified_email, - verified_phone, - invite_url, - None, - None, - additional_login_ids, - sso_app_ids, - status=status, - family_associations=family_associations, - ), - ) - return response.json() - - def invite( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - invite_url: Optional[str] = None, - send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings - send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - template_id: str = "", - test: bool = False, - locale: Optional[str] = None, # locale for the invite message - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Create a new user and invite them via an email / text message. - - Functions exactly the same as the `create` function with the additional invitation - behavior. See the documentation above for the general creation behavior. - - IMPORTANT: Since the invitation is sent by email / phone, make sure either - the email / phone is explicitly set, or the login_id itself is an email address / phone number. - You must configure the invitation URL in the Descope console prior to - calling the method. - """ - role_names = [] if role_names is None else role_names - user_tenants = [] if user_tenants is None else user_tenants - - response = self._http.post( - MgmtV1.user_create_path, - body=UserBase._compose_create_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - True, - test, - picture, - custom_attributes, - verified_email, - verified_phone, - invite_url, - send_mail, - send_sms, - additional_login_ids, - sso_app_ids, - template_id, - locale, - family_associations=family_associations, - ), - ) - return response.json() - - def invite_batch( - self, - users: List[UserObj], - invite_url: Optional[str] = None, - send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings - send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings - locale: Optional[str] = None, # locale for the invite message - template_options: Optional[dict] = None, # for providing messaging template options - template_id: str = "", # for overriding the default messaging template - ) -> dict: - """ - Create users in batch and invite them via an email / text message. - - Functions exactly the same as the `create` function with the additional invitation - behavior. See the documentation above for the general creation behavior. - - IMPORTANT: Since the invitation is sent by email / phone, make sure either - the email / phone is explicitly set, or the login_id itself is an email address / phone number. - You must configure the invitation URL in the Descope console prior to - calling the method. - """ - - response = self._http.post( - MgmtV1.user_create_batch_path, - body=UserBase._compose_create_batch_body( - users, - invite_url, - send_mail, - send_sms, - locale, - template_options=template_options, - template_id=template_id, - ), - ) - return response.json() - - def update( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - test: bool = False, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides - to the existing user. Empty fields will override populated fields, including family memberships: - omitting family_associations removes the user from all of their families, and updating a dependent - requires family_associations with the dependent's family. Use carefully. - Use `patch` for partial updates instead. - - Args: - login_id (str): The login ID of the user to update. - email (str): Optional user email address. - phone (str): Optional user phone number. - display_name (str): Optional user display name. - given_name (str): Optional user given name. - middle_name (str): Optional user middle name. - family_name (str): Optional user family name. - role_names (List[str]): An optional list of the user's roles without tenant association. These roles are - mutually exclusive with the `user_tenant` roles. - user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`. - picture (str): Optional url for user picture - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. - test (bool, optional): Set to True to update a test user. Defaults to False. - family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and - family-scoped attribute values per family. Replaces the user's family memberships, so None or an - empty list removes the user from all families. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if update operation fails - """ - role_names = [] if role_names is None else role_names - user_tenants = [] if user_tenants is None else user_tenants - family_associations = [] if family_associations is None else family_associations - - response = self._http.post( - MgmtV1.user_update_path, - body=UserBase._compose_update_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - test, - picture, - custom_attributes, - verified_email, - verified_phone, - additional_login_ids, - sso_app_ids, - None, - family_associations=family_associations, - ), - ) - return response.json() - - def patch( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - sso_app_ids: Optional[List[str]] = None, - status: Optional[str] = None, - test: bool = False, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Patches an existing user with the given various fields. Only the given fields will be used to update the user. - - Args: - login_id (str): The login ID of the user to update. - email (str): Optional user email address. - phone (str): Optional user phone number. - display_name (str): Optional user display name. - given_name (str): Optional user given name. - middle_name (str): Optional user middle name. - family_name (str): Optional user family name. - role_names (List[str]): An optional list of the user's roles without tenant association. These roles are - mutually exclusive with the `user_tenant` roles. - user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`. - picture (str): Optional url for user picture - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. - status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". - test (bool, optional): Set to True to update a test user. Defaults to False. - family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and - family-scoped attribute values per family. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the patched user information. - - Raise: - AuthException: raised if patch operation fails - """ - UserBase._validate_status(status) - response = self._http.patch( - MgmtV1.user_patch_path, - body=UserBase._compose_patch_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - picture, - custom_attributes, - verified_email, - verified_phone, - sso_app_ids, - status, - test, - family_associations=family_associations, - ), - ) - return response.json() - - def patch_batch( - self, - users: List[UserObj], - test: bool = False, - ) -> dict: - """ - Patch users in batch. Only the provided fields will be updated for each user. - - Args: - users (List[UserObj]): A list of UserObj instances representing users to be patched. - Each UserObj should have a login_id and the fields to be updated. - test (bool, optional): Set to True to patch test users. Defaults to False. - - Return value (dict): - Return dict in the format - {"patchedUsers": [...], "failedUsers": [...]} - "patchedUsers" contains successfully patched users, - "failedUsers" contains users that failed to be patched with error details. - - Raise: - AuthException: raised if patch batch operation fails - """ - for user in users: - UserBase._validate_status(user.status, user.login_id) - - response = self._http.patch( - MgmtV1.user_patch_batch_path, - body=UserBase._compose_patch_batch_body(users, test), - ) - return response.json() - - def delete( - self, - login_id: str, - ): - """ - Delete an existing user. IMPORTANT: This action is irreversible. Use carefully. - - Args: - login_id (str): The login ID of the user to be deleted. - - Raise: - AuthException: raised if delete operation fails - """ - self._http.post( - MgmtV1.user_delete_path, - body={"loginId": login_id}, - ) - - def delete_by_user_id( - self, - user_id: str, - ): - """ - Delete an existing user by user ID. IMPORTANT: This action is irreversible. Use carefully. - - Args: - user_id (str): The user ID from the user's JWT. - - Raise: - AuthException: raised if delete operation fails - """ - self._http.post( - MgmtV1.user_delete_path, - body={"userId": user_id}, - ) - - def delete_all_test_users( - self, - ): - """ - Delete all test users in the project. IMPORTANT: This action is irreversible. Use carefully. - - Raise: - AuthException: raised if delete operation fails - """ - self._http.delete( - MgmtV1.user_delete_all_test_users_path, - ) - - def load( - self, - login_id: str, - ) -> dict: - """ - Load an existing user. - - Args: - login_id (str): The login ID of the user to be loaded. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the loaded user information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - uri=MgmtV1.user_load_path, - params={"loginId": login_id}, - ) - return response.json() - - def load_by_user_id( - self, - user_id: str, - ) -> dict: - """ - Load an existing user by user ID. - The user ID can be found on the user's JWT. - - Args: - user_id (str): The user ID from the user's JWT. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the loaded user information. - - Raise: - AuthException: raised if load operation fails - """ - response = self._http.get( - uri=MgmtV1.user_load_path, - params={"userId": user_id}, - ) - return response.json() - - def logout_user( - self, - login_id: str, - ): - """ - Logout a user from all devices. - - Args: - login_id (str): The login ID of the user to be logged out. - - Raise: - AuthException: raised if logout operation fails - """ - self._http.post( - MgmtV1.user_logout_path, - body={"loginId": login_id}, - ) - - def logout_user_by_user_id( - self, - user_id: str, - ): - """ - Logout a user from all devices. - - Args: - user_id (str): The login ID of the user to be logged out. - - Raise: - AuthException: raised if logout operation fails - """ - self._http.post( - MgmtV1.user_logout_path, - body={"userId": user_id}, - ) - - def load_users( - self, - user_ids: List[str], - include_invalid_users: Optional[bool] = None, - ) -> dict: - """ - Load users by their user IDs. - - Args: - user_ids (List[str]): Optional list of user IDs to filter by - include_invalid_users (bool): Optional flag to include invalid users in the response - - Return value (dict): - Return dict in the format - {"users": []} - "users" contains a list of all of the found users and their information - - Raise: - AuthException: raised if search operation fails - """ - if user_ids is None or len(user_ids) == 0: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "At least one user id needs to be supplied", - ) - - body: dict[str, Union[List[str], bool]] = { - "userIds": user_ids, - } - - if include_invalid_users is not None: - body["includeInvalidUsers"] = include_invalid_users - - response = self._http.post( - MgmtV1.users_load_path, - body=body, - ) - return response.json() - - def search_all( - self, - tenant_ids: Optional[List[str]] = None, - role_names: Optional[List[str]] = None, - limit: int = 0, - page: int = 0, - test_users_only: bool = False, - with_test_user: bool = False, - custom_attributes: Optional[dict] = None, - statuses: Optional[List[str]] = None, - emails: Optional[List[str]] = None, - phones: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - sort: Optional[List[Sort]] = None, - text: Optional[str] = None, - login_ids: Optional[List[str]] = None, - from_created_time: Optional[int] = None, - to_created_time: Optional[int] = None, - from_modified_time: Optional[int] = None, - to_modified_time: Optional[int] = None, - user_ids: Optional[List[str]] = None, - tenant_role_ids: Optional[dict] = None, - tenant_role_names: Optional[dict] = None, - family_ids: Optional[List[str]] = None, - dependent: Optional[bool] = None, - lock_reasons: Optional[List[str]] = None, - temp_lock_reasons: Optional[List[str]] = None, - ) -> dict: - """ - Search all users. - - Args: - tenant_ids (List[str]): Optional list of tenant IDs to filter by - role_names (List[str]): Optional list of role names to filter by - limit (int): Optional limit of the number of users returned. Leave empty for default. - page (int): Optional pagination control. Pages start at 0 and must be non-negative. - test_users_only (bool): Optional filter only test users. - with_test_user (bool): Optional include test users in search. - custom_attributes (dict): Optional search for a attribute with a given value - statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") - emails (List[str]): Optional list of emails to search for - phones (List[str]): Optional list of phones to search for - sso_app_ids (List[str]): Optional list of SSO application IDs to filter by - text (str): Optional string, allows free text search among all user's attributes. - login_ids (List[str]): Optional list of login ids - sort (List[Sort]): Optional List[dict], allows to sort by fields. - from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) - to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) - from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) - to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) - user_ids (List[str]): Optional list of user IDs to filter by - tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. - Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). - tenant_role_names (dict): Optional mapping of tenant ID to list of role names. - Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). - family_ids (List[str]): Optional list of family IDs. Only users that are members of at least one of these families are returned. - dependent (bool): Optional, filter by whether the user is a family dependent (a user with no login credentials of their own). - lock_reasons (List[str]): Optional list of lock reasons. Only users disabled by brute-force protection with one of these - reasons are returned. Allowed values: "password", "totp", "recovery_codes", "security_questions". - temp_lock_reasons (List[str]): Optional list of lock reasons. Only users currently temporarily locked with one of these - reasons are returned. Allowed values: "password", "recovery_codes", "security_questions" (TOTP has no temporary lock). - A user is temporarily locked while its tempLockExpiration (unix seconds, 0 when none) is greater than now. - - Return value (dict): - Return dict in the format - {"users": []} - "users" contains a list of all of the found users and their information - - Raise: - AuthException: raised if search operation fails - """ - tenant_ids = [] if tenant_ids is None else tenant_ids - role_names = [] if role_names is None else role_names - - self._validate_search_pagination(limit, page) - body = { - "tenantIds": tenant_ids, - "roleNames": role_names, - "limit": limit, - "page": page, - "testUsersOnly": test_users_only, - "withTestUser": with_test_user, - } - if statuses is not None: - body["statuses"] = statuses - - if emails is not None: - body["emails"] = emails - - if phones is not None: - body["phones"] = phones - - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - - if sso_app_ids is not None: - body["ssoAppIds"] = sso_app_ids - - if login_ids is not None: - body["loginIds"] = login_ids - - if user_ids is not None: - body["userIds"] = user_ids - - if text is not None: - body["text"] = text - - if sort is not None: - body["sort"] = sort_to_dict(sort) - - if from_created_time is not None: - body["fromCreatedTime"] = from_created_time - if to_created_time is not None: - body["toCreatedTime"] = to_created_time - if from_modified_time is not None: - body["fromModifiedTime"] = from_modified_time - if to_modified_time is not None: - body["toModifiedTime"] = to_modified_time - - if tenant_role_ids is not None: - body["tenantRoleIds"] = tenant_role_ids - if tenant_role_names is not None: - body["tenantRoleNames"] = tenant_role_names - if family_ids is not None: - body["familyIds"] = family_ids - if dependent is not None: - body["dependent"] = dependent - if lock_reasons is not None: - body["lockReasons"] = lock_reasons - if temp_lock_reasons is not None: - body["tempLockReasons"] = temp_lock_reasons - - response = self._http.post( - MgmtV1.users_search_path, - body=body, - ) - return response.json() - - def search_all_test_users( - self, - tenant_ids: Optional[List[str]] = None, - role_names: Optional[List[str]] = None, - limit: int = 0, - page: int = 0, - custom_attributes: Optional[dict] = None, - statuses: Optional[List[str]] = None, - emails: Optional[List[str]] = None, - phones: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - sort: Optional[List[Sort]] = None, - text: Optional[str] = None, - login_ids: Optional[List[str]] = None, - from_created_time: Optional[int] = None, - to_created_time: Optional[int] = None, - from_modified_time: Optional[int] = None, - to_modified_time: Optional[int] = None, - tenant_role_ids: Optional[dict] = None, - tenant_role_names: Optional[dict] = None, - family_ids: Optional[List[str]] = None, - dependent: Optional[bool] = None, - lock_reasons: Optional[List[str]] = None, - temp_lock_reasons: Optional[List[str]] = None, - ) -> dict: - """ - Search all test users. - - Args: - tenant_ids (List[str]): Optional list of tenant IDs to filter by - role_names (List[str]): Optional list of role names to filter by - limit (int): Optional limit of the number of users returned. Leave empty for default. - page (int): Optional pagination control. Pages start at 0 and must be non-negative. - custom_attributes (dict): Optional search for a attribute with a given value - statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") - emails (List[str]): Optional list of emails to search for - phones (List[str]): Optional list of phones to search for - sso_app_ids (List[str]): Optional list of SSO application IDs to filter by - text (str): Optional string, allows free text search among all user's attributes. - login_ids (List[str]): Optional list of login ids - sort (List[Sort]): Optional List[dict], allows to sort by fields. - from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) - to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) - from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) - to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) - tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. - Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). - tenant_role_names (dict): Optional mapping of tenant ID to list of role names. - Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). - family_ids (List[str]): Optional list of family IDs. Only users that are members of at least one of these families are returned. - dependent (bool): Optional, filter by whether the user is a family dependent (a user with no login credentials of their own). - lock_reasons (List[str]): Optional list of lock reasons. Only users disabled by brute-force protection with one of these - reasons are returned. Allowed values: "password", "totp", "recovery_codes", "security_questions". - temp_lock_reasons (List[str]): Optional list of lock reasons. Only users currently temporarily locked with one of these - reasons are returned. Allowed values: "password", "recovery_codes", "security_questions" (TOTP has no temporary lock). - A user is temporarily locked while its tempLockExpiration (unix seconds, 0 when none) is greater than now. - - Return value (dict): - Return dict in the format - {"users": []} - "users" contains a list of all of the found users and their information - - Raise: - AuthException: raised if search operation fails - """ - tenant_ids = [] if tenant_ids is None else tenant_ids - role_names = [] if role_names is None else role_names - - self._validate_search_pagination(limit, page) - body = { - "tenantIds": tenant_ids, - "roleNames": role_names, - "limit": limit, - "page": page, - "testUsersOnly": True, - "withTestUser": True, - } - if statuses is not None: - body["statuses"] = statuses - - if emails is not None: - body["emails"] = emails - - if phones is not None: - body["phones"] = phones - - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - - if sso_app_ids is not None: - body["ssoAppIds"] = sso_app_ids - - if login_ids is not None: - body["loginIds"] = login_ids - - if text is not None: - body["text"] = text - - if sort is not None: - body["sort"] = sort_to_dict(sort) - - if from_created_time is not None: - body["fromCreatedTime"] = from_created_time - if to_created_time is not None: - body["toCreatedTime"] = to_created_time - if from_modified_time is not None: - body["fromModifiedTime"] = from_modified_time - if to_modified_time is not None: - body["toModifiedTime"] = to_modified_time - - if tenant_role_ids is not None: - body["tenantRoleIds"] = tenant_role_ids - if tenant_role_names is not None: - body["tenantRoleNames"] = tenant_role_names - if family_ids is not None: - body["familyIds"] = family_ids - if dependent is not None: - body["dependent"] = dependent - if lock_reasons is not None: - body["lockReasons"] = lock_reasons - if temp_lock_reasons is not None: - body["tempLockReasons"] = temp_lock_reasons - - response = self._http.post( - MgmtV1.test_users_search_path, - body=body, - ) - return response.json() - - def get_provider_token( - self, - login_id: str, - provider: str, - withRefreshToken: Optional[bool] = False, - forceRefresh: Optional[bool] = False, - ) -> dict: - """ - Get the provider token for the given login ID. - Only users that sign-in using social providers will have token. - Note: The 'Manage tokens from provider' setting must be enabled. - - Args: - login_id (str): The login ID of the user. - provider (str): The provider name (google, facebook, etc'). - withRefreshToken (bool): Optional, set to true to get also the refresh token. - forceRefresh (bool): Optional, set to true to force refresh the token. - - Return value (dict): - Return dict in the format - {"provider": "", "providerUserId": "", "accessToken": "", "expiration": "", "scopes": "[]"} - Containing the provider token of the given user and provider. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.get( - MgmtV1.user_get_provider_token, - params={ - "loginId": login_id, - "provider": provider, - "withRefreshToken": withRefreshToken, - "forceRefresh": forceRefresh, - }, - ) - return response.json() - - def activate( - self, - login_id: str, - ) -> dict: - """ - Activate an existing user. - - Args: - login_id (str): The login ID of the user to be activated. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if activate operation fails - """ - response = self._http.post( - MgmtV1.user_update_status_path, - body={"loginId": login_id, "status": "enabled"}, - ) - return response.json() - - def deactivate( - self, - login_id: str, - ) -> dict: - """ - Deactivate an existing user. - - Args: - login_id (str): The login ID of the user to be deactivated. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if deactivate operation fails - """ - response = self._http.post( - MgmtV1.user_update_status_path, - body={"loginId": login_id, "status": "disabled"}, - ) - return response.json() - - def update_login_id( - self, - login_id: str, - new_login_id: Optional[str] = None, - ) -> dict: - """ - Update login id of user, leave new login empty to remove the ID. - A user must have at least one login ID. Trying to remove the last one will fail. - - Args: - login_id (str): The login ID of the user to update. - new_login_id (str): New login ID to set for the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = self._http.post( - MgmtV1.user_update_login_id_path, - body={"loginId": login_id, "newLoginId": new_login_id}, - ) - return response.json() - - def update_email( - self, - login_id: str, - email: Optional[str] = None, - verified: Optional[bool] = None, - fail_on_conflict: Optional[bool] = None, - ) -> dict: - """ - Update the email address for an existing user. - - Args: - login_id (str): The login ID of the user to update the email for. - email (str): The user email address. Leave empty to remove. - verified (bool): Set to true for the user to be able to login with the email address. - fail_on_conflict (bool): Set to true to raise an error if the email is used as a login id and already exists. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = self._http.post( - MgmtV1.user_update_email_path, - body={ - "loginId": login_id, - "email": email, - "verified": verified, - "failOnConflict": fail_on_conflict, - }, - ) - return response.json() - - def update_phone( - self, - login_id: str, - phone: Optional[str] = None, - verified: Optional[bool] = None, - fail_on_conflict: Optional[bool] = None, - ) -> dict: - """ - Update the phone number for an existing user. - - Args: - login_id (str): The login ID of the user to update the phone for. - phone (str): The user phone number. Leave empty to remove. - verified (bool): Set to true for the user to be able to login with the phone number. - fail_on_conflict (bool): Set to true to raise an error if the phone is used as a login id and already exists. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = self._http.post( - MgmtV1.user_update_phone_path, - body={ - "loginId": login_id, - "phone": phone, - "verified": verified, - "failOnConflict": fail_on_conflict, - }, - ) - return response.json() - - def update_display_name( - self, - login_id: str, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - ) -> dict: - """ - Update the display name for an existing user. - - Args: - login_id (str): The login ID of the user to update. - display_name (str): Optional user display name. Leave empty to remove. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - bdy = {"loginId": login_id} - if display_name is not None: - bdy["displayName"] = display_name - if given_name is not None: - bdy["givenName"] = given_name - if middle_name is not None: - bdy["middleName"] = middle_name - if family_name is not None: - bdy["familyName"] = family_name - response = self._http.post( - MgmtV1.user_update_name_path, - body=bdy, - ) - return response.json() - - def update_picture( - self, - login_id: str, - picture: Optional[str] = None, - ) -> dict: - """ - Update the picture for an existing user. - - Args: - login_id (str): The login ID of the user to update. - picture (str): Optional url to user avatar. Leave empty to remove. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = self._http.post( - MgmtV1.user_update_picture_path, - body={"loginId": login_id, "picture": picture}, - ) - return response.json() - - def update_custom_attribute(self, login_id: str, attribute_key: str, attribute_val: Union[str, int, bool]) -> dict: - """ - Update a custom attribute of an existing user. - - Args: - login_id (str): The login ID of the user to update. - attribute_key (str): The custom attribute that needs to be updated, this attribute needs to exists in Descope console app - attribute_val: The value to be updated - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = self._http.post( - MgmtV1.user_update_custom_attribute_path, - body={ - "loginId": login_id, - "attributeKey": attribute_key, - "attributeValue": attribute_val, - }, - ) - return response.json() - - def set_roles( - self, - login_id: str, - role_names: List[str], - ) -> dict: - """ - Set roles to a user without tenant association. Use set_tenant_roles - for users that are part of a multi-tenant project. - - Args: - login_id (str): The login ID of the user to update. - role_names (List[str]): A list of roles to set to a user without tenant association. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_set_role_path, - body={"loginId": login_id, "roleNames": role_names}, - ) - return response.json() - - def add_roles( - self, - login_id: str, - role_names: List[str], - ) -> dict: - """ - Add roles to a user without tenant association. Use add_tenant_roles - for users that are part of a multi-tenant project. - - Args: - login_id (str): The login ID of the user to update. - role_names (List[str]): A list of roles to add to a user without tenant association. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_add_role_path, - body={"loginId": login_id, "roleNames": role_names}, - ) - return response.json() - - def remove_roles( - self, - login_id: str, - role_names: List[str], - ) -> dict: - """ - Remove roles from a user without tenant association. Use remove_tenant_roles - for users that are part of a multi-tenant project. - - Args: - login_id (str): The login ID of the user to update. - role_names (List[str]): A list of roles to remove from a user without tenant association. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_remove_role_path, - body={"loginId": login_id, "roleNames": role_names}, - ) - return response.json() - - def set_sso_apps( - self, - login_id: str, - sso_app_ids: List[str], - ) -> dict: - """ - Set SSO applications association to a user. - - Args: - login_id (str): The login ID of the user to update. - sso_app_ids (List[str]): A list of sso applications ids for associate with a user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_set_sso_apps, - body={"loginId": login_id, "ssoAppIds": sso_app_ids}, - ) - return response.json() - - def add_sso_apps( - self, - login_id: str, - sso_app_ids: List[str], - ) -> dict: - """ - Add SSO applications association to a user. - - Args: - login_id (str): The login ID of the user to update. - sso_app_ids (List[str]): A list of sso applications ids for associate with a user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_add_sso_apps, - body={"loginId": login_id, "ssoAppIds": sso_app_ids}, - ) - return response.json() - - def remove_sso_apps( - self, - login_id: str, - sso_app_ids: List[str], - ) -> dict: - """ - Remove SSO applications association from a user. - - Args: - login_id (str): The login ID of the user to update. - sso_app_ids (List[str]): A list of sso applications ids to remove association from a user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_remove_sso_apps, - body={"loginId": login_id, "ssoAppIds": sso_app_ids}, - ) - return response.json() - - def add_tenant( - self, - login_id: str, - tenant_id: str, - ) -> dict: - """ - Add a tenant association to an existing user. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the tenant to add to the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_add_tenant_path, - body={"loginId": login_id, "tenantId": tenant_id}, - ) - return response.json() - - def remove_tenant( - self, - login_id: str, - tenant_id: str, - ) -> dict: - """ - Remove a tenant association from an existing user. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the tenant to add to the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_remove_tenant_path, - body={"loginId": login_id, "tenantId": tenant_id}, - ) - return response.json() - - def add_families( - self, - login_id: str, - family_associations: List[AssociatedFamily], - ) -> dict: - """ - Add a user to one or more families. Each association may also set the user's roles and - family-scoped attribute values in that family. Adding a family the user already belongs to - merges: given role names are granted on top of the existing ones, given family-scoped - attributes are merged into the stored ones, and omitting either leaves it unchanged. - - Args: - login_id (str): The login ID of the user to update. - family_associations (List[AssociatedFamily]): The families to add the user to (at least one). - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_add_families_path, - body={ - "loginId": login_id, - "familyAssociations": associated_families_to_dict(family_associations), - }, - ) - return response.json() - - def remove_families( - self, - login_id: str, - family_ids: List[str], - ) -> dict: - """ - Remove a user from one or more families. - - Args: - login_id (str): The login ID of the user to update. - family_ids (List[str]): The IDs of the families to remove the user from (at least one). - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_remove_families_path, - body={"loginId": login_id, "familyIds": family_ids}, - ) - return response.json() - - def set_tenant_roles( - self, - login_id: str, - tenant_id: str, - role_names: List[str], - ) -> dict: - """ - Set roles to a user in a specific tenant. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the user's tenant. - role_names (List[str]): A list of roles to set on the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_set_role_path, - body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - ) - return response.json() - - def add_tenant_roles( - self, - login_id: str, - tenant_id: str, - role_names: List[str], - ) -> dict: - """ - Add roles to a user in a specific tenant. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the user's tenant. - role_names (List[str]): A list of roles to add to the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_add_role_path, - body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - ) - return response.json() - - def remove_tenant_roles( - self, - login_id: str, - tenant_id: str, - role_names: List[str], - ) -> dict: - """ - Remove roles from a user in a specific tenant. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the user's tenant. - role_names (List[str]): A list of roles to remove from the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_remove_role_path, - body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - ) - return response.json() - - def set_temporary_password( - self, - login_id: str, - password: str, - ) -> None: - """ - Set the temporary password for the given login ID. - Note: The password will automatically be set as expired. - The user will not be able to log-in with this password, and will be required to replace it on next login. - See also: expire_password - - Args: - login_id (str): The login ID of the user to set the password to. - password (str): The new password to set to the user. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_set_temporary_password_path, - body={ - "loginId": login_id, - "password": password, - "setActive": False, - }, - ) - return - - def set_active_password( - self, - login_id: str, - password: str, - ) -> None: - """ - Set the password for the given login ID. - - Args: - login_id (str): The login ID of the user to set the password to. - password (str): The new password to set to the user. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_set_active_password_path, - body={ - "loginId": login_id, - "password": password, - "setActive": True, - }, - ) - return - - # Deprecated (use set_temporary_password instead) - def set_password( - self, - login_id: str, - password: str, - ) -> None: - """ - Set the password for the given login ID. - Note: The password will automatically be set as expired. - The user will not be able to log-in with this password, and will be required to replace it on next login. - See also: expire_password - - Args: - login_id (str): The login ID of the user to set the password to. - password (str): The new password to set to the user. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_set_password_path, - body={ - "loginId": login_id, - "password": password, - }, - ) - return - - def expire_password( - self, - login_id: str, - ) -> None: - """ - Expires the password for the given login ID. - Note: user sign-in with an expired password, the user will get an error with code. - Use the `password.send_reset` or `password.replace` methods to reset/replace the password. - - Args: - login_id (str): The login ID of the user to expire the password to. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_expire_password_path, - body={"loginId": login_id}, - ) - return - - def remove_all_passkeys( - self, - login_id: str, - ) -> None: - """ - Removes all registered passkeys (WebAuthn devices) for the user with the given login ID. - Note: The user might not be able to login anymore if they have no other authentication - methods or a verified email/phone. - - Args: - login_id (str): The login ID of the user to remove passkeys for. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_remove_all_passkeys_path, - body={"loginId": login_id}, - ) - return - - def remove_totp_seed( - self, - login_id: str, - ) -> None: - """ - Removes TOTP seed for the user with the given login ID. - Note: The user might not be able to login anymore if they have no other authentication - methods or a verified email/phone. - - Args: - login_id (str): The login ID of the user to remove totp seed for. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_remove_totp_seed_path, - body={"loginId": login_id}, - ) - return - - def remove_recovery_codes( - self, - login_id: str, - ) -> None: - """ - Removes all recovery codes for the user with the given login ID or user ID. - - Args: - login_id (str): The login ID or user ID of the user to remove recovery codes for. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_remove_recovery_codes_path, - body={"loginId": login_id}, - ) - return - - def generate_otp_for_test_user( - self, - method: DeliveryMethod, - login_id: str, - login_options: Optional[LoginOptions] = None, - ) -> dict: - """ - Generate OTP for the given login ID of a test user. - This is useful when running tests and don't want to use 3rd party messaging services. - - Args: - method (DeliveryMethod): The method to use for "delivering" the OTP verification code to the user, for example - EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED - login_id (str): The login ID of the test user being validated. - login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. - - Return value (dict): - Return dict in the format - {"code": "", "loginId": ""} - Containing the code for the login (exactly as it sent via Email or Phone messaging). - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_generate_otp_for_test_path, - body={ - "loginId": login_id, - "deliveryMethod": get_method_string(method), - "loginOptions": login_options.__dict__ if login_options else {}, - }, - ) - return response.json() - - def generate_magic_link_for_test_user( - self, - method: DeliveryMethod, - login_id: str, - uri: str, - login_options: Optional[LoginOptions] = None, - ) -> dict: - """ - Generate Magic Link for the given login ID of a test user. - This is useful when running tests and don't want to use 3rd party messaging services. - - Args: - method (DeliveryMethod): The method to use for "delivering" the verification magic link to the user, for example - EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED - login_id (str): The login ID of the test user being validated. - uri (str): Optional redirect uri which will be used instead of any global configuration. - login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. - - Return value (dict): - Return dict in the format - {"link": "", "loginId": ""} - Containing the magic link for the login (exactly as it sent via Email or Phone messaging). - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_generate_magic_link_for_test_path, - body={ - "loginId": login_id, - "deliveryMethod": get_method_string(method), - "URI": uri, - "loginOptions": login_options.__dict__ if login_options else {}, - }, - ) - return response.json() - - def generate_enchanted_link_for_test_user( - self, - login_id: str, - uri: str, - login_options: Optional[LoginOptions] = None, - ) -> dict: - """ - Generate Enchanted Link for the given login ID of a test user. - This is useful when running tests and don't want to use 3rd party messaging services. - - Args: - login_id (str): The login ID of the test user being validated. - uri (str): Optional redirect uri which will be used instead of any global configuration. - login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. - - Return value (dict): - Return dict in the format - {"link": "", "loginId": "", "pendingRef": ""} - Containing the enchanted link for the login (exactly as it sent via Email or Phone messaging) and pendingRef. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_generate_enchanted_link_for_test_path, - body={ - "loginId": login_id, - "URI": uri, - "loginOptions": login_options.__dict__ if login_options else {}, - }, - ) - return response.json() - - def generate_embedded_link(self, login_id: str, custom_claims: Optional[dict] = None, timeout: int = 0) -> str: - """ - Generate Embedded Link for the given user login ID. - The return value is a token that can be verified via magic link, or using flows - - Args: - login_id (str): The login ID of the user to authenticate with. - custom_claims (dict): Additional claims to place on the jwt after verification - - Return value (str): - Return the token to be used in verification process - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_generate_embedded_link_path, - body={ - "loginId": login_id, - "customClaims": custom_claims, - "timeout": timeout, - }, - ) - return response.json()["token"] - - def generate_sign_up_embedded_link( - self, - login_id: str, - user: Optional[CreateUserObj] = None, - email_verified: bool = False, - phone_verified: bool = False, - login_options: Optional[LoginOptions] = None, - timeout: int = 0, - ) -> str: - """ - Generate sign up Embedded Link for the given user login ID. - The return value is a token that can be verified via magic link, or using flows - - Args: - login_id (str): The login ID of the user to authenticate with. - user (CreateUserObj): Optional user object to create the user with - email_verified (bool): Optional, set to true if the email is verified - phone_verified (bool): Optional, set to true if the phone is verified - login_options (LoginOptions): Optional login options to customize the link - timeout (int): Optional, the timeout in seconds for the link to be valid - - Return value (str): - Return the token to be used in verification process - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_generate_sign_up_embedded_link_path, - body={ - "loginId": login_id, - "user": user.__dict__ if user else {}, - "loginOptions": login_options.__dict__ if login_options else {}, - "emailVerified": email_verified, - "phoneVerified": phone_verified, - "timeout": timeout, - }, - ) - return response.json()["token"] - - def history(self, user_ids: List[str]) -> List[dict]: - """ - Retrieve users' authentication history, by the given user's ids. - - Args: - login_ids (List[str]): List of Users' IDs. - - Return value (List[dict]): - Return List in the format - [ - { - "userId": "User's ID", - "loginTime": "User'sLogin time", - "city": "User's city", - "country": "User's country", - "ip": User's IP - } - ] - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_history_path, - body=user_ids, - ) - return response.json() - - def create_custom_attribute( - self, - name: str, - display_name: str, - type: str, - required: Optional[bool] = None, - options: Optional[List[str]] = None, - ) -> dict: - """ - Create a new custom attribute for users. - - Args: - name (str): The name of the custom attribute. - display_name (str): The display name of the custom attribute. - type (str): The type of the custom attribute (e.g., "string", "number", "boolean"). - required (bool): Optional, whether the custom attribute is required. - options (List[str]): Optional, list of options for the custom attribute. - - Return value (dict): - Return dict containing the custom attributes list. - - Raise: - AuthException: raised if the operation fails - """ - body: dict = { - "attributes": [ - { - "name": name, - "displayName": display_name, - "type": type, - } - ] - } - if required is not None: - body["attributes"][0]["required"] = required - if options is not None: - body["attributes"][0]["options"] = options - - response = self._http.post( - MgmtV1.user_create_custom_attribute_path, - body=body, - ) - return response.json() - - def delete_custom_attribute( - self, - name: str, - ) -> dict: - """ - Delete a custom attribute for users. - - Args: - name (str): The name of the custom attribute to delete. - - Return value (dict): - Return dict containing the remaining custom attributes list. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_delete_custom_attribute_path, - body={"names": [name]}, - ) - return response.json() - - def load_custom_attributes(self) -> dict: - """ - Load all custom attributes for users. - - Return value (dict): - Return dict containing the custom attributes list. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.get( - MgmtV1.user_load_custom_attributes_path, - params={}, - ) - return response.json() - - def load_family_scoped_custom_attributes(self) -> dict: - """ - Load the family-scoped user custom attribute definitions. These are user attributes whose - values are held per family membership (see `AssociatedFamily.family_scoped_attributes`), - and are a separate set from the plain user custom attributes. - - Return value (dict): - Return dict in the format - {"data": [{"name": , "type": , "displayName": , ...}]} - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.get(MgmtV1.user_load_family_scoped_custom_attributes_path) - return response.json() - - def create_family_scoped_custom_attributes(self, attributes: List[CustomAttribute]) -> dict: - """ - Create family-scoped user custom attribute definitions. - - Args: - attributes (List[CustomAttribute]): The custom attribute definitions to create. - - Return value (dict): - Return dict in the format - {"data": [...]} - Containing the updated family-scoped custom attribute definitions. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_create_family_scoped_custom_attributes_path, - body={"attributes": custom_attributes_to_dict(attributes)}, - ) - return response.json() - - def delete_family_scoped_custom_attributes(self, names: List[str]) -> dict: - """ - Delete family-scoped user custom attribute definitions by name. - - Args: - names (List[str]): The names of the custom attributes to delete. - - Return value (dict): - Return dict in the format - {"data": [...]} - Containing the remaining family-scoped custom attribute definitions. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_delete_family_scoped_custom_attributes_path, - body={"names": names}, - ) - return response.json() - - def delete_batch( - self, - user_ids: List[str], - ): - """ - Delete multiple users by their user IDs. IMPORTANT: This action is irreversible. Use carefully. - - Args: - user_ids (List[str]): List of user IDs to delete. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_delete_batch_path, - body={"userIds": user_ids}, - ) - - def import_users( - self, - source: str, - users: Optional[bytes] = None, - hashes: Optional[bytes] = None, - dryrun: bool = False, - ) -> dict: - """ - Import users from an external source. - - Args: - source (str): The source of the users (e.g., "auth0", "firebase"). - users (bytes): Optional, JSON bytes containing the users to import. - hashes (bytes): Optional, JSON bytes containing password hashes. - dryrun (bool): Optional, if True, perform a dry run without actually importing. - - Return value (dict): - Return dict containing the import results. - - Raise: - AuthException: raised if the operation fails - """ - body = { - "source": source, - "dryrun": dryrun, - } - if users is not None: - body["users"] = base64.b64encode(users).decode("utf-8") - if hashes is not None: - body["hashes"] = base64.b64encode(hashes).decode("utf-8") - - response = self._http.post( - MgmtV1.user_import_path, - body=body, - ) - return response.json() - - def delete_passkey( - self, - login_id: str, - credential_id: str, - ): - """ - Delete a specific passkey (WebAuthn device) for a user. - - Args: - login_id (str): The login ID of the user. - credential_id (str): The credential ID of the passkey to delete. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_delete_passkey_path, - body={"loginId": login_id, "credentialId": credential_id}, - ) - - def list_passkeys( - self, - login_id: str, - ) -> dict: - """ - List all passkeys (WebAuthn devices) registered for a user. - - Args: - login_id (str): The login ID of the user. - - Return value (dict): - Return dict containing the list of passkeys. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_list_passkeys_path, - body={"loginId": login_id}, - ) - return response.json() - - def list_trusted_devices( - self, - identifiers: List[str], - ) -> dict: - """ - List all trusted devices for the specified users. - - Args: - identifiers (List[str]): List of login IDs or user IDs. - - Return value (dict): - Return dict containing the list of trusted devices. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_list_trusted_devices_path, - body={"identifiers": identifiers}, - ) - return response.json() - - def remove_trusted_device( - self, - identifier: str, - device_ids: List[str], - ): - """ - Remove trusted devices for a user. - - Args: - identifier (str): The login ID or user ID. - device_ids (List[str]): List of device IDs to remove. - - Raise: - AuthException: raised if the operation fails - """ - self._http.post( - MgmtV1.user_remove_trusted_device_path, - body={"identifier": identifier, "deviceIds": device_ids}, - ) - - def update_recovery_email( - self, - login_id: str, - recovery_email: str, - verified: bool = False, - ) -> dict: - """ - Update the recovery email for a user. - - Args: - login_id (str): The login ID of the user. - recovery_email (str): The recovery email address. - verified (bool): Whether the recovery email is verified. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_update_recovery_email_path, - body={ - "loginId": login_id, - "recoveryEmail": recovery_email, - "verified": verified, - }, - ) - return response.json() - - def update_recovery_phone( - self, - login_id: str, - recovery_phone: str, - verified: bool = False, - ) -> dict: - """ - Update the recovery phone number for a user. - - Args: - login_id (str): The login ID of the user. - recovery_phone (str): The recovery phone number. - verified (bool): Whether the recovery phone is verified. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = self._http.post( - MgmtV1.user_update_recovery_phone_path, - body={ - "loginId": login_id, - "recoveryPhone": recovery_phone, - "verified": verified, - }, - ) - return response.json() diff --git a/descope/management/user_async.py b/descope/management/user_async.py deleted file mode 100644 index 5405cd103..000000000 --- a/descope/management/user_async.py +++ /dev/null @@ -1,2258 +0,0 @@ -from __future__ import annotations - -import base64 -from typing import List, Optional, Union - -from descope._http_base import AsyncHTTPBase -from descope.common import DeliveryMethod, LoginOptions, get_method_string -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -from descope.management._user_base import CreateUserObj, UserBase, UserObj -from descope.management.common import ( - AssociatedFamily, - AssociatedTenant, - CustomAttribute, - MgmtV1, - Sort, - associated_families_to_dict, - custom_attributes_to_dict, - sort_to_dict, -) - - -class UserAsync(UserBase, AsyncHTTPBase): - """Async counterpart of User — all HTTP calls are coroutines.""" - - async def create( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - invite_url: Optional[str] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - status: Optional[str] = None, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Create a new user. Users can have any number of optional fields, including email, phone number and authorization. - - Args: - login_id (str): user login ID. - email (str): Optional user email address. - phone (str): Optional user phone number. - display_name (str): Optional user display name. - role_names (List[str]): An optional list of the user's roles without tenant association. These roles are - mutually exclusive with the `user_tenant` roles. - user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`. - picture (str): Optional url for user picture - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. - status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". - family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and - family-scoped attribute values per family. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the created user information. - - Raise: - AuthException: raised if create operation fails - """ - UserBase._validate_status(status) - role_names = [] if role_names is None else role_names - user_tenants = [] if user_tenants is None else user_tenants - - response = await self._http.post( - MgmtV1.user_create_path, - body=UserBase._compose_create_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - False, - False, - picture, - custom_attributes, - verified_email, - verified_phone, - invite_url, - None, - None, - additional_login_ids, - sso_app_ids, - status=status, - family_associations=family_associations, - ), - ) - return response.json() - - async def create_test_user( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - invite_url: Optional[str] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - status: Optional[str] = None, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Create a new test user. - The login_id is required and will determine what the user will use to sign in. - Make sure the login id is unique for test. All other fields are optional. - - Args: - login_id (str): user login ID. - email (str): Optional user email address. - phone (str): Optional user phone number. - display_name (str): Optional user display name. - role_names (List[str]): An optional list of the user's roles without tenant association. These roles are - mutually exclusive with the `user_tenant` roles. - user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`. - picture (str): Optional url for user picture - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. - status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". - family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and - family-scoped attribute values per family. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the created test user information. - - Raise: - AuthException: raised if create operation fails - """ - UserBase._validate_status(status) - role_names = [] if role_names is None else role_names - user_tenants = [] if user_tenants is None else user_tenants - - response = await self._http.post( - MgmtV1.test_user_create_path, - body=UserBase._compose_create_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - False, - True, - picture, - custom_attributes, - verified_email, - verified_phone, - invite_url, - None, - None, - additional_login_ids, - sso_app_ids, - status=status, - family_associations=family_associations, - ), - ) - return response.json() - - async def invite( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - invite_url: Optional[str] = None, - send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings - send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - template_id: str = "", - test: bool = False, - locale: Optional[str] = None, # locale for the invite message - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Create a new user and invite them via an email / text message. - - Functions exactly the same as the `create` function with the additional invitation - behavior. See the documentation above for the general creation behavior. - - IMPORTANT: Since the invitation is sent by email / phone, make sure either - the email / phone is explicitly set, or the login_id itself is an email address / phone number. - You must configure the invitation URL in the Descope console prior to - calling the method. - """ - role_names = [] if role_names is None else role_names - user_tenants = [] if user_tenants is None else user_tenants - - response = await self._http.post( - MgmtV1.user_create_path, - body=UserBase._compose_create_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - True, - test, - picture, - custom_attributes, - verified_email, - verified_phone, - invite_url, - send_mail, - send_sms, - additional_login_ids, - sso_app_ids, - template_id, - locale, - family_associations=family_associations, - ), - ) - return response.json() - - async def invite_batch( - self, - users: List[UserObj], - invite_url: Optional[str] = None, - send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings - send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings - locale: Optional[str] = None, # locale for the invite message - template_options: Optional[dict] = None, # for providing messaging template options - template_id: str = "", # for overriding the default messaging template - ) -> dict: - """ - Create users in batch and invite them via an email / text message. - - Functions exactly the same as the `create` function with the additional invitation - behavior. See the documentation above for the general creation behavior. - - IMPORTANT: Since the invitation is sent by email / phone, make sure either - the email / phone is explicitly set, or the login_id itself is an email address / phone number. - You must configure the invitation URL in the Descope console prior to - calling the method. - """ - - response = await self._http.post( - MgmtV1.user_create_batch_path, - body=UserBase._compose_create_batch_body( - users, - invite_url, - send_mail, - send_sms, - locale, - template_options=template_options, - template_id=template_id, - ), - ) - return response.json() - - async def update( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - test: bool = False, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides - to the existing user. Empty fields will override populated fields, including family memberships: - omitting family_associations removes the user from all of their families, and updating a dependent - requires family_associations with the dependent's family. Use carefully. - Use `patch` for partial updates instead. - - Args: - login_id (str): The login ID of the user to update. - email (str): Optional user email address. - phone (str): Optional user phone number. - display_name (str): Optional user display name. - given_name (str): Optional user given name. - middle_name (str): Optional user middle name. - family_name (str): Optional user family name. - role_names (List[str]): An optional list of the user's roles without tenant association. These roles are - mutually exclusive with the `user_tenant` roles. - user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`. - picture (str): Optional url for user picture - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. - test (bool, optional): Set to True to update a test user. Defaults to False. - family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and - family-scoped attribute values per family. Replaces the user's family memberships, so None or an - empty list removes the user from all families. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if update operation fails - """ - role_names = [] if role_names is None else role_names - user_tenants = [] if user_tenants is None else user_tenants - family_associations = [] if family_associations is None else family_associations - - response = await self._http.post( - MgmtV1.user_update_path, - body=UserBase._compose_update_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - test, - picture, - custom_attributes, - verified_email, - verified_phone, - additional_login_ids, - sso_app_ids, - None, - family_associations=family_associations, - ), - ) - return response.json() - - async def patch( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - sso_app_ids: Optional[List[str]] = None, - status: Optional[str] = None, - test: bool = False, - family_associations: Optional[List[AssociatedFamily]] = None, - ) -> dict: - """ - Patches an existing user with the given various fields. Only the given fields will be used to update the user. - - Args: - login_id (str): The login ID of the user to update. - email (str): Optional user email address. - phone (str): Optional user phone number. - display_name (str): Optional user display name. - given_name (str): Optional user given name. - middle_name (str): Optional user middle name. - family_name (str): Optional user family name. - role_names (List[str]): An optional list of the user's roles without tenant association. These roles are - mutually exclusive with the `user_tenant` roles. - user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are - mutually exclusive with the general `role_names`. - picture (str): Optional url for user picture - custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app - sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. - status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". - test (bool, optional): Set to True to update a test user. Defaults to False. - family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and - family-scoped attribute values per family. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the patched user information. - - Raise: - AuthException: raised if patch operation fails - """ - UserBase._validate_status(status) - response = await self._http.patch( - MgmtV1.user_patch_path, - body=UserBase._compose_patch_body( - login_id, - email, - phone, - display_name, - given_name, - middle_name, - family_name, - role_names, - user_tenants, - picture, - custom_attributes, - verified_email, - verified_phone, - sso_app_ids, - status, - test, - family_associations=family_associations, - ), - ) - return response.json() - - async def patch_batch( - self, - users: List[UserObj], - test: bool = False, - ) -> dict: - """ - Patch users in batch. Only the provided fields will be updated for each user. - - Args: - users (List[UserObj]): A list of UserObj instances representing users to be patched. - Each UserObj should have a login_id and the fields to be updated. - test (bool, optional): Set to True to patch test users. Defaults to False. - - Return value (dict): - Return dict in the format - {"patchedUsers": [...], "failedUsers": [...]} - "patchedUsers" contains successfully patched users, - "failedUsers" contains users that failed to be patched with error details. - - Raise: - AuthException: raised if patch batch operation fails - """ - # Validate status fields for all users - for user in users: - UserBase._validate_status(user.status, user.login_id) - - response = await self._http.patch( - MgmtV1.user_patch_batch_path, - body=UserBase._compose_patch_batch_body(users, test), - ) - return response.json() - - async def delete( - self, - login_id: str, - ): - """ - Delete an existing user. IMPORTANT: This action is irreversible. Use carefully. - - Args: - login_id (str): The login ID of the user to be deleted. - - Raise: - AuthException: raised if delete operation fails - """ - await self._http.post( - MgmtV1.user_delete_path, - body={"loginId": login_id}, - ) - - async def delete_by_user_id( - self, - user_id: str, - ): - """ - Delete an existing user by user ID. IMPORTANT: This action is irreversible. Use carefully. - - Args: - user_id (str): The user ID from the user's JWT. - - Raise: - AuthException: raised if delete operation fails - """ - await self._http.post( - MgmtV1.user_delete_path, - body={"userId": user_id}, - ) - - async def delete_all_test_users( - self, - ): - """ - Delete all test users in the project. IMPORTANT: This action is irreversible. Use carefully. - - Raise: - AuthException: raised if delete operation fails - """ - await self._http.delete( - MgmtV1.user_delete_all_test_users_path, - ) - - async def load( - self, - login_id: str, - ) -> dict: - """ - Load an existing user. - - Args: - login_id (str): The login ID of the user to be loaded. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the loaded user information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - uri=MgmtV1.user_load_path, - params={"loginId": login_id}, - ) - return response.json() - - async def load_by_user_id( - self, - user_id: str, - ) -> dict: - """ - Load an existing user by user ID. - The user ID can be found on the user's JWT. - - Args: - user_id (str): The user ID from the user's JWT. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the loaded user information. - - Raise: - AuthException: raised if load operation fails - """ - response = await self._http.get( - uri=MgmtV1.user_load_path, - params={"userId": user_id}, - ) - return response.json() - - async def logout_user( - self, - login_id: str, - ): - """ - Logout a user from all devices. - - Args: - login_id (str): The login ID of the user to be logged out. - - Raise: - AuthException: raised if logout operation fails - """ - await self._http.post( - MgmtV1.user_logout_path, - body={"loginId": login_id}, - ) - - async def logout_user_by_user_id( - self, - user_id: str, - ): - """ - Logout a user from all devices. - - Args: - user_id (str): The login ID of the user to be logged out. - - Raise: - AuthException: raised if logout operation fails - """ - await self._http.post( - MgmtV1.user_logout_path, - body={"userId": user_id}, - ) - - async def load_users( - self, - user_ids: List[str], - include_invalid_users: Optional[bool] = None, - ) -> dict: - """ - Load users by their user IDs. - - Args: - user_ids (List[str]): Optional list of user IDs to filter by - include_invalid_users (bool): Optional flag to include invalid users in the response - - Return value (dict): - Return dict in the format - {"users": []} - "users" contains a list of all of the found users and their information - - Raise: - AuthException: raised if search operation fails - """ - if user_ids is None or len(user_ids) == 0: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "At least one user id needs to be supplied", - ) - - body: dict[str, Union[List[str], bool]] = { - "userIds": user_ids, - } - - if include_invalid_users is not None: - body["includeInvalidUsers"] = include_invalid_users - - response = await self._http.post( - MgmtV1.users_load_path, - body=body, - ) - return response.json() - - async def search_all( - self, - tenant_ids: Optional[List[str]] = None, - role_names: Optional[List[str]] = None, - limit: int = 0, - page: int = 0, - test_users_only: bool = False, - with_test_user: bool = False, - custom_attributes: Optional[dict] = None, - statuses: Optional[List[str]] = None, - emails: Optional[List[str]] = None, - phones: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - sort: Optional[List[Sort]] = None, - text: Optional[str] = None, - login_ids: Optional[List[str]] = None, - from_created_time: Optional[int] = None, - to_created_time: Optional[int] = None, - from_modified_time: Optional[int] = None, - to_modified_time: Optional[int] = None, - user_ids: Optional[List[str]] = None, - tenant_role_ids: Optional[dict] = None, - tenant_role_names: Optional[dict] = None, - family_ids: Optional[List[str]] = None, - dependent: Optional[bool] = None, - lock_reasons: Optional[List[str]] = None, - temp_lock_reasons: Optional[List[str]] = None, - ) -> dict: - """ - Search all users. - - Args: - tenant_ids (List[str]): Optional list of tenant IDs to filter by - role_names (List[str]): Optional list of role names to filter by - limit (int): Optional limit of the number of users returned. Leave empty for default. - page (int): Optional pagination control. Pages start at 0 and must be non-negative. - test_users_only (bool): Optional filter only test users. - with_test_user (bool): Optional include test users in search. - custom_attributes (dict): Optional search for a attribute with a given value - statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") - emails (List[str]): Optional list of emails to search for - phones (List[str]): Optional list of phones to search for - sso_app_ids (List[str]): Optional list of SSO application IDs to filter by - text (str): Optional string, allows free text search among all user's attributes. - login_ids (List[str]): Optional list of login ids - sort (List[Sort]): Optional List[dict], allows to sort by fields. - from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) - to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) - from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) - to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) - user_ids (List[str]): Optional list of user IDs to filter by - tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. - Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). - tenant_role_names (dict): Optional mapping of tenant ID to list of role names. - Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). - family_ids (List[str]): Optional list of family IDs. Only users that are members of at least one of these families are returned. - dependent (bool): Optional, filter by whether the user is a family dependent (a user with no login credentials of their own). - lock_reasons (List[str]): Optional list of lock reasons. Only users disabled by brute-force protection with one of these - reasons are returned. Allowed values: "password", "totp", "recovery_codes", "security_questions". - temp_lock_reasons (List[str]): Optional list of lock reasons. Only users currently temporarily locked with one of these - reasons are returned. Allowed values: "password", "recovery_codes", "security_questions" (TOTP has no temporary lock). - A user is temporarily locked while its tempLockExpiration (unix seconds, 0 when none) is greater than now. - - Return value (dict): - Return dict in the format - {"users": []} - "users" contains a list of all of the found users and their information - - Raise: - AuthException: raised if search operation fails - """ - tenant_ids = [] if tenant_ids is None else tenant_ids - role_names = [] if role_names is None else role_names - - self._validate_search_pagination(limit, page) - body = { - "tenantIds": tenant_ids, - "roleNames": role_names, - "limit": limit, - "page": page, - "testUsersOnly": test_users_only, - "withTestUser": with_test_user, - } - if statuses is not None: - body["statuses"] = statuses - - if emails is not None: - body["emails"] = emails - - if phones is not None: - body["phones"] = phones - - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - - if sso_app_ids is not None: - body["ssoAppIds"] = sso_app_ids - - if login_ids is not None: - body["loginIds"] = login_ids - - if user_ids is not None: - body["userIds"] = user_ids - - if text is not None: - body["text"] = text - - if sort is not None: - body["sort"] = sort_to_dict(sort) - - if from_created_time is not None: - body["fromCreatedTime"] = from_created_time - if to_created_time is not None: - body["toCreatedTime"] = to_created_time - if from_modified_time is not None: - body["fromModifiedTime"] = from_modified_time - if to_modified_time is not None: - body["toModifiedTime"] = to_modified_time - - if tenant_role_ids is not None: - body["tenantRoleIds"] = tenant_role_ids - if tenant_role_names is not None: - body["tenantRoleNames"] = tenant_role_names - if family_ids is not None: - body["familyIds"] = family_ids - if dependent is not None: - body["dependent"] = dependent - if lock_reasons is not None: - body["lockReasons"] = lock_reasons - if temp_lock_reasons is not None: - body["tempLockReasons"] = temp_lock_reasons - - response = await self._http.post( - MgmtV1.users_search_path, - body=body, - ) - return response.json() - - async def search_all_test_users( - self, - tenant_ids: Optional[List[str]] = None, - role_names: Optional[List[str]] = None, - limit: int = 0, - page: int = 0, - custom_attributes: Optional[dict] = None, - statuses: Optional[List[str]] = None, - emails: Optional[List[str]] = None, - phones: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - sort: Optional[List[Sort]] = None, - text: Optional[str] = None, - login_ids: Optional[List[str]] = None, - from_created_time: Optional[int] = None, - to_created_time: Optional[int] = None, - from_modified_time: Optional[int] = None, - to_modified_time: Optional[int] = None, - tenant_role_ids: Optional[dict] = None, - tenant_role_names: Optional[dict] = None, - family_ids: Optional[List[str]] = None, - dependent: Optional[bool] = None, - lock_reasons: Optional[List[str]] = None, - temp_lock_reasons: Optional[List[str]] = None, - ) -> dict: - """ - Search all test users. - - Args: - tenant_ids (List[str]): Optional list of tenant IDs to filter by - role_names (List[str]): Optional list of role names to filter by - limit (int): Optional limit of the number of users returned. Leave empty for default. - page (int): Optional pagination control. Pages start at 0 and must be non-negative. - custom_attributes (dict): Optional search for a attribute with a given value - statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") - emails (List[str]): Optional list of emails to search for - phones (List[str]): Optional list of phones to search for - sso_app_ids (List[str]): Optional list of SSO application IDs to filter by - text (str): Optional string, allows free text search among all user's attributes. - login_ids (List[str]): Optional list of login ids - sort (List[Sort]): Optional List[dict], allows to sort by fields. - from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) - to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) - from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) - to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) - tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. - Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). - tenant_role_names (dict): Optional mapping of tenant ID to list of role names. - Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). - family_ids (List[str]): Optional list of family IDs. Only users that are members of at least one of these families are returned. - dependent (bool): Optional, filter by whether the user is a family dependent (a user with no login credentials of their own). - lock_reasons (List[str]): Optional list of lock reasons. Only users disabled by brute-force protection with one of these - reasons are returned. Allowed values: "password", "totp", "recovery_codes", "security_questions". - temp_lock_reasons (List[str]): Optional list of lock reasons. Only users currently temporarily locked with one of these - reasons are returned. Allowed values: "password", "recovery_codes", "security_questions" (TOTP has no temporary lock). - A user is temporarily locked while its tempLockExpiration (unix seconds, 0 when none) is greater than now. - - Return value (dict): - Return dict in the format - {"users": []} - "users" contains a list of all of the found users and their information - - Raise: - AuthException: raised if search operation fails - """ - tenant_ids = [] if tenant_ids is None else tenant_ids - role_names = [] if role_names is None else role_names - - self._validate_search_pagination(limit, page) - body = { - "tenantIds": tenant_ids, - "roleNames": role_names, - "limit": limit, - "page": page, - "testUsersOnly": True, - "withTestUser": True, - } - if statuses is not None: - body["statuses"] = statuses - - if emails is not None: - body["emails"] = emails - - if phones is not None: - body["phones"] = phones - - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - - if sso_app_ids is not None: - body["ssoAppIds"] = sso_app_ids - - if login_ids is not None: - body["loginIds"] = login_ids - - if text is not None: - body["text"] = text - - if sort is not None: - body["sort"] = sort_to_dict(sort) - - if from_created_time is not None: - body["fromCreatedTime"] = from_created_time - if to_created_time is not None: - body["toCreatedTime"] = to_created_time - if from_modified_time is not None: - body["fromModifiedTime"] = from_modified_time - if to_modified_time is not None: - body["toModifiedTime"] = to_modified_time - - if tenant_role_ids is not None: - body["tenantRoleIds"] = tenant_role_ids - if tenant_role_names is not None: - body["tenantRoleNames"] = tenant_role_names - if family_ids is not None: - body["familyIds"] = family_ids - if dependent is not None: - body["dependent"] = dependent - if lock_reasons is not None: - body["lockReasons"] = lock_reasons - if temp_lock_reasons is not None: - body["tempLockReasons"] = temp_lock_reasons - - response = await self._http.post( - MgmtV1.test_users_search_path, - body=body, - ) - return response.json() - - async def get_provider_token( - self, - login_id: str, - provider: str, - withRefreshToken: Optional[bool] = False, - forceRefresh: Optional[bool] = False, - ) -> dict: - """ - Get the provider token for the given login ID. - Only users that sign-in using social providers will have token. - Note: The 'Manage tokens from provider' setting must be enabled. - - Args: - login_id (str): The login ID of the user. - provider (str): The provider name (google, facebook, etc'). - withRefreshToken (bool): Optional, set to true to get also the refresh token. - forceRefresh (bool): Optional, set to true to force refresh the token. - - Return value (dict): - Return dict in the format - {"provider": "", "providerUserId": "", "accessToken": "", "expiration": "", "scopes": "[]"} - Containing the provider token of the given user and provider. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.get( - MgmtV1.user_get_provider_token, - params={ - "loginId": login_id, - "provider": provider, - "withRefreshToken": withRefreshToken, - "forceRefresh": forceRefresh, - }, - ) - return response.json() - - async def activate( - self, - login_id: str, - ) -> dict: - """ - Activate an existing user. - - Args: - login_id (str): The login ID of the user to be activated. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if activate operation fails - """ - response = await self._http.post( - MgmtV1.user_update_status_path, - body={"loginId": login_id, "status": "enabled"}, - ) - return response.json() - - async def deactivate( - self, - login_id: str, - ) -> dict: - """ - Deactivate an existing user. - - Args: - login_id (str): The login ID of the user to be deactivated. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if deactivate operation fails - """ - response = await self._http.post( - MgmtV1.user_update_status_path, - body={"loginId": login_id, "status": "disabled"}, - ) - return response.json() - - async def update_login_id( - self, - login_id: str, - new_login_id: Optional[str] = None, - ) -> dict: - """ - Update login id of user, leave new login empty to remove the ID. - A user must have at least one login ID. Trying to remove the last one will fail. - - Args: - login_id (str): The login ID of the user to update. - new_login_id (str): New login ID to set for the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = await self._http.post( - MgmtV1.user_update_login_id_path, - body={"loginId": login_id, "newLoginId": new_login_id}, - ) - return response.json() - - async def update_email( - self, - login_id: str, - email: Optional[str] = None, - verified: Optional[bool] = None, - fail_on_conflict: Optional[bool] = None, - ) -> dict: - """ - Update the email address for an existing user. - - Args: - login_id (str): The login ID of the user to update the email for. - email (str): The user email address. Leave empty to remove. - verified (bool): Set to true for the user to be able to login with the email address. - fail_on_conflict (bool): Set to true to raise an error if the email is used as a login id and already exists. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = await self._http.post( - MgmtV1.user_update_email_path, - body={ - "loginId": login_id, - "email": email, - "verified": verified, - "failOnConflict": fail_on_conflict, - }, - ) - return response.json() - - async def update_phone( - self, - login_id: str, - phone: Optional[str] = None, - verified: Optional[bool] = None, - fail_on_conflict: Optional[bool] = None, - ) -> dict: - """ - Update the phone number for an existing user. - - Args: - login_id (str): The login ID of the user to update the phone for. - phone (str): The user phone number. Leave empty to remove. - verified (bool): Set to true for the user to be able to login with the phone number. - fail_on_conflict (bool): Set to true to raise an error if the phone is used as a login id and already exists. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = await self._http.post( - MgmtV1.user_update_phone_path, - body={ - "loginId": login_id, - "phone": phone, - "verified": verified, - "failOnConflict": fail_on_conflict, - }, - ) - return response.json() - - async def update_display_name( - self, - login_id: str, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - ) -> dict: - """ - Update the display name for an existing user. - - Args: - login_id (str): The login ID of the user to update. - display_name (str): Optional user display name. Leave empty to remove. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - bdy = {"loginId": login_id} - if display_name is not None: - bdy["displayName"] = display_name - if given_name is not None: - bdy["givenName"] = given_name - if middle_name is not None: - bdy["middleName"] = middle_name - if family_name is not None: - bdy["familyName"] = family_name - response = await self._http.post( - MgmtV1.user_update_name_path, - body=bdy, - ) - return response.json() - - async def update_picture( - self, - login_id: str, - picture: Optional[str] = None, - ) -> dict: - """ - Update the picture for an existing user. - - Args: - login_id (str): The login ID of the user to update. - picture (str): Optional url to user avatar. Leave empty to remove. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = await self._http.post( - MgmtV1.user_update_picture_path, - body={"loginId": login_id, "picture": picture}, - ) - return response.json() - - async def update_custom_attribute( - self, login_id: str, attribute_key: str, attribute_val: Union[str, int, bool] - ) -> dict: - """ - Update a custom attribute of an existing user. - - Args: - login_id (str): The login ID of the user to update. - attribute_key (str): The custom attribute that needs to be updated, this attribute needs to exists in Descope console app - attribute_val: The value to be updated - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the update operation fails - """ - response = await self._http.post( - MgmtV1.user_update_custom_attribute_path, - body={ - "loginId": login_id, - "attributeKey": attribute_key, - "attributeValue": attribute_val, - }, - ) - return response.json() - - async def set_roles( - self, - login_id: str, - role_names: List[str], - ) -> dict: - """ - Set roles to a user without tenant association. Use set_tenant_roles - for users that are part of a multi-tenant project. - - Args: - login_id (str): The login ID of the user to update. - role_names (List[str]): A list of roles to set to a user without tenant association. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_set_role_path, - body={"loginId": login_id, "roleNames": role_names}, - ) - return response.json() - - async def add_roles( - self, - login_id: str, - role_names: List[str], - ) -> dict: - """ - Add roles to a user without tenant association. Use add_tenant_roles - for users that are part of a multi-tenant project. - - Args: - login_id (str): The login ID of the user to update. - role_names (List[str]): A list of roles to add to a user without tenant association. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_add_role_path, - body={"loginId": login_id, "roleNames": role_names}, - ) - return response.json() - - async def remove_roles( - self, - login_id: str, - role_names: List[str], - ) -> dict: - """ - Remove roles from a user without tenant association. Use remove_tenant_roles - for users that are part of a multi-tenant project. - - Args: - login_id (str): The login ID of the user to update. - role_names (List[str]): A list of roles to remove from a user without tenant association. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_remove_role_path, - body={"loginId": login_id, "roleNames": role_names}, - ) - return response.json() - - async def set_sso_apps( - self, - login_id: str, - sso_app_ids: List[str], - ) -> dict: - """ - Set SSO applications association to a user. - - Args: - login_id (str): The login ID of the user to update. - sso_app_ids (List[str]): A list of sso applications ids for associate with a user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_set_sso_apps, - body={"loginId": login_id, "ssoAppIds": sso_app_ids}, - ) - return response.json() - - async def add_sso_apps( - self, - login_id: str, - sso_app_ids: List[str], - ) -> dict: - """ - Add SSO applications association to a user. - - Args: - login_id (str): The login ID of the user to update. - sso_app_ids (List[str]): A list of sso applications ids for associate with a user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_add_sso_apps, - body={"loginId": login_id, "ssoAppIds": sso_app_ids}, - ) - return response.json() - - async def remove_sso_apps( - self, - login_id: str, - sso_app_ids: List[str], - ) -> dict: - """ - Remove SSO applications association from a user. - - Args: - login_id (str): The login ID of the user to update. - sso_app_ids (List[str]): A list of sso applications ids to remove association from a user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_remove_sso_apps, - body={"loginId": login_id, "ssoAppIds": sso_app_ids}, - ) - return response.json() - - async def add_tenant( - self, - login_id: str, - tenant_id: str, - ) -> dict: - """ - Add a tenant association to an existing user. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the tenant to add to the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_add_tenant_path, - body={"loginId": login_id, "tenantId": tenant_id}, - ) - return response.json() - - async def remove_tenant( - self, - login_id: str, - tenant_id: str, - ) -> dict: - """ - Remove a tenant association from an existing user. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the tenant to add to the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_remove_tenant_path, - body={"loginId": login_id, "tenantId": tenant_id}, - ) - return response.json() - - async def add_families( - self, - login_id: str, - family_associations: List[AssociatedFamily], - ) -> dict: - """ - Add a user to one or more families. Each association may also set the user's roles and - family-scoped attribute values in that family. Adding a family the user already belongs to - merges: given role names are granted on top of the existing ones, given family-scoped - attributes are merged into the stored ones, and omitting either leaves it unchanged. - - Args: - login_id (str): The login ID of the user to update. - family_associations (List[AssociatedFamily]): The families to add the user to (at least one). - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_add_families_path, - body={ - "loginId": login_id, - "familyAssociations": associated_families_to_dict(family_associations), - }, - ) - return response.json() - - async def remove_families( - self, - login_id: str, - family_ids: List[str], - ) -> dict: - """ - Remove a user from one or more families. - - Args: - login_id (str): The login ID of the user to update. - family_ids (List[str]): The IDs of the families to remove the user from (at least one). - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_remove_families_path, - body={"loginId": login_id, "familyIds": family_ids}, - ) - return response.json() - - async def set_tenant_roles( - self, - login_id: str, - tenant_id: str, - role_names: List[str], - ) -> dict: - """ - Set roles to a user in a specific tenant. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the user's tenant. - role_names (List[str]): A list of roles to set on the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_set_role_path, - body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - ) - return response.json() - - async def add_tenant_roles( - self, - login_id: str, - tenant_id: str, - role_names: List[str], - ) -> dict: - """ - Add roles to a user in a specific tenant. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the user's tenant. - role_names (List[str]): A list of roles to add to the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_add_role_path, - body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - ) - return response.json() - - async def remove_tenant_roles( - self, - login_id: str, - tenant_id: str, - role_names: List[str], - ) -> dict: - """ - Remove roles from a user in a specific tenant. - - Args: - login_id (str): The login ID of the user to update. - tenant_id (str): The ID of the user's tenant. - role_names (List[str]): A list of roles to remove from the user. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_remove_role_path, - body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, - ) - return response.json() - - async def set_temporary_password( - self, - login_id: str, - password: str, - ) -> None: - """ - Set the temporary password for the given login ID. - Note: The password will automatically be set as expired. - The user will not be able to log-in with this password, and will be required to replace it on next login. - See also: expire_password - - Args: - login_id (str): The login ID of the user to set the password to. - password (str): The new password to set to the user. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_set_temporary_password_path, - body={ - "loginId": login_id, - "password": password, - "setActive": False, - }, - ) - return - - async def set_active_password( - self, - login_id: str, - password: str, - ) -> None: - """ - Set the password for the given login ID. - - Args: - login_id (str): The login ID of the user to set the password to. - password (str): The new password to set to the user. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_set_active_password_path, - body={ - "loginId": login_id, - "password": password, - "setActive": True, - }, - ) - return - - # Deprecated (use set_temporary_password instead) - async def set_password( - self, - login_id: str, - password: str, - ) -> None: - """ - Set the password for the given login ID. - Note: The password will automatically be set as expired. - The user will not be able to log-in with this password, and will be required to replace it on next login. - See also: expire_password - - Args: - login_id (str): The login ID of the user to set the password to. - password (str): The new password to set to the user. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_set_password_path, - body={ - "loginId": login_id, - "password": password, - }, - ) - return - - async def expire_password( - self, - login_id: str, - ) -> None: - """ - Expires the password for the given login ID. - Note: user sign-in with an expired password, the user will get an error with code. - Use the `password.send_reset` or `password.replace` methods to reset/replace the password. - - Args: - login_id (str): The login ID of the user to expire the password to. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_expire_password_path, - body={"loginId": login_id}, - ) - return - - async def remove_all_passkeys( - self, - login_id: str, - ) -> None: - """ - Removes all registered passkeys (WebAuthn devices) for the user with the given login ID. - Note: The user might not be able to login anymore if they have no other authentication - methods or a verified email/phone. - - Args: - login_id (str): The login ID of the user to remove passkeys for. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_remove_all_passkeys_path, - body={"loginId": login_id}, - ) - return - - async def remove_totp_seed( - self, - login_id: str, - ) -> None: - """ - Removes TOTP seed for the user with the given login ID. - Note: The user might not be able to login anymore if they have no other authentication - methods or a verified email/phone. - - Args: - login_id (str): The login ID of the user to remove totp seed for. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_remove_totp_seed_path, - body={"loginId": login_id}, - ) - return - - async def remove_recovery_codes( - self, - login_id: str, - ) -> None: - """ - Removes all recovery codes for the user with the given login ID or user ID. - - Args: - login_id (str): The login ID or user ID of the user to remove recovery codes for. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_remove_recovery_codes_path, - body={"loginId": login_id}, - ) - return - - async def generate_otp_for_test_user( - self, - method: DeliveryMethod, - login_id: str, - login_options: Optional[LoginOptions] = None, - ) -> dict: - """ - Generate OTP for the given login ID of a test user. - This is useful when running tests and don't want to use 3rd party messaging services. - - Args: - method (DeliveryMethod): The method to use for "delivering" the OTP verification code to the user, for example - EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED - login_id (str): The login ID of the test user being validated. - login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. - - Return value (dict): - Return dict in the format - {"code": "", "loginId": ""} - Containing the code for the login (exactly as it sent via Email or Phone messaging). - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_generate_otp_for_test_path, - body={ - "loginId": login_id, - "deliveryMethod": get_method_string(method), - "loginOptions": login_options.__dict__ if login_options else {}, - }, - ) - return response.json() - - async def generate_magic_link_for_test_user( - self, - method: DeliveryMethod, - login_id: str, - uri: str, - login_options: Optional[LoginOptions] = None, - ) -> dict: - """ - Generate Magic Link for the given login ID of a test user. - This is useful when running tests and don't want to use 3rd party messaging services. - - Args: - method (DeliveryMethod): The method to use for "delivering" the verification magic link to the user, for example - EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED - login_id (str): The login ID of the test user being validated. - uri (str): Optional redirect uri which will be used instead of any global configuration. - login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. - - Return value (dict): - Return dict in the format - {"link": "", "loginId": ""} - Containing the magic link for the login (exactly as it sent via Email or Phone messaging). - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_generate_magic_link_for_test_path, - body={ - "loginId": login_id, - "deliveryMethod": get_method_string(method), - "URI": uri, - "loginOptions": login_options.__dict__ if login_options else {}, - }, - ) - return response.json() - - async def generate_enchanted_link_for_test_user( - self, - login_id: str, - uri: str, - login_options: Optional[LoginOptions] = None, - ) -> dict: - """ - Generate Enchanted Link for the given login ID of a test user. - This is useful when running tests and don't want to use 3rd party messaging services. - - Args: - login_id (str): The login ID of the test user being validated. - uri (str): Optional redirect uri which will be used instead of any global configuration. - login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. - - Return value (dict): - Return dict in the format - {"link": "", "loginId": "", "pendingRef": ""} - Containing the enchanted link for the login (exactly as it sent via Email or Phone messaging) and pendingRef. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_generate_enchanted_link_for_test_path, - body={ - "loginId": login_id, - "URI": uri, - "loginOptions": login_options.__dict__ if login_options else {}, - }, - ) - return response.json() - - async def generate_embedded_link( - self, login_id: str, custom_claims: Optional[dict] = None, timeout: int = 0 - ) -> str: - """ - Generate Embedded Link for the given user login ID. - The return value is a token that can be verified via magic link, or using flows - - Args: - login_id (str): The login ID of the user to authenticate with. - custom_claims (dict): Additional claims to place on the jwt after verification - - Return value (str): - Return the token to be used in verification process - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_generate_embedded_link_path, - body={ - "loginId": login_id, - "customClaims": custom_claims, - "timeout": timeout, - }, - ) - return response.json()["token"] - - async def generate_sign_up_embedded_link( - self, - login_id: str, - user: Optional[CreateUserObj] = None, - email_verified: bool = False, - phone_verified: bool = False, - login_options: Optional[LoginOptions] = None, - timeout: int = 0, - ) -> str: - """ - Generate sign up Embedded Link for the given user login ID. - The return value is a token that can be verified via magic link, or using flows - - Args: - login_id (str): The login ID of the user to authenticate with. - user (CreateUserObj): Optional user object to create the user with - email_verified (bool): Optional, set to true if the email is verified - phone_verified (bool): Optional, set to true if the phone is verified - login_options (LoginOptions): Optional login options to customize the link - timeout (int): Optional, the timeout in seconds for the link to be valid - - Return value (str): - Return the token to be used in verification process - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_generate_sign_up_embedded_link_path, - body={ - "loginId": login_id, - "user": user.__dict__ if user else {}, - "loginOptions": login_options.__dict__ if login_options else {}, - "emailVerified": email_verified, - "phoneVerified": phone_verified, - "timeout": timeout, - }, - ) - return response.json()["token"] - - async def history(self, user_ids: List[str]) -> List[dict]: - """ - Retrieve users' authentication history, by the given user's ids. - - Args: - login_ids (List[str]): List of Users' IDs. - - Return value (List[dict]): - Return List in the format - [ - { - "userId": "User's ID", - "loginTime": "User'sLogin time", - "city": "User's city", - "country": "User's country", - "ip": User's IP - } - ] - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_history_path, - body=user_ids, - ) - return response.json() - - async def create_custom_attribute( - self, - name: str, - display_name: str, - type: str, - required: Optional[bool] = None, - options: Optional[List[str]] = None, - ) -> dict: - """ - Create a new custom attribute for users. - - Args: - name (str): The name of the custom attribute. - display_name (str): The display name of the custom attribute. - type (str): The type of the custom attribute (e.g., "string", "number", "boolean"). - required (bool): Optional, whether the custom attribute is required. - options (List[str]): Optional, list of options for the custom attribute. - - Return value (dict): - Return dict containing the custom attributes list. - - Raise: - AuthException: raised if the operation fails - """ - body: dict = { - "attributes": [ - { - "name": name, - "displayName": display_name, - "type": type, - } - ] - } - if required is not None: - body["attributes"][0]["required"] = required - if options is not None: - body["attributes"][0]["options"] = options - - response = await self._http.post( - MgmtV1.user_create_custom_attribute_path, - body=body, - ) - return response.json() - - async def delete_custom_attribute( - self, - name: str, - ) -> dict: - """ - Delete a custom attribute for users. - - Args: - name (str): The name of the custom attribute to delete. - - Return value (dict): - Return dict containing the remaining custom attributes list. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_delete_custom_attribute_path, - body={"names": [name]}, - ) - return response.json() - - async def load_custom_attributes(self) -> dict: - """ - Load all custom attributes for users. - - Return value (dict): - Return dict containing the custom attributes list. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.get( - MgmtV1.user_load_custom_attributes_path, - params={}, - ) - return response.json() - - async def load_family_scoped_custom_attributes(self) -> dict: - """ - Load the family-scoped user custom attribute definitions. These are user attributes whose - values are held per family membership (see `AssociatedFamily.family_scoped_attributes`), - and are a separate set from the plain user custom attributes. - - Return value (dict): - Return dict in the format - {"data": [{"name": , "type": , "displayName": , ...}]} - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.get(MgmtV1.user_load_family_scoped_custom_attributes_path) - return response.json() - - async def create_family_scoped_custom_attributes(self, attributes: List[CustomAttribute]) -> dict: - """ - Create family-scoped user custom attribute definitions. - - Args: - attributes (List[CustomAttribute]): The custom attribute definitions to create. - - Return value (dict): - Return dict in the format - {"data": [...]} - Containing the updated family-scoped custom attribute definitions. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_create_family_scoped_custom_attributes_path, - body={"attributes": custom_attributes_to_dict(attributes)}, - ) - return response.json() - - async def delete_family_scoped_custom_attributes(self, names: List[str]) -> dict: - """ - Delete family-scoped user custom attribute definitions by name. - - Args: - names (List[str]): The names of the custom attributes to delete. - - Return value (dict): - Return dict in the format - {"data": [...]} - Containing the remaining family-scoped custom attribute definitions. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_delete_family_scoped_custom_attributes_path, - body={"names": names}, - ) - return response.json() - - async def delete_batch( - self, - user_ids: List[str], - ): - """ - Delete multiple users by their user IDs. IMPORTANT: This action is irreversible. Use carefully. - - Args: - user_ids (List[str]): List of user IDs to delete. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_delete_batch_path, - body={"userIds": user_ids}, - ) - - async def import_users( - self, - source: str, - users: Optional[bytes] = None, - hashes: Optional[bytes] = None, - dryrun: bool = False, - ) -> dict: - """ - Import users from an external source. - - Args: - source (str): The source of the users (e.g., "auth0", "firebase"). - users (bytes): Optional, JSON bytes containing the users to import. - hashes (bytes): Optional, JSON bytes containing password hashes. - dryrun (bool): Optional, if True, perform a dry run without actually importing. - - Return value (dict): - Return dict containing the import results. - - Raise: - AuthException: raised if the operation fails - """ - body = { - "source": source, - "dryrun": dryrun, - } - if users is not None: - body["users"] = base64.b64encode(users).decode("utf-8") - if hashes is not None: - body["hashes"] = base64.b64encode(hashes).decode("utf-8") - - response = await self._http.post( - MgmtV1.user_import_path, - body=body, - ) - return response.json() - - async def delete_passkey( - self, - login_id: str, - credential_id: str, - ): - """ - Delete a specific passkey (WebAuthn device) for a user. - - Args: - login_id (str): The login ID of the user. - credential_id (str): The credential ID of the passkey to delete. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_delete_passkey_path, - body={"loginId": login_id, "credentialId": credential_id}, - ) - - async def list_passkeys( - self, - login_id: str, - ) -> dict: - """ - List all passkeys (WebAuthn devices) registered for a user. - - Args: - login_id (str): The login ID of the user. - - Return value (dict): - Return dict containing the list of passkeys. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_list_passkeys_path, - body={"loginId": login_id}, - ) - return response.json() - - async def list_trusted_devices( - self, - identifiers: List[str], - ) -> dict: - """ - List all trusted devices for the specified users. - - Args: - identifiers (List[str]): List of login IDs or user IDs. - - Return value (dict): - Return dict containing the list of trusted devices. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_list_trusted_devices_path, - body={"identifiers": identifiers}, - ) - return response.json() - - async def remove_trusted_device( - self, - identifier: str, - device_ids: List[str], - ): - """ - Remove trusted devices for a user. - - Args: - identifier (str): The login ID or user ID. - device_ids (List[str]): List of device IDs to remove. - - Raise: - AuthException: raised if the operation fails - """ - await self._http.post( - MgmtV1.user_remove_trusted_device_path, - body={"identifier": identifier, "deviceIds": device_ids}, - ) - - async def update_recovery_email( - self, - login_id: str, - recovery_email: str, - verified: bool = False, - ) -> dict: - """ - Update the recovery email for a user. - - Args: - login_id (str): The login ID of the user. - recovery_email (str): The recovery email address. - verified (bool): Whether the recovery email is verified. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_update_recovery_email_path, - body={ - "loginId": login_id, - "recoveryEmail": recovery_email, - "verified": verified, - }, - ) - return response.json() - - async def update_recovery_phone( - self, - login_id: str, - recovery_phone: str, - verified: bool = False, - ) -> dict: - """ - Update the recovery phone number for a user. - - Args: - login_id (str): The login ID of the user. - recovery_phone (str): The recovery phone number. - verified (bool): Whether the recovery phone is verified. - - Return value (dict): - Return dict in the format - {"user": {}} - Containing the updated user information. - - Raise: - AuthException: raised if the operation fails - """ - response = await self._http.post( - MgmtV1.user_update_recovery_phone_path, - body={ - "loginId": login_id, - "recoveryPhone": recovery_phone, - "verified": verified, - }, - ) - return response.json() diff --git a/descope/management/user_pwd.py b/descope/management/user_pwd.py deleted file mode 100644 index c40661ad4..000000000 --- a/descope/management/user_pwd.py +++ /dev/null @@ -1,123 +0,0 @@ -from typing import Optional, Union - - -class UserPasswordBcrypt: - def __init__( - self, - hash: str, - ): - """ - The bcrypt hash in plaintext format, for example "$2a$..." - """ - self.hash = hash - - def to_dict(self) -> dict: - return { - "bcrypt": { - "hash": self.hash, - }, - } - - -class UserPasswordFirebase: - def __init__( - self, - hash: str, - salt: str, - salt_separator: str, - signer_key: str, - memory: int, - rounds: int, - ): - """ - The hash, salt, salt separator, and signer key should be base64 strings using - standard encoding with padding. - The memory cost value is an integer, usually between 12 to 17. - The rounds cost value is an integer, usually between 6 to 10. - """ - self.hash = hash - self.salt = salt - self.salt_separator = salt_separator - self.signer_key = signer_key - self.memory = memory - self.rounds = rounds - - def to_dict(self) -> dict: - return { - "firebase": { - "hash": self.hash, - "salt": self.salt, - "saltSeparator": self.salt_separator, - "signerKey": self.signer_key, - "memory": self.memory, - "rounds": self.rounds, - }, - } - - -class UserPasswordPbkdf2: - def __init__( - self, - hash: str, - salt: str, - iterations: int, - variant: str, - ): - """ - The hash and salt should be base64 strings using standard encoding with padding. - The iterations cost value is an integer, usually in the thousands. - The hash variant should be either "sha1", "sha256", or "sha512". - """ - self.hash = hash - self.salt = salt - self.iterations = iterations - self.variant = variant - - def to_dict(self) -> dict: - return { - "pbkdf2": { - "hash": self.hash, - "salt": self.salt, - "iterations": self.iterations, - "type": self.variant, - }, - } - - -class UserPasswordDjango: - def __init__( - self, - hash: str, - ): - """ - The django hash in plaintext format, for example "pbkdf2_sha256$..." - """ - self.hash = hash - - def to_dict(self) -> dict: - return { - "django": { - "hash": self.hash, - }, - } - - -class UserPassword: - def __init__( - self, - cleartext: Optional[str] = None, - hashed: Optional[ - Union[ - UserPasswordBcrypt, - UserPasswordFirebase, - UserPasswordPbkdf2, - UserPasswordDjango, - ] - ] = None, - ): - """ - Set a UserPassword on UserObj objects when calling invite_batch to create or invite users - with a cleartext or prehashed password. Note that only one of the two options should be set. - """ - self.cleartext = cleartext - self.hashed = hashed diff --git a/descope/mgmt.py b/descope/mgmt.py deleted file mode 100644 index d2b6d3a10..000000000 --- a/descope/mgmt.py +++ /dev/null @@ -1,218 +0,0 @@ -from typing import Optional - -from descope.auth import Auth -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -from descope.http_client import HTTPClient -from descope.management.access_key import AccessKey -from descope.management.analytics import Analytics -from descope.management.audit import Audit -from descope.management.authz import Authz -from descope.management.descoper import Descoper -from descope.management.engine import Engine -from descope.management.family import Family -from descope.management.fga import FGA -from descope.management.flow import Flow -from descope.management.group import Group -from descope.management.jwt import JWT -from descope.management.license import License -from descope.management.lists import Lists -from descope.management.management_key import ManagementKey -from descope.management.outbound_application import ( - OutboundApplication, - OutboundApplicationByToken, -) -from descope.management.outbound_scim import OutboundSCIM -from descope.management.password import Password -from descope.management.permission import Permission -from descope.management.project import Project -from descope.management.role import Role -from descope.management.scope_claim_mapping import ScopeClaimMapping -from descope.management.sso_application import SSOApplication -from descope.management.sso_settings import SSOSettings - -# Import management modules after adapter to avoid circularities -from descope.management.tenant import Tenant -from descope.management.third_party_application import ThirdPartyApplication -from descope.management.user import User - - -class MGMT: - _http: HTTPClient - - def __init__(self, http_client: HTTPClient, auth: Auth, fga_cache_url: Optional[str] = None): - """Create a management API facade. - - Args: - http_client: HTTP client to use for all management HTTP calls. - """ - self._http = http_client - self._access_key = AccessKey(http_client) - self._analytics = Analytics(http_client) - self._audit = Audit(http_client) - self._authz = Authz(http_client, fga_cache_url=fga_cache_url) - self._descoper = Descoper(http_client) - self._engine = Engine(http_client) - self._family = Family(http_client) - self._fga = FGA(http_client, fga_cache_url=fga_cache_url) - self._flow = Flow(http_client) - self._group = Group(http_client) - self._jwt = JWT(http_client, auth=auth) - self._license = License(http_client) - self._lists = Lists(http_client) - self._management_key = ManagementKey(http_client) - self._outbound_application = OutboundApplication(http_client) - self._outbound_application_by_token = OutboundApplicationByToken(http_client) - self._outbound_scim = OutboundSCIM(http_client) - self._password = Password(http_client) - self._permission = Permission(http_client) - self._project = Project(http_client) - self._role = Role(http_client) - self._scope_claim_mapping = ScopeClaimMapping(http_client) - self._sso = SSOSettings(http_client) - self._sso_application = SSOApplication(http_client) - self._third_party_application = ThirdPartyApplication(http_client) - self._tenant = Tenant(http_client) - self._user = User(http_client) - - def _ensure_management_key(self, property_name: str): - """Check if management key is available for the given property.""" - if not self._http.management_key: - raise AuthException( - error_type=ERROR_TYPE_INVALID_ARGUMENT, - error_message=f"Management key is required to access '{property_name}' functionality", - ) - - @property - def tenant(self): - self._ensure_management_key("tenant") - return self._tenant - - @property - def family(self): - self._ensure_management_key("family") - return self._family - - @property - def sso_application(self): - self._ensure_management_key("sso_application") - return self._sso_application - - @property - def user(self): - self._ensure_management_key("user") - return self._user - - @property - def access_key(self): - self._ensure_management_key("access_key") - return self._access_key - - @property - def sso(self): - self._ensure_management_key("sso") - return self._sso - - @property - def jwt(self): - self._ensure_management_key("jwt") - return self._jwt - - @property - def license(self): - self._ensure_management_key("license") - return self._license - - @property - def permission(self): - self._ensure_management_key("permission") - return self._permission - - @property - def role(self): - self._ensure_management_key("role") - return self._role - - @property - def group(self): - self._ensure_management_key("group") - return self._group - - @property - def flow(self): - self._ensure_management_key("flow") - return self._flow - - @property - def audit(self): - self._ensure_management_key("audit") - return self._audit - - @property - def authz(self): - self._ensure_management_key("authz") - return self._authz - - @property - def fga(self): - self._ensure_management_key("fga") - return self._fga - - @property - def project(self): - self._ensure_management_key("project") - return self._project - - @property - def outbound_application(self): - self._ensure_management_key("outbound_application") - return self._outbound_application - - @property - def outbound_application_by_token(self): - # No management key check for outbound_app_token (as authentication for those methods is done by inbound app token) - return self._outbound_application_by_token - - @property - def outbound_scim(self): - self._ensure_management_key("outbound_scim") - return self._outbound_scim - - @property - def descoper(self): - self._ensure_management_key("descoper") - return self._descoper - - @property - def engine(self): - self._ensure_management_key("engine") - return self._engine - - @property - def management_key(self): - self._ensure_management_key("management_key") - return self._management_key - - @property - def analytics(self): - self._ensure_management_key("analytics") - return self._analytics - - @property - def scope_claim_mapping(self): - self._ensure_management_key("scope_claim_mapping") - return self._scope_claim_mapping - - @property - def list(self): - self._ensure_management_key("list") - return self._lists - - @property - def password(self): - self._ensure_management_key("password") - return self._password - - @property - def third_party_application(self): - self._ensure_management_key("third_party_application") - return self._third_party_application diff --git a/descope/mgmt_async.py b/descope/mgmt_async.py deleted file mode 100644 index 24ccc8d8c..000000000 --- a/descope/mgmt_async.py +++ /dev/null @@ -1,220 +0,0 @@ -from typing import Optional - -from descope.auth_async import AuthAsync -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -from descope.http_client_async import HTTPClientAsync -from descope.management.access_key_async import AccessKeyAsync -from descope.management.analytics_async import AnalyticsAsync -from descope.management.audit_async import AuditAsync -from descope.management.authz_async import AuthzAsync -from descope.management.descoper_async import DescoperAsync -from descope.management.engine_async import EngineAsync -from descope.management.family_async import FamilyAsync -from descope.management.fga_async import FGAAsync -from descope.management.flow_async import FlowAsync -from descope.management.group_async import GroupAsync -from descope.management.jwt_async import JWTAsync -from descope.management.license_async import LicenseAsync -from descope.management.lists_async import ListsAsync -from descope.management.management_key_async import ManagementKeyAsync -from descope.management.outbound_application_async import ( - OutboundApplicationAsync, - OutboundApplicationByTokenAsync, -) -from descope.management.outbound_scim_async import OutboundSCIMAsync -from descope.management.password_async import PasswordAsync -from descope.management.permission_async import PermissionAsync -from descope.management.project_async import ProjectAsync -from descope.management.role_async import RoleAsync -from descope.management.scope_claim_mapping_async import ScopeClaimMappingAsync -from descope.management.sso_application_async import SSOApplicationAsync -from descope.management.sso_settings_async import SSOSettingsAsync - -# Import management modules after adapter to avoid circularities -from descope.management.tenant_async import TenantAsync -from descope.management.third_party_application_async import ( - ThirdPartyApplicationAsync, -) -from descope.management.user_async import UserAsync - - -class MGMTAsync: - _http: HTTPClientAsync - - def __init__(self, http_client: HTTPClientAsync, auth: AuthAsync, fga_cache_url: Optional[str] = None): - """Create an async management API facade. - - Args: - http_client: Async HTTP client to use for all management HTTP calls. - """ - self._http = http_client - self._access_key = AccessKeyAsync(http_client) - self._analytics = AnalyticsAsync(http_client) - self._audit = AuditAsync(http_client) - self._authz = AuthzAsync(http_client, fga_cache_url=fga_cache_url) - self._descoper = DescoperAsync(http_client) - self._engine = EngineAsync(http_client) - self._family = FamilyAsync(http_client) - self._fga = FGAAsync(http_client, fga_cache_url=fga_cache_url) - self._flow = FlowAsync(http_client) - self._group = GroupAsync(http_client) - self._jwt = JWTAsync(http_client, auth=auth) - self._license = LicenseAsync(http_client) - self._lists = ListsAsync(http_client) - self._management_key = ManagementKeyAsync(http_client) - self._outbound_application = OutboundApplicationAsync(http_client) - self._outbound_application_by_token = OutboundApplicationByTokenAsync(http_client) - self._outbound_scim = OutboundSCIMAsync(http_client) - self._password = PasswordAsync(http_client) - self._permission = PermissionAsync(http_client) - self._project = ProjectAsync(http_client) - self._role = RoleAsync(http_client) - self._scope_claim_mapping = ScopeClaimMappingAsync(http_client) - self._sso = SSOSettingsAsync(http_client) - self._sso_application = SSOApplicationAsync(http_client) - self._third_party_application = ThirdPartyApplicationAsync(http_client) - self._tenant = TenantAsync(http_client) - self._user = UserAsync(http_client) - - def _ensure_management_key(self, property_name: str): - """Check if management key is available for the given property.""" - if not self._http.management_key: - raise AuthException( - error_type=ERROR_TYPE_INVALID_ARGUMENT, - error_message=f"Management key is required to access '{property_name}' functionality", - ) - - @property - def tenant(self) -> TenantAsync: - self._ensure_management_key("tenant") - return self._tenant - - @property - def family(self) -> FamilyAsync: - self._ensure_management_key("family") - return self._family - - @property - def sso_application(self) -> SSOApplicationAsync: - self._ensure_management_key("sso_application") - return self._sso_application - - @property - def user(self) -> UserAsync: - self._ensure_management_key("user") - return self._user - - @property - def access_key(self) -> AccessKeyAsync: - self._ensure_management_key("access_key") - return self._access_key - - @property - def sso(self) -> SSOSettingsAsync: - self._ensure_management_key("sso") - return self._sso - - @property - def jwt(self) -> JWTAsync: - self._ensure_management_key("jwt") - return self._jwt - - @property - def license(self) -> LicenseAsync: - self._ensure_management_key("license") - return self._license - - @property - def permission(self) -> PermissionAsync: - self._ensure_management_key("permission") - return self._permission - - @property - def role(self) -> RoleAsync: - self._ensure_management_key("role") - return self._role - - @property - def group(self) -> GroupAsync: - self._ensure_management_key("group") - return self._group - - @property - def flow(self) -> FlowAsync: - self._ensure_management_key("flow") - return self._flow - - @property - def audit(self) -> AuditAsync: - self._ensure_management_key("audit") - return self._audit - - @property - def authz(self) -> AuthzAsync: - self._ensure_management_key("authz") - return self._authz - - @property - def fga(self) -> FGAAsync: - self._ensure_management_key("fga") - return self._fga - - @property - def project(self) -> ProjectAsync: - self._ensure_management_key("project") - return self._project - - @property - def outbound_application(self) -> OutboundApplicationAsync: - self._ensure_management_key("outbound_application") - return self._outbound_application - - @property - def outbound_application_by_token(self) -> OutboundApplicationByTokenAsync: - # No management key check for outbound_app_token (as authentication for those methods is done by inbound app token) - return self._outbound_application_by_token - - @property - def outbound_scim(self) -> OutboundSCIMAsync: - self._ensure_management_key("outbound_scim") - return self._outbound_scim - - @property - def descoper(self) -> DescoperAsync: - self._ensure_management_key("descoper") - return self._descoper - - @property - def engine(self) -> EngineAsync: - self._ensure_management_key("engine") - return self._engine - - @property - def management_key(self) -> ManagementKeyAsync: - self._ensure_management_key("management_key") - return self._management_key - - @property - def analytics(self) -> AnalyticsAsync: - self._ensure_management_key("analytics") - return self._analytics - - @property - def scope_claim_mapping(self) -> ScopeClaimMappingAsync: - self._ensure_management_key("scope_claim_mapping") - return self._scope_claim_mapping - - @property - def list(self) -> ListsAsync: - self._ensure_management_key("list") - return self._lists - - @property - def password(self) -> PasswordAsync: - self._ensure_management_key("password") - return self._password - - @property - def third_party_application(self) -> ThirdPartyApplicationAsync: - self._ensure_management_key("third_party_application") - return self._third_party_application diff --git a/descope/py.typed b/descope/py.typed deleted file mode 100644 index e69de29bb..000000000 diff --git a/endpoint.json b/endpoint.json new file mode 100644 index 000000000..a46714c01 --- /dev/null +++ b/endpoint.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "label": "Coverage", "message": "98%", "color": "orange"} \ No newline at end of file diff --git a/htmlcov/coverage_html.js b/htmlcov/coverage_html.js new file mode 100644 index 000000000..593488286 --- /dev/null +++ b/htmlcov/coverage_html.js @@ -0,0 +1,624 @@ +// Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +// For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt + +// Coverage.py HTML report browser code. +/*jslint browser: true, sloppy: true, vars: true, plusplus: true, maxerr: 50, indent: 4 */ +/*global coverage: true, document, window, $ */ + +coverage = {}; + +// General helpers +function debounce(callback, wait) { + let timeoutId = null; + return function(...args) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + callback.apply(this, args); + }, wait); + }; +}; + +function checkVisible(element) { + const rect = element.getBoundingClientRect(); + const viewBottom = Math.max(document.documentElement.clientHeight, window.innerHeight); + const viewTop = 30; + return !(rect.bottom < viewTop || rect.top >= viewBottom); +} + +function on_click(sel, fn) { + const elt = document.querySelector(sel); + if (elt) { + elt.addEventListener("click", fn); + } +} + +// Helpers for table sorting +function getCellValue(row, column = 0) { + const cell = row.cells[column] // nosemgrep: eslint.detect-object-injection + if (cell.childElementCount == 1) { + const child = cell.firstElementChild + if (child instanceof HTMLTimeElement && child.dateTime) { + return child.dateTime + } else if (child instanceof HTMLDataElement && child.value) { + return child.value + } + } + return cell.innerText || cell.textContent; +} + +function rowComparator(rowA, rowB, column = 0) { + let valueA = getCellValue(rowA, column); + let valueB = getCellValue(rowB, column); + if (!isNaN(valueA) && !isNaN(valueB)) { + return valueA - valueB + } + return valueA.localeCompare(valueB, undefined, {numeric: true}); +} + +function sortColumn(th) { + // Get the current sorting direction of the selected header, + // clear state on other headers and then set the new sorting direction + const currentSortOrder = th.getAttribute("aria-sort"); + [...th.parentElement.cells].forEach(header => header.setAttribute("aria-sort", "none")); + if (currentSortOrder === "none") { + th.setAttribute("aria-sort", th.dataset.defaultSortOrder || "ascending"); + } else { + th.setAttribute("aria-sort", currentSortOrder === "ascending" ? "descending" : "ascending"); + } + + const column = [...th.parentElement.cells].indexOf(th) + + // Sort all rows and afterwards append them in order to move them in the DOM + Array.from(th.closest("table").querySelectorAll("tbody tr")) + .sort((rowA, rowB) => rowComparator(rowA, rowB, column) * (th.getAttribute("aria-sort") === "ascending" ? 1 : -1)) + .forEach(tr => tr.parentElement.appendChild(tr) ); +} + +// Find all the elements with data-shortcut attribute, and use them to assign a shortcut key. +coverage.assign_shortkeys = function () { + document.querySelectorAll("[data-shortcut]").forEach(element => { + document.addEventListener("keypress", event => { + if (event.target.tagName.toLowerCase() === "input") { + return; // ignore keypress from search filter + } + if (event.key === element.dataset.shortcut) { + element.click(); + } + }); + }); +}; + +// Create the events for the filter box. +coverage.wire_up_filter = function () { + // Cache elements. + const table = document.querySelector("table.index"); + const table_body_rows = table.querySelectorAll("tbody tr"); + const no_rows = document.getElementById("no_rows"); + + // Observe filter keyevents. + document.getElementById("filter").addEventListener("input", debounce(event => { + // Keep running total of each metric, first index contains number of shown rows + const totals = new Array(table.rows[0].cells.length).fill(0); + // Accumulate the percentage as fraction + totals[totals.length - 1] = { "numer": 0, "denom": 0 }; // nosemgrep: eslint.detect-object-injection + + // Hide / show elements. + table_body_rows.forEach(row => { + if (!row.cells[0].textContent.includes(event.target.value)) { + // hide + row.classList.add("hidden"); + return; + } + + // show + row.classList.remove("hidden"); + totals[0]++; + + for (let column = 1; column < totals.length; column++) { + // Accumulate dynamic totals + cell = row.cells[column] // nosemgrep: eslint.detect-object-injection + if (column === totals.length - 1) { + // Last column contains percentage + const [numer, denom] = cell.dataset.ratio.split(" "); + totals[column]["numer"] += parseInt(numer, 10); // nosemgrep: eslint.detect-object-injection + totals[column]["denom"] += parseInt(denom, 10); // nosemgrep: eslint.detect-object-injection + } else { + totals[column] += parseInt(cell.textContent, 10); // nosemgrep: eslint.detect-object-injection + } + } + }); + + // Show placeholder if no rows will be displayed. + if (!totals[0]) { + // Show placeholder, hide table. + no_rows.style.display = "block"; + table.style.display = "none"; + return; + } + + // Hide placeholder, show table. + no_rows.style.display = null; + table.style.display = null; + + const footer = table.tFoot.rows[0]; + // Calculate new dynamic sum values based on visible rows. + for (let column = 1; column < totals.length; column++) { + // Get footer cell element. + const cell = footer.cells[column]; // nosemgrep: eslint.detect-object-injection + + // Set value into dynamic footer cell element. + if (column === totals.length - 1) { + // Percentage column uses the numerator and denominator, + // and adapts to the number of decimal places. + const match = /\.([0-9]+)/.exec(cell.textContent); + const places = match ? match[1].length : 0; + const { numer, denom } = totals[column]; // nosemgrep: eslint.detect-object-injection + cell.dataset.ratio = `${numer} ${denom}`; + // Check denom to prevent NaN if filtered files contain no statements + cell.textContent = denom + ? `${(numer * 100 / denom).toFixed(places)}%` + : `${(100).toFixed(places)}%`; + } else { + cell.textContent = totals[column]; // nosemgrep: eslint.detect-object-injection + } + } + })); + + // Trigger change event on setup, to force filter on page refresh + // (filter value may still be present). + document.getElementById("filter").dispatchEvent(new Event("input")); +}; + +coverage.INDEX_SORT_STORAGE = "COVERAGE_INDEX_SORT_2"; + +// Loaded on index.html +coverage.index_ready = function () { + coverage.assign_shortkeys(); + coverage.wire_up_filter(); + document.querySelectorAll("[data-sortable] th[aria-sort]").forEach( + th => th.addEventListener("click", e => sortColumn(e.target)) + ); + + // Look for a localStorage item containing previous sort settings: + const stored_list = localStorage.getItem(coverage.INDEX_SORT_STORAGE); + + if (stored_list) { + const {column, direction} = JSON.parse(stored_list); + const th = document.querySelector("[data-sortable]").tHead.rows[0].cells[column]; // nosemgrep: eslint.detect-object-injection + th.setAttribute("aria-sort", direction === "ascending" ? "descending" : "ascending"); + th.click() + } + + // Watch for page unload events so we can save the final sort settings: + window.addEventListener("unload", function () { + const th = document.querySelector('[data-sortable] th[aria-sort="ascending"], [data-sortable] [aria-sort="descending"]'); + if (!th) { + return; + } + localStorage.setItem(coverage.INDEX_SORT_STORAGE, JSON.stringify({ + column: [...th.parentElement.cells].indexOf(th), + direction: th.getAttribute("aria-sort"), + })); + }); + + on_click(".button_prev_file", coverage.to_prev_file); + on_click(".button_next_file", coverage.to_next_file); + + on_click(".button_show_hide_help", coverage.show_hide_help); +}; + +// -- pyfile stuff -- + +coverage.LINE_FILTERS_STORAGE = "COVERAGE_LINE_FILTERS"; + +coverage.pyfile_ready = function () { + // If we're directed to a particular line number, highlight the line. + var frag = location.hash; + if (frag.length > 2 && frag[1] === "t") { + document.querySelector(frag).closest(".n").classList.add("highlight"); + coverage.set_sel(parseInt(frag.substr(2), 10)); + } else { + coverage.set_sel(0); + } + + on_click(".button_toggle_run", coverage.toggle_lines); + on_click(".button_toggle_mis", coverage.toggle_lines); + on_click(".button_toggle_exc", coverage.toggle_lines); + on_click(".button_toggle_par", coverage.toggle_lines); + + on_click(".button_next_chunk", coverage.to_next_chunk_nicely); + on_click(".button_prev_chunk", coverage.to_prev_chunk_nicely); + on_click(".button_top_of_page", coverage.to_top); + on_click(".button_first_chunk", coverage.to_first_chunk); + + on_click(".button_prev_file", coverage.to_prev_file); + on_click(".button_next_file", coverage.to_next_file); + on_click(".button_to_index", coverage.to_index); + + on_click(".button_show_hide_help", coverage.show_hide_help); + + coverage.filters = undefined; + try { + coverage.filters = localStorage.getItem(coverage.LINE_FILTERS_STORAGE); + } catch(err) {} + + if (coverage.filters) { + coverage.filters = JSON.parse(coverage.filters); + } + else { + coverage.filters = {run: false, exc: true, mis: true, par: true}; + } + + for (cls in coverage.filters) { + coverage.set_line_visibilty(cls, coverage.filters[cls]); // nosemgrep: eslint.detect-object-injection + } + + coverage.assign_shortkeys(); + coverage.init_scroll_markers(); + coverage.wire_up_sticky_header(); + + document.querySelectorAll("[id^=ctxs]").forEach( + cbox => cbox.addEventListener("click", coverage.expand_contexts) + ); + + // Rebuild scroll markers when the window height changes. + window.addEventListener("resize", coverage.build_scroll_markers); +}; + +coverage.toggle_lines = function (event) { + const btn = event.target.closest("button"); + const category = btn.value + const show = !btn.classList.contains("show_" + category); + coverage.set_line_visibilty(category, show); + coverage.build_scroll_markers(); + coverage.filters[category] = show; + try { + localStorage.setItem(coverage.LINE_FILTERS_STORAGE, JSON.stringify(coverage.filters)); + } catch(err) {} +}; + +coverage.set_line_visibilty = function (category, should_show) { + const cls = "show_" + category; + const btn = document.querySelector(".button_toggle_" + category); + if (btn) { + if (should_show) { + document.querySelectorAll("#source ." + category).forEach(e => e.classList.add(cls)); + btn.classList.add(cls); + } + else { + document.querySelectorAll("#source ." + category).forEach(e => e.classList.remove(cls)); + btn.classList.remove(cls); + } + } +}; + +// Return the nth line div. +coverage.line_elt = function (n) { + return document.getElementById("t" + n)?.closest("p"); +}; + +// Set the selection. b and e are line numbers. +coverage.set_sel = function (b, e) { + // The first line selected. + coverage.sel_begin = b; + // The next line not selected. + coverage.sel_end = (e === undefined) ? b+1 : e; +}; + +coverage.to_top = function () { + coverage.set_sel(0, 1); + coverage.scroll_window(0); +}; + +coverage.to_first_chunk = function () { + coverage.set_sel(0, 1); + coverage.to_next_chunk(); +}; + +coverage.to_prev_file = function () { + window.location = document.getElementById("prevFileLink").href; +} + +coverage.to_next_file = function () { + window.location = document.getElementById("nextFileLink").href; +} + +coverage.to_index = function () { + location.href = document.getElementById("indexLink").href; +} + +coverage.show_hide_help = function () { + const helpCheck = document.getElementById("help_panel_state") + helpCheck.checked = !helpCheck.checked; +} + +// Return a string indicating what kind of chunk this line belongs to, +// or null if not a chunk. +coverage.chunk_indicator = function (line_elt) { + const classes = line_elt?.className; + if (!classes) { + return null; + } + const match = classes.match(/\bshow_\w+\b/); + if (!match) { + return null; + } + return match[0]; +}; + +coverage.to_next_chunk = function () { + const c = coverage; + + // Find the start of the next colored chunk. + var probe = c.sel_end; + var chunk_indicator, probe_line; + while (true) { + probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + chunk_indicator = c.chunk_indicator(probe_line); + if (chunk_indicator) { + break; + } + probe++; + } + + // There's a next chunk, `probe` points to it. + var begin = probe; + + // Find the end of this chunk. + var next_indicator = chunk_indicator; + while (next_indicator === chunk_indicator) { + probe++; + probe_line = c.line_elt(probe); + next_indicator = c.chunk_indicator(probe_line); + } + c.set_sel(begin, probe); + c.show_selection(); +}; + +coverage.to_prev_chunk = function () { + const c = coverage; + + // Find the end of the prev colored chunk. + var probe = c.sel_begin-1; + var probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + var chunk_indicator = c.chunk_indicator(probe_line); + while (probe > 1 && !chunk_indicator) { + probe--; + probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + chunk_indicator = c.chunk_indicator(probe_line); + } + + // There's a prev chunk, `probe` points to its last line. + var end = probe+1; + + // Find the beginning of this chunk. + var prev_indicator = chunk_indicator; + while (prev_indicator === chunk_indicator) { + probe--; + if (probe <= 0) { + return; + } + probe_line = c.line_elt(probe); + prev_indicator = c.chunk_indicator(probe_line); + } + c.set_sel(probe+1, end); + c.show_selection(); +}; + +// Returns 0, 1, or 2: how many of the two ends of the selection are on +// the screen right now? +coverage.selection_ends_on_screen = function () { + if (coverage.sel_begin === 0) { + return 0; + } + + const begin = coverage.line_elt(coverage.sel_begin); + const end = coverage.line_elt(coverage.sel_end-1); + + return ( + (checkVisible(begin) ? 1 : 0) + + (checkVisible(end) ? 1 : 0) + ); +}; + +coverage.to_next_chunk_nicely = function () { + if (coverage.selection_ends_on_screen() === 0) { + // The selection is entirely off the screen: + // Set the top line on the screen as selection. + + // This will select the top-left of the viewport + // As this is most likely the span with the line number we take the parent + const line = document.elementFromPoint(0, 0).parentElement; + if (line.parentElement !== document.getElementById("source")) { + // The element is not a source line but the header or similar + coverage.select_line_or_chunk(1); + } else { + // We extract the line number from the id + coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); + } + } + coverage.to_next_chunk(); +}; + +coverage.to_prev_chunk_nicely = function () { + if (coverage.selection_ends_on_screen() === 0) { + // The selection is entirely off the screen: + // Set the lowest line on the screen as selection. + + // This will select the bottom-left of the viewport + // As this is most likely the span with the line number we take the parent + const line = document.elementFromPoint(document.documentElement.clientHeight-1, 0).parentElement; + if (line.parentElement !== document.getElementById("source")) { + // The element is not a source line but the header or similar + coverage.select_line_or_chunk(coverage.lines_len); + } else { + // We extract the line number from the id + coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); + } + } + coverage.to_prev_chunk(); +}; + +// Select line number lineno, or if it is in a colored chunk, select the +// entire chunk +coverage.select_line_or_chunk = function (lineno) { + var c = coverage; + var probe_line = c.line_elt(lineno); + if (!probe_line) { + return; + } + var the_indicator = c.chunk_indicator(probe_line); + if (the_indicator) { + // The line is in a highlighted chunk. + // Search backward for the first line. + var probe = lineno; + var indicator = the_indicator; + while (probe > 0 && indicator === the_indicator) { + probe--; + probe_line = c.line_elt(probe); + if (!probe_line) { + break; + } + indicator = c.chunk_indicator(probe_line); + } + var begin = probe + 1; + + // Search forward for the last line. + probe = lineno; + indicator = the_indicator; + while (indicator === the_indicator) { + probe++; + probe_line = c.line_elt(probe); + indicator = c.chunk_indicator(probe_line); + } + + coverage.set_sel(begin, probe); + } + else { + coverage.set_sel(lineno); + } +}; + +coverage.show_selection = function () { + // Highlight the lines in the chunk + document.querySelectorAll("#source .highlight").forEach(e => e.classList.remove("highlight")); + for (let probe = coverage.sel_begin; probe < coverage.sel_end; probe++) { + coverage.line_elt(probe).querySelector(".n").classList.add("highlight"); + } + + coverage.scroll_to_selection(); +}; + +coverage.scroll_to_selection = function () { + // Scroll the page if the chunk isn't fully visible. + if (coverage.selection_ends_on_screen() < 2) { + const element = coverage.line_elt(coverage.sel_begin); + coverage.scroll_window(element.offsetTop - 60); + } +}; + +coverage.scroll_window = function (to_pos) { + window.scroll({top: to_pos, behavior: "smooth"}); +}; + +coverage.init_scroll_markers = function () { + // Init some variables + coverage.lines_len = document.querySelectorAll("#source > p").length; + + // Build html + coverage.build_scroll_markers(); +}; + +coverage.build_scroll_markers = function () { + const temp_scroll_marker = document.getElementById("scroll_marker") + if (temp_scroll_marker) temp_scroll_marker.remove(); + // Don't build markers if the window has no scroll bar. + if (document.body.scrollHeight <= window.innerHeight) { + return; + } + + const marker_scale = window.innerHeight / document.body.scrollHeight; + const line_height = Math.min(Math.max(3, window.innerHeight / coverage.lines_len), 10); + + let previous_line = -99, last_mark, last_top; + + const scroll_marker = document.createElement("div"); + scroll_marker.id = "scroll_marker"; + document.getElementById("source").querySelectorAll( + "p.show_run, p.show_mis, p.show_exc, p.show_exc, p.show_par" + ).forEach(element => { + const line_top = Math.floor(element.offsetTop * marker_scale); + const line_number = parseInt(element.querySelector(".n a").id.substr(1)); + + if (line_number === previous_line + 1) { + // If this solid missed block just make previous mark higher. + last_mark.style.height = `${line_top + line_height - last_top}px`; + } else { + // Add colored line in scroll_marker block. + last_mark = document.createElement("div"); + last_mark.id = `m${line_number}`; + last_mark.classList.add("marker"); + last_mark.style.height = `${line_height}px`; + last_mark.style.top = `${line_top}px`; + scroll_marker.append(last_mark); + last_top = line_top; + } + + previous_line = line_number; + }); + + // Append last to prevent layout calculation + document.body.append(scroll_marker); +}; + +coverage.wire_up_sticky_header = function () { + const header = document.querySelector("header"); + const header_bottom = ( + header.querySelector(".content h2").getBoundingClientRect().top - + header.getBoundingClientRect().top + ); + + function updateHeader() { + if (window.scrollY > header_bottom) { + header.classList.add("sticky"); + } else { + header.classList.remove("sticky"); + } + } + + window.addEventListener("scroll", updateHeader); + updateHeader(); +}; + +coverage.expand_contexts = function (e) { + var ctxs = e.target.parentNode.querySelector(".ctxs"); + + if (!ctxs.classList.contains("expanded")) { + var ctxs_text = ctxs.textContent; + var width = Number(ctxs_text[0]); + ctxs.textContent = ""; + for (var i = 1; i < ctxs_text.length; i += width) { + key = ctxs_text.substring(i, i + width).trim(); + ctxs.appendChild(document.createTextNode(contexts[key])); + ctxs.appendChild(document.createElement("br")); + } + ctxs.classList.add("expanded"); + } +}; + +document.addEventListener("DOMContentLoaded", () => { + if (document.body.classList.contains("indexfile")) { + coverage.index_ready(); + } else { + coverage.pyfile_ready(); + } +}); diff --git a/htmlcov/d_2a85bba178da7a61__enchantedlink_base_py.html b/htmlcov/d_2a85bba178da7a61__enchantedlink_base_py.html new file mode 100644 index 000000000..f5790e27d --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61__enchantedlink_base_py.html @@ -0,0 +1,233 @@ + + + + + Coverage for descope/authmethod/_enchantedlink_base.py: 89% + + + + + +
+
+

+ Coverage for descope/authmethod/_enchantedlink_base.py: + 89% +

+ +

+ 64 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1# This is not part of the public API but a code helper 

+

2from __future__ import annotations 

+

3 

+

4from descope.auth import Auth 

+

5from descope.common import ( 

+

6 DeliveryMethod, 

+

7 EndpointsV1, 

+

8 LoginOptions, 

+

9 SignUpOptions, 

+

10 signup_options_to_dict, 

+

11) 

+

12from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

13 

+

14 

+

15class EnchantedLinkBase: 

+

16 """Shared, I/O-free base for EnchantedLink auth-method classes. 

+

17 

+

18 Holds only static validation guards, URL composers and body builders — no 

+

19 network I/O, no ``__init__``. The two concrete subclasses add the network layer: 

+

20 

+

21 - ``EnchantedLink(EnchantedLinkBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) 

+

22 - ``EnchantedLinkAsync(EnchantedLinkBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` 

+

23 """ 

+

24 

+

25 @staticmethod 

+

26 def _validate_sign_in_login_id(login_id: str) -> None: 

+

27 if not login_id: 

+

28 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id is empty") 

+

29 

+

30 @staticmethod 

+

31 def _validate_login_id(login_id: str) -> None: 

+

32 if not login_id: 

+

33 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") 

+

34 

+

35 @staticmethod 

+

36 def _compose_signin_url(method: DeliveryMethod) -> str: 

+

37 return Auth.compose_url(EndpointsV1.sign_in_auth_enchantedlink_path, method) 

+

38 

+

39 @staticmethod 

+

40 def _compose_signup_url(method: DeliveryMethod) -> str: 

+

41 return Auth.compose_url(EndpointsV1.sign_up_auth_enchantedlink_path, method) 

+

42 

+

43 @staticmethod 

+

44 def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: 

+

45 return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_enchantedlink_path, method) 

+

46 

+

47 @staticmethod 

+

48 def _compose_update_phone_url(method: DeliveryMethod) -> str: 

+

49 return Auth.compose_url(EndpointsV1.update_user_phone_enchantedlink_path, method) 

+

50 

+

51 @staticmethod 

+

52 def _compose_signin_body( 

+

53 login_id: str, 

+

54 uri: str, 

+

55 login_options: LoginOptions | None = None, 

+

56 ) -> dict: 

+

57 return { 

+

58 "loginId": login_id, 

+

59 "URI": uri, 

+

60 "loginOptions": login_options.__dict__ if login_options else {}, 

+

61 } 

+

62 

+

63 @staticmethod 

+

64 def _compose_signup_body( 

+

65 method: DeliveryMethod, 

+

66 login_id: str, 

+

67 uri: str, 

+

68 user: dict | None = None, 

+

69 signup_options: SignUpOptions | None = None, 

+

70 ) -> dict: 

+

71 body: dict[str, str | bool | dict] = {"loginId": login_id, "URI": uri} 

+

72 

+

73 if signup_options is not None: 

+

74 body["loginOptions"] = signup_options_to_dict(signup_options) 

+

75 

+

76 if user is not None: 

+

77 body["user"] = user 

+

78 method_str, val = Auth.get_login_id_by_method(method, user) 

+

79 body[method_str] = val 

+

80 return body 

+

81 

+

82 @staticmethod 

+

83 def _compose_verify_body(token: str) -> dict: 

+

84 return {"token": token} 

+

85 

+

86 @staticmethod 

+

87 def _compose_update_user_email_body( 

+

88 login_id: str, 

+

89 email: str, 

+

90 add_to_login_ids: bool, 

+

91 on_merge_use_existing: bool, 

+

92 template_options: dict | None = None, 

+

93 template_id: str | None = None, 

+

94 provider_id: str | None = None, 

+

95 ) -> dict: 

+

96 body: dict[str, str | bool | dict] = { 

+

97 "loginId": login_id, 

+

98 "email": email, 

+

99 "addToLoginIDs": add_to_login_ids, 

+

100 "onMergeUseExisting": on_merge_use_existing, 

+

101 } 

+

102 if template_options is not None: 

+

103 body["templateOptions"] = template_options 

+

104 if template_id is not None: 

+

105 body["templateId"] = template_id 

+

106 if provider_id is not None: 

+

107 body["providerId"] = provider_id 

+

108 return body 

+

109 

+

110 @staticmethod 

+

111 def _compose_update_user_phone_body( 

+

112 login_id: str, 

+

113 phone: str, 

+

114 add_to_login_ids: bool, 

+

115 on_merge_use_existing: bool, 

+

116 template_options: dict | None = None, 

+

117 template_id: str | None = None, 

+

118 provider_id: str | None = None, 

+

119 ) -> dict: 

+

120 body: dict[str, str | bool | dict] = { 

+

121 "loginId": login_id, 

+

122 "phone": phone, 

+

123 "addToLoginIDs": add_to_login_ids, 

+

124 "onMergeUseExisting": on_merge_use_existing, 

+

125 } 

+

126 if template_options is not None: 

+

127 body["templateOptions"] = template_options 

+

128 if template_id is not None: 

+

129 body["templateId"] = template_id 

+

130 if provider_id is not None: 

+

131 body["providerId"] = provider_id 

+

132 return body 

+

133 

+

134 @staticmethod 

+

135 def _compose_get_session_body(pending_ref: str) -> dict: 

+

136 return {"pendingRef": pending_ref} 

+
+ + + diff --git a/htmlcov/d_2a85bba178da7a61__magiclink_base_py.html b/htmlcov/d_2a85bba178da7a61__magiclink_base_py.html new file mode 100644 index 000000000..866b9b206 --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61__magiclink_base_py.html @@ -0,0 +1,229 @@ + + + + + Coverage for descope/authmethod/_magiclink_base.py: 89% + + + + + +
+
+

+ Coverage for descope/authmethod/_magiclink_base.py: + 89% +

+ +

+ 61 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1# This is not part of the public API but a code helper 

+

2from __future__ import annotations 

+

3 

+

4from descope.auth import Auth 

+

5from descope.common import ( 

+

6 DeliveryMethod, 

+

7 EndpointsV1, 

+

8 LoginOptions, 

+

9 SignUpOptions, 

+

10 signup_options_to_dict, 

+

11) 

+

12from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

13 

+

14 

+

15class MagicLinkBase: 

+

16 """Shared, I/O-free base for MagicLink auth-method classes. 

+

17 

+

18 Holds only static validation guards, URL composers and body builders — no 

+

19 network I/O, no ``__init__``. The two concrete subclasses add the network layer: 

+

20 

+

21 - ``MagicLink(MagicLinkBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) 

+

22 - ``MagicLinkAsync(MagicLinkBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) 

+

23 """ 

+

24 

+

25 @staticmethod 

+

26 def _validate_sign_in_login_id(login_id: str) -> None: 

+

27 if not login_id: 

+

28 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier is empty") 

+

29 

+

30 @staticmethod 

+

31 def _validate_login_id(login_id: str) -> None: 

+

32 if not login_id: 

+

33 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") 

+

34 

+

35 @staticmethod 

+

36 def _compose_signin_url(method: DeliveryMethod) -> str: 

+

37 return Auth.compose_url(EndpointsV1.sign_in_auth_magiclink_path, method) 

+

38 

+

39 @staticmethod 

+

40 def _compose_signup_url(method: DeliveryMethod) -> str: 

+

41 return Auth.compose_url(EndpointsV1.sign_up_auth_magiclink_path, method) 

+

42 

+

43 @staticmethod 

+

44 def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: 

+

45 return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_magiclink_path, method) 

+

46 

+

47 @staticmethod 

+

48 def _compose_update_phone_url(method: DeliveryMethod) -> str: 

+

49 return Auth.compose_url(EndpointsV1.update_user_phone_magiclink_path, method) 

+

50 

+

51 @staticmethod 

+

52 def _compose_signin_body( 

+

53 login_id: str, 

+

54 uri: str, 

+

55 login_options: LoginOptions | None = None, 

+

56 ) -> dict: 

+

57 return { 

+

58 "loginId": login_id, 

+

59 "URI": uri, 

+

60 "loginOptions": login_options.__dict__ if login_options else {}, 

+

61 } 

+

62 

+

63 @staticmethod 

+

64 def _compose_signup_body( 

+

65 method: DeliveryMethod, 

+

66 login_id: str, 

+

67 uri: str, 

+

68 user: dict | None = None, 

+

69 signup_options: SignUpOptions | None = None, 

+

70 ) -> dict: 

+

71 body: dict[str, str | bool | dict] = {"loginId": login_id, "URI": uri} 

+

72 

+

73 if signup_options is not None: 

+

74 body["loginOptions"] = signup_options_to_dict(signup_options) 

+

75 

+

76 if user is not None: 

+

77 body["user"] = user 

+

78 method_str, val = Auth.get_login_id_by_method(method, user) 

+

79 body[method_str] = val 

+

80 return body 

+

81 

+

82 @staticmethod 

+

83 def _compose_verify_body(token: str) -> dict: 

+

84 return {"token": token} 

+

85 

+

86 @staticmethod 

+

87 def _compose_update_user_email_body( 

+

88 login_id: str, 

+

89 email: str, 

+

90 add_to_login_ids: bool, 

+

91 on_merge_use_existing: bool, 

+

92 template_options: dict | None = None, 

+

93 template_id: str | None = None, 

+

94 provider_id: str | None = None, 

+

95 ) -> dict: 

+

96 body: dict[str, str | bool | dict] = { 

+

97 "loginId": login_id, 

+

98 "email": email, 

+

99 "addToLoginIDs": add_to_login_ids, 

+

100 "onMergeUseExisting": on_merge_use_existing, 

+

101 } 

+

102 if template_options is not None: 

+

103 body["templateOptions"] = template_options 

+

104 if template_id is not None: 

+

105 body["templateId"] = template_id 

+

106 if provider_id is not None: 

+

107 body["providerId"] = provider_id 

+

108 return body 

+

109 

+

110 @staticmethod 

+

111 def _compose_update_user_phone_body( 

+

112 login_id: str, 

+

113 phone: str, 

+

114 add_to_login_ids: bool, 

+

115 on_merge_use_existing: bool, 

+

116 template_options: dict | None = None, 

+

117 template_id: str | None = None, 

+

118 provider_id: str | None = None, 

+

119 ) -> dict: 

+

120 body: dict[str, str | bool | dict] = { 

+

121 "loginId": login_id, 

+

122 "phone": phone, 

+

123 "addToLoginIDs": add_to_login_ids, 

+

124 "onMergeUseExisting": on_merge_use_existing, 

+

125 } 

+

126 if template_options is not None: 

+

127 body["templateOptions"] = template_options 

+

128 if template_id is not None: 

+

129 body["templateId"] = template_id 

+

130 if provider_id is not None: 

+

131 body["providerId"] = provider_id 

+

132 return body 

+
+ + + diff --git a/htmlcov/d_2a85bba178da7a61__otp_base_py.html b/htmlcov/d_2a85bba178da7a61__otp_base_py.html new file mode 100644 index 000000000..ef9161065 --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61__otp_base_py.html @@ -0,0 +1,228 @@ + + + + + Coverage for descope/authmethod/_otp_base.py: 89% + + + + + +
+
+

+ Coverage for descope/authmethod/_otp_base.py: + 89% +

+ +

+ 64 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1# This is not part of the public API but a code helper 

+

2from __future__ import annotations 

+

3 

+

4from descope.auth import Auth 

+

5from descope.common import ( 

+

6 DeliveryMethod, 

+

7 EndpointsV1, 

+

8 LoginOptions, 

+

9 SignUpOptions, 

+

10 signup_options_to_dict, 

+

11) 

+

12from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

13 

+

14 

+

15class OTPBase: 

+

16 """Shared, I/O-free base for OTP auth-method classes. 

+

17 

+

18 Holds only static validation guards, URL composers and body builders — no 

+

19 network I/O, no ``__init__``. The two concrete subclasses add the network layer: 

+

20 

+

21 - ``OTP(OTPBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) 

+

22 - ``OTPAsync(OTPBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) 

+

23 """ 

+

24 

+

25 @staticmethod 

+

26 def _validate_login_id(login_id: str) -> None: 

+

27 if not login_id: 

+

28 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") 

+

29 

+

30 @staticmethod 

+

31 def _compose_signup_url(method: DeliveryMethod) -> str: 

+

32 return Auth.compose_url(EndpointsV1.sign_up_auth_otp_path, method) 

+

33 

+

34 @staticmethod 

+

35 def _compose_signin_url(method: DeliveryMethod) -> str: 

+

36 return Auth.compose_url(EndpointsV1.sign_in_auth_otp_path, method) 

+

37 

+

38 @staticmethod 

+

39 def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: 

+

40 return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_otp_path, method) 

+

41 

+

42 @staticmethod 

+

43 def _compose_verify_code_url(method: DeliveryMethod) -> str: 

+

44 return Auth.compose_url(EndpointsV1.verify_code_auth_path, method) 

+

45 

+

46 @staticmethod 

+

47 def _compose_update_phone_url(method: DeliveryMethod) -> str: 

+

48 return Auth.compose_url(EndpointsV1.update_user_phone_otp_path, method) 

+

49 

+

50 @staticmethod 

+

51 def _compose_signup_body( 

+

52 method: DeliveryMethod, 

+

53 login_id: str, 

+

54 user: dict, 

+

55 signup_options: SignUpOptions | None = None, 

+

56 ) -> dict: 

+

57 body: dict[str, str | bool | dict] = {"loginId": login_id} 

+

58 

+

59 if signup_options is not None: 

+

60 body["loginOptions"] = signup_options_to_dict(signup_options) 

+

61 

+

62 if user is not None: 

+

63 body["user"] = user 

+

64 method_str, val = Auth.get_login_id_by_method(method, user) 

+

65 body[method_str] = val 

+

66 return body 

+

67 

+

68 @staticmethod 

+

69 def _compose_signin_body(login_id: str, login_options: LoginOptions | None = None) -> dict: 

+

70 return { 

+

71 "loginId": login_id, 

+

72 "loginOptions": login_options.__dict__ if login_options else {}, 

+

73 } 

+

74 

+

75 @staticmethod 

+

76 def _compose_verify_code_body(login_id: str, code: str) -> dict: 

+

77 return {"loginId": login_id, "code": code} 

+

78 

+

79 @staticmethod 

+

80 def _compose_update_user_email_body( 

+

81 login_id: str, 

+

82 email: str, 

+

83 add_to_login_ids: bool, 

+

84 on_merge_use_existing: bool, 

+

85 template_options: dict | None = None, 

+

86 template_id: str | None = None, 

+

87 provider_id: str | None = None, 

+

88 mfa: bool = False, 

+

89 ) -> dict: 

+

90 body: dict[str, str | bool | dict] = { 

+

91 "loginId": login_id, 

+

92 "email": email, 

+

93 "addToLoginIDs": add_to_login_ids, 

+

94 "onMergeUseExisting": on_merge_use_existing, 

+

95 } 

+

96 if template_options is not None: 

+

97 body["templateOptions"] = template_options 

+

98 if template_id is not None: 

+

99 body["templateId"] = template_id 

+

100 if provider_id is not None: 

+

101 body["providerId"] = provider_id 

+

102 if mfa: 

+

103 body["mfa"] = mfa 

+

104 return body 

+

105 

+

106 @staticmethod 

+

107 def _compose_update_user_phone_body( 

+

108 login_id: str, 

+

109 phone: str, 

+

110 add_to_login_ids: bool, 

+

111 on_merge_use_existing: bool, 

+

112 template_options: dict | None = None, 

+

113 template_id: str | None = None, 

+

114 provider_id: str | None = None, 

+

115 mfa: bool = False, 

+

116 ) -> dict: 

+

117 body: dict[str, str | bool | dict] = { 

+

118 "loginId": login_id, 

+

119 "phone": phone, 

+

120 "addToLoginIDs": add_to_login_ids, 

+

121 "onMergeUseExisting": on_merge_use_existing, 

+

122 } 

+

123 if template_options is not None: 

+

124 body["templateOptions"] = template_options 

+

125 if template_id is not None: 

+

126 body["templateId"] = template_id 

+

127 if provider_id is not None: 

+

128 body["providerId"] = provider_id 

+

129 if mfa: 

+

130 body["mfa"] = mfa 

+

131 return body 

+
+ + + diff --git a/htmlcov/d_2a85bba178da7a61_enchantedlink_async_py.html b/htmlcov/d_2a85bba178da7a61_enchantedlink_async_py.html new file mode 100644 index 000000000..70d55e0a8 --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61_enchantedlink_async_py.html @@ -0,0 +1,300 @@ + + + + + Coverage for descope/authmethod/enchantedlink_async.py: 97% + + + + + +
+
+

+ Coverage for descope/authmethod/enchantedlink_async.py: + 97% +

+ +

+ 78 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from descope._authmethod_base import AsyncAuthMethodBase 

+

4from descope.auth import Auth 

+

5from descope.authmethod._enchantedlink_base import EnchantedLinkBase 

+

6from descope.common import ( 

+

7 REFRESH_SESSION_COOKIE_NAME, 

+

8 DeliveryMethod, 

+

9 EndpointsV1, 

+

10 LoginOptions, 

+

11 SignUpOptions, 

+

12 validate_refresh_token_provided, 

+

13) 

+

14from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

15 

+

16 

+

17class EnchantedLinkAsync(EnchantedLinkBase, AsyncAuthMethodBase): 

+

18 """Async EnchantedLink auth-method. All network calls are coroutines; validation is sync (no I/O).""" 

+

19 

+

20 async def sign_in( 

+

21 self, 

+

22 login_id: str, 

+

23 uri: str, 

+

24 login_options: LoginOptions | None = None, 

+

25 refresh_token: str | None = None, 

+

26 ) -> dict: 

+

27 """Send an enchanted-link email for sign-in; returns the pending-ref and link-id.""" 

+

28 self._validate_sign_in_login_id(login_id) 

+

29 

+

30 validate_refresh_token_provided(login_options, refresh_token) 

+

31 

+

32 body = self._compose_signin_body(login_id, uri, login_options) 

+

33 url = self._compose_signin_url(DeliveryMethod.EMAIL) 

+

34 response = await self._http.post(url, body=body, pswd=refresh_token) 

+

35 return response.json() 

+

36 

+

37 async def sign_in_with_phone( 

+

38 self, 

+

39 phone: str, 

+

40 uri: str, 

+

41 login_options: LoginOptions | None = None, 

+

42 refresh_token: str | None = None, 

+

43 ) -> dict: 

+

44 """Send an enchanted-link SMS for sign-in; returns the pending-ref and link-id.""" 

+

45 self._validate_sign_in_login_id(phone) 

+

46 

+

47 validate_refresh_token_provided(login_options, refresh_token) 

+

48 

+

49 body = self._compose_signin_body(phone, uri, login_options) 

+

50 url = self._compose_signin_url(DeliveryMethod.SMS) 

+

51 response = await self._http.post(url, body=body, pswd=refresh_token) 

+

52 return response.json() 

+

53 

+

54 async def sign_up( 

+

55 self, 

+

56 login_id: str, 

+

57 uri: str, 

+

58 user: dict | None, 

+

59 signup_options: SignUpOptions | None = None, 

+

60 ) -> dict: 

+

61 """Send an enchanted-link email for sign-up; returns the pending-ref and link-id.""" 

+

62 if not user: 

+

63 user = {} 

+

64 

+

65 if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, login_id, user): 

+

66 raise AuthException( 

+

67 400, 

+

68 ERROR_TYPE_INVALID_ARGUMENT, 

+

69 f"Login ID {login_id} is not valid for email", 

+

70 ) 

+

71 

+

72 body = self._compose_signup_body(DeliveryMethod.EMAIL, login_id, uri, user, signup_options) 

+

73 url = self._compose_signup_url(DeliveryMethod.EMAIL) 

+

74 response = await self._http.post(url, body=body) 

+

75 return response.json() 

+

76 

+

77 async def sign_up_with_phone( 

+

78 self, 

+

79 phone: str, 

+

80 uri: str, 

+

81 user: dict | None = None, 

+

82 signup_options: SignUpOptions | None = None, 

+

83 ) -> dict: 

+

84 """Send an enchanted-link SMS for sign-up; returns the pending-ref and link-id.""" 

+

85 if not user: 

+

86 user = {} 

+

87 

+

88 if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, phone, user): 

+

89 raise AuthException( 

+

90 400, 

+

91 ERROR_TYPE_INVALID_ARGUMENT, 

+

92 f"Login ID {phone} is not valid for phone", 

+

93 ) 

+

94 

+

95 body = self._compose_signup_body(DeliveryMethod.SMS, phone, uri, user, signup_options) 

+

96 url = self._compose_signup_url(DeliveryMethod.SMS) 

+

97 response = await self._http.post(url, body=body) 

+

98 return response.json() 

+

99 

+

100 async def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: 

+

101 """Send an enchanted-link for sign-up or sign-in depending on whether the user exists.""" 

+

102 login_options: LoginOptions | None = None 

+

103 if signup_options is not None: 

+

104 login_options = LoginOptions( 

+

105 revoke_other_sessions=signup_options.revokeOtherSessions, 

+

106 custom_claims=signup_options.customClaims, 

+

107 template_options=signup_options.templateOptions, 

+

108 template_id=signup_options.templateId, 

+

109 ) 

+

110 

+

111 body = self._compose_signin_body(login_id, uri, login_options) 

+

112 url = self._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) 

+

113 response = await self._http.post(url, body=body) 

+

114 return response.json() 

+

115 

+

116 async def sign_up_or_in_with_phone(self, phone: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: 

+

117 """Send an enchanted-link SMS for sign-up or sign-in depending on whether the user exists.""" 

+

118 login_options: LoginOptions | None = None 

+

119 if signup_options is not None: 

+

120 login_options = LoginOptions( 

+

121 revoke_other_sessions=signup_options.revokeOtherSessions, 

+

122 custom_claims=signup_options.customClaims, 

+

123 template_options=signup_options.templateOptions, 

+

124 template_id=signup_options.templateId, 

+

125 ) 

+

126 

+

127 body = self._compose_signin_body(phone, uri, login_options) 

+

128 url = self._compose_sign_up_or_in_url(DeliveryMethod.SMS) 

+

129 response = await self._http.post(url, body=body) 

+

130 return response.json() 

+

131 

+

132 async def get_session(self, pending_ref: str) -> dict: 

+

133 """Poll for session JWTs once the user has clicked the enchanted link.""" 

+

134 uri = EndpointsV1.get_session_enchantedlink_auth_path 

+

135 body = self._compose_get_session_body(pending_ref) 

+

136 response = await self._http.post(uri, body=body) 

+

137 return await self._auth.prepare_jwt_response( 

+

138 response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None 

+

139 ) 

+

140 

+

141 async def verify(self, token: str) -> None: 

+

142 """Mark the enchanted-link token as clicked (called by the link-handler endpoint).""" 

+

143 uri = EndpointsV1.verify_enchantedlink_auth_path 

+

144 body = self._compose_verify_body(token) 

+

145 await self._http.post(uri, body=body) 

+

146 

+

147 async def update_user_email( 

+

148 self, 

+

149 login_id: str, 

+

150 email: str, 

+

151 refresh_token: str, 

+

152 add_to_login_ids: bool = False, 

+

153 on_merge_use_existing: bool = False, 

+

154 template_options: dict | None = None, 

+

155 template_id: str | None = None, 

+

156 provider_id: str | None = None, 

+

157 ) -> dict: 

+

158 """Send an enchanted-link to a new email address to verify the update.""" 

+

159 self._validate_login_id(login_id) 

+

160 

+

161 Auth.validate_email(email) 

+

162 

+

163 body = self._compose_update_user_email_body( 

+

164 login_id, 

+

165 email, 

+

166 add_to_login_ids, 

+

167 on_merge_use_existing, 

+

168 template_options, 

+

169 template_id, 

+

170 provider_id, 

+

171 ) 

+

172 uri = EndpointsV1.update_user_email_enchantedlink_path 

+

173 response = await self._http.post(uri, body=body, pswd=refresh_token) 

+

174 return response.json() 

+

175 

+

176 async def update_user_phone( 

+

177 self, 

+

178 login_id: str, 

+

179 phone: str, 

+

180 refresh_token: str, 

+

181 add_to_login_ids: bool = False, 

+

182 on_merge_use_existing: bool = False, 

+

183 template_options: dict | None = None, 

+

184 template_id: str | None = None, 

+

185 provider_id: str | None = None, 

+

186 ) -> dict: 

+

187 """Send an enchanted-link SMS to a new phone number to verify the update.""" 

+

188 self._validate_login_id(login_id) 

+

189 

+

190 Auth.validate_phone(DeliveryMethod.SMS, phone) 

+

191 

+

192 body = self._compose_update_user_phone_body( 

+

193 login_id, 

+

194 phone, 

+

195 add_to_login_ids, 

+

196 on_merge_use_existing, 

+

197 template_options, 

+

198 template_id, 

+

199 provider_id, 

+

200 ) 

+

201 url = self._compose_update_phone_url(DeliveryMethod.SMS) 

+

202 response = await self._http.post(url, body=body, pswd=refresh_token) 

+

203 return response.json() 

+
+ + + diff --git a/htmlcov/d_2a85bba178da7a61_enchantedlink_py.html b/htmlcov/d_2a85bba178da7a61_enchantedlink_py.html new file mode 100644 index 000000000..a4f3bfcfd --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61_enchantedlink_py.html @@ -0,0 +1,287 @@ + + + + + Coverage for descope/authmethod/enchantedlink.py: 97% + + + + + +
+
+

+ Coverage for descope/authmethod/enchantedlink.py: + 97% +

+ +

+ 79 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from descope._authmethod_base import AuthMethodBase 

+

4from descope.auth import Auth 

+

5from descope.authmethod._enchantedlink_base import EnchantedLinkBase 

+

6from descope.common import ( 

+

7 REFRESH_SESSION_COOKIE_NAME, 

+

8 DeliveryMethod, 

+

9 EndpointsV1, 

+

10 LoginOptions, 

+

11 SignUpOptions, 

+

12 validate_refresh_token_provided, 

+

13) 

+

14from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

15 

+

16 

+

17class EnchantedLink(EnchantedLinkBase, AuthMethodBase): 

+

18 def sign_in( 

+

19 self, 

+

20 login_id: str, 

+

21 uri: str, 

+

22 login_options: LoginOptions | None = None, 

+

23 refresh_token: str | None = None, 

+

24 ) -> dict: 

+

25 self._validate_sign_in_login_id(login_id) 

+

26 

+

27 validate_refresh_token_provided(login_options, refresh_token) 

+

28 

+

29 body = self._compose_signin_body(login_id, uri, login_options) 

+

30 url = self._compose_signin_url(DeliveryMethod.EMAIL) 

+

31 response = self._http.post(url, body=body, pswd=refresh_token) 

+

32 return response.json() 

+

33 

+

34 def sign_in_with_phone( 

+

35 self, 

+

36 phone: str, 

+

37 uri: str, 

+

38 login_options: LoginOptions | None = None, 

+

39 refresh_token: str | None = None, 

+

40 ) -> dict: 

+

41 self._validate_sign_in_login_id(phone) 

+

42 

+

43 validate_refresh_token_provided(login_options, refresh_token) 

+

44 

+

45 body = self._compose_signin_body(phone, uri, login_options) 

+

46 url = self._compose_signin_url(DeliveryMethod.SMS) 

+

47 response = self._http.post(url, body=body, pswd=refresh_token) 

+

48 return response.json() 

+

49 

+

50 def sign_up( 

+

51 self, 

+

52 login_id: str, 

+

53 uri: str, 

+

54 user: dict | None, 

+

55 signup_options: SignUpOptions | None = None, 

+

56 ) -> dict: 

+

57 if not user: 

+

58 user = {} 

+

59 

+

60 if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, login_id, user): 

+

61 raise AuthException( 

+

62 400, 

+

63 ERROR_TYPE_INVALID_ARGUMENT, 

+

64 f"Login ID {login_id} is not valid for email", 

+

65 ) 

+

66 

+

67 body = self._compose_signup_body(DeliveryMethod.EMAIL, login_id, uri, user, signup_options) 

+

68 url = self._compose_signup_url(DeliveryMethod.EMAIL) 

+

69 response = self._http.post(url, body=body) 

+

70 return response.json() 

+

71 

+

72 def sign_up_with_phone( 

+

73 self, 

+

74 phone: str, 

+

75 uri: str, 

+

76 user: dict | None = None, 

+

77 signup_options: SignUpOptions | None = None, 

+

78 ) -> dict: 

+

79 if not user: 

+

80 user = {} 

+

81 

+

82 if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, phone, user): 

+

83 raise AuthException( 

+

84 400, 

+

85 ERROR_TYPE_INVALID_ARGUMENT, 

+

86 f"Login ID {phone} is not valid for phone", 

+

87 ) 

+

88 

+

89 body = self._compose_signup_body(DeliveryMethod.SMS, phone, uri, user, signup_options) 

+

90 url = self._compose_signup_url(DeliveryMethod.SMS) 

+

91 response = self._http.post(url, body=body) 

+

92 return response.json() 

+

93 

+

94 def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: 

+

95 login_options: LoginOptions | None = None 

+

96 if signup_options is not None: 

+

97 login_options = LoginOptions( 

+

98 revoke_other_sessions=signup_options.revokeOtherSessions, 

+

99 custom_claims=signup_options.customClaims, 

+

100 template_options=signup_options.templateOptions, 

+

101 template_id=signup_options.templateId, 

+

102 ) 

+

103 

+

104 body = self._compose_signin_body(login_id, uri, login_options) 

+

105 url = self._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) 

+

106 response = self._http.post(url, body=body) 

+

107 return response.json() 

+

108 

+

109 def sign_up_or_in_with_phone(self, phone: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: 

+

110 login_options: LoginOptions | None = None 

+

111 if signup_options is not None: 

+

112 login_options = LoginOptions( 

+

113 revoke_other_sessions=signup_options.revokeOtherSessions, 

+

114 custom_claims=signup_options.customClaims, 

+

115 template_options=signup_options.templateOptions, 

+

116 template_id=signup_options.templateId, 

+

117 ) 

+

118 

+

119 body = self._compose_signin_body(phone, uri, login_options) 

+

120 url = self._compose_sign_up_or_in_url(DeliveryMethod.SMS) 

+

121 response = self._http.post(url, body=body) 

+

122 return response.json() 

+

123 

+

124 def get_session(self, pending_ref: str) -> dict: 

+

125 uri = EndpointsV1.get_session_enchantedlink_auth_path 

+

126 body = self._compose_get_session_body(pending_ref) 

+

127 response = self._http.post(uri, body=body) 

+

128 resp = response.json() 

+

129 return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None) 

+

130 

+

131 def verify(self, token: str) -> None: 

+

132 uri = EndpointsV1.verify_enchantedlink_auth_path 

+

133 body = self._compose_verify_body(token) 

+

134 self._http.post(uri, body=body) 

+

135 

+

136 def update_user_email( 

+

137 self, 

+

138 login_id: str, 

+

139 email: str, 

+

140 refresh_token: str, 

+

141 add_to_login_ids: bool = False, 

+

142 on_merge_use_existing: bool = False, 

+

143 template_options: dict | None = None, 

+

144 template_id: str | None = None, 

+

145 provider_id: str | None = None, 

+

146 ) -> dict: 

+

147 self._validate_login_id(login_id) 

+

148 

+

149 Auth.validate_email(email) 

+

150 

+

151 body = self._compose_update_user_email_body( 

+

152 login_id, 

+

153 email, 

+

154 add_to_login_ids, 

+

155 on_merge_use_existing, 

+

156 template_options, 

+

157 template_id, 

+

158 provider_id, 

+

159 ) 

+

160 uri = EndpointsV1.update_user_email_enchantedlink_path 

+

161 response = self._http.post(uri, body=body, pswd=refresh_token) 

+

162 return response.json() 

+

163 

+

164 def update_user_phone( 

+

165 self, 

+

166 login_id: str, 

+

167 phone: str, 

+

168 refresh_token: str, 

+

169 add_to_login_ids: bool = False, 

+

170 on_merge_use_existing: bool = False, 

+

171 template_options: dict | None = None, 

+

172 template_id: str | None = None, 

+

173 provider_id: str | None = None, 

+

174 ) -> dict: 

+

175 self._validate_login_id(login_id) 

+

176 

+

177 Auth.validate_phone(DeliveryMethod.SMS, phone) 

+

178 

+

179 body = self._compose_update_user_phone_body( 

+

180 login_id, 

+

181 phone, 

+

182 add_to_login_ids, 

+

183 on_merge_use_existing, 

+

184 template_options, 

+

185 template_id, 

+

186 provider_id, 

+

187 ) 

+

188 url = self._compose_update_phone_url(DeliveryMethod.SMS) 

+

189 response = self._http.post(url, body=body, pswd=refresh_token) 

+

190 return response.json() 

+
+ + + diff --git a/htmlcov/d_2a85bba178da7a61_magiclink_async_py.html b/htmlcov/d_2a85bba178da7a61_magiclink_async_py.html new file mode 100644 index 000000000..c61c76efa --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61_magiclink_async_py.html @@ -0,0 +1,248 @@ + + + + + Coverage for descope/authmethod/magiclink_async.py: 98% + + + + + +
+
+

+ Coverage for descope/authmethod/magiclink_async.py: + 98% +

+ +

+ 51 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import Iterable 

+

4 

+

5from descope._authmethod_base import AsyncAuthMethodBase 

+

6from descope.auth import Auth 

+

7from descope.authmethod._magiclink_base import MagicLinkBase 

+

8from descope.common import ( 

+

9 REFRESH_SESSION_COOKIE_NAME, 

+

10 DeliveryMethod, 

+

11 EndpointsV1, 

+

12 LoginOptions, 

+

13 SignUpOptions, 

+

14 validate_refresh_token_provided, 

+

15) 

+

16from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

17 

+

18 

+

19class MagicLinkAsync(MagicLinkBase, AsyncAuthMethodBase): 

+

20 """Async MagicLink auth-method. All network calls are coroutines; validation is sync (no I/O).""" 

+

21 

+

22 async def sign_in( 

+

23 self, 

+

24 method: DeliveryMethod, 

+

25 login_id: str, 

+

26 uri: str, 

+

27 login_options: LoginOptions | None = None, 

+

28 refresh_token: str | None = None, 

+

29 ) -> str: 

+

30 """Send a magic link for sign-in; returns the masked delivery address.""" 

+

31 self._validate_sign_in_login_id(login_id) 

+

32 

+

33 validate_refresh_token_provided(login_options, refresh_token) 

+

34 

+

35 body = self._compose_signin_body(login_id, uri, login_options) 

+

36 url = self._compose_signin_url(method) 

+

37 response = await self._http.post(url, body=body, pswd=refresh_token) 

+

38 return Auth.extract_masked_address(response.json(), method) 

+

39 

+

40 async def sign_up( 

+

41 self, 

+

42 method: DeliveryMethod, 

+

43 login_id: str, 

+

44 uri: str, 

+

45 user: dict | None = None, 

+

46 signup_options: SignUpOptions | None = None, 

+

47 ) -> str: 

+

48 """Send a magic link for sign-up to a new user; returns the masked delivery address.""" 

+

49 if not user: 

+

50 user = {} 

+

51 

+

52 if not self._auth.adjust_and_verify_delivery_method(method, login_id, user): 

+

53 raise AuthException( 

+

54 400, 

+

55 ERROR_TYPE_INVALID_ARGUMENT, 

+

56 f"Login ID {login_id} is not valid by delivery method {method}", 

+

57 ) 

+

58 

+

59 body = self._compose_signup_body(method, login_id, uri, user, signup_options) 

+

60 url = self._compose_signup_url(method) 

+

61 response = await self._http.post(url, body=body) 

+

62 return Auth.extract_masked_address(response.json(), method) 

+

63 

+

64 async def sign_up_or_in( 

+

65 self, 

+

66 method: DeliveryMethod, 

+

67 login_id: str, 

+

68 uri: str, 

+

69 signup_options: SignUpOptions | None = None, 

+

70 ) -> str: 

+

71 """Send a magic link for sign-up or sign-in depending on whether the user exists.""" 

+

72 login_options: LoginOptions | None = None 

+

73 if signup_options is not None: 

+

74 login_options = LoginOptions( 

+

75 revoke_other_sessions=signup_options.revokeOtherSessions, 

+

76 custom_claims=signup_options.customClaims, 

+

77 template_options=signup_options.templateOptions, 

+

78 template_id=signup_options.templateId, 

+

79 ) 

+

80 body = self._compose_signin_body(login_id, uri, login_options) 

+

81 url = self._compose_sign_up_or_in_url(method) 

+

82 response = await self._http.post(url, body=body) 

+

83 return Auth.extract_masked_address(response.json(), method) 

+

84 

+

85 async def verify(self, token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

86 """Verify a magic link token and return session JWTs.""" 

+

87 url = EndpointsV1.verify_magiclink_auth_path 

+

88 body = self._compose_verify_body(token) 

+

89 response = await self._http.post(url, body=body) 

+

90 return await self._auth.prepare_jwt_response( 

+

91 response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

92 ) 

+

93 

+

94 async def update_user_email( 

+

95 self, 

+

96 login_id: str, 

+

97 email: str, 

+

98 refresh_token: str, 

+

99 add_to_login_ids: bool = False, 

+

100 on_merge_use_existing: bool = False, 

+

101 template_options: dict | None = None, 

+

102 template_id: str | None = None, 

+

103 provider_id: str | None = None, 

+

104 ) -> str: 

+

105 """Send a magic link to a new email to verify the update; returns the masked address.""" 

+

106 self._validate_login_id(login_id) 

+

107 

+

108 Auth.validate_email(email) 

+

109 

+

110 body = self._compose_update_user_email_body( 

+

111 login_id, 

+

112 email, 

+

113 add_to_login_ids, 

+

114 on_merge_use_existing, 

+

115 template_options, 

+

116 template_id, 

+

117 provider_id, 

+

118 ) 

+

119 url = EndpointsV1.update_user_email_magiclink_path 

+

120 response = await self._http.post(url, body=body, pswd=refresh_token) 

+

121 return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) 

+

122 

+

123 async def update_user_phone( 

+

124 self, 

+

125 method: DeliveryMethod, 

+

126 login_id: str, 

+

127 phone: str, 

+

128 refresh_token: str, 

+

129 add_to_login_ids: bool = False, 

+

130 on_merge_use_existing: bool = False, 

+

131 template_options: dict | None = None, 

+

132 template_id: str | None = None, 

+

133 provider_id: str | None = None, 

+

134 ) -> str: 

+

135 """Send a magic link to a new phone number to verify the update; returns the masked address.""" 

+

136 self._validate_login_id(login_id) 

+

137 

+

138 Auth.validate_phone(method, phone) 

+

139 

+

140 body = self._compose_update_user_phone_body( 

+

141 login_id, 

+

142 phone, 

+

143 add_to_login_ids, 

+

144 on_merge_use_existing, 

+

145 template_options, 

+

146 template_id, 

+

147 provider_id, 

+

148 ) 

+

149 url = EndpointsV1.update_user_phone_magiclink_path 

+

150 response = await self._http.post(url, body=body, pswd=refresh_token) 

+

151 return Auth.extract_masked_address(response.json(), DeliveryMethod.SMS) 

+
+ + + diff --git a/htmlcov/d_2a85bba178da7a61_magiclink_py.html b/htmlcov/d_2a85bba178da7a61_magiclink_py.html new file mode 100644 index 000000000..21cbbf2ab --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61_magiclink_py.html @@ -0,0 +1,246 @@ + + + + + Coverage for descope/authmethod/magiclink.py: 98% + + + + + +
+
+

+ Coverage for descope/authmethod/magiclink.py: + 98% +

+ +

+ 53 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import Iterable 

+

4 

+

5from descope._authmethod_base import AuthMethodBase 

+

6from descope.auth import Auth 

+

7from descope.authmethod._magiclink_base import MagicLinkBase 

+

8from descope.common import ( 

+

9 REFRESH_SESSION_COOKIE_NAME, 

+

10 DeliveryMethod, 

+

11 EndpointsV1, 

+

12 LoginOptions, 

+

13 SignUpOptions, 

+

14 validate_refresh_token_provided, 

+

15) 

+

16from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

17 

+

18 

+

19class MagicLink(MagicLinkBase, AuthMethodBase): 

+

20 def sign_in( 

+

21 self, 

+

22 method: DeliveryMethod, 

+

23 login_id: str, 

+

24 uri: str, 

+

25 login_options: LoginOptions | None = None, 

+

26 refresh_token: str | None = None, 

+

27 ) -> str: 

+

28 self._validate_sign_in_login_id(login_id) 

+

29 

+

30 validate_refresh_token_provided(login_options, refresh_token) 

+

31 

+

32 body = MagicLink._compose_signin_body(login_id, uri, login_options) 

+

33 url = MagicLink._compose_signin_url(method) 

+

34 response = self._http.post(url, body=body, pswd=refresh_token) 

+

35 return Auth.extract_masked_address(response.json(), method) 

+

36 

+

37 def sign_up( 

+

38 self, 

+

39 method: DeliveryMethod, 

+

40 login_id: str, 

+

41 uri: str, 

+

42 user: dict | None = None, 

+

43 signup_options: SignUpOptions | None = None, 

+

44 ) -> str: 

+

45 if not user: 

+

46 user = {} 

+

47 

+

48 if not self._auth.adjust_and_verify_delivery_method(method, login_id, user): 

+

49 raise AuthException( 

+

50 400, 

+

51 ERROR_TYPE_INVALID_ARGUMENT, 

+

52 f"Login ID {login_id} is not valid by delivery method {method}", 

+

53 ) 

+

54 

+

55 body = MagicLink._compose_signup_body(method, login_id, uri, user, signup_options) 

+

56 url = MagicLink._compose_signup_url(method) 

+

57 response = self._http.post(url, body=body) 

+

58 return Auth.extract_masked_address(response.json(), method) 

+

59 

+

60 def sign_up_or_in( 

+

61 self, 

+

62 method: DeliveryMethod, 

+

63 login_id: str, 

+

64 uri: str, 

+

65 signup_options: SignUpOptions | None = None, 

+

66 ) -> str: 

+

67 login_options: LoginOptions | None = None 

+

68 if signup_options is not None: 

+

69 login_options = LoginOptions( 

+

70 revoke_other_sessions=signup_options.revokeOtherSessions, 

+

71 custom_claims=signup_options.customClaims, 

+

72 template_options=signup_options.templateOptions, 

+

73 template_id=signup_options.templateId, 

+

74 ) 

+

75 body = MagicLink._compose_signin_body( 

+

76 login_id, 

+

77 uri, 

+

78 login_options, 

+

79 ) 

+

80 url = MagicLink._compose_sign_up_or_in_url(method) 

+

81 response = self._http.post(url, body=body) 

+

82 return Auth.extract_masked_address(response.json(), method) 

+

83 

+

84 def verify(self, token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

85 url = EndpointsV1.verify_magiclink_auth_path 

+

86 body = MagicLink._compose_verify_body(token) 

+

87 response = self._http.post(url, body=body) 

+

88 resp = response.json() 

+

89 jwt_response = self._auth.generate_jwt_response( 

+

90 resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

91 ) 

+

92 return jwt_response 

+

93 

+

94 def update_user_email( 

+

95 self, 

+

96 login_id: str, 

+

97 email: str, 

+

98 refresh_token: str, 

+

99 add_to_login_ids: bool = False, 

+

100 on_merge_use_existing: bool = False, 

+

101 template_options: dict | None = None, 

+

102 template_id: str | None = None, 

+

103 provider_id: str | None = None, 

+

104 ) -> str: 

+

105 self._validate_login_id(login_id) 

+

106 

+

107 Auth.validate_email(email) 

+

108 

+

109 body = MagicLink._compose_update_user_email_body( 

+

110 login_id, 

+

111 email, 

+

112 add_to_login_ids, 

+

113 on_merge_use_existing, 

+

114 template_options, 

+

115 template_id, 

+

116 provider_id, 

+

117 ) 

+

118 url = EndpointsV1.update_user_email_magiclink_path 

+

119 response = self._http.post(url, body=body, pswd=refresh_token) 

+

120 return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) 

+

121 

+

122 def update_user_phone( 

+

123 self, 

+

124 method: DeliveryMethod, 

+

125 login_id: str, 

+

126 phone: str, 

+

127 refresh_token: str, 

+

128 add_to_login_ids: bool = False, 

+

129 on_merge_use_existing: bool = False, 

+

130 template_options: dict | None = None, 

+

131 template_id: str | None = None, 

+

132 provider_id: str | None = None, 

+

133 ) -> str: 

+

134 self._validate_login_id(login_id) 

+

135 

+

136 Auth.validate_phone(method, phone) 

+

137 

+

138 body = MagicLink._compose_update_user_phone_body( 

+

139 login_id, 

+

140 phone, 

+

141 add_to_login_ids, 

+

142 on_merge_use_existing, 

+

143 template_options, 

+

144 template_id, 

+

145 provider_id, 

+

146 ) 

+

147 url = EndpointsV1.update_user_phone_magiclink_path 

+

148 response = self._http.post(url, body=body, pswd=refresh_token) 

+

149 return Auth.extract_masked_address(response.json(), DeliveryMethod.SMS) 

+
+ + + diff --git a/htmlcov/d_2a85bba178da7a61_password_async_py.html b/htmlcov/d_2a85bba178da7a61_password_async_py.html new file mode 100644 index 000000000..a856724f4 --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61_password_async_py.html @@ -0,0 +1,205 @@ + + + + + Coverage for descope/authmethod/password_async.py: 98% + + + + + +
+
+

+ Coverage for descope/authmethod/password_async.py: + 98% +

+ +

+ 43 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import Iterable 

+

4 

+

5from descope._authmethod_base import AsyncAuthMethodBase 

+

6from descope.authmethod._password_base import PasswordBase 

+

7from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 

+

8 

+

9 

+

10class PasswordAsync(PasswordBase, AsyncAuthMethodBase): 

+

11 """Async Password auth-method. All network calls are coroutines; validation is sync (no I/O).""" 

+

12 

+

13 async def sign_up( 

+

14 self, 

+

15 login_id: str, 

+

16 password: str, 

+

17 user: dict | None = None, 

+

18 audience: str | None | Iterable[str] = None, 

+

19 ) -> dict: 

+

20 """Create a new user with a password and return session JWTs.""" 

+

21 self._validate_login_id(login_id) 

+

22 self._validate_password(password) 

+

23 

+

24 uri = EndpointsV1.sign_up_password_path 

+

25 body = self._compose_signup_body(login_id, password, user) 

+

26 response = await self._http.post(uri, body=body) 

+

27 return await self._auth.prepare_jwt_response( 

+

28 response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

29 ) 

+

30 

+

31 async def sign_in( 

+

32 self, 

+

33 login_id: str, 

+

34 password: str, 

+

35 audience: str | None | Iterable[str] = None, 

+

36 ) -> dict: 

+

37 """Verify the user's password and return session JWTs.""" 

+

38 self._validate_login_id(login_id) 

+

39 self._validate_sign_in_password(password) 

+

40 

+

41 uri = EndpointsV1.sign_in_password_path 

+

42 response = await self._http.post(uri, body={"loginId": login_id, "password": password}) 

+

43 return await self._auth.prepare_jwt_response( 

+

44 response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

45 ) 

+

46 

+

47 async def send_reset( 

+

48 self, 

+

49 login_id: str, 

+

50 redirect_url: str | None = None, 

+

51 template_options: dict | None = None, 

+

52 ) -> dict: 

+

53 """Send a password reset prompt to the user per the configured reset method.""" 

+

54 self._validate_login_id(login_id) 

+

55 

+

56 uri = EndpointsV1.send_reset_password_path 

+

57 body: dict[str, str | bool | dict | None] = { 

+

58 "loginId": login_id, 

+

59 "redirectUrl": redirect_url, 

+

60 } 

+

61 if template_options is not None: 

+

62 body["templateOptions"] = template_options 

+

63 

+

64 response = await self._http.post(uri, body=body) 

+

65 return response.json() 

+

66 

+

67 async def update(self, login_id: str, new_password: str, refresh_token: str) -> None: 

+

68 """Update the password for an existing logged-in user.""" 

+

69 self._validate_login_id(login_id) 

+

70 self._validate_new_password(new_password) 

+

71 self._validate_refresh_token(refresh_token) 

+

72 

+

73 uri = EndpointsV1.update_password_path 

+

74 await self._http.post( 

+

75 uri, 

+

76 body={"loginId": login_id, "newPassword": new_password}, 

+

77 pswd=refresh_token, 

+

78 ) 

+

79 

+

80 async def replace( 

+

81 self, 

+

82 login_id: str, 

+

83 old_password: str, 

+

84 new_password: str, 

+

85 audience: str | None | Iterable[str] = None, 

+

86 ) -> dict: 

+

87 """Authenticate with old_password, then replace it with new_password; returns session JWTs.""" 

+

88 self._validate_login_id(login_id) 

+

89 self._validate_old_password(old_password) 

+

90 self._validate_new_password(new_password) 

+

91 

+

92 uri = EndpointsV1.replace_password_path 

+

93 response = await self._http.post( 

+

94 uri, 

+

95 body={ 

+

96 "loginId": login_id, 

+

97 "oldPassword": old_password, 

+

98 "newPassword": new_password, 

+

99 }, 

+

100 ) 

+

101 return await self._auth.prepare_jwt_response( 

+

102 response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

103 ) 

+

104 

+

105 async def get_policy(self) -> dict: 

+

106 """Return the project's password policy (min length, character requirements, etc.).""" 

+

107 response = await self._http.get(uri=EndpointsV1.password_policy_path) 

+

108 return response.json() 

+
+ + + diff --git a/htmlcov/d_2a85bba178da7a61_password_py.html b/htmlcov/d_2a85bba178da7a61_password_py.html new file mode 100644 index 000000000..dd39dc3db --- /dev/null +++ b/htmlcov/d_2a85bba178da7a61_password_py.html @@ -0,0 +1,311 @@ + + + + + Coverage for descope/authmethod/password.py: 98% + + + + + +
+
+

+ Coverage for descope/authmethod/password.py: + 98% +

+ +

+ 49 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import Iterable 

+

4 

+

5from descope._authmethod_base import AuthMethodBase 

+

6from descope.authmethod._password_base import PasswordBase 

+

7from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 

+

8 

+

9 

+

10class Password(PasswordBase, AuthMethodBase): 

+

11 def sign_up( 

+

12 self, 

+

13 login_id: str, 

+

14 password: str, 

+

15 user: dict | None = None, 

+

16 audience: str | None | Iterable[str] = None, 

+

17 ) -> dict: 

+

18 """ 

+

19 Sign up (create) a new user using a login ID and password. 

+

20 (optional) Include additional user metadata that you wish to save. 

+

21 

+

22 Args: 

+

23 login_id (str): The login ID of the user being signed up 

+

24 password (str): The new user's password 

+

25 user (dict) optional: Preserve additional user metadata in the form of 

+

26 {"name": "Desmond Copeland", "phone": "2125551212", "email": "des@cope.com"} 

+

27 

+

28 Return value (dict): 

+

29 Return dict in the format 

+

30 {"jwts": [], "user": "", "firstSeen": "", "error": ""} 

+

31 Includes all the jwts tokens (session token, refresh token), token claims, and user information 

+

32 

+

33 Raise: 

+

34 AuthException: raised if sign-up operation fails 

+

35 """ 

+

36 

+

37 self._validate_login_id(login_id) 

+

38 self._validate_password(password) 

+

39 

+

40 uri = EndpointsV1.sign_up_password_path 

+

41 body = Password._compose_signup_body(login_id, password, user) 

+

42 response = self._http.post(uri, body=body) 

+

43 

+

44 resp = response.json() 

+

45 jwt_response = self._auth.generate_jwt_response( 

+

46 resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

47 ) 

+

48 return jwt_response 

+

49 

+

50 def sign_in( 

+

51 self, 

+

52 login_id: str, 

+

53 password: str, 

+

54 audience: str | None | Iterable[str] = None, 

+

55 ) -> dict: 

+

56 """ 

+

57 Sign in by verifying the validity of a password entered by an end user. 

+

58 

+

59 Args: 

+

60 login_id (str): The login ID of the user being validated 

+

61 password (str): The password to be validated 

+

62 

+

63 Return value (dict): 

+

64 Return dict in the format 

+

65 {"sessionToken": {"jwt": "..."}, "refreshSessionToken": {"jwt": "..."}, ...} 

+

66 Includes the session token, refresh session token, token claims, and user information 

+

67 

+

68 Raise: 

+

69 AuthException: raised if sign in operation fails 

+

70 """ 

+

71 

+

72 self._validate_login_id(login_id) 

+

73 self._validate_sign_in_password(password) 

+

74 

+

75 uri = EndpointsV1.sign_in_password_path 

+

76 response = self._http.post(uri, body={"loginId": login_id, "password": password}) 

+

77 

+

78 resp = response.json() 

+

79 jwt_response = self._auth.generate_jwt_response( 

+

80 resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

81 ) 

+

82 return jwt_response 

+

83 

+

84 def send_reset( 

+

85 self, 

+

86 login_id: str, 

+

87 redirect_url: str | None = None, 

+

88 template_options: dict | None = None, 

+

89 ) -> dict: 

+

90 """ 

+

91 Sends a password reset prompt to the user with the given 

+

92 login_id according to the password settings defined in the Descope console. 

+

93 NOTE: The user must be verified according to the configured password reset method. 

+

94 

+

95 Args: 

+

96 login_id (str): The login ID of the user to receive a password reset prompt 

+

97 redirect_url (str): Optional parameter that is used by Magic Link or Enchanted Link 

+

98 if those are the chosen reset methods. See the Magic Link and Enchanted Link sections 

+

99 for more details. 

+

100 

+

101 Return value (dict): 

+

102 Return dict in the format 

+

103 {"resetMethod": "", "pendingRef": "", "linkId": "", "maskedEmail": ""} 

+

104 The contents will differ according to the chosen reset method. 'pendingRef' 

+

105 and 'linkId' will only appear of 'resetMethod' == 'enchantedlink' 

+

106 

+

107 Raise: 

+

108 AuthException: raised if send reset operation fails 

+

109 """ 

+

110 

+

111 self._validate_login_id(login_id) 

+

112 

+

113 uri = EndpointsV1.send_reset_password_path 

+

114 body: dict[str, str | bool | dict | None] = { 

+

115 "loginId": login_id, 

+

116 "redirectUrl": redirect_url, 

+

117 } 

+

118 if template_options is not None: 

+

119 body["templateOptions"] = template_options 

+

120 

+

121 response = self._http.post(uri, body=body) 

+

122 

+

123 return response.json() 

+

124 

+

125 def update(self, login_id: str, new_password: str, refresh_token: str) -> None: 

+

126 """ 

+

127 Update a password for an existing logged in user using their refresh token. 

+

128 

+

129 Args: 

+

130 login_id (str): The login ID of the user whose information is being updated 

+

131 new_password (str): The new password to use 

+

132 refresh_token (str): The session's refresh token (used for verification) 

+

133 

+

134 Raise: 

+

135 AuthException: raised if refresh token is invalid or update operation fails 

+

136 """ 

+

137 

+

138 self._validate_login_id(login_id) 

+

139 self._validate_new_password(new_password) 

+

140 self._validate_refresh_token(refresh_token) 

+

141 

+

142 uri = EndpointsV1.update_password_path 

+

143 self._http.post( 

+

144 uri, 

+

145 body={"loginId": login_id, "newPassword": new_password}, 

+

146 pswd=refresh_token, 

+

147 ) 

+

148 

+

149 def replace( 

+

150 self, 

+

151 login_id: str, 

+

152 old_password: str, 

+

153 new_password: str, 

+

154 audience: str | None | Iterable[str] = None, 

+

155 ) -> dict: 

+

156 """ 

+

157 Replace a valid active password with a new one. The old_password is used to 

+

158 authenticate the user. If the user cannot be authenticated, this operation 

+

159 will fail. 

+

160 

+

161 Args: 

+

162 login_id (str): The login ID of the user whose information is being updated 

+

163 old_password (str): The user's current active password 

+

164 new_password (str): The new password to use 

+

165 

+

166 Return value (dict): 

+

167 Return dict in the format 

+

168 {"jwts": [], "user": "", "firstSeen": false, "error": ""} 

+

169 Includes all the jwts tokens (session token, refresh token), token claims, and user information 

+

170 

+

171 Raise: 

+

172 AuthException: raised if replace operation fails 

+

173 """ 

+

174 

+

175 self._validate_login_id(login_id) 

+

176 self._validate_old_password(old_password) 

+

177 self._validate_new_password(new_password) 

+

178 

+

179 uri = EndpointsV1.replace_password_path 

+

180 response = self._http.post( 

+

181 uri, 

+

182 body={ 

+

183 "loginId": login_id, 

+

184 "oldPassword": old_password, 

+

185 "newPassword": new_password, 

+

186 }, 

+

187 ) 

+

188 

+

189 resp = response.json() 

+

190 jwt_response = self._auth.generate_jwt_response( 

+

191 resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

192 ) 

+

193 return jwt_response 

+

194 

+

195 def get_policy(self) -> dict: 

+

196 """ 

+

197 Get a subset of the password policy defined in the Descope console and enforced 

+

198 by Descope. The goal is to enable client-side validations to give users a better UX 

+

199 

+

200 Return value (dict): 

+

201 Return dict in the format 

+

202 {"minLength": 8, "lowercase": true, "uppercase": true, "number": true, "nonAlphanumeric": true} 

+

203 minLength - the minimum length of a password 

+

204 lowercase - the password required at least one lowercase character 

+

205 uppercase - the password required at least one uppercase character 

+

206 number - the password required at least one number character 

+

207 nonAlphanumeric - the password required at least one non alphanumeric character 

+

208 

+

209 Raise: 

+

210 AuthException: raised if get policy operation fails 

+

211 """ 

+

212 

+

213 response = self._http.get(uri=EndpointsV1.password_policy_path) 

+

214 return response.json() 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5__auth_base_py.html b/htmlcov/d_ed9d64cdfac9d9b5__auth_base_py.html new file mode 100644 index 000000000..d7296ba86 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5__auth_base_py.html @@ -0,0 +1,476 @@ + + + + + Coverage for descope/_auth_base.py: 99% + + + + + +
+
+

+ Coverage for descope/_auth_base.py: + 99% +

+ +

+ 185 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3import json 

+

4import os 

+

5import re 

+

6from http import HTTPStatus 

+

7from typing import Iterable, Optional 

+

8 

+

9import jwt 

+

10from email_validator import EmailNotValidError, validate_email 

+

11from jwt import ExpiredSignatureError, ImmatureSignatureError 

+

12 

+

13from descope.common import ( 

+

14 DEFAULT_BASE_URL, 

+

15 DEFAULT_DOMAIN, 

+

16 DEFAULT_URL_PREFIX, 

+

17 PHONE_REGEX, 

+

18 DeliveryMethod, 

+

19) 

+

20from descope.exceptions import ( 

+

21 API_RATE_LIMIT_RETRY_AFTER_HEADER, 

+

22 ERROR_TYPE_API_RATE_LIMIT, 

+

23 ERROR_TYPE_INVALID_ARGUMENT, 

+

24 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

25 ERROR_TYPE_INVALID_TOKEN, 

+

26 ERROR_TYPE_SERVER_ERROR, 

+

27 AuthException, 

+

28 RateLimitException, 

+

29) 

+

30from descope.jwt_common import adjust_properties as jwt_adjust_properties 

+

31from descope.jwt_common import generate_auth_info as jwt_generate_auth_info 

+

32from descope.jwt_common import generate_jwt_response as jwt_generate_jwt_response 

+

33 

+

34 

+

35class AuthBase: 

+

36 """Pure-CPU base shared by ``Auth`` and ``AuthAsync``. 

+

37 

+

38 Holds project identity, the JWKS cache, and JWT helpers. Subclasses add 

+

39 the HTTP client, lock primitive, and I/O methods. 

+

40 """ 

+

41 

+

42 ALGORITHM_KEY = "alg" 

+

43 

+

44 def __init__( 

+

45 self, 

+

46 project_id: Optional[str] = None, 

+

47 public_key: Optional[dict | str] = None, 

+

48 jwt_validation_leeway: int = 5, 

+

49 ): 

+

50 if not project_id: 

+

51 raise AuthException( 

+

52 400, 

+

53 ERROR_TYPE_INVALID_ARGUMENT, 

+

54 ( 

+

55 "Unable to init Auth object because project_id cannot be empty. " 

+

56 "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." 

+

57 ), 

+

58 ) 

+

59 self.project_id = project_id 

+

60 self.jwt_validation_leeway = jwt_validation_leeway 

+

61 

+

62 public_key = public_key or os.getenv("DESCOPE_PUBLIC_KEY") 

+

63 if not public_key: 

+

64 self.public_keys: dict = {} 

+

65 else: 

+

66 kid, pub_key, alg = self._validate_and_load_public_key(public_key) 

+

67 self.public_keys = {kid: (pub_key, alg)} 

+

68 

+

69 def _raise_rate_limit_exception(self, response): 

+

70 try: 

+

71 resp = response.json() 

+

72 raise RateLimitException( 

+

73 resp.get("errorCode", HTTPStatus.TOO_MANY_REQUESTS), 

+

74 ERROR_TYPE_API_RATE_LIMIT, 

+

75 resp.get("errorDescription", ""), 

+

76 resp.get("errorMessage", ""), 

+

77 rate_limit_parameters={API_RATE_LIMIT_RETRY_AFTER_HEADER: self._parse_retry_after(response.headers)}, 

+

78 ) 

+

79 except RateLimitException: 

+

80 raise 

+

81 except Exception: 

+

82 raise RateLimitException( 

+

83 status_code=HTTPStatus.TOO_MANY_REQUESTS, 

+

84 error_type=ERROR_TYPE_API_RATE_LIMIT, 

+

85 error_message=ERROR_TYPE_API_RATE_LIMIT, 

+

86 error_description=ERROR_TYPE_API_RATE_LIMIT, 

+

87 ) 

+

88 

+

89 def _parse_retry_after(self, headers): 

+

90 try: 

+

91 return int(headers.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, 0)) 

+

92 except (ValueError, TypeError): 

+

93 return 0 

+

94 

+

95 def _raise_from_response(self, response): 

+

96 """Raise appropriate exception from response, does nothing if response.ok is True.""" 

+

97 if response.ok: 

+

98 return 

+

99 

+

100 if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: 

+

101 self._raise_rate_limit_exception(response) # Raise RateLimitException 

+

102 

+

103 raise AuthException( 

+

104 response.status_code, 

+

105 ERROR_TYPE_SERVER_ERROR, 

+

106 response.text, 

+

107 ) 

+

108 

+

109 @staticmethod 

+

110 def base_url_for_project_id(project_id): 

+

111 if len(project_id) >= 32: 

+

112 region = project_id[1:5] 

+

113 return ".".join([DEFAULT_URL_PREFIX, region, DEFAULT_DOMAIN]) 

+

114 return DEFAULT_BASE_URL 

+

115 

+

116 @staticmethod 

+

117 def adjust_and_verify_delivery_method(method: DeliveryMethod, login_id: str, user: dict) -> bool: 

+

118 if not login_id: 

+

119 return False 

+

120 

+

121 if not isinstance(user, dict): 

+

122 return False 

+

123 

+

124 if method == DeliveryMethod.EMAIL: 

+

125 if not user.get("email", None): 

+

126 user["email"] = login_id 

+

127 try: 

+

128 validate_email(user["email"], check_deliverability=False) 

+

129 return True 

+

130 except EmailNotValidError: 

+

131 return False 

+

132 elif method == DeliveryMethod.SMS: 

+

133 if not user.get("phone", None): 

+

134 user["phone"] = login_id 

+

135 if not re.match(PHONE_REGEX, user["phone"]): 

+

136 return False 

+

137 elif method == DeliveryMethod.VOICE: 

+

138 if not user.get("phone", None): 

+

139 user["phone"] = login_id 

+

140 if not re.match(PHONE_REGEX, user["phone"]): 

+

141 return False 

+

142 elif method == DeliveryMethod.WHATSAPP: 

+

143 if not user.get("phone", None): 

+

144 user["phone"] = login_id 

+

145 if not re.match(PHONE_REGEX, user["phone"]): 

+

146 return False 

+

147 else: 

+

148 return False 

+

149 

+

150 return True 

+

151 

+

152 @staticmethod 

+

153 def compose_url(base: str, method: DeliveryMethod) -> str: 

+

154 suffix = { 

+

155 DeliveryMethod.EMAIL: "email", 

+

156 DeliveryMethod.SMS: "sms", 

+

157 DeliveryMethod.VOICE: "voice", 

+

158 DeliveryMethod.WHATSAPP: "whatsapp", 

+

159 }.get(method) 

+

160 

+

161 if not suffix: 

+

162 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}") 

+

163 

+

164 return f"{base}/{suffix}" 

+

165 

+

166 @staticmethod 

+

167 def get_login_id_by_method(method: DeliveryMethod, user: dict) -> tuple[str, str]: 

+

168 login_id = { 

+

169 DeliveryMethod.EMAIL: ("email", user.get("email", "")), 

+

170 DeliveryMethod.SMS: ("phone", user.get("phone", "")), 

+

171 DeliveryMethod.VOICE: ("voice", user.get("phone", "")), 

+

172 DeliveryMethod.WHATSAPP: ("whatsapp", user.get("phone", "")), 

+

173 }.get(method) 

+

174 

+

175 if not login_id: 

+

176 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}") 

+

177 

+

178 return login_id 

+

179 

+

180 @staticmethod 

+

181 def validate_email(email: str): 

+

182 if email == "": 

+

183 raise AuthException( 

+

184 400, 

+

185 ERROR_TYPE_INVALID_ARGUMENT, 

+

186 "email address argument cannot be empty", 

+

187 ) 

+

188 

+

189 try: 

+

190 validate_email(email, check_deliverability=False) 

+

191 except EmailNotValidError as ex: 

+

192 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, f"Invalid email address: {ex}") 

+

193 

+

194 @staticmethod 

+

195 def validate_phone(method: DeliveryMethod, phone: str): 

+

196 if phone == "": 

+

197 raise AuthException( 

+

198 400, 

+

199 ERROR_TYPE_INVALID_ARGUMENT, 

+

200 "Phone number argument cannot be empty", 

+

201 ) 

+

202 

+

203 if not re.match(PHONE_REGEX, phone): 

+

204 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Invalid phone number") 

+

205 

+

206 if method != DeliveryMethod.SMS and method != DeliveryMethod.VOICE and method != DeliveryMethod.WHATSAPP: 

+

207 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Invalid delivery method") 

+

208 

+

209 @staticmethod 

+

210 def _compose_exchange_body(code: str) -> dict: 

+

211 return {"code": code} 

+

212 

+

213 @staticmethod 

+

214 def _validate_and_load_public_key(public_key) -> tuple[str, jwt.PyJWK, str]: 

+

215 if not isinstance(public_key, (str, dict)): 

+

216 raise AuthException( 

+

217 500, 

+

218 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

219 "Unable to load public key. Invalid public key error: (unknown type)", 

+

220 ) 

+

221 

+

222 if isinstance(public_key, str): 

+

223 try: 

+

224 public_key = json.loads(public_key) 

+

225 except (ValueError, TypeError) as e: 

+

226 raise AuthException( 

+

227 500, 

+

228 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

229 f"Unable to load public key. error: {e}", 

+

230 ) 

+

231 

+

232 alg = public_key.get(AuthBase.ALGORITHM_KEY, None) 

+

233 if alg is None: 

+

234 raise AuthException( 

+

235 500, 

+

236 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

237 "Unable to load public key. Missing property: alg", 

+

238 ) 

+

239 

+

240 kid = public_key.get("kid", None) 

+

241 if kid is None: 

+

242 raise AuthException( 

+

243 500, 

+

244 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

245 "Unable to load public key. Missing property: kid", 

+

246 ) 

+

247 try: 

+

248 # Load and validate public key 

+

249 return (kid, jwt.PyJWK(public_key), alg) 

+

250 except (jwt.PyJWKError, jwt.InvalidKeyError) as e: 

+

251 raise AuthException( 

+

252 500, 

+

253 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

254 f"Unable to load public key {e}", 

+

255 ) 

+

256 

+

257 def adjust_properties(self, jwt_response: dict, user_jwt: bool): 

+

258 # Delegate to shared JWT utilities for normalization 

+

259 return jwt_adjust_properties(jwt_response, user_jwt) 

+

260 

+

261 def _generate_auth_info( 

+

262 self, 

+

263 response_body: dict, 

+

264 refresh_token: str | None, 

+

265 user_jwt: bool, 

+

266 audience: str | None | Iterable[str] = None, 

+

267 ) -> dict: 

+

268 return jwt_generate_auth_info( 

+

269 response_body, 

+

270 refresh_token, 

+

271 user_jwt, 

+

272 audience, 

+

273 token_validator=self._validate_token, # type: ignore[attr-defined] 

+

274 ) 

+

275 

+

276 def generate_jwt_response( 

+

277 self, 

+

278 response_body: dict, 

+

279 refresh_cookie: str | None, 

+

280 audience: str | None | Iterable[str] = None, 

+

281 ) -> dict: 

+

282 return jwt_generate_jwt_response( 

+

283 response_body, 

+

284 refresh_cookie, 

+

285 audience, 

+

286 token_validator=self._validate_token, # type: ignore[attr-defined] 

+

287 ) 

+

288 

+

289 @staticmethod 

+

290 def extract_masked_address(response: dict, method: DeliveryMethod) -> str: 

+

291 if method == DeliveryMethod.SMS or method == DeliveryMethod.VOICE or method == DeliveryMethod.WHATSAPP: 

+

292 return response["maskedPhone"] 

+

293 elif method == DeliveryMethod.EMAIL: 

+

294 return response["maskedEmail"] 

+

295 return "" 

+

296 

+

297 @staticmethod 

+

298 def _kid_alg_from_token(token: str) -> tuple[str, str]: 

+

299 """Return ``(kid, alg)`` from the unverified JWT header; raises on missing/invalid fields.""" 

+

300 if not token: 

+

301 raise AuthException(500, ERROR_TYPE_INVALID_TOKEN, "Token validation received empty token") 

+

302 

+

303 try: 

+

304 unverified_header = jwt.get_unverified_header(token) 

+

305 except jwt.exceptions.PyJWTError as e: 

+

306 raise AuthException(500, ERROR_TYPE_INVALID_TOKEN, f"Unable to parse token header. Error: {e}") from e 

+

307 

+

308 alg_header = unverified_header.get(AuthBase.ALGORITHM_KEY, None) 

+

309 if alg_header is None or alg_header == "none": 

+

310 raise AuthException(500, ERROR_TYPE_INVALID_TOKEN, "Token header is missing property: alg") 

+

311 

+

312 kid = unverified_header.get("kid", None) 

+

313 if kid is None: 

+

314 raise AuthException(500, ERROR_TYPE_INVALID_TOKEN, "Token header is missing property: kid") 

+

315 

+

316 return kid, alg_header 

+

317 

+

318 def _decode_and_verify_token( 

+

319 self, 

+

320 token: str, 

+

321 audience: str | None | Iterable[str], 

+

322 copy_key, 

+

323 alg_header: str, 

+

324 ) -> dict: 

+

325 """Pure-CPU JWT verification. Assumes ``copy_key`` was already retrieved from the JWKS cache.""" 

+

326 alg_from_key = copy_key[1] 

+

327 if alg_header != alg_from_key: 

+

328 raise AuthException( 

+

329 500, 

+

330 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

331 "Algorithm signature in JWT header does not match the algorithm signature in the public key", 

+

332 ) 

+

333 

+

334 validation_audience = audience 

+

335 if audience is None: 

+

336 try: 

+

337 unverified_claims = jwt.decode( 

+

338 jwt=token, 

+

339 key=copy_key[0].key, 

+

340 algorithms=[alg_header], 

+

341 options={"verify_aud": False}, 

+

342 leeway=self.jwt_validation_leeway, 

+

343 ) 

+

344 token_audience = unverified_claims.get("aud") 

+

345 if token_audience and self.project_id: 

+

346 if isinstance(token_audience, list): 

+

347 if self.project_id in token_audience: 

+

348 validation_audience = self.project_id 

+

349 else: 

+

350 if token_audience == self.project_id: 

+

351 validation_audience = self.project_id 

+

352 except Exception: 

+

353 # Best-effort audience sniff. Any failure falls through to the 

+

354 # verified decode below, which raises the proper error message. 

+

355 pass 

+

356 

+

357 try: 

+

358 claims = jwt.decode( 

+

359 jwt=token, 

+

360 key=copy_key[0].key, 

+

361 algorithms=[alg_header], 

+

362 audience=validation_audience, 

+

363 leeway=self.jwt_validation_leeway, 

+

364 ) 

+

365 except ImmatureSignatureError: 

+

366 raise AuthException( 

+

367 400, 

+

368 ERROR_TYPE_INVALID_TOKEN, 

+

369 "Received Invalid token (nbf in future) during jwt validation. Error can be due to time glitch (between machines), try to set the jwt_validation_leeway parameter (in DescopeClient) to higher value than 5sec which is the default", 

+

370 ) 

+

371 except ExpiredSignatureError: 

+

372 raise AuthException( 

+

373 401, 

+

374 ERROR_TYPE_INVALID_TOKEN, 

+

375 "Received expired token (exp in past) during jwt validation. (sometimes can be due to time glitch (between machines), try to set the jwt_validation_leeway parameter (in DescopeClient) to higher value than 5sec which is the default)", 

+

376 ) 

+

377 

+

378 claims["jwt"] = token 

+

379 return claims 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5__authmethod_base_py.html b/htmlcov/d_ed9d64cdfac9d9b5__authmethod_base_py.html new file mode 100644 index 000000000..0a140f5c9 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5__authmethod_base_py.html @@ -0,0 +1,124 @@ + + + + + Coverage for descope/_authmethod_base.py: 92% + + + + + +
+
+

+ Coverage for descope/_authmethod_base.py: + 92% +

+ +

+ 13 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1# This is not part of the public API but a code helper. 

+

2# 

+

3# The sync / async classes below are structurally identical — same body, 

+

4# same derived ``_http``. They stay split so type checkers can ensure a 

+

5# sync auth-method class is never handed an ``AuthAsync`` (or vice versa); 

+

6# collapsing into one ``Generic[T]`` would work but loses concrete-type 

+

7# tooltips at the call sites. 

+

8from __future__ import annotations 

+

9 

+

10from typing import TYPE_CHECKING 

+

11 

+

12from descope.auth import Auth 

+

13 

+

14if TYPE_CHECKING: 

+

15 from descope.auth_async import AuthAsync 

+

16 

+

17 

+

18class AuthMethodBase: 

+

19 def __init__(self, auth: Auth): 

+

20 self._auth = auth 

+

21 self._http = auth.http_client 

+

22 

+

23 

+

24class AsyncAuthMethodBase: 

+

25 def __init__(self, auth: AuthAsync): 

+

26 self._auth = auth 

+

27 self._http = auth.http_client 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5__client_base_py.html b/htmlcov/d_ed9d64cdfac9d9b5__client_base_py.html new file mode 100644 index 000000000..57494dfe6 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5__client_base_py.html @@ -0,0 +1,297 @@ + + + + + Coverage for descope/_client_base.py: 97% + + + + + +
+
+

+ Coverage for descope/_client_base.py: + 97% +

+ +

+ 89 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3import logging 

+

4import os 

+

5import warnings 

+

6 

+

7from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

8 

+

9logger = logging.getLogger(__name__) 

+

10 

+

11 

+

12class DescopeClientBase: 

+

13 """Shared base for ``DescopeClient`` and ``DescopeClientAsync``. 

+

14 

+

15 Holds: 

+

16 - project_id resolution + validation, ``skip_verify`` warning 

+

17 - The pure-computation permission / role helpers that work on a 

+

18 decoded JWT dict and need no I/O. 

+

19 

+

20 ``Auth`` / ``AuthAsync`` construction and any operation that touches the 

+

21 network (``validate_session``, ``refresh_session``, ``logout``, etc.) 

+

22 lives on each concrete subclass. 

+

23 """ 

+

24 

+

25 def __init__( 

+

26 self, 

+

27 project_id: str, 

+

28 public_key: dict | None, 

+

29 skip_verify: bool, 

+

30 timeout_seconds: float, 

+

31 jwt_validation_leeway: int, 

+

32 auth_management_key: str | None, 

+

33 *, 

+

34 base_url: str | None, 

+

35 verbose: bool, 

+

36 ): 

+

37 project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "") 

+

38 if not project_id: 

+

39 raise AuthException( 

+

40 400, 

+

41 ERROR_TYPE_INVALID_ARGUMENT, 

+

42 ( 

+

43 "Unable to init client because project_id cannot be empty. " 

+

44 "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." 

+

45 ), 

+

46 ) 

+

47 

+

48 if skip_verify: 

+

49 warnings.warn( 

+

50 "⚠️ SECURITY WARNING: TLS certificate verification is DISABLED (skip_verify=True). " 

+

51 "This makes your application vulnerable to man-in-the-middle attacks. " 

+

52 "ONLY use this for local development with self-signed certificates. " 

+

53 "NEVER use skip_verify=True in production environments.", 

+

54 category=UserWarning, 

+

55 # stacklevel 3: warn → base.__init__ → subclass.__init__ → user code 

+

56 stacklevel=3, 

+

57 ) 

+

58 

+

59 self._project_id = project_id 

+

60 self._public_key = public_key 

+

61 self._skip_verify = skip_verify 

+

62 self._timeout_seconds = timeout_seconds 

+

63 self._jwt_validation_leeway = jwt_validation_leeway 

+

64 self._auth_management_key_arg = auth_management_key 

+

65 self._base_url_arg = base_url 

+

66 self._verbose = verbose 

+

67 

+

68 @staticmethod 

+

69 def _ensure_present(value, message: str, error_type: str = ERROR_TYPE_INVALID_ARGUMENT) -> None: 

+

70 """Raise AuthException(400, error_type, message) if *value* is falsy.""" 

+

71 if not value: 

+

72 raise AuthException(400, error_type, message) 

+

73 

+

74 @staticmethod 

+

75 def _require_refresh_token(refresh_token) -> None: 

+

76 """Guard for ops that act on a refresh token (logout, me, history, my_tenants). 

+

77 

+

78 Uses an ``is None`` check (not falsy) to preserve the historical error 

+

79 message that echoes the token value. 

+

80 """ 

+

81 if refresh_token is None: 

+

82 raise AuthException( 

+

83 400, 

+

84 ERROR_TYPE_INVALID_ARGUMENT, 

+

85 f"signed refresh token {refresh_token} is empty", 

+

86 ) 

+

87 

+

88 @staticmethod 

+

89 def _require_access_key(access_key) -> None: 

+

90 """Guard for exchange_access_key.""" 

+

91 if not access_key: 

+

92 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Access key cannot be empty") 

+

93 

+

94 @staticmethod 

+

95 def _validate_tenant_selector(dct: bool, ids) -> None: 

+

96 """Guard for my_tenants: exactly one of *dct* or *ids* must be supplied.""" 

+

97 if dct is True and ids is not None and len(ids) > 0: 

+

98 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Only one of 'dct' or 'ids' should be supplied") 

+

99 if dct is False and (ids is None or len(ids) == 0): 

+

100 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Only one of 'dct' or 'ids' should be supplied") 

+

101 

+

102 def validate_permissions(self, jwt_response: dict, permissions: list[str]) -> bool: 

+

103 """ 

+

104 Validate that jwt_response has been granted the specified permissions. 

+

105 For multi-tenant use validate_tenant_permissions. 

+

106 """ 

+

107 return self.validate_tenant_permissions(jwt_response, "", permissions) 

+

108 

+

109 def get_matched_permissions(self, jwt_response: dict, permissions: list[str]) -> list[str]: 

+

110 """Return the subset of permissions that jwt_response has been granted.""" 

+

111 return self.get_matched_tenant_permissions(jwt_response, "", permissions) 

+

112 

+

113 def validate_tenant_permissions(self, jwt_response: dict, tenant: str, permissions: list[str]) -> bool: 

+

114 """ 

+

115 Validate that jwt_response has been granted the specified permissions on tenant. 

+

116 Returns True only if all permissions are granted. 

+

117 """ 

+

118 if not jwt_response: 

+

119 return False 

+

120 

+

121 if isinstance(permissions, str): 

+

122 permissions = [permissions] 

+

123 

+

124 granted = [] 

+

125 if tenant == "": 

+

126 granted = jwt_response.get("permissions", []) 

+

127 else: 

+

128 if tenant not in jwt_response.get("tenants", {}): 

+

129 return False 

+

130 granted = jwt_response.get("tenants", {}).get(tenant, {}).get("permissions", []) 

+

131 

+

132 return all(p in granted for p in permissions) 

+

133 

+

134 def get_matched_tenant_permissions(self, jwt_response: dict, tenant: str, permissions: list[str]) -> list[str]: 

+

135 """Return the subset of permissions that jwt_response has been granted on tenant.""" 

+

136 if not jwt_response: 

+

137 return [] 

+

138 

+

139 if isinstance(permissions, str): 

+

140 permissions = [permissions] 

+

141 

+

142 if tenant != "" and tenant not in jwt_response.get("tenants", {}): 

+

143 return [] 

+

144 

+

145 granted = ( 

+

146 jwt_response.get("permissions", []) 

+

147 if tenant == "" 

+

148 else jwt_response.get("tenants", {}).get(tenant, {}).get("permissions", []) 

+

149 ) 

+

150 return [p for p in permissions if p in granted] 

+

151 

+

152 def validate_roles(self, jwt_response: dict, roles: list[str]) -> bool: 

+

153 """ 

+

154 Validate that jwt_response has been granted the specified roles. 

+

155 For multi-tenant use validate_tenant_roles. 

+

156 """ 

+

157 return self.validate_tenant_roles(jwt_response, "", roles) 

+

158 

+

159 def get_matched_roles(self, jwt_response: dict, roles: list[str]) -> list[str]: 

+

160 """Return the subset of roles that jwt_response has been granted.""" 

+

161 return self.get_matched_tenant_roles(jwt_response, "", roles) 

+

162 

+

163 def validate_tenant_roles(self, jwt_response: dict, tenant: str, roles: list[str]) -> bool: 

+

164 """ 

+

165 Validate that jwt_response has been granted the specified roles on tenant. 

+

166 Returns True only if all roles are granted. 

+

167 """ 

+

168 if not jwt_response: 

+

169 return False 

+

170 

+

171 if isinstance(roles, str): 

+

172 roles = [roles] 

+

173 

+

174 granted = [] 

+

175 if tenant == "": 

+

176 granted = jwt_response.get("roles", []) 

+

177 else: 

+

178 if tenant not in jwt_response.get("tenants", {}): 

+

179 return False 

+

180 granted = jwt_response.get("tenants", {}).get(tenant, {}).get("roles", []) 

+

181 

+

182 return all(r in granted for r in roles) 

+

183 

+

184 def get_matched_tenant_roles(self, jwt_response: dict, tenant: str, roles: list[str]) -> list[str]: 

+

185 """Return the subset of roles that jwt_response has been granted on tenant.""" 

+

186 if not jwt_response: 

+

187 return [] 

+

188 

+

189 if isinstance(roles, str): 

+

190 roles = [roles] 

+

191 

+

192 if tenant != "" and tenant not in jwt_response.get("tenants", {}): 

+

193 return [] 

+

194 

+

195 granted = ( 

+

196 jwt_response.get("roles", []) 

+

197 if tenant == "" 

+

198 else jwt_response.get("tenants", {}).get(tenant, {}).get("roles", []) 

+

199 ) 

+

200 return [r for r in roles if r in granted] 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5__http_base_py.html b/htmlcov/d_ed9d64cdfac9d9b5__http_base_py.html new file mode 100644 index 000000000..6ace23126 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5__http_base_py.html @@ -0,0 +1,119 @@ + + + + + Coverage for descope/_http_base.py: 91% + + + + + +
+
+

+ Coverage for descope/_http_base.py: + 91% +

+ +

+ 11 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import TYPE_CHECKING 

+

4 

+

5from descope.http_client import HTTPClient 

+

6 

+

7if TYPE_CHECKING: 

+

8 from descope.http_client_async import HTTPClientAsync 

+

9 

+

10 

+

11class HTTPBase: 

+

12 """Base class for classes that only need HTTP access.""" 

+

13 

+

14 def __init__(self, http_client: HTTPClient): 

+

15 self._http = http_client 

+

16 

+

17 

+

18class AsyncHTTPBase: 

+

19 """Base for async management classes.""" 

+

20 

+

21 def __init__(self, http_client: HTTPClientAsync): 

+

22 self._http = http_client 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5_auth_async_py.html b/htmlcov/d_ed9d64cdfac9d9b5_auth_async_py.html new file mode 100644 index 000000000..90de03577 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5_auth_async_py.html @@ -0,0 +1,367 @@ + + + + + Coverage for descope/auth_async.py: 99% + + + + + +
+
+

+ Coverage for descope/auth_async.py: + 99% +

+ +

+ 127 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3import asyncio 

+

4import copy 

+

5import json 

+

6from typing import Iterable, Optional 

+

7 

+

8from descope._auth_base import AuthBase 

+

9from descope.common import ( 

+

10 REFRESH_SESSION_COOKIE_NAME, 

+

11 SESSION_TOKEN_NAME, 

+

12 AccessKeyLoginOptions, 

+

13 EndpointsV1, 

+

14 EndpointsV2, 

+

15) 

+

16from descope.exceptions import ( 

+

17 ERROR_TYPE_INVALID_ARGUMENT, 

+

18 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

19 ERROR_TYPE_INVALID_TOKEN, 

+

20 AuthException, 

+

21) 

+

22from descope.http_client_async import HTTPClientAsync 

+

23 

+

24 

+

25class AuthAsync(AuthBase): 

+

26 """Async counterpart of ``Auth``. 

+

27 

+

28 All network I/O is awaitable; JWT validation stays sync and runs after 

+

29 JWKS is pre-warmed by ``ensure_keys``. 

+

30 """ 

+

31 

+

32 def __init__( 

+

33 self, 

+

34 project_id: Optional[str] = None, 

+

35 public_key: Optional[dict | str] = None, 

+

36 jwt_validation_leeway: int = 5, 

+

37 *, 

+

38 http_client: HTTPClientAsync, 

+

39 ): 

+

40 super().__init__(project_id, public_key, jwt_validation_leeway) 

+

41 self._http = http_client 

+

42 # Lazy-init: constructed on first ``ensure_keys`` to avoid 

+

43 # "no running event loop" if the client is built before the loop starts. 

+

44 self._lock_public_keys: asyncio.Lock | None = None 

+

45 

+

46 @property 

+

47 def http_client(self) -> HTTPClientAsync: 

+

48 return self._http 

+

49 

+

50 @property 

+

51 def lock_public_keys(self) -> asyncio.Lock: 

+

52 if self._lock_public_keys is None: 

+

53 self._lock_public_keys = asyncio.Lock() 

+

54 return self._lock_public_keys 

+

55 

+

56 async def _fetch_public_keys(self) -> None: 

+

57 """Fetch JWKS via async HTTP. Caller must hold ``lock_public_keys``.""" 

+

58 response = await self._http.get(f"{EndpointsV2.public_key_path}/{self.project_id}") 

+

59 jwks_data = response.text 

+

60 try: 

+

61 jwkeys_wrapper = json.loads(jwks_data) 

+

62 jwkeys = jwkeys_wrapper["keys"] 

+

63 except (ValueError, TypeError, KeyError) as e: 

+

64 raise AuthException(500, ERROR_TYPE_INVALID_PUBLIC_KEY, f"Unable to load jwks. Error: {e}") 

+

65 

+

66 new_keys: dict = {} 

+

67 for key in jwkeys: 

+

68 try: 

+

69 loaded_kid, pub_key, alg = AuthAsync._validate_and_load_public_key(key) 

+

70 new_keys[loaded_kid] = (pub_key, alg) 

+

71 except AuthException: 

+

72 # Skip invalid JWKS entries and continue loading the rest of the keys. 

+

73 continue 

+

74 self.public_keys = new_keys 

+

75 

+

76 async def ensure_keys(self, kid: str | None = None) -> None: 

+

77 """Pre-warm the JWKS cache. 

+

78 

+

79 Idempotent: returns immediately when the cache already contains the 

+

80 requested kid (or any key, when kid is None). Otherwise serializes 

+

81 concurrent callers behind ``lock_public_keys`` and fetches once. 

+

82 """ 

+

83 if self._cache_has_kid(kid): 

+

84 return 

+

85 async with self.lock_public_keys: 

+

86 if self._cache_has_kid(kid): 

+

87 return 

+

88 await self._fetch_public_keys() 

+

89 

+

90 def _cache_has_kid(self, kid: str | None) -> bool: 

+

91 if not self.public_keys: 

+

92 return False 

+

93 if kid is None: 

+

94 return True 

+

95 return kid in self.public_keys 

+

96 

+

97 def _validate_token(self, token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

98 """Sync JWT validation. Caller must have awaited ``ensure_keys(kid)`` first. 

+

99 

+

100 Stays sync because ``AuthBase`` passes ``self._validate_token`` as the 

+

101 ``token_validator`` callback into ``jwt_common.generate_jwt_response`` / 

+

102 ``generate_auth_info`` (see ``_auth_base.py``), which invoke it 

+

103 synchronously. The async ``prepare_jwt_response`` / ``prepare_auth_info`` 

+

104 wrappers warm JWKS *before* that sync flow runs. 

+

105 """ 

+

106 kid, alg_header = self._kid_alg_from_token(token) 

+

107 found_key = self.public_keys.get(kid) 

+

108 if found_key is None: 

+

109 raise AuthException( 

+

110 500, 

+

111 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

112 "Unable to validate public key. Public key not found.", 

+

113 ) 

+

114 return self._decode_and_verify_token(token, audience, found_key, alg_header) 

+

115 

+

116 async def _validate_token_async(self, token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

117 """Async direct-validation entrypoint: warms JWKS then runs sync validation.""" 

+

118 kid, _ = self._kid_alg_from_token(token) 

+

119 await self.ensure_keys(kid) 

+

120 return self._validate_token(token, audience) 

+

121 

+

122 async def prepare_jwt_response( 

+

123 self, 

+

124 response_body: dict, 

+

125 refresh_cookie: str | None, 

+

126 audience: str | None | Iterable[str] = None, 

+

127 ) -> dict: 

+

128 """Pre-warm JWKS then build the JWT response.""" 

+

129 await self._ensure_keys_for_jwt_response(response_body, refresh_cookie) 

+

130 return self.generate_jwt_response(response_body, refresh_cookie, audience) 

+

131 

+

132 async def prepare_auth_info( 

+

133 self, 

+

134 response_body: dict, 

+

135 refresh_token: str | None, 

+

136 user_jwt: bool, 

+

137 audience: str | None | Iterable[str] = None, 

+

138 ) -> dict: 

+

139 """Pre-warm JWKS then build the auth info dict.""" 

+

140 await self._ensure_keys_for_jwt_response(response_body, refresh_token) 

+

141 return self._generate_auth_info(response_body, refresh_token, user_jwt, audience) 

+

142 

+

143 async def _ensure_keys_for_jwt_response(self, response_body: dict, refresh_token: str | None = None) -> None: 

+

144 """Pre-warm JWKS for tokens that ``generate_jwt_response`` will validate. 

+

145 

+

146 Mirrors the token-extraction logic in ``jwt_common.generate_auth_info``: 

+

147 validates ``sessionJwt`` and ``refreshJwt`` from the body, or the 

+

148 provided ``refresh_token`` when ``refreshJwt`` is absent. 

+

149 """ 

+

150 kids_seen: set[str] = set() 

+

151 for body_key in ("sessionJwt", "refreshJwt"): 

+

152 tok = response_body.get(body_key, "") 

+

153 if not tok: 

+

154 continue 

+

155 try: 

+

156 kid, _ = self._kid_alg_from_token(tok) 

+

157 except AuthException: 

+

158 continue 

+

159 if kid in kids_seen: 

+

160 continue 

+

161 kids_seen.add(kid) 

+

162 await self.ensure_keys(kid) 

+

163 if not response_body.get("refreshJwt") and refresh_token: 

+

164 try: 

+

165 kid, _ = self._kid_alg_from_token(refresh_token) 

+

166 except AuthException: 

+

167 return 

+

168 if kid not in kids_seen: 

+

169 await self.ensure_keys(kid) 

+

170 

+

171 async def exchange_token(self, uri: str, code: str, audience: Optional[Iterable[str] | str] = None) -> dict: 

+

172 if not code: 

+

173 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") 

+

174 

+

175 body = AuthAsync._compose_exchange_body(code) 

+

176 response = await self._http.post(uri, body=body) 

+

177 return await self.prepare_jwt_response( 

+

178 response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME), audience 

+

179 ) 

+

180 

+

181 async def exchange_access_key( 

+

182 self, 

+

183 access_key: str, 

+

184 audience: str | Iterable[str] | None = None, 

+

185 login_options: AccessKeyLoginOptions | None = None, 

+

186 ) -> dict: 

+

187 body = { 

+

188 "loginOptions": ( 

+

189 {k: v for k, v in login_options.__dict__.items() if v is not None} if login_options else {} 

+

190 ), 

+

191 } 

+

192 server_response = await self._http.post(EndpointsV1.exchange_auth_access_key_path, body=body, pswd=access_key) 

+

193 return await self.prepare_auth_info( 

+

194 response_body=server_response.json(), 

+

195 refresh_token=None, 

+

196 user_jwt=False, 

+

197 audience=audience, 

+

198 ) 

+

199 

+

200 async def validate_token(self, token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

201 """Public token-validation entrypoint (parity with ``Auth.validate_token``).""" 

+

202 return await self._validate_token_async(token, audience) 

+

203 

+

204 async def validate_session(self, session_token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

205 if not session_token: 

+

206 raise AuthException( 

+

207 400, 

+

208 ERROR_TYPE_INVALID_TOKEN, 

+

209 "Session token is required for validation", 

+

210 ) 

+

211 

+

212 res = await self._validate_token_async(session_token, audience) 

+

213 res[SESSION_TOKEN_NAME] = copy.deepcopy(res) 

+

214 return self.adjust_properties(res, True) 

+

215 

+

216 async def refresh_session(self, refresh_token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

217 if not refresh_token: 

+

218 raise AuthException( 

+

219 400, 

+

220 ERROR_TYPE_INVALID_TOKEN, 

+

221 "Refresh token is required to refresh a session", 

+

222 ) 

+

223 

+

224 await self._validate_token_async(refresh_token, audience) 

+

225 

+

226 response = await self._http.post(EndpointsV1.refresh_token_path, body={}, pswd=refresh_token) 

+

227 effective_refresh = response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) or refresh_token 

+

228 return await self.prepare_jwt_response(response.json(), effective_refresh, audience) 

+

229 

+

230 async def validate_and_refresh_session( 

+

231 self, 

+

232 session_token: str, 

+

233 refresh_token: str, 

+

234 audience: str | None | Iterable[str] = None, 

+

235 ) -> dict: 

+

236 if not session_token: 

+

237 raise AuthException( 

+

238 400, 

+

239 ERROR_TYPE_INVALID_TOKEN, 

+

240 "Session token is missing", 

+

241 ) 

+

242 

+

243 try: 

+

244 return await self.validate_session(session_token, audience) 

+

245 except AuthException: 

+

246 if not refresh_token: 

+

247 raise AuthException( 

+

248 400, 

+

249 ERROR_TYPE_INVALID_TOKEN, 

+

250 "Refresh token is missing", 

+

251 ) 

+

252 return await self.refresh_session(refresh_token, audience) 

+

253 

+

254 async def select_tenant( 

+

255 self, 

+

256 tenant_id: str, 

+

257 refresh_token: str, 

+

258 audience: str | None | Iterable[str] = None, 

+

259 ) -> dict: 

+

260 if not refresh_token: 

+

261 raise AuthException( 

+

262 400, 

+

263 ERROR_TYPE_INVALID_TOKEN, 

+

264 "Refresh token is required to refresh a session", 

+

265 ) 

+

266 

+

267 response = await self._http.post(EndpointsV1.select_tenant_path, body={"tenant": tenant_id}, pswd=refresh_token) 

+

268 return await self.prepare_jwt_response( 

+

269 response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

270 ) 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5_auth_py.html b/htmlcov/d_ed9d64cdfac9d9b5_auth_py.html new file mode 100644 index 000000000..3ec823d27 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5_auth_py.html @@ -0,0 +1,298 @@ + + + + + Coverage for descope/auth.py: 94% + + + + + +
+
+

+ Coverage for descope/auth.py: + 94% +

+ +

+ 90 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3import copy 

+

4import json 

+

5from threading import Lock 

+

6from typing import Iterable, Optional 

+

7 

+

8from descope._auth_base import AuthBase 

+

9from descope.common import ( 

+

10 REFRESH_SESSION_COOKIE_NAME, 

+

11 SESSION_TOKEN_NAME, 

+

12 AccessKeyLoginOptions, 

+

13 EndpointsV1, 

+

14 EndpointsV2, 

+

15) 

+

16from descope.exceptions import ( 

+

17 ERROR_TYPE_INVALID_ARGUMENT, 

+

18 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

19 ERROR_TYPE_INVALID_TOKEN, 

+

20 AuthException, 

+

21) 

+

22from descope.http_client import HTTPClient 

+

23 

+

24 

+

25class Auth(AuthBase): 

+

26 def __init__( 

+

27 self, 

+

28 project_id: Optional[str] = None, 

+

29 public_key: Optional[dict | str] = None, 

+

30 jwt_validation_leeway: int = 5, 

+

31 *, 

+

32 http_client: HTTPClient, 

+

33 ): 

+

34 self.lock_public_keys = Lock() 

+

35 super().__init__(project_id, public_key, jwt_validation_leeway) 

+

36 self._http = http_client 

+

37 

+

38 @property 

+

39 def http_client(self) -> HTTPClient: 

+

40 return self._http 

+

41 

+

42 def exchange_token(self, uri, code: str, audience: Optional[Iterable[str] | str] = None) -> dict: 

+

43 if not code: 

+

44 raise AuthException( 

+

45 400, 

+

46 ERROR_TYPE_INVALID_ARGUMENT, 

+

47 "exchange code is empty", 

+

48 ) 

+

49 

+

50 body = Auth._compose_exchange_body(code) 

+

51 response = self._http.post(uri, body=body) 

+

52 resp = response.json() 

+

53 jwt_response = self.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME), audience) 

+

54 return jwt_response 

+

55 

+

56 def exchange_access_key( 

+

57 self, 

+

58 access_key: str, 

+

59 audience: str | Iterable[str] | None = None, 

+

60 login_options: AccessKeyLoginOptions | None = None, 

+

61 ) -> dict: 

+

62 uri = EndpointsV1.exchange_auth_access_key_path 

+

63 body = { 

+

64 "loginOptions": ( 

+

65 {k: v for k, v in login_options.__dict__.items() if v is not None} if login_options else {} 

+

66 ), 

+

67 } 

+

68 server_response = self._http.post(uri, body=body, pswd=access_key) 

+

69 json_body = server_response.json() 

+

70 return self._generate_auth_info( 

+

71 response_body=json_body, 

+

72 refresh_token=None, 

+

73 user_jwt=False, 

+

74 audience=audience, 

+

75 ) 

+

76 

+

77 def _fetch_public_keys(self) -> None: 

+

78 # This function called under mutex protection so no need to acquire it once again 

+

79 response = self._http.get( 

+

80 f"{EndpointsV2.public_key_path}/{self.project_id}", 

+

81 ) 

+

82 

+

83 jwks_data = response.text 

+

84 try: 

+

85 jwkeys_wrapper = json.loads(jwks_data) 

+

86 jwkeys = jwkeys_wrapper["keys"] 

+

87 except (ValueError, TypeError, KeyError) as e: 

+

88 raise AuthException(500, ERROR_TYPE_INVALID_PUBLIC_KEY, f"Unable to load jwks. Error: {e}") 

+

89 

+

90 # Load all public keys for this project 

+

91 self.public_keys = {} 

+

92 for key in jwkeys: 

+

93 try: 

+

94 loaded_kid, pub_key, alg = Auth._validate_and_load_public_key(key) 

+

95 self.public_keys[loaded_kid] = (pub_key, alg) 

+

96 except AuthException: 

+

97 # just continue to the next key 

+

98 pass 

+

99 

+

100 # public method to validate a token from the management class 

+

101 def validate_token(self, token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

102 return self._validate_token(token, audience) 

+

103 

+

104 # Validate a token and load the public key if needed 

+

105 def _validate_token(self, token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

106 kid, alg_header = self._kid_alg_from_token(token) 

+

107 

+

108 with self.lock_public_keys: 

+

109 if self.public_keys == {} or self.public_keys.get(kid, None) is None: 

+

110 self._fetch_public_keys() 

+

111 

+

112 found_key = self.public_keys.get(kid, None) 

+

113 if found_key is None: 

+

114 raise AuthException( 

+

115 500, 

+

116 ERROR_TYPE_INVALID_PUBLIC_KEY, 

+

117 "Unable to validate public key. Public key not found.", 

+

118 ) 

+

119 # save reference to the founded key 

+

120 # (as another thread can change the self.public_keys dict) 

+

121 copy_key = found_key 

+

122 

+

123 return self._decode_and_verify_token(token, audience, copy_key, alg_header) 

+

124 

+

125 def validate_session(self, session_token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

126 if not session_token: 

+

127 raise AuthException( 

+

128 400, 

+

129 ERROR_TYPE_INVALID_TOKEN, 

+

130 "Session token is required for validation", 

+

131 ) 

+

132 

+

133 res = self._validate_token(session_token, audience) 

+

134 res[SESSION_TOKEN_NAME] = copy.deepcopy( 

+

135 res 

+

136 ) # Duplicate for saving backward compatibility but keep the same structure as the refresh operation response 

+

137 return self.adjust_properties(res, True) 

+

138 

+

139 def refresh_session(self, refresh_token: str, audience: str | None | Iterable[str] = None) -> dict: 

+

140 if not refresh_token: 

+

141 raise AuthException( 

+

142 400, 

+

143 ERROR_TYPE_INVALID_TOKEN, 

+

144 "Refresh token is required to refresh a session", 

+

145 ) 

+

146 

+

147 self._validate_token(refresh_token, audience) 

+

148 

+

149 uri = EndpointsV1.refresh_token_path 

+

150 response = self._http.post(uri, body={}, pswd=refresh_token) 

+

151 

+

152 resp = response.json() 

+

153 refresh_token = response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) or refresh_token 

+

154 return self.generate_jwt_response(resp, refresh_token, audience) 

+

155 

+

156 def validate_and_refresh_session( 

+

157 self, 

+

158 session_token: str, 

+

159 refresh_token: str, 

+

160 audience: str | None | Iterable[str] = None, 

+

161 ) -> dict: 

+

162 if not session_token: 

+

163 raise AuthException( 

+

164 400, 

+

165 ERROR_TYPE_INVALID_TOKEN, 

+

166 "Session token is missing", 

+

167 ) 

+

168 

+

169 try: 

+

170 return self.validate_session(session_token, audience) 

+

171 except AuthException: 

+

172 # Session is invalid - try to refresh it 

+

173 if not refresh_token: 

+

174 raise AuthException( 

+

175 400, 

+

176 ERROR_TYPE_INVALID_TOKEN, 

+

177 "Refresh token is missing", 

+

178 ) 

+

179 return self.refresh_session(refresh_token, audience) 

+

180 

+

181 def select_tenant( 

+

182 self, 

+

183 tenant_id: str, 

+

184 refresh_token: str, 

+

185 audience: str | None | Iterable[str] = None, 

+

186 ) -> dict: 

+

187 if not refresh_token: 

+

188 raise AuthException( 

+

189 400, 

+

190 ERROR_TYPE_INVALID_TOKEN, 

+

191 "Refresh token is required to refresh a session", 

+

192 ) 

+

193 

+

194 uri = EndpointsV1.select_tenant_path 

+

195 response = self._http.post(uri, body={"tenant": tenant_id}, pswd=refresh_token) 

+

196 

+

197 resp = response.json() 

+

198 jwt_response = self.generate_jwt_response( 

+

199 resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience 

+

200 ) 

+

201 return jwt_response 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5_descope_client_async_py.html b/htmlcov/d_ed9d64cdfac9d9b5_descope_client_async_py.html new file mode 100644 index 000000000..c3ef31639 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5_descope_client_async_py.html @@ -0,0 +1,428 @@ + + + + + Coverage for descope/descope_client_async.py: 99% + + + + + +
+
+

+ Coverage for descope/descope_client_async.py: + 99% +

+ +

+ 154 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3import asyncio 

+

4import logging 

+

5import os 

+

6from typing import Iterable 

+

7 

+

8import httpx 

+

9 

+

10from descope._client_base import DescopeClientBase 

+

11from descope._http_client_base import ContextVarLastResponseStore 

+

12from descope.auth_async import AuthAsync 

+

13from descope.authmethod.enchantedlink_async import EnchantedLinkAsync 

+

14from descope.authmethod.magiclink_async import MagicLinkAsync 

+

15from descope.authmethod.oauth_async import OAuthAsync 

+

16from descope.authmethod.otp_async import OTPAsync 

+

17from descope.authmethod.password_async import PasswordAsync 

+

18from descope.authmethod.saml_async import SAMLAsync 

+

19from descope.authmethod.sso_async import SSOAsync 

+

20from descope.authmethod.totp_async import TOTPAsync 

+

21from descope.authmethod.webauthn_async import WebAuthnAsync 

+

22from descope.common import ( 

+

23 DEFAULT_TIMEOUT_SECONDS, 

+

24 AccessKeyLoginOptions, 

+

25 EndpointsV1, 

+

26) 

+

27from descope.exceptions import ( 

+

28 ERROR_TYPE_INVALID_TOKEN, 

+

29 AuthException, 

+

30) 

+

31from descope.http_client_async import HTTPClientAsync 

+

32from descope.management.common import MgmtV1 

+

33from descope.mgmt_async import MGMTAsync 

+

34 

+

35logger = logging.getLogger(__name__) 

+

36 

+

37LICENSE_HANDSHAKE_TIMEOUT_SECONDS = 5.0 

+

38 

+

39 

+

40class DescopeClientAsync(DescopeClientBase): 

+

41 """ 

+

42 Async counterpart of DescopeClient. 

+

43 

+

44 All network-bound operations — including ``validate_session`` — are 

+

45 ``async def`` because JWKS may need to be fetched. Pure JWT-claim helpers 

+

46 (``validate_permissions``, ``validate_roles``, etc.) are inherited sync 

+

47 from ``DescopeClientBase`` — they take a decoded JWT dict and run no I/O. 

+

48 

+

49 The license handshake (rate-limit tier) is run lazily on the first 

+

50 management request and is also runnable eagerly via ``aopen`` / 

+

51 ``__aenter__``. Either way, it never blocks the event loop in 

+

52 ``__init__``. 

+

53 

+

54 Usage (recommended — context manager): 

+

55 async with DescopeClientAsync(project_id="P...") as client: 

+

56 jwt = await client.refresh_session(refresh_token) 

+

57 

+

58 Usage (explicit close): 

+

59 client = DescopeClientAsync(project_id="P...") 

+

60 try: 

+

61 jwt = await client.refresh_session(refresh_token) 

+

62 finally: 

+

63 await client.aclose() 

+

64 """ 

+

65 

+

66 def __init__( 

+

67 self, 

+

68 project_id: str, 

+

69 public_key: dict | None = None, 

+

70 skip_verify: bool = False, 

+

71 management_key: str | None = None, 

+

72 timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, 

+

73 jwt_validation_leeway: int = 5, 

+

74 auth_management_key: str | None = None, 

+

75 fga_cache_url: str | None = None, 

+

76 *, 

+

77 base_url: str | None = None, 

+

78 verbose: bool = False, 

+

79 ): 

+

80 super().__init__( 

+

81 project_id, 

+

82 public_key, 

+

83 skip_verify, 

+

84 timeout_seconds, 

+

85 jwt_validation_leeway, 

+

86 auth_management_key, 

+

87 base_url=base_url, 

+

88 verbose=verbose, 

+

89 ) 

+

90 

+

91 self._last_response_store = ContextVarLastResponseStore() 

+

92 self._auth_http = HTTPClientAsync( 

+

93 project_id=self._project_id, 

+

94 base_url=base_url, 

+

95 timeout_seconds=timeout_seconds, 

+

96 secure=not skip_verify, 

+

97 management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), 

+

98 verbose=verbose, 

+

99 last_response_store=self._last_response_store, 

+

100 ) 

+

101 self._mgmt_http = HTTPClientAsync( 

+

102 project_id=self._project_id, 

+

103 base_url=base_url, 

+

104 timeout_seconds=timeout_seconds, 

+

105 secure=not skip_verify, 

+

106 management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), 

+

107 verbose=verbose, 

+

108 last_response_store=self._last_response_store, 

+

109 ) 

+

110 self._auth = AuthAsync( 

+

111 self._project_id, 

+

112 public_key, 

+

113 jwt_validation_leeway, 

+

114 http_client=self._auth_http, 

+

115 ) 

+

116 self._fga_cache_url = fga_cache_url 

+

117 self._mgmt = MGMTAsync( 

+

118 http_client=self._mgmt_http, 

+

119 auth=self._auth, 

+

120 fga_cache_url=fga_cache_url, 

+

121 ) 

+

122 

+

123 self._magiclink = MagicLinkAsync(self._auth) 

+

124 self._enchantedlink = EnchantedLinkAsync(self._auth) 

+

125 self._oauth = OAuthAsync(self._auth) 

+

126 self._saml = SAMLAsync(self._auth) # deprecated 

+

127 self._sso = SSOAsync(self._auth) 

+

128 self._otp = OTPAsync(self._auth) 

+

129 self._totp = TOTPAsync(self._auth) 

+

130 self._webauthn = WebAuthnAsync(self._auth) 

+

131 self._password = PasswordAsync(self._auth) 

+

132 

+

133 # Lazy license handshake: any request through ``_mgmt_http`` triggers 

+

134 # ``_ensure_license`` once. Constructed on first use to avoid binding 

+

135 # the lock to an event loop that may not exist at __init__ time. 

+

136 self._license_lock: asyncio.Lock | None = None 

+

137 self._license_attempted = False 

+

138 self._mgmt_http._pre_request_hook = self._ensure_license 

+

139 

+

140 @property 

+

141 def magiclink(self) -> MagicLinkAsync: 

+

142 return self._magiclink 

+

143 

+

144 @property 

+

145 def enchantedlink(self) -> EnchantedLinkAsync: 

+

146 return self._enchantedlink 

+

147 

+

148 @property 

+

149 def otp(self) -> OTPAsync: 

+

150 return self._otp 

+

151 

+

152 @property 

+

153 def totp(self) -> TOTPAsync: 

+

154 return self._totp 

+

155 

+

156 @property 

+

157 def oauth(self) -> OAuthAsync: 

+

158 return self._oauth 

+

159 

+

160 # deprecated (use sso instead) 

+

161 @property 

+

162 def saml(self) -> SAMLAsync: 

+

163 return self._saml 

+

164 

+

165 @property 

+

166 def sso(self) -> SSOAsync: 

+

167 return self._sso 

+

168 

+

169 @property 

+

170 def webauthn(self) -> WebAuthnAsync: 

+

171 return self._webauthn 

+

172 

+

173 @property 

+

174 def password(self) -> PasswordAsync: 

+

175 return self._password 

+

176 

+

177 @property 

+

178 def mgmt(self) -> MGMTAsync: 

+

179 return self._mgmt 

+

180 

+

181 async def aopen(self) -> DescopeClientAsync: 

+

182 """Pre-warm the license handshake. Optional. 

+

183 

+

184 The handshake also runs lazily on the first management request, so 

+

185 callers may safely skip this. Use it when you want to pay the 

+

186 handshake latency at startup rather than on the first mgmt call. 

+

187 Idempotent — subsequent calls are no-ops. 

+

188 """ 

+

189 await self._ensure_license() 

+

190 return self 

+

191 

+

192 async def _ensure_license(self) -> None: 

+

193 """Idempotent license handshake, safe to call from concurrent requests. 

+

194 

+

195 Wired as ``_mgmt_http._pre_request_hook`` so every management request 

+

196 awaits it; the lock+flag pair ensures the actual network call runs at 

+

197 most once per client instance. 

+

198 """ 

+

199 if self._license_attempted: 

+

200 return 

+

201 if not self._mgmt_http.management_key: 

+

202 return 

+

203 if self._license_lock is None: 

+

204 self._license_lock = asyncio.Lock() 

+

205 async with self._license_lock: 

+

206 if self._license_attempted: 

+

207 return 

+

208 await self._fetch_rate_limit_tier_async(self._mgmt_http) 

+

209 self._license_attempted = True 

+

210 

+

211 async def _fetch_rate_limit_tier_async(self, mgmt_http: HTTPClientAsync) -> None: 

+

212 """Async license handshake using a one-shot ``httpx.AsyncClient``.""" 

+

213 try: 

+

214 async with httpx.AsyncClient( 

+

215 verify=mgmt_http.client_verify, 

+

216 timeout=LICENSE_HANDSHAKE_TIMEOUT_SECONDS, 

+

217 ) as client: 

+

218 response = await client.get( 

+

219 f"{mgmt_http.base_url}{MgmtV1.license_get_path}", 

+

220 headers={"Authorization": (f"Bearer {mgmt_http.project_id}:{mgmt_http.management_key}")}, 

+

221 follow_redirects=True, 

+

222 ) 

+

223 if not response.is_success: 

+

224 logger.warning( 

+

225 "License handshake returned non-success status %s", 

+

226 response.status_code, 

+

227 ) 

+

228 return 

+

229 tier = response.json().get("rateLimitTier") 

+

230 if tier: 

+

231 mgmt_http.rate_limit_tier = tier 

+

232 except Exception as e: 

+

233 logger.warning("License handshake failed: %s", e) 

+

234 

+

235 async def aclose(self) -> None: 

+

236 """Close the underlying async HTTP clients and release connections.""" 

+

237 await self._auth_http.aclose() 

+

238 await self._mgmt_http.aclose() 

+

239 # OutboundApplicationByTokenAsync owns a separate no-management-key client. 

+

240 await self._mgmt._outbound_application_by_token.aclose() 

+

241 

+

242 async def __aenter__(self) -> DescopeClientAsync: 

+

243 await self.aopen() 

+

244 return self 

+

245 

+

246 async def __aexit__(self, *args) -> None: 

+

247 await self.aclose() 

+

248 

+

249 async def validate_session(self, session_token: str, audience: Iterable[str] | str | None = None) -> dict: 

+

250 """Validate a session token. Awaitable so JWKS can be fetched without blocking the loop.""" 

+

251 return await self._auth.validate_session(session_token, audience) 

+

252 

+

253 async def refresh_session(self, refresh_token: str, audience: Iterable[str] | str | None = None) -> dict: 

+

254 """Refresh a session using the refresh token. Makes an async network call.""" 

+

255 self._ensure_present(refresh_token, "Refresh token is required to refresh a session", ERROR_TYPE_INVALID_TOKEN) 

+

256 return await self._auth.refresh_session(refresh_token, audience) 

+

257 

+

258 async def validate_and_refresh_session( 

+

259 self, 

+

260 session_token: str, 

+

261 refresh_token: str, 

+

262 audience: Iterable[str] | str | None = None, 

+

263 ) -> dict: 

+

264 """Validate the session token; refresh it if expired.""" 

+

265 self._ensure_present(session_token, "Session token is missing", ERROR_TYPE_INVALID_TOKEN) 

+

266 try: 

+

267 return await self._auth.validate_session(session_token, audience) 

+

268 except AuthException: 

+

269 self._ensure_present(refresh_token, "Refresh token is missing", ERROR_TYPE_INVALID_TOKEN) 

+

270 return await self.refresh_session(refresh_token, audience) 

+

271 

+

272 async def logout(self, refresh_token: str): 

+

273 """Logout the user from the current session and revoke the refresh token.""" 

+

274 self._require_refresh_token(refresh_token) 

+

275 return await self._auth_http.post(EndpointsV1.logout_path, body={}, pswd=refresh_token) 

+

276 

+

277 async def logout_all(self, refresh_token: str): 

+

278 """Logout the user from all active sessions and revoke the refresh token.""" 

+

279 self._require_refresh_token(refresh_token) 

+

280 return await self._auth_http.post(EndpointsV1.logout_all_path, body={}, pswd=refresh_token) 

+

281 

+

282 async def me(self, refresh_token: str) -> dict: 

+

283 """Retrieve user details for the refresh token.""" 

+

284 self._require_refresh_token(refresh_token) 

+

285 response = await self._auth_http.get(EndpointsV1.me_path, allow_redirects=None, pswd=refresh_token) 

+

286 return response.json() 

+

287 

+

288 async def my_tenants( 

+

289 self, 

+

290 refresh_token: str, 

+

291 dct: bool = False, 

+

292 ids: list[str] | None = None, 

+

293 ) -> dict: 

+

294 """Retrieve tenant attributes that the user belongs to.""" 

+

295 self._require_refresh_token(refresh_token) 

+

296 self._validate_tenant_selector(dct, ids) 

+

297 

+

298 body: dict[str, bool | list[str]] = {"dct": dct} 

+

299 if ids is not None: 

+

300 body["ids"] = ids 

+

301 response = await self._auth_http.post(EndpointsV1.my_tenants_path, body=body, pswd=refresh_token) 

+

302 return response.json() 

+

303 

+

304 async def history(self, refresh_token: str) -> list[dict]: 

+

305 """Retrieve user authentication history for the refresh token.""" 

+

306 self._require_refresh_token(refresh_token) 

+

307 response = await self._auth_http.get(EndpointsV1.history_path, allow_redirects=None, pswd=refresh_token) 

+

308 return response.json() 

+

309 

+

310 async def exchange_access_key( 

+

311 self, 

+

312 access_key: str, 

+

313 audience: Iterable[str] | str | None = None, 

+

314 login_options: AccessKeyLoginOptions | None = None, 

+

315 ) -> dict: 

+

316 """Return a new session token for the given access key.""" 

+

317 self._require_access_key(access_key) 

+

318 return await self._auth.exchange_access_key(access_key, audience, login_options) 

+

319 

+

320 async def select_tenant(self, tenant_id: str, refresh_token: str) -> dict: 

+

321 """Add a selected tenant claim to the JWT.""" 

+

322 self._ensure_present(refresh_token, "Refresh token is required to refresh a session", ERROR_TYPE_INVALID_TOKEN) 

+

323 return await self._auth.select_tenant(tenant_id, refresh_token) 

+

324 

+

325 def get_last_response(self): 

+

326 """Get the last HTTP response when verbose mode is enabled. 

+

327 

+

328 Returns the most recent response across auth and mgmt operations, 

+

329 whichever ran last. 

+

330 """ 

+

331 return self._last_response_store.get() 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5_descope_client_py.html b/htmlcov/d_ed9d64cdfac9d9b5_descope_client_py.html new file mode 100644 index 000000000..9c1e533d7 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5_descope_client_py.html @@ -0,0 +1,496 @@ + + + + + Coverage for descope/descope_client.py: 98% + + + + + +
+
+

+ Coverage for descope/descope_client.py: + 98% +

+ +

+ 126 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3import logging 

+

4import os 

+

5from typing import Iterable 

+

6 

+

7import httpx 

+

8 

+

9from descope._client_base import DescopeClientBase 

+

10from descope._http_client_base import ThreadLocalLastResponseStore 

+

11from descope.auth import Auth 

+

12from descope.authmethod.enchantedlink import EnchantedLink # noqa: F401 

+

13from descope.authmethod.magiclink import MagicLink # noqa: F401 

+

14from descope.authmethod.oauth import OAuth # noqa: F401 

+

15from descope.authmethod.otp import OTP # noqa: F401 

+

16from descope.authmethod.password import Password # noqa: F401 

+

17from descope.authmethod.saml import SAML # noqa: F401 

+

18from descope.authmethod.sso import SSO # noqa: F401 

+

19from descope.authmethod.totp import TOTP # noqa: F401 

+

20from descope.authmethod.webauthn import WebAuthn # noqa: F401 

+

21from descope.common import DEFAULT_TIMEOUT_SECONDS, AccessKeyLoginOptions, EndpointsV1 

+

22from descope.http_client import HTTPClient 

+

23from descope.management.common import MgmtV1 

+

24from descope.mgmt import MGMT # noqa: F401 

+

25 

+

26logger = logging.getLogger(__name__) 

+

27 

+

28LICENSE_HANDSHAKE_TIMEOUT_SECONDS = 5.0 

+

29 

+

30 

+

31class DescopeClient(DescopeClientBase): 

+

32 ALGORITHM_KEY = "alg" 

+

33 

+

34 def __init__( 

+

35 self, 

+

36 project_id: str, 

+

37 public_key: dict | None = None, 

+

38 skip_verify: bool = False, 

+

39 management_key: str | None = None, 

+

40 timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, 

+

41 jwt_validation_leeway: int = 5, 

+

42 auth_management_key: str | None = None, 

+

43 fga_cache_url: str | None = None, 

+

44 *, 

+

45 base_url: str | None = None, 

+

46 verbose: bool = False, 

+

47 ): 

+

48 super().__init__( 

+

49 project_id, 

+

50 public_key, 

+

51 skip_verify, 

+

52 timeout_seconds, 

+

53 jwt_validation_leeway, 

+

54 auth_management_key, 

+

55 base_url=base_url, 

+

56 verbose=verbose, 

+

57 ) 

+

58 self._last_response_store = ThreadLocalLastResponseStore() 

+

59 auth_http_client = HTTPClient( 

+

60 project_id=self._project_id, 

+

61 base_url=base_url, 

+

62 timeout_seconds=timeout_seconds, 

+

63 secure=not skip_verify, 

+

64 management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), 

+

65 verbose=verbose, 

+

66 last_response_store=self._last_response_store, 

+

67 ) 

+

68 self._auth = Auth( 

+

69 self._project_id, 

+

70 public_key, 

+

71 jwt_validation_leeway, 

+

72 http_client=auth_http_client, 

+

73 ) 

+

74 

+

75 self._magiclink = MagicLink(self._auth) 

+

76 self._enchantedlink = EnchantedLink(self._auth) 

+

77 self._oauth = OAuth(self._auth) 

+

78 self._saml = SAML(self._auth) # deprecated 

+

79 self._sso = SSO(self._auth) 

+

80 self._otp = OTP(self._auth) 

+

81 self._totp = TOTP(self._auth) 

+

82 self._webauthn = WebAuthn(self._auth) 

+

83 self._password = Password(self._auth) 

+

84 

+

85 # Management Initialization 

+

86 mgmt_http_client = HTTPClient( 

+

87 project_id=project_id, 

+

88 base_url=auth_http_client.base_url, 

+

89 timeout_seconds=auth_http_client.timeout_seconds, 

+

90 secure=auth_http_client.secure, 

+

91 management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), 

+

92 verbose=verbose, 

+

93 last_response_store=self._last_response_store, 

+

94 ) 

+

95 self._mgmt = MGMT( 

+

96 http_client=mgmt_http_client, 

+

97 auth=self._auth, 

+

98 fga_cache_url=fga_cache_url, 

+

99 ) 

+

100 

+

101 self._auth_http_client = auth_http_client 

+

102 self._mgmt_http_client = mgmt_http_client 

+

103 

+

104 # Synchronous license handshake so the first management request after 

+

105 # construction can carry the x-descope-license header. Backend skips 

+

106 # license-header validation for the GetLicense endpoint itself, so the 

+

107 # initial request is safe before the tier is cached. 

+

108 if mgmt_http_client.management_key: 

+

109 self._fetch_rate_limit_tier(mgmt_http_client) 

+

110 

+

111 def _fetch_rate_limit_tier(self, mgmt_http) -> None: 

+

112 """Sync license handshake so the x-descope-license header is ready for the first mgmt call.""" 

+

113 try: 

+

114 response = httpx.get( 

+

115 f"{mgmt_http.base_url}{MgmtV1.license_get_path}", 

+

116 headers={"Authorization": f"Bearer {mgmt_http.project_id}:{mgmt_http.management_key}"}, 

+

117 follow_redirects=True, 

+

118 verify=mgmt_http.client_verify, 

+

119 timeout=LICENSE_HANDSHAKE_TIMEOUT_SECONDS, 

+

120 ) 

+

121 if not response.is_success: 

+

122 logger.warning( 

+

123 "License handshake returned non-success status %s", 

+

124 response.status_code, 

+

125 ) 

+

126 return 

+

127 tier = response.json().get("rateLimitTier") 

+

128 if tier: 

+

129 mgmt_http.rate_limit_tier = tier 

+

130 except Exception as e: 

+

131 logger.warning("License handshake failed: %s", e) 

+

132 

+

133 def validate_session(self, session_token: str, audience: Iterable[str] | str | None = None) -> dict: 

+

134 """Validate a session token. Pure CPU once JWKS is cached — no I/O on the hot path.""" 

+

135 return self._auth.validate_session(session_token, audience) 

+

136 

+

137 @property 

+

138 def mgmt(self): 

+

139 return self._mgmt 

+

140 

+

141 @property 

+

142 def magiclink(self): 

+

143 return self._magiclink 

+

144 

+

145 @property 

+

146 def enchantedlink(self): 

+

147 return self._enchantedlink 

+

148 

+

149 @property 

+

150 def otp(self): 

+

151 return self._otp 

+

152 

+

153 @property 

+

154 def totp(self): 

+

155 return self._totp 

+

156 

+

157 @property 

+

158 def oauth(self): 

+

159 return self._oauth 

+

160 

+

161 # ######## deprecated (use sso instead) 

+

162 @property 

+

163 def saml(self): 

+

164 return self._saml 

+

165 

+

166 # ################### 

+

167 

+

168 @property 

+

169 def sso(self): 

+

170 return self._sso 

+

171 

+

172 @property 

+

173 def webauthn(self): 

+

174 return self._webauthn 

+

175 

+

176 @property 

+

177 def password(self): 

+

178 return self._password 

+

179 

+

180 def refresh_session(self, refresh_token: str, audience: Iterable[str] | str | None = None) -> dict: 

+

181 """ 

+

182 Refresh a session. Call this function when a session expires and needs to be refreshed. 

+

183 

+

184 Args: 

+

185 refresh_token (str): The refresh token that will be used to refresh the session 

+

186 audience (str|Iterable[str]|None): Optional recipients that the JWT is intended for (must be equal to the 'aud' claim on the provided token) 

+

187 

+

188 Return value (dict): 

+

189 Return dict includes the session token, refresh token, and all JWT claims 

+

190 

+

191 Raise: 

+

192 AuthException: Exception is raised if refresh token is not authorized or any other error occurs 

+

193 """ 

+

194 return self._auth.refresh_session(refresh_token, audience) 

+

195 

+

196 def validate_and_refresh_session( 

+

197 self, 

+

198 session_token: str, 

+

199 refresh_token: str, 

+

200 audience: Iterable[str] | str | None = None, 

+

201 ) -> dict: 

+

202 """ 

+

203 Validate the session token and refresh it if it has expired, the session token will automatically be refreshed. 

+

204 Either the session_token or the refresh_token must be provided. 

+

205 Call this function for every incoming request to your 

+

206 private endpoints. Alternatively, use validate_session to only validate the session. 

+

207 

+

208 Args: 

+

209 session_token (str): The session token to be validated 

+

210 refresh_token (str): The refresh token that will be used to refresh the session token, if needed 

+

211 audience (str|Iterable[str]|None): Optional recipients that the JWT is intended for (must be equal to the 'aud' claim on the provided token) 

+

212 

+

213 Return value (dict): 

+

214 Return dict includes the session token, refresh token, and all JWT claims 

+

215 

+

216 Raise: 

+

217 AuthException: Exception is raised if session is not authorized or another error occurs 

+

218 """ 

+

219 return self._auth.validate_and_refresh_session(session_token, refresh_token, audience) 

+

220 

+

221 def logout(self, refresh_token: str) -> httpx.Response: 

+

222 """ 

+

223 Logout user from current session and revoke the refresh_token. After calling this function, 

+

224 you must invalidate or remove any cookies you have created. 

+

225 

+

226 Args: 

+

227 refresh_token (str): The refresh token 

+

228 

+

229 Return value (httpx.Response): returns the response from the Descope server 

+

230 

+

231 Raise: 

+

232 AuthException: Exception is raised if session is not authorized or another error occurs 

+

233 """ 

+

234 self._require_refresh_token(refresh_token) 

+

235 uri = EndpointsV1.logout_path 

+

236 return self._auth.http_client.post(uri, body={}, pswd=refresh_token) 

+

237 

+

238 def logout_all(self, refresh_token: str) -> httpx.Response: 

+

239 """ 

+

240 Logout user from all active sessions and revoke the refresh_token. After calling this function, 

+

241 you must invalidate or remove any cookies you have created. 

+

242 

+

243 Args: 

+

244 refresh_token (str): The refresh token 

+

245 

+

246 Return value (httpx.Response): returns the response from the Descope server 

+

247 

+

248 Raise: 

+

249 AuthException: Exception is raised if session is not authorized or another error occurs 

+

250 """ 

+

251 self._require_refresh_token(refresh_token) 

+

252 uri = EndpointsV1.logout_all_path 

+

253 return self._auth.http_client.post(uri, body={}, pswd=refresh_token) 

+

254 

+

255 def me(self, refresh_token: str) -> dict: 

+

256 """ 

+

257 Retrieve user details for the refresh token. The returned data includes email, name, phone, 

+

258 list of loginIds and boolean flags for verifiedEmail, verifiedPhone. 

+

259 

+

260 Args: 

+

261 refresh_token (str): The refresh token 

+

262 

+

263 Return value (dict): returns the user details from the server 

+

264 (email:str, name:str, phone:str, loginIds[str], verifiedEmail:bool, verifiedPhone:bool) 

+

265 

+

266 Raise: 

+

267 AuthException: Exception is raised if session is not authorized or another error occurs 

+

268 """ 

+

269 self._require_refresh_token(refresh_token) 

+

270 uri = EndpointsV1.me_path 

+

271 response = self._auth.http_client.get(uri=uri, allow_redirects=None, pswd=refresh_token) 

+

272 return response.json() 

+

273 

+

274 def my_tenants( 

+

275 self, 

+

276 refresh_token: str, 

+

277 dct: bool = False, 

+

278 ids: list[str] | None = None, 

+

279 ) -> dict: 

+

280 """ 

+

281 Retrieve tenant attributes that user belongs to, one of dct/ids must be populated . 

+

282 

+

283 Args: 

+

284 dct (bool): Get only the selected tenant from jwt 

+

285 ids (List[str]): Get the list of tenants 

+

286 refresh_token (str): The refresh token 

+

287 

+

288 Return value (dict): returns the tenant requested from the server 

+

289 (id:str, name:str, customAttributes:dict) 

+

290 

+

291 Raise: 

+

292 AuthException: Exception is raised if session is not authorized or another error occurs 

+

293 """ 

+

294 self._require_refresh_token(refresh_token) 

+

295 self._validate_tenant_selector(dct, ids) 

+

296 

+

297 body: dict[str, bool | list[str]] = {"dct": dct} 

+

298 if ids is not None: 

+

299 body["ids"] = ids 

+

300 

+

301 uri = EndpointsV1.my_tenants_path 

+

302 response = self._auth.http_client.post(uri, body=body, pswd=refresh_token) 

+

303 return response.json() 

+

304 

+

305 def history(self, refresh_token: str) -> list[dict]: 

+

306 """ 

+

307 Retrieve user authentication history for the refresh token 

+

308 

+

309 Args: 

+

310 refresh_token (str): The refresh token 

+

311 

+

312 Return value (List[dict]): 

+

313 Return List in the format 

+

314 [ 

+

315 { 

+

316 "userId": "User's ID", 

+

317 "loginTime": "User'sLogin time", 

+

318 "city": "User's city", 

+

319 "country": "User's country", 

+

320 "ip": User's IP 

+

321 } 

+

322 ] 

+

323 

+

324 Raise: 

+

325 AuthException: Exception is raised if session is not authorized or another error occurs 

+

326 """ 

+

327 self._require_refresh_token(refresh_token) 

+

328 uri = EndpointsV1.history_path 

+

329 response = self._auth.http_client.get(uri=uri, allow_redirects=None, pswd=refresh_token) 

+

330 return response.json() 

+

331 

+

332 def exchange_access_key( 

+

333 self, 

+

334 access_key: str, 

+

335 audience: Iterable[str] | str | None = None, 

+

336 login_options: AccessKeyLoginOptions | None = None, 

+

337 ) -> dict: 

+

338 """ 

+

339 Return a new session token for the given access key 

+

340 

+

341 Args: 

+

342 access_key (str): The access key 

+

343 audience (str|Iterable[str]|None): Optional recipients that the JWT is intended for (must be equal to the 'aud' claim on the provided token) 

+

344 login_options (AccessKeyLoginOptions): Optional advanced controls over login parameters 

+

345 

+

346 Return dict includes the session token and all JWT claims 

+

347 

+

348 Raise: 

+

349 AuthException: Exception is raised if access key is not valid or another error occurs 

+

350 """ 

+

351 self._require_access_key(access_key) 

+

352 return self._auth.exchange_access_key(access_key, audience, login_options) 

+

353 

+

354 def select_tenant( 

+

355 self, 

+

356 tenant_id: str, 

+

357 refresh_token: str, 

+

358 ) -> dict: 

+

359 """ 

+

360 Add to JWT a selected tenant claim 

+

361 

+

362 Args: 

+

363 refresh_token (str): The refresh token that will be used to refresh the session token, if needed 

+

364 tenant_id (str): The tenant id to place on JWT 

+

365 

+

366 Return value (dict): 

+

367 Return dict includes the session token, refresh token, with the tenant id on the jwt 

+

368 

+

369 Raise: 

+

370 AuthException: Exception is raised if session is not authorized or another error occurs 

+

371 """ 

+

372 return self._auth.select_tenant(tenant_id, refresh_token) 

+

373 

+

374 def get_last_response(self): 

+

375 """ 

+

376 Get the last HTTP response from either auth or management operations. 

+

377 

+

378 Only available when verbose mode is enabled during client initialization. 

+

379 This provides access to HTTP metadata like headers (cf-ray), status codes, 

+

380 and raw response data for debugging failed requests. 

+

381 

+

382 Returns: 

+

383 DescopeResponse: The last response if verbose mode is enabled. 

+

384 Returns the most recent response across auth and mgmt 

+

385 operations, whichever ran last. 

+

386 None if verbose mode is disabled or no requests have been made. 

+

387 

+

388 Example: 

+

389 client = DescopeClient(project_id, management_key, verbose=True) 

+

390 try: 

+

391 client.mgmt.user.create(login_id="test@example.com") 

+

392 except AuthException: 

+

393 resp = client.get_last_response() 

+

394 if resp: 

+

395 # Access metadata for debugging 

+

396 cf_ray = resp.headers.get("cf-ray") 

+

397 status = resp.status_code 

+

398 """ 

+

399 return self._last_response_store.get() 

+
+ + + diff --git a/htmlcov/d_ed9d64cdfac9d9b5_jwt_common_py.html b/htmlcov/d_ed9d64cdfac9d9b5_jwt_common_py.html new file mode 100644 index 000000000..dee3a8829 --- /dev/null +++ b/htmlcov/d_ed9d64cdfac9d9b5_jwt_common_py.html @@ -0,0 +1,230 @@ + + + + + Coverage for descope/jwt_common.py: 92% + + + + + +
+
+

+ Coverage for descope/jwt_common.py: + 92% +

+ +

+ 51 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3import warnings 

+

4from typing import Callable, Iterable, Optional 

+

5 

+

6import jwt 

+

7 

+

8from descope.common import ( 

+

9 COOKIE_DATA_NAME, 

+

10 REFRESH_SESSION_TOKEN_NAME, 

+

11 SESSION_TOKEN_NAME, 

+

12) 

+

13 

+

14 

+

15def adjust_properties(jwt_response: dict, user_jwt: bool) -> dict: 

+

16 """Normalize top-level fields on a JWT response. 

+

17 

+

18 Copies permissions/roles/tenants from present token claims (session or refresh) 

+

19 to the top-level and sets projectId and userId/keyId for convenience. 

+

20 """ 

+

21 # Save permissions, roles and tenants info from Session token or from refresh token on the json top level 

+

22 if SESSION_TOKEN_NAME in jwt_response: 

+

23 jwt_response["permissions"] = jwt_response[SESSION_TOKEN_NAME].get("permissions", []) 

+

24 jwt_response["roles"] = jwt_response[SESSION_TOKEN_NAME].get("roles", []) 

+

25 jwt_response["tenants"] = jwt_response[SESSION_TOKEN_NAME].get("tenants", {}) 

+

26 elif REFRESH_SESSION_TOKEN_NAME in jwt_response: 

+

27 jwt_response["permissions"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("permissions", []) 

+

28 jwt_response["roles"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("roles", []) 

+

29 jwt_response["tenants"] = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("tenants", {}) 

+

30 else: 

+

31 jwt_response["permissions"] = jwt_response.get("permissions", []) 

+

32 jwt_response["roles"] = jwt_response.get("roles", []) 

+

33 jwt_response["tenants"] = jwt_response.get("tenants", {}) 

+

34 

+

35 # Save the projectID also in the dict top level 

+

36 issuer = ( 

+

37 jwt_response.get(SESSION_TOKEN_NAME, {}).get("iss", None) 

+

38 or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("iss", None) 

+

39 or jwt_response.get("iss", "") 

+

40 ) 

+

41 jwt_response["projectId"] = issuer.rsplit("/")[-1] # support both url issuer and project ID issuer 

+

42 

+

43 sub = ( 

+

44 jwt_response.get(SESSION_TOKEN_NAME, {}).get("dsub", None) 

+

45 or jwt_response.get(SESSION_TOKEN_NAME, {}).get("sub", None) 

+

46 or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("dsub", None) 

+

47 or jwt_response.get(REFRESH_SESSION_TOKEN_NAME, {}).get("sub", None) 

+

48 or jwt_response.get("sub", "") 

+

49 ) 

+

50 if user_jwt: 

+

51 # Save the userID also in the dict top level 

+

52 jwt_response["userId"] = sub 

+

53 else: 

+

54 # Save the AccessKeyID also in the dict top level 

+

55 jwt_response["keyId"] = sub 

+

56 

+

57 return jwt_response 

+

58 

+

59 

+

60def decode_token_unverified(token: str, audience: Optional[str | Iterable[str]] = None) -> dict: 

+

61 """Decode a JWT without verifying signature (used when no validator is provided). 

+

62 

+

63 WARNING: This function does NOT verify JWT signatures and should NEVER be used 

+

64 for authentication or authorization decisions. It is only intended for testing 

+

65 or scenarios where token validation happens elsewhere. 

+

66 

+

67 Audience verification is disabled by default since no key is provided. 

+

68 Returns an empty dict if decoding fails. 

+

69 """ 

+

70 warnings.warn( 

+

71 "decode_token_unverified() does not verify JWT signatures. " 

+

72 "Do not use this for authentication or authorization. " 

+

73 "Use proper token validation with signature verification instead.", 

+

74 UserWarning, 

+

75 stacklevel=2, 

+

76 ) 

+

77 try: 

+

78 return jwt.decode(token, options={"verify_signature": False, "verify_aud": False}) 

+

79 except Exception: 

+

80 return {} 

+

81 

+

82 

+

83def generate_auth_info( 

+

84 response_body: dict, 

+

85 refresh_token: Optional[str], 

+

86 user_jwt: bool, 

+

87 audience: Optional[str | Iterable[str]] = None, 

+

88 token_validator: Optional[Callable[[str, Optional[str | Iterable[str]]], dict]] = None, 

+

89) -> dict: 

+

90 """Build the normalized JWT info dict using a provided token validator. 

+

91 

+

92 token_validator should accept (token, audience) and return decoded claims dict. 

+

93 If not provided, tokens will be decoded without signature verification. 

+

94 """ 

+

95 if token_validator is None: 

+

96 token_validator = decode_token_unverified 

+

97 

+

98 jwt_response: dict = {} 

+

99 

+

100 st_jwt = response_body.get("sessionJwt", "") 

+

101 if st_jwt: 

+

102 jwt_response[SESSION_TOKEN_NAME] = token_validator(st_jwt, audience) 

+

103 

+

104 rt_jwt = response_body.get("refreshJwt", "") 

+

105 if rt_jwt: 

+

106 jwt_response[REFRESH_SESSION_TOKEN_NAME] = token_validator(rt_jwt, audience) 

+

107 elif refresh_token: 

+

108 jwt_response[REFRESH_SESSION_TOKEN_NAME] = token_validator(refresh_token, audience) 

+

109 

+

110 jwt_response = adjust_properties(jwt_response, user_jwt) 

+

111 

+

112 if user_jwt: 

+

113 jwt_response[COOKIE_DATA_NAME] = { 

+

114 "exp": response_body.get("cookieExpiration", 0), 

+

115 "maxAge": response_body.get("cookieMaxAge", 0), 

+

116 "domain": response_body.get("cookieDomain", ""), 

+

117 "path": response_body.get("cookiePath", "/"), 

+

118 } 

+

119 

+

120 return jwt_response 

+

121 

+

122 

+

123def generate_jwt_response( 

+

124 response_body: dict, 

+

125 refresh_cookie: Optional[str], 

+

126 audience: Optional[str | Iterable[str]] = None, 

+

127 token_validator: Optional[Callable[[str, Optional[str | Iterable[str]]], dict]] = None, 

+

128) -> dict: 

+

129 """Compose the final JWT response body using the provided token validator.""" 

+

130 jwt_response = generate_auth_info(response_body, refresh_cookie, True, audience, token_validator) 

+

131 jwt_response["user"] = response_body.get("user", {}) 

+

132 jwt_response["firstSeen"] = response_body.get("firstSeen", True) 

+

133 return jwt_response 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d__lists_base_py.html b/htmlcov/d_f54762f9f1d5aa4d__lists_base_py.html new file mode 100644 index 000000000..5456f0c77 --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d__lists_base_py.html @@ -0,0 +1,152 @@ + + + + + Coverage for descope/management/_lists_base.py: 98% + + + + + +
+
+

+ Coverage for descope/management/_lists_base.py: + 98% +

+ +

+ 43 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import Any, List, Optional 

+

4 

+

5 

+

6class ListsBase: 

+

7 @staticmethod 

+

8 def _compose_create_body(name: str, description: Optional[str], list_type: str, data: Any) -> dict: 

+

9 body = {"name": name, "type": list_type} 

+

10 if description is not None: 

+

11 body["description"] = description 

+

12 if data is not None: 

+

13 body["data"] = data 

+

14 return body 

+

15 

+

16 @staticmethod 

+

17 def _compose_update_body(id: str, name: str, description: Optional[str], list_type: str, data: Any) -> dict: 

+

18 body = {"id": id, "name": name, "type": list_type} 

+

19 if description is not None: 

+

20 body["description"] = description 

+

21 if data is not None: 

+

22 body["data"] = data 

+

23 return body 

+

24 

+

25 @staticmethod 

+

26 def _compose_delete_body(id: str) -> dict: 

+

27 return {"id": id} 

+

28 

+

29 @staticmethod 

+

30 def _compose_import_body(lists: List[dict]) -> dict: 

+

31 return {"lists": lists} 

+

32 

+

33 @staticmethod 

+

34 def _compose_ip_body(id: str, ips: List[str]) -> dict: 

+

35 return {"id": id, "ips": ips} 

+

36 

+

37 @staticmethod 

+

38 def _compose_check_ip_body(id: str, ip: str) -> dict: 

+

39 return {"id": id, "ip": ip} 

+

40 

+

41 @staticmethod 

+

42 def _compose_text_body(id: str, texts: List[str]) -> dict: 

+

43 return {"id": id, "texts": texts} 

+

44 

+

45 @staticmethod 

+

46 def _compose_check_text_body(id: str, text: str) -> dict: 

+

47 return {"id": id, "text": text} 

+

48 

+

49 @staticmethod 

+

50 def _compose_check_texts_body(id: str, texts: List[str], match_all: bool) -> dict: 

+

51 return {"id": id, "texts": texts, "matchAll": match_all} 

+

52 

+

53 @staticmethod 

+

54 def _compose_clear_body(id: str) -> dict: 

+

55 return {"id": id} 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d__outbound_application_base_py.html b/htmlcov/d_f54762f9f1d5aa4d__outbound_application_base_py.html new file mode 100644 index 000000000..7b75fa26c --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d__outbound_application_base_py.html @@ -0,0 +1,200 @@ + + + + + Coverage for descope/management/_outbound_application_base.py: 93% + + + + + +
+
+

+ Coverage for descope/management/_outbound_application_base.py: + 93% +

+ +

+ 58 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import Any, List, Optional 

+

4 

+

5from descope.management.common import ( 

+

6 AccessType, 

+

7 PromptType, 

+

8 URLParam, 

+

9 url_params_to_dict, 

+

10) 

+

11 

+

12 

+

13class OutboundApplicationBase: 

+

14 @staticmethod 

+

15 def _compose_create_update_body( 

+

16 name: str, 

+

17 description: Optional[str] = None, 

+

18 logo: Optional[str] = None, 

+

19 id: Optional[str] = None, 

+

20 client_secret: Optional[str] = None, 

+

21 client_id: Optional[str] = None, 

+

22 discovery_url: Optional[str] = None, 

+

23 authorization_url: Optional[str] = None, 

+

24 authorization_url_params: Optional[List[URLParam]] = None, 

+

25 token_url: Optional[str] = None, 

+

26 token_url_params: Optional[List[URLParam]] = None, 

+

27 revocation_url: Optional[str] = None, 

+

28 default_scopes: Optional[List[str]] = None, 

+

29 default_redirect_url: Optional[str] = None, 

+

30 callback_domain: Optional[str] = None, 

+

31 pkce: Optional[bool] = None, 

+

32 access_type: Optional[AccessType] = None, 

+

33 prompt: Optional[List[PromptType]] = None, 

+

34 ) -> dict: 

+

35 body: dict[str, Any] = { 

+

36 "name": name, 

+

37 "id": id, 

+

38 "description": description, 

+

39 "logo": logo, 

+

40 } 

+

41 if client_secret: 

+

42 body["clientSecret"] = client_secret 

+

43 if client_id: 

+

44 body["clientId"] = client_id 

+

45 if discovery_url: 

+

46 body["discoveryUrl"] = discovery_url 

+

47 if authorization_url: 

+

48 body["authorizationUrl"] = authorization_url 

+

49 if authorization_url_params is not None: 

+

50 body["authorizationUrlParams"] = url_params_to_dict(authorization_url_params) 

+

51 if token_url: 

+

52 body["tokenUrl"] = token_url 

+

53 if token_url_params is not None: 

+

54 body["tokenUrlParams"] = url_params_to_dict(token_url_params) 

+

55 if revocation_url: 

+

56 body["revocationUrl"] = revocation_url 

+

57 if default_scopes is not None: 

+

58 body["defaultScopes"] = default_scopes 

+

59 if default_redirect_url: 

+

60 body["defaultRedirectUrl"] = default_redirect_url 

+

61 if callback_domain: 

+

62 body["callbackDomain"] = callback_domain 

+

63 if pkce is not None: 

+

64 body["pkce"] = pkce 

+

65 if access_type: 

+

66 body["accessType"] = access_type.value 

+

67 if prompt is not None: 

+

68 body["prompt"] = [p.value for p in prompt] 

+

69 return body 

+

70 

+

71 @staticmethod 

+

72 def _compose_oauth_upload_body( 

+

73 base: dict, 

+

74 refresh_token: Optional[str] = None, 

+

75 access_token: Optional[str] = None, 

+

76 access_token_expiry: Optional[int] = None, 

+

77 access_token_type: Optional[str] = None, 

+

78 scopes: Optional[List[str]] = None, 

+

79 external_identifier: Optional[str] = None, 

+

80 id_token: Optional[str] = None, 

+

81 granted_by: Optional[str] = None, 

+

82 verify_refresh: Optional[bool] = None, 

+

83 ) -> dict: 

+

84 body = dict(base) 

+

85 if refresh_token is not None: 

+

86 body["refreshToken"] = refresh_token 

+

87 if access_token is not None: 

+

88 body["accessToken"] = access_token 

+

89 if access_token_expiry is not None: 

+

90 body["accessTokenExpiry"] = access_token_expiry 

+

91 if access_token_type is not None: 

+

92 body["accessTokenType"] = access_token_type 

+

93 if scopes is not None: 

+

94 body["scopes"] = scopes 

+

95 if external_identifier is not None: 

+

96 body["externalIdentifier"] = external_identifier 

+

97 if id_token is not None: 

+

98 body["idToken"] = id_token 

+

99 if granted_by is not None: 

+

100 body["grantedBy"] = granted_by 

+

101 if verify_refresh is not None: 

+

102 body["verifyRefresh"] = verify_refresh 

+

103 return body 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d__sso_settings_base_py.html b/htmlcov/d_f54762f9f1d5aa4d__sso_settings_base_py.html new file mode 100644 index 000000000..374c5c22c --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d__sso_settings_base_py.html @@ -0,0 +1,425 @@ + + + + + Coverage for descope/management/_sso_settings_base.py: 99% + + + + + +
+
+

+ Coverage for descope/management/_sso_settings_base.py: + 99% +

+ +

+ 98 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1# This is not part of the public API but a code helper 

+

2from __future__ import annotations 

+

3 

+

4from typing import TYPE_CHECKING, Dict, List, Optional 

+

5 

+

6if TYPE_CHECKING: 

+

7 from descope.management.sso_settings import ( 

+

8 AttributeMapping, 

+

9 FGAGroupMapping, 

+

10 RoleMapping, 

+

11 SSOOIDCSettings, 

+

12 SSOSAMLSettings, 

+

13 SSOSAMLSettingsByMetadata, 

+

14 XAAIssuerSettings, 

+

15 XAAJWTBearerSettings, 

+

16 XAASettings, 

+

17 ) 

+

18 

+

19 

+

20class SSOSettingsBase: 

+

21 """Shared, I/O-free base for SSOSettings management classes. 

+

22 

+

23 Holds only static body composers and dict converters — no network I/O, no 

+

24 ``__init__``. The two concrete subclasses add the network layer: 

+

25 

+

26 - ``SSOSettings(SSOSettingsBase, HTTPBase)`` — sync 

+

27 - ``SSOSettingsAsync(SSOSettingsBase, AsyncHTTPBase)`` — async 

+

28 """ 

+

29 

+

30 @staticmethod 

+

31 def _compose_configure_body( 

+

32 tenant_id: str, 

+

33 idp_url: str, 

+

34 entity_id: str, 

+

35 idp_cert: str, 

+

36 redirect_url: str, 

+

37 domains: Optional[List[str]], 

+

38 ) -> dict: 

+

39 return { 

+

40 "tenantId": tenant_id, 

+

41 "idpURL": idp_url, 

+

42 "entityId": entity_id, 

+

43 "idpCert": idp_cert, 

+

44 "redirectURL": redirect_url, 

+

45 "domains": domains, 

+

46 } 

+

47 

+

48 @staticmethod 

+

49 def _compose_metadata_body( 

+

50 tenant_id: str, 

+

51 idp_metadata_url: str, 

+

52 redirect_url: Optional[str] = None, 

+

53 domains: Optional[List[str]] = None, 

+

54 ) -> dict: 

+

55 return { 

+

56 "tenantId": tenant_id, 

+

57 "idpMetadataURL": idp_metadata_url, 

+

58 "redirectURL": redirect_url, 

+

59 "domains": domains, 

+

60 } 

+

61 

+

62 @staticmethod 

+

63 def _compose_mapping_body( 

+

64 tenant_id: str, 

+

65 role_mapping: Optional[List[RoleMapping]], 

+

66 attribute_mapping: Optional[AttributeMapping], 

+

67 ) -> dict: 

+

68 return { 

+

69 "tenantId": tenant_id, 

+

70 "roleMappings": SSOSettingsBase._role_mapping_to_dict(role_mapping), 

+

71 "attributeMapping": SSOSettingsBase._attribute_mapping_to_dict(attribute_mapping), 

+

72 } 

+

73 

+

74 @staticmethod 

+

75 def _role_mapping_to_dict(role_mapping: Optional[List[RoleMapping]]) -> list: 

+

76 if role_mapping is None: 

+

77 role_mapping = [] 

+

78 role_mapping_list = [] 

+

79 for mapping in role_mapping: 

+

80 role_mapping_list.append( 

+

81 { 

+

82 "groups": mapping.groups, 

+

83 "roleName": mapping.role_name, 

+

84 } 

+

85 ) 

+

86 return role_mapping_list 

+

87 

+

88 @staticmethod 

+

89 def _attribute_mapping_to_dict( 

+

90 attribute_mapping: Optional[AttributeMapping], 

+

91 ) -> dict: 

+

92 if attribute_mapping is None: 

+

93 raise ValueError("Attribute mapping cannot be None") 

+

94 return { 

+

95 "name": attribute_mapping.name, 

+

96 "email": attribute_mapping.email, 

+

97 "phoneNumber": attribute_mapping.phone_number, 

+

98 "group": attribute_mapping.group, 

+

99 "givenName": attribute_mapping.given_name, 

+

100 "middleName": attribute_mapping.middle_name, 

+

101 "familyName": attribute_mapping.family_name, 

+

102 "picture": attribute_mapping.picture, 

+

103 "customAttributes": attribute_mapping.custom_attributes, 

+

104 } 

+

105 

+

106 @staticmethod 

+

107 def _fga_mappings_to_dict( 

+

108 fga_mappings: Optional[Dict[str, FGAGroupMapping]], 

+

109 ) -> Optional[dict]: 

+

110 if fga_mappings is None: 

+

111 return None 

+

112 result: dict = {} 

+

113 for group_name, mapping in fga_mappings.items(): 

+

114 relations = [] 

+

115 if mapping is not None and mapping.relations: 

+

116 for relation in mapping.relations: 

+

117 relations.append( 

+

118 { 

+

119 "resource": relation.resource, 

+

120 "relationDefinition": relation.relation_definition, 

+

121 "namespace": relation.namespace, 

+

122 } 

+

123 ) 

+

124 result[group_name] = {"relations": relations} 

+

125 return result 

+

126 

+

127 @staticmethod 

+

128 def _set_replace_default_sso_roles(target: dict, default_sso_roles: Optional[List[str]]) -> None: 

+

129 # The server cannot tell an empty defaultSSORoles from an omitted one and keeps the stored roles, 

+

130 # so an explicit empty list also sends replaceDefaultSSORoles to clear them. The flag is sent only 

+

131 # then, and never as False, so every other request stays readable by servers without the field. 

+

132 if default_sso_roles is not None and len(default_sso_roles) == 0: 

+

133 target["replaceDefaultSSORoles"] = True 

+

134 

+

135 @staticmethod 

+

136 def _compose_configure_oidc_settings_body( 

+

137 tenant_id: str, 

+

138 settings: SSOOIDCSettings, 

+

139 domains: Optional[List[str]], 

+

140 ) -> dict: 

+

141 attr_mapping = None 

+

142 if settings.attribute_mapping: 

+

143 attr_mapping = { 

+

144 "loginId": settings.attribute_mapping.login_id, 

+

145 "name": settings.attribute_mapping.name, 

+

146 "givenName": settings.attribute_mapping.given_name, 

+

147 "middleName": settings.attribute_mapping.middle_name, 

+

148 "familyName": settings.attribute_mapping.family_name, 

+

149 "email": settings.attribute_mapping.email, 

+

150 "verifiedEmail": settings.attribute_mapping.verified_email, 

+

151 "username": settings.attribute_mapping.username, 

+

152 "phoneNumber": settings.attribute_mapping.phone_number, 

+

153 "verifiedPhone": settings.attribute_mapping.verified_phone, 

+

154 "picture": settings.attribute_mapping.picture, 

+

155 } 

+

156 

+

157 body: dict = { 

+

158 "tenantId": tenant_id, 

+

159 "settings": { 

+

160 "name": settings.name, 

+

161 "clientId": settings.client_id, 

+

162 "clientSecret": settings.client_secret, 

+

163 "redirectUrl": settings.redirect_url, 

+

164 "authUrl": settings.auth_url, 

+

165 "tokenUrl": settings.token_url, 

+

166 "userDataUrl": settings.user_data_url, 

+

167 "scope": settings.scope, 

+

168 "JWKsUrl": settings.jwks_url, 

+

169 "userAttrMapping": attr_mapping, 

+

170 "manageProviderTokens": settings.manage_provider_tokens, 

+

171 "callbackDomain": settings.callback_domain, 

+

172 "prompt": settings.prompt, 

+

173 "grantType": settings.grant_type, 

+

174 "issuer": settings.issuer, 

+

175 "groupsPriority": settings.groups_priority, 

+

176 "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), 

+

177 }, 

+

178 "domains": domains, 

+

179 } 

+

180 # Optional on the server: an omitted usePkce keeps the stored value, so send it only when set. 

+

181 if settings.use_pkce is not None: 

+

182 body["settings"]["usePkce"] = settings.use_pkce 

+

183 return body 

+

184 

+

185 @staticmethod 

+

186 def _issuer_settings_to_dict( 

+

187 issuer: Optional[XAAIssuerSettings], 

+

188 ) -> Optional[dict]: 

+

189 if issuer is None: 

+

190 return None 

+

191 attr_mapping = None 

+

192 if issuer.attribute_mapping is not None: 

+

193 attr_mapping = SSOSettingsBase._attribute_mapping_to_dict(issuer.attribute_mapping) 

+

194 return { 

+

195 "jwksUri": issuer.jwks_uri, 

+

196 "signAlgorithm": issuer.sign_algorithm, 

+

197 "userInfoUri": issuer.user_info_uri, 

+

198 "externalIdFieldName": issuer.external_id_field_name, 

+

199 "jitDisabled": issuer.jit_disabled, 

+

200 "attributeMapping": attr_mapping, 

+

201 } 

+

202 

+

203 @staticmethod 

+

204 def _jwt_bearer_settings_to_dict( 

+

205 settings: Optional[XAAJWTBearerSettings], 

+

206 ) -> Optional[dict]: 

+

207 if settings is None: 

+

208 return None 

+

209 issuers = None 

+

210 if settings.issuers is not None: 

+

211 issuers = { 

+

212 url: SSOSettingsBase._issuer_settings_to_dict(issuer) for url, issuer in settings.issuers.items() 

+

213 } 

+

214 return { 

+

215 "issuers": issuers, 

+

216 "jwtBearerGrantTypeAudienceToUse": settings.jwt_bearer_grant_type_audience_to_use, 

+

217 "jwtBearerGrantTypeScopeToUse": settings.jwt_bearer_grant_type_scope_to_use, 

+

218 "jwtBearerGrantTypeCustomClaimsToUse": settings.jwt_bearer_grant_type_custom_claims_to_use, 

+

219 } 

+

220 

+

221 @staticmethod 

+

222 def _compose_configure_auth_type_body( 

+

223 tenant_id: str, 

+

224 auth_type: str, 

+

225 sso_id: Optional[str], 

+

226 ) -> dict: 

+

227 body: dict = { 

+

228 "tenantId": tenant_id, 

+

229 "authType": auth_type, 

+

230 } 

+

231 if sso_id: 

+

232 body["ssoId"] = sso_id 

+

233 return body 

+

234 

+

235 @staticmethod 

+

236 def _compose_configure_xaa_settings_body( 

+

237 tenant_id: str, 

+

238 settings: XAASettings, 

+

239 sso_id: Optional[str], 

+

240 ) -> dict: 

+

241 body: dict = { 

+

242 "tenantId": tenant_id, 

+

243 "enabled": settings.enabled, 

+

244 "settings": SSOSettingsBase._jwt_bearer_settings_to_dict(settings.settings), 

+

245 "roleMappings": SSOSettingsBase._role_mapping_to_dict(settings.role_mappings), 

+

246 "defaultSSORoles": settings.default_sso_roles, 

+

247 "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), 

+

248 "groupsPriority": settings.groups_priority, 

+

249 "groupPriorityEnabled": settings.group_priority_enabled, 

+

250 "allowOverrideRoles": settings.allow_override_roles, 

+

251 } 

+

252 if sso_id: 

+

253 body["ssoId"] = sso_id 

+

254 if settings.provider_id: 

+

255 body["providerID"] = settings.provider_id 

+

256 return body 

+

257 

+

258 @staticmethod 

+

259 def _compose_configure_saml_settings_body( 

+

260 tenant_id: str, 

+

261 settings: SSOSAMLSettings, 

+

262 redirect_url: Optional[str], 

+

263 domains: Optional[List[str]], 

+

264 ) -> dict: 

+

265 attr_mapping = None 

+

266 if settings.attribute_mapping: 

+

267 attr_mapping = SSOSettingsBase._attribute_mapping_to_dict(settings.attribute_mapping) 

+

268 

+

269 body: dict = { 

+

270 "tenantId": tenant_id, 

+

271 "settings": { 

+

272 "idpUrl": settings.idp_url, 

+

273 "entityId": settings.idp_entity_id, 

+

274 "idpCert": settings.idp_cert, 

+

275 "idpAdditionalCerts": settings.idp_additional_certs, 

+

276 "spACSUrl": settings.sp_acs_url, 

+

277 "spEntityId": settings.sp_entity_id, 

+

278 "attributeMapping": attr_mapping, 

+

279 "roleMappings": SSOSettingsBase._role_mapping_to_dict(settings.role_mappings), 

+

280 "defaultSSORoles": settings.default_sso_roles, 

+

281 "groupsPriority": settings.groups_priority, 

+

282 "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), 

+

283 "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, 

+

284 "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, 

+

285 # always sent: the server takes the settings object as a full replacement, so omitting the 

+

286 # flag on an update would silently turn request signing back on 

+

287 "disableSignRequest": settings.disable_sign_request, 

+

288 }, 

+

289 "redirectUrl": redirect_url, 

+

290 "domains": domains, 

+

291 } 

+

292 SSOSettingsBase._set_replace_default_sso_roles(body["settings"], settings.default_sso_roles) 

+

293 return body 

+

294 

+

295 @staticmethod 

+

296 def _compose_configure_saml_settings_by_metadata_body( 

+

297 tenant_id: str, 

+

298 settings: SSOSAMLSettingsByMetadata, 

+

299 redirect_url: Optional[str], 

+

300 domains: Optional[List[str]], 

+

301 ) -> dict: 

+

302 attr_mapping = None 

+

303 if settings.attribute_mapping: 

+

304 attr_mapping = SSOSettingsBase._attribute_mapping_to_dict(settings.attribute_mapping) 

+

305 

+

306 body: dict = { 

+

307 "tenantId": tenant_id, 

+

308 "settings": { 

+

309 "idpMetadataUrl": settings.idp_metadata_url, 

+

310 "entityId": settings.idp_entity_id, 

+

311 "spACSUrl": settings.sp_acs_url, 

+

312 "spEntityId": settings.sp_entity_id, 

+

313 "attributeMapping": attr_mapping, 

+

314 "roleMappings": SSOSettingsBase._role_mapping_to_dict(settings.role_mappings), 

+

315 "defaultSSORoles": settings.default_sso_roles, 

+

316 "groupsPriority": settings.groups_priority, 

+

317 "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), 

+

318 "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, 

+

319 "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, 

+

320 # always sent: the server takes the settings object as a full replacement, so omitting the 

+

321 # flag on an update would silently turn request signing back on 

+

322 "disableSignRequest": settings.disable_sign_request, 

+

323 }, 

+

324 "redirectUrl": redirect_url, 

+

325 "domains": domains, 

+

326 } 

+

327 SSOSettingsBase._set_replace_default_sso_roles(body["settings"], settings.default_sso_roles) 

+

328 return body 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d__third_party_application_base_py.html b/htmlcov/d_f54762f9f1d5aa4d__third_party_application_base_py.html new file mode 100644 index 000000000..c1bb4f759 --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d__third_party_application_base_py.html @@ -0,0 +1,143 @@ + + + + + Coverage for descope/management/_third_party_application_base.py: 85% + + + + + +
+
+

+ Coverage for descope/management/_third_party_application_base.py: + 85% +

+ +

+ 26 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from typing import Any, Dict, List, Optional 

+

2 

+

3 

+

4# This is not part of the public API but a code helper 

+

5def compose_create_update_body( 

+

6 name: str, 

+

7 login_page_url: str, 

+

8 id: Optional[str] = None, 

+

9 description: Optional[str] = None, 

+

10 logo: Optional[str] = None, 

+

11 approved_callback_urls: Optional[List[str]] = None, 

+

12 permissions_scopes: Optional[List[Dict[str, Any]]] = None, 

+

13 attributes_scopes: Optional[List[Dict[str, Any]]] = None, 

+

14 jwt_bearer_settings: Optional[Dict[str, Any]] = None, 

+

15 custom_attributes: Optional[Dict[str, Any]] = None, 

+

16 force_pkce: Optional[bool] = None, 

+

17 default_audience: Optional[str] = None, 

+

18 client_type: Optional[str] = None, 

+

19) -> dict: 

+

20 body: Dict[str, Any] = { 

+

21 "name": name, 

+

22 "loginPageUrl": login_page_url, 

+

23 } 

+

24 if id is not None: 

+

25 body["id"] = id 

+

26 if description is not None: 

+

27 body["description"] = description 

+

28 if logo is not None: 

+

29 body["logo"] = logo 

+

30 if approved_callback_urls is not None: 

+

31 body["approvedCallbackUrls"] = approved_callback_urls 

+

32 if permissions_scopes is not None: 

+

33 body["permissionsScopes"] = permissions_scopes 

+

34 if attributes_scopes is not None: 

+

35 body["attributesScopes"] = attributes_scopes 

+

36 if jwt_bearer_settings is not None: 

+

37 body["jwtBearerSettings"] = jwt_bearer_settings 

+

38 if custom_attributes is not None: 

+

39 body["customAttributes"] = custom_attributes 

+

40 if force_pkce is not None: 

+

41 body["forcePkce"] = force_pkce 

+

42 if default_audience is not None: 

+

43 body["defaultAudience"] = default_audience 

+

44 if client_type is not None: 

+

45 body["clientType"] = client_type 

+

46 return body 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d__user_base_py.html b/htmlcov/d_f54762f9f1d5aa4d__user_base_py.html new file mode 100644 index 000000000..5e19624a6 --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d__user_base_py.html @@ -0,0 +1,458 @@ + + + + + Coverage for descope/management/_user_base.py: 93% + + + + + +
+
+

+ Coverage for descope/management/_user_base.py: + 93% +

+ +

+ 162 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import Any, List, Optional 

+

4 

+

5from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

6from descope.management.common import ( 

+

7 AssociatedFamily, 

+

8 AssociatedTenant, 

+

9 associated_families_to_dict, 

+

10 associated_tenants_to_dict, 

+

11) 

+

12from descope.management.user_pwd import UserPassword 

+

13 

+

14VALID_USER_STATUSES = ["enabled", "disabled", "invited", "expired"] 

+

15 

+

16 

+

17class UserObj: 

+

18 def __init__( 

+

19 self, 

+

20 login_id: str, 

+

21 email: Optional[str] = None, 

+

22 phone: Optional[str] = None, 

+

23 display_name: Optional[str] = None, 

+

24 given_name: Optional[str] = None, 

+

25 middle_name: Optional[str] = None, 

+

26 family_name: Optional[str] = None, 

+

27 role_names: Optional[List[str]] = None, 

+

28 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

29 picture: Optional[str] = None, 

+

30 custom_attributes: Optional[dict] = None, 

+

31 verified_email: Optional[bool] = None, 

+

32 verified_phone: Optional[bool] = None, 

+

33 additional_login_ids: Optional[List[str]] = None, 

+

34 sso_app_ids: Optional[List[str]] = None, 

+

35 password: Optional[UserPassword] = None, 

+

36 seed: Optional[str] = None, 

+

37 status: Optional[str] = None, 

+

38 family_associations: Optional[List[AssociatedFamily]] = None, 

+

39 ): 

+

40 self.login_id = login_id 

+

41 self.email = email 

+

42 self.phone = phone 

+

43 self.display_name = display_name 

+

44 self.given_name = given_name 

+

45 self.middle_name = middle_name 

+

46 self.family_name = family_name 

+

47 self.role_names = role_names 

+

48 self.user_tenants = user_tenants 

+

49 self.picture = picture 

+

50 self.custom_attributes = custom_attributes 

+

51 self.verified_email = verified_email 

+

52 self.verified_phone = verified_phone 

+

53 self.additional_login_ids = additional_login_ids 

+

54 self.sso_app_ids = sso_app_ids 

+

55 self.password = password 

+

56 self.seed = seed 

+

57 self.status = status 

+

58 self.family_associations = family_associations 

+

59 

+

60 

+

61class CreateUserObj: 

+

62 def __init__( 

+

63 self, 

+

64 email: Optional[str] = None, 

+

65 phone: Optional[str] = None, 

+

66 name: Optional[str] = None, 

+

67 given_name: Optional[str] = None, 

+

68 middle_name: Optional[str] = None, 

+

69 family_name: Optional[str] = None, 

+

70 ): 

+

71 self.email = email 

+

72 self.phone = phone 

+

73 self.name = name 

+

74 self.given_name = given_name 

+

75 self.middle_name = middle_name 

+

76 self.family_name = family_name 

+

77 

+

78 

+

79class UserBase: 

+

80 @staticmethod 

+

81 def _validate_status(status: Optional[str], login_id: Optional[str] = None) -> None: 

+

82 if status is not None and status not in VALID_USER_STATUSES: 

+

83 suffix = f" for user {login_id}" if login_id is not None else "" 

+

84 raise AuthException( 

+

85 400, 

+

86 ERROR_TYPE_INVALID_ARGUMENT, 

+

87 f"Invalid status value: {status}{suffix}. Must be one of: {', '.join(VALID_USER_STATUSES)}", 

+

88 ) 

+

89 

+

90 @staticmethod 

+

91 def _compose_create_body( 

+

92 login_id: str, 

+

93 email: Optional[str], 

+

94 phone: Optional[str], 

+

95 display_name: Optional[str], 

+

96 given_name: Optional[str], 

+

97 middle_name: Optional[str], 

+

98 family_name: Optional[str], 

+

99 role_names: List[str], 

+

100 user_tenants: List[AssociatedTenant], 

+

101 invite: bool, 

+

102 test: bool, 

+

103 picture: Optional[str], 

+

104 custom_attributes: Optional[dict], 

+

105 verified_email: Optional[bool], 

+

106 verified_phone: Optional[bool], 

+

107 invite_url: Optional[str], 

+

108 send_mail: Optional[bool], 

+

109 send_sms: Optional[bool], 

+

110 additional_login_ids: Optional[List[str]], 

+

111 sso_app_ids: Optional[List[str]] = None, 

+

112 template_id: str = "", 

+

113 locale: Optional[str] = None, 

+

114 status: Optional[str] = None, 

+

115 family_associations: Optional[List[AssociatedFamily]] = None, 

+

116 ) -> dict: 

+

117 body = UserBase._compose_update_body( 

+

118 login_id=login_id, 

+

119 email=email, 

+

120 phone=phone, 

+

121 display_name=display_name, 

+

122 given_name=given_name, 

+

123 middle_name=middle_name, 

+

124 family_name=family_name, 

+

125 role_names=role_names, 

+

126 user_tenants=user_tenants, 

+

127 test=test, 

+

128 picture=picture, 

+

129 custom_attributes=custom_attributes, 

+

130 additional_login_ids=additional_login_ids, 

+

131 sso_app_ids=sso_app_ids, 

+

132 family_associations=family_associations, 

+

133 ) 

+

134 body["invite"] = invite 

+

135 if verified_email is not None: 

+

136 body["verifiedEmail"] = verified_email 

+

137 if verified_phone is not None: 

+

138 body["verifiedPhone"] = verified_phone 

+

139 if invite_url is not None: 

+

140 body["inviteUrl"] = invite_url 

+

141 if send_mail is not None: 

+

142 body["sendMail"] = send_mail 

+

143 if send_sms is not None: 

+

144 body["sendSMS"] = send_sms 

+

145 if template_id != "": 

+

146 body["templateId"] = template_id 

+

147 if locale is not None: 

+

148 body["locale"] = locale 

+

149 if status is not None: 

+

150 body["status"] = status 

+

151 return body 

+

152 

+

153 @staticmethod 

+

154 def _compose_create_batch_body( 

+

155 users: List[UserObj], 

+

156 invite_url: Optional[str], 

+

157 send_mail: Optional[bool], 

+

158 send_sms: Optional[bool], 

+

159 locale: Optional[str] = None, 

+

160 template_options: Optional[dict] = None, 

+

161 template_id: str = "", 

+

162 ) -> dict: 

+

163 users_body = [] 

+

164 for user in users: 

+

165 role_names = [] if user.role_names is None else user.role_names 

+

166 user_tenants = [] if user.user_tenants is None else user.user_tenants 

+

167 sso_app_ids = [] if user.sso_app_ids is None else user.sso_app_ids 

+

168 password = None if user.password is None else user.password.cleartext 

+

169 hashed_password = None 

+

170 if (user.password is not None) and (user.password.hashed is not None): 

+

171 hashed_password = user.password.hashed.to_dict() 

+

172 u_body = UserBase._compose_update_body( 

+

173 login_id=user.login_id, 

+

174 email=user.email, 

+

175 phone=user.phone, 

+

176 display_name=user.display_name, 

+

177 given_name=user.given_name, 

+

178 middle_name=user.middle_name, 

+

179 family_name=user.family_name, 

+

180 role_names=role_names, 

+

181 user_tenants=user_tenants, 

+

182 picture=user.picture, 

+

183 custom_attributes=user.custom_attributes, 

+

184 additional_login_ids=user.additional_login_ids, 

+

185 verified_email=user.verified_email, 

+

186 verified_phone=user.verified_phone, 

+

187 test=False, 

+

188 sso_app_ids=sso_app_ids, 

+

189 password=password, 

+

190 hashed_password=hashed_password, 

+

191 seed=user.seed, 

+

192 family_associations=user.family_associations, 

+

193 ) 

+

194 if user.status is not None: 

+

195 u_body["status"] = user.status 

+

196 users_body.append(u_body) 

+

197 

+

198 body = {"users": users_body, "invite": True} 

+

199 if invite_url is not None: 

+

200 body["inviteUrl"] = invite_url 

+

201 if send_mail is not None: 

+

202 body["sendMail"] = send_mail 

+

203 if send_sms is not None: 

+

204 body["sendSMS"] = send_sms 

+

205 if template_options is not None: 

+

206 body["templateOptions"] = template_options 

+

207 if template_id != "": 

+

208 body["templateId"] = template_id 

+

209 if locale is not None: 

+

210 body["locale"] = locale 

+

211 return body 

+

212 

+

213 @staticmethod 

+

214 def _compose_update_body( 

+

215 login_id: str, 

+

216 email: Optional[str], 

+

217 phone: Optional[str], 

+

218 display_name: Optional[str], 

+

219 given_name: Optional[str], 

+

220 middle_name: Optional[str], 

+

221 family_name: Optional[str], 

+

222 role_names: List[str], 

+

223 user_tenants: List[AssociatedTenant], 

+

224 test: bool, 

+

225 picture: Optional[str], 

+

226 custom_attributes: Optional[dict], 

+

227 verified_email: Optional[bool] = None, 

+

228 verified_phone: Optional[bool] = None, 

+

229 additional_login_ids: Optional[List[str]] = None, 

+

230 sso_app_ids: Optional[List[str]] = None, 

+

231 password: Optional[str] = None, 

+

232 hashed_password: Optional[dict] = None, 

+

233 seed: Optional[str] = None, 

+

234 family_associations: Optional[List[AssociatedFamily]] = None, 

+

235 ) -> dict: 

+

236 res = { 

+

237 "loginId": login_id, 

+

238 "email": email, 

+

239 "phone": phone, 

+

240 "displayName": display_name, 

+

241 "roleNames": role_names, 

+

242 "userTenants": associated_tenants_to_dict(user_tenants), 

+

243 "test": test, 

+

244 "picture": picture, 

+

245 "customAttributes": custom_attributes, 

+

246 "additionalLoginIds": additional_login_ids, 

+

247 "ssoAppIDs": sso_app_ids, 

+

248 } 

+

249 if verified_email is not None: 

+

250 res["verifiedEmail"] = verified_email 

+

251 if given_name is not None: 

+

252 res["givenName"] = given_name 

+

253 if middle_name is not None: 

+

254 res["middleName"] = middle_name 

+

255 if family_name is not None: 

+

256 res["familyName"] = family_name 

+

257 if verified_phone is not None: 

+

258 res["verifiedPhone"] = verified_phone 

+

259 if password is not None: 

+

260 res["password"] = password 

+

261 if hashed_password is not None: 

+

262 res["hashedPassword"] = hashed_password 

+

263 if seed is not None: 

+

264 res["seed"] = seed 

+

265 if family_associations is not None: 

+

266 res["familyAssociations"] = associated_families_to_dict(family_associations) 

+

267 return res 

+

268 

+

269 @staticmethod 

+

270 def _compose_patch_body( 

+

271 login_id: str, 

+

272 email: Optional[str], 

+

273 phone: Optional[str], 

+

274 display_name: Optional[str], 

+

275 given_name: Optional[str], 

+

276 middle_name: Optional[str], 

+

277 family_name: Optional[str], 

+

278 role_names: Optional[List[str]], 

+

279 user_tenants: Optional[List[AssociatedTenant]], 

+

280 picture: Optional[str], 

+

281 custom_attributes: Optional[dict], 

+

282 verified_email: Optional[bool], 

+

283 verified_phone: Optional[bool], 

+

284 sso_app_ids: Optional[List[str]], 

+

285 status: Optional[str], 

+

286 test: bool = False, 

+

287 family_associations: Optional[List[AssociatedFamily]] = None, 

+

288 ) -> dict: 

+

289 res: dict[str, Any] = { 

+

290 "loginId": login_id, 

+

291 } 

+

292 if email is not None: 

+

293 res["email"] = email 

+

294 if phone is not None: 

+

295 res["phone"] = phone 

+

296 if display_name is not None: 

+

297 res["displayName"] = display_name 

+

298 if given_name is not None: 

+

299 res["givenName"] = given_name 

+

300 if middle_name is not None: 

+

301 res["middleName"] = middle_name 

+

302 if family_name is not None: 

+

303 res["familyName"] = family_name 

+

304 if role_names is not None: 

+

305 res["roleNames"] = role_names 

+

306 if user_tenants is not None: 

+

307 res["userTenants"] = associated_tenants_to_dict(user_tenants) 

+

308 if picture is not None: 

+

309 res["picture"] = picture 

+

310 if custom_attributes is not None: 

+

311 res["customAttributes"] = custom_attributes 

+

312 if verified_email is not None: 

+

313 res["verifiedEmail"] = verified_email 

+

314 if verified_phone is not None: 

+

315 res["verifiedPhone"] = verified_phone 

+

316 if sso_app_ids is not None: 

+

317 res["ssoAppIds"] = sso_app_ids 

+

318 if status is not None: 

+

319 res["status"] = status 

+

320 if test: 

+

321 res["test"] = test 

+

322 if family_associations is not None: 

+

323 res["familyAssociations"] = associated_families_to_dict(family_associations) 

+

324 return res 

+

325 

+

326 @staticmethod 

+

327 def _compose_patch_batch_body( 

+

328 users: List[UserObj], 

+

329 test: bool = False, 

+

330 ) -> dict: 

+

331 users_body = [] 

+

332 for user in users: 

+

333 user_body = UserBase._compose_patch_body( 

+

334 login_id=user.login_id, 

+

335 email=user.email, 

+

336 phone=user.phone, 

+

337 display_name=user.display_name, 

+

338 given_name=user.given_name, 

+

339 middle_name=user.middle_name, 

+

340 family_name=user.family_name, 

+

341 role_names=user.role_names, 

+

342 user_tenants=user.user_tenants, 

+

343 picture=user.picture, 

+

344 custom_attributes=user.custom_attributes, 

+

345 verified_email=user.verified_email, 

+

346 verified_phone=user.verified_phone, 

+

347 sso_app_ids=user.sso_app_ids, 

+

348 status=user.status, 

+

349 test=test, 

+

350 family_associations=user.family_associations, 

+

351 ) 

+

352 users_body.append(user_body) 

+

353 

+

354 return {"users": users_body} 

+

355 

+

356 @staticmethod 

+

357 def _validate_search_pagination(limit: int, page: int) -> None: 

+

358 if limit < 0: 

+

359 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") 

+

360 if page < 0: 

+

361 raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_audit_async_py.html b/htmlcov/d_f54762f9f1d5aa4d_audit_async_py.html new file mode 100644 index 000000000..301b0b013 --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_audit_async_py.html @@ -0,0 +1,253 @@ + + + + + Coverage for descope/management/audit_async.py: 78% + + + + + +
+
+

+ Coverage for descope/management/audit_async.py: + 78% +

+ +

+ 49 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from datetime import datetime 

+

4from typing import Any, List, Optional 

+

5 

+

6from descope._http_base import AsyncHTTPBase 

+

7from descope.management._audit_base import AuditBase 

+

8from descope.management.common import MgmtV1 

+

9 

+

10 

+

11class AuditAsync(AuditBase, AsyncHTTPBase): 

+

12 """Async counterpart of Audit — all HTTP calls are coroutines.""" 

+

13 

+

14 async def search( 

+

15 self, 

+

16 user_ids: Optional[List[str]] = None, 

+

17 actions: Optional[List[str]] = None, 

+

18 excluded_actions: Optional[List[str]] = None, 

+

19 devices: Optional[List[str]] = None, 

+

20 methods: Optional[List[str]] = None, 

+

21 geos: Optional[List[str]] = None, 

+

22 remote_addresses: Optional[List[str]] = None, 

+

23 login_ids: Optional[List[str]] = None, 

+

24 tenants: Optional[List[str]] = None, 

+

25 no_tenants: bool = False, 

+

26 text: Optional[str] = None, 

+

27 from_ts: Optional[datetime] = None, 

+

28 to_ts: Optional[datetime] = None, 

+

29 ) -> dict: 

+

30 """ 

+

31 Search the audit trail up to last 30 days based on given parameters 

+

32 

+

33 Args: 

+

34 user_ids (List[str]): Optional list of user IDs to filter by 

+

35 actions (List[str]): Optional list of actions to filter by 

+

36 excluded_actions (List[str]): Optional list of actions to exclude 

+

37 devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown" 

+

38 methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password" 

+

39 geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. 

+

40 remote_addresses (List[str]): Optional list of remote addresses to filter by 

+

41 login_ids (List[str]): Optional list of login IDs to filter by 

+

42 tenants (List[str]): Optional list of tenants to filter by 

+

43 no_tenants (bool): Should audits without any tenants always be included 

+

44 text (str): Free text search across all fields 

+

45 from_ts (datetime): Retrieve records newer than given time but not older than 30 days 

+

46 to_ts (datetime): Retrieve records older than given time 

+

47 

+

48 Return value (dict): 

+

49 Return dict in the format 

+

50 { 

+

51 "audits": [ 

+

52 { 

+

53 "projectId":"", 

+

54 "userId": "", 

+

55 "action": "", 

+

56 "occurred": 0 (unix-time-milli), 

+

57 "device": "", 

+

58 "method": "", 

+

59 "geo": "", 

+

60 "remoteAddress": "", 

+

61 "externalIds": [""], 

+

62 "tenants": [""], 

+

63 "data": { 

+

64 "field1": "field1-value", 

+

65 "more-details": "in-console-examples" 

+

66 } 

+

67 } 

+

68 ] 

+

69 } 

+

70 Raise: 

+

71 AuthException: raised if search operation fails 

+

72 """ 

+

73 body: dict[str, Any] = {"noTenants": no_tenants} 

+

74 if user_ids is not None: 

+

75 body["userIds"] = user_ids 

+

76 if actions is not None: 

+

77 body["actions"] = actions 

+

78 if excluded_actions is not None: 

+

79 body["excludedActions"] = excluded_actions 

+

80 if devices is not None: 

+

81 body["devices"] = devices 

+

82 if methods is not None: 

+

83 body["methods"] = methods 

+

84 if geos is not None: 

+

85 body["geos"] = geos 

+

86 if remote_addresses is not None: 

+

87 body["remoteAddresses"] = remote_addresses 

+

88 if login_ids is not None: 

+

89 body["externalIds"] = login_ids 

+

90 if tenants is not None: 

+

91 body["tenants"] = tenants 

+

92 if text is not None: 

+

93 body["text"] = text 

+

94 if from_ts is not None: 

+

95 body["from"] = int(from_ts.timestamp() * 1000) 

+

96 if to_ts is not None: 

+

97 body["to"] = int(to_ts.timestamp() * 1000) 

+

98 

+

99 response = await self._http.post(MgmtV1.audit_search, body=body) 

+

100 return {"audits": list(map(AuditAsync._convert_audit_record, response.json()["audits"]))} 

+

101 

+

102 async def create_event( 

+

103 self, 

+

104 action: str, 

+

105 type: str, 

+

106 actor_id: str, 

+

107 tenant_id: str, 

+

108 user_id: Optional[str] = None, 

+

109 data: Optional[dict] = None, 

+

110 ): 

+

111 """ 

+

112 Create audit event based on given parameters 

+

113 

+

114 Args: 

+

115 action (str): Audit action 

+

116 type (str): Audit type (info/warn/error) 

+

117 actor_id (str): Audit actor id 

+

118 tenant_id (str): Audit tenant id 

+

119 user_id (str): Optional, Audit user id 

+

120 data (dict): Optional, Audit data 

+

121 

+

122 Raise: 

+

123 AuthException: raised if search operation fails 

+

124 """ 

+

125 body: dict[str, Any] = { 

+

126 "action": action, 

+

127 "type": type, 

+

128 "actorId": actor_id, 

+

129 "tenantId": tenant_id, 

+

130 } 

+

131 if user_id is not None: 

+

132 body["userId"] = user_id 

+

133 if data is not None: 

+

134 body["data"] = data 

+

135 

+

136 await self._http.post(MgmtV1.audit_create_event, body=body) 

+

137 

+

138 async def create_audit_webhook(self, name: str, url: Optional[str] = None, headers: Optional[dict] = None): 

+

139 """ 

+

140 Create an audit webhook to receive audit events. 

+

141 

+

142 Args: 

+

143 name (str): The webhook name. 

+

144 url (str): Optional webhook URL. 

+

145 headers (dict): Optional headers to include in webhook requests. 

+

146 

+

147 Raise: 

+

148 AuthException: raised if create operation fails 

+

149 """ 

+

150 body: dict[str, Any] = {"name": name} 

+

151 if url is not None: 

+

152 body["url"] = url 

+

153 if headers is not None: 

+

154 body["headers"] = headers 

+

155 

+

156 await self._http.post(MgmtV1.audit_webhook_set_path, body=body) 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_audit_py.html b/htmlcov/d_f54762f9f1d5aa4d_audit_py.html new file mode 100644 index 000000000..3fc06f828 --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_audit_py.html @@ -0,0 +1,251 @@ + + + + + Coverage for descope/management/audit.py: 78% + + + + + +
+
+

+ Coverage for descope/management/audit.py: + 78% +

+ +

+ 49 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from datetime import datetime 

+

4from typing import Any, List, Optional 

+

5 

+

6from descope._http_base import HTTPBase 

+

7from descope.management._audit_base import AuditBase 

+

8from descope.management.common import MgmtV1 

+

9 

+

10 

+

11class Audit(AuditBase, HTTPBase): 

+

12 def search( 

+

13 self, 

+

14 user_ids: Optional[List[str]] = None, 

+

15 actions: Optional[List[str]] = None, 

+

16 excluded_actions: Optional[List[str]] = None, 

+

17 devices: Optional[List[str]] = None, 

+

18 methods: Optional[List[str]] = None, 

+

19 geos: Optional[List[str]] = None, 

+

20 remote_addresses: Optional[List[str]] = None, 

+

21 login_ids: Optional[List[str]] = None, 

+

22 tenants: Optional[List[str]] = None, 

+

23 no_tenants: bool = False, 

+

24 text: Optional[str] = None, 

+

25 from_ts: Optional[datetime] = None, 

+

26 to_ts: Optional[datetime] = None, 

+

27 ) -> dict: 

+

28 """ 

+

29 Search the audit trail up to last 30 days based on given parameters 

+

30 

+

31 Args: 

+

32 user_ids (List[str]): Optional list of user IDs to filter by 

+

33 actions (List[str]): Optional list of actions to filter by 

+

34 excluded_actions (List[str]): Optional list of actions to exclude 

+

35 devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown" 

+

36 methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password" 

+

37 geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. 

+

38 remote_addresses (List[str]): Optional list of remote addresses to filter by 

+

39 login_ids (List[str]): Optional list of login IDs to filter by 

+

40 tenants (List[str]): Optional list of tenants to filter by 

+

41 no_tenants (bool): Should audits without any tenants always be included 

+

42 text (str): Free text search across all fields 

+

43 from_ts (datetime): Retrieve records newer than given time but not older than 30 days 

+

44 to_ts (datetime): Retrieve records older than given time 

+

45 

+

46 Return value (dict): 

+

47 Return dict in the format 

+

48 { 

+

49 "audits": [ 

+

50 { 

+

51 "projectId":"", 

+

52 "userId": "", 

+

53 "action": "", 

+

54 "occurred": 0 (unix-time-milli), 

+

55 "device": "", 

+

56 "method": "", 

+

57 "geo": "", 

+

58 "remoteAddress": "", 

+

59 "externalIds": [""], 

+

60 "tenants": [""], 

+

61 "data": { 

+

62 "field1": "field1-value", 

+

63 "more-details": "in-console-examples" 

+

64 } 

+

65 } 

+

66 ] 

+

67 } 

+

68 Raise: 

+

69 AuthException: raised if search operation fails 

+

70 """ 

+

71 body: dict[str, Any] = {"noTenants": no_tenants} 

+

72 if user_ids is not None: 

+

73 body["userIds"] = user_ids 

+

74 if actions is not None: 

+

75 body["actions"] = actions 

+

76 if excluded_actions is not None: 

+

77 body["excludedActions"] = excluded_actions 

+

78 if devices is not None: 

+

79 body["devices"] = devices 

+

80 if methods is not None: 

+

81 body["methods"] = methods 

+

82 if geos is not None: 

+

83 body["geos"] = geos 

+

84 if remote_addresses is not None: 

+

85 body["remoteAddresses"] = remote_addresses 

+

86 if login_ids is not None: 

+

87 body["externalIds"] = login_ids 

+

88 if tenants is not None: 

+

89 body["tenants"] = tenants 

+

90 if text is not None: 

+

91 body["text"] = text 

+

92 if from_ts is not None: 

+

93 body["from"] = int(from_ts.timestamp() * 1000) 

+

94 if to_ts is not None: 

+

95 body["to"] = int(to_ts.timestamp() * 1000) 

+

96 

+

97 response = self._http.post(MgmtV1.audit_search, body=body) 

+

98 return {"audits": list(map(Audit._convert_audit_record, response.json()["audits"]))} 

+

99 

+

100 def create_event( 

+

101 self, 

+

102 action: str, 

+

103 type: str, 

+

104 actor_id: str, 

+

105 tenant_id: str, 

+

106 user_id: Optional[str] = None, 

+

107 data: Optional[dict] = None, 

+

108 ): 

+

109 """ 

+

110 Create audit event based on given parameters 

+

111 

+

112 Args: 

+

113 action (str): Audit action 

+

114 type (str): Audit type (info/warn/error) 

+

115 actor_id (str): Audit actor id 

+

116 tenant_id (str): Audit tenant id 

+

117 user_id (str): Optional, Audit user id 

+

118 data (dict): Optional, Audit data 

+

119 

+

120 Raise: 

+

121 AuthException: raised if search operation fails 

+

122 """ 

+

123 body: dict[str, Any] = { 

+

124 "action": action, 

+

125 "type": type, 

+

126 "actorId": actor_id, 

+

127 "tenantId": tenant_id, 

+

128 } 

+

129 if user_id is not None: 

+

130 body["userId"] = user_id 

+

131 if data is not None: 

+

132 body["data"] = data 

+

133 

+

134 self._http.post(MgmtV1.audit_create_event, body=body) 

+

135 

+

136 def create_audit_webhook(self, name: str, url: Optional[str] = None, headers: Optional[dict] = None): 

+

137 """ 

+

138 Create an audit webhook to receive audit events. 

+

139 

+

140 Args: 

+

141 name (str): The webhook name. 

+

142 url (str): Optional webhook URL. 

+

143 headers (dict): Optional headers to include in webhook requests. 

+

144 

+

145 Raise: 

+

146 AuthException: raised if create operation fails 

+

147 """ 

+

148 body: dict[str, Any] = {"name": name} 

+

149 if url is not None: 

+

150 body["url"] = url 

+

151 if headers is not None: 

+

152 body["headers"] = headers 

+

153 

+

154 self._http.post(MgmtV1.audit_webhook_set_path, body=body) 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_common_py.html b/htmlcov/d_f54762f9f1d5aa4d_common_py.html new file mode 100644 index 000000000..d12a5e3ee --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_common_py.html @@ -0,0 +1,1068 @@ + + + + + Coverage for descope/management/common.py: 99% + + + + + +
+
+

+ Coverage for descope/management/common.py: + 99% +

+ +

+ 545 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from enum import Enum 

+

2from typing import Any, Dict, List, Optional, Union 

+

3 

+

4 

+

5class SessionExpirationUnit(Enum): 

+

6 MINUTES = "minutes" 

+

7 HOURS = "hours" 

+

8 DAYS = "days" 

+

9 WEEKS = "weeks" 

+

10 

+

11 

+

12class TenantAuthType(Enum): 

+

13 NONE = "none" 

+

14 SAML = "saml" 

+

15 OIDC = "oidc" 

+

16 

+

17 

+

18class SSOSetupSuiteSettingsDisabledFeatures: 

+

19 def __init__( 

+

20 self, 

+

21 saml: bool = False, 

+

22 oidc: bool = False, 

+

23 scim: bool = False, 

+

24 sso_domains: bool = False, 

+

25 group_mapping: bool = False, 

+

26 ): 

+

27 self.saml = saml 

+

28 self.oidc = oidc 

+

29 self.scim = scim 

+

30 self.sso_domains = sso_domains 

+

31 self.group_mapping = group_mapping 

+

32 

+

33 def to_dict(self) -> Dict[str, bool]: 

+

34 return { 

+

35 "saml": self.saml, 

+

36 "oidc": self.oidc, 

+

37 "scim": self.scim, 

+

38 "ssoDomains": self.sso_domains, 

+

39 "groupMapping": self.group_mapping, 

+

40 } 

+

41 

+

42 

+

43class SSOSetupSuiteSettings: 

+

44 def __init__( 

+

45 self, 

+

46 enabled: bool, 

+

47 style_id: Optional[str] = None, 

+

48 disabled_features: Optional[SSOSetupSuiteSettingsDisabledFeatures] = None, 

+

49 ): 

+

50 self.enabled = enabled 

+

51 self.style_id = style_id 

+

52 self.disabled_features = disabled_features 

+

53 

+

54 def to_dict(self) -> Dict[str, Any]: 

+

55 result: Dict[str, Any] = {"enabled": self.enabled} 

+

56 if self.style_id is not None: 

+

57 result["styleId"] = self.style_id 

+

58 if self.disabled_features is not None: 

+

59 result["disabledFeatures"] = self.disabled_features.to_dict() 

+

60 return result 

+

61 

+

62 

+

63class AccessType(Enum): 

+

64 OFFLINE = "offline" 

+

65 ONLINE = "online" 

+

66 

+

67 

+

68class PromptType(Enum): 

+

69 NONE = "none" 

+

70 LOGIN = "login" 

+

71 CONSENT = "consent" 

+

72 SELECT_ACCOUNT = "select_account" 

+

73 

+

74 

+

75class URLParam: 

+

76 def __init__(self, name: str, value: str): 

+

77 self.name = name 

+

78 self.value = value 

+

79 

+

80 def to_dict(self) -> dict: 

+

81 return {"name": self.name, "value": self.value} 

+

82 

+

83 

+

84def url_params_to_dict(url_params: Optional[List[URLParam]] = None) -> list: 

+

85 if url_params is None: 

+

86 return [] 

+

87 return [param.to_dict() for param in url_params] 

+

88 

+

89 

+

90def role_delete_batch_body( 

+

91 roles: List[Union[str, dict]], 

+

92 tenant_id: Optional[str] = None, 

+

93) -> dict: 

+

94 """ 

+

95 Build the request body for the role batch delete endpoint. 

+

96 

+

97 Accepts role names, or role objects with a "name" key and an optional 

+

98 "tenantId", and returns the roleNames/tenantId body the endpoint filters on. 

+

99 An empty list is rejected: the endpoint deletes every role when it receives 

+

100 no filter. 

+

101 """ 

+

102 if not roles: 

+

103 raise ValueError("roles list cannot be empty") 

+

104 

+

105 role_names: List[str] = [] 

+

106 tenant_ids = set() 

+

107 for role in roles: 

+

108 if isinstance(role, str): 

+

109 role_names.append(role) 

+

110 elif isinstance(role, dict) and isinstance(role.get("name"), str): 

+

111 role_names.append(role["name"]) 

+

112 if role.get("tenantId") is not None: 

+

113 tenant_ids.add(role["tenantId"]) 

+

114 else: 

+

115 raise ValueError('roles must be role names or dicts with a "name" key') 

+

116 

+

117 if tenant_id is not None: 

+

118 tenant_ids.add(tenant_id) 

+

119 

+

120 if len(tenant_ids) > 1: 

+

121 raise ValueError("all roles in a batch delete must belong to the same tenant") 

+

122 

+

123 return {"roleNames": role_names, "tenantId": next(iter(tenant_ids), None)} 

+

124 

+

125 

+

126class MgmtV1: 

+

127 # tenant 

+

128 tenant_create_path = "/v1/mgmt/tenant/create" 

+

129 tenant_update_path = "/v1/mgmt/tenant/update" 

+

130 tenant_patch_path = "/v1/mgmt/tenant/patch" 

+

131 tenant_delete_path = "/v1/mgmt/tenant/delete" 

+

132 tenant_load_path = "/v1/mgmt/tenant" 

+

133 tenant_settings_path = "/v1/mgmt/tenant/settings" 

+

134 tenant_load_all_path = "/v1/mgmt/tenant/all" 

+

135 tenant_search_all_path = "/v1/mgmt/tenant/search" 

+

136 tenant_update_default_roles_path = "/v1/mgmt/tenant/updateDefaultRoles" 

+

137 tenant_generate_sso_configuration_link_path = "/v2/mgmt/tenant/adminlinks/sso/generate" 

+

138 

+

139 # family 

+

140 family_create_path = "/v1/mgmt/family/create" 

+

141 family_update_path = "/v1/mgmt/family/update" 

+

142 family_delete_path = "/v1/mgmt/family/delete" 

+

143 family_search_path = "/v1/mgmt/family/search" 

+

144 family_dependent_create_path = "/v1/mgmt/family/dependent/create" 

+

145 family_dependent_delete_path = "/v1/mgmt/family/dependent/delete" 

+

146 family_impersonate_path = "/v1/mgmt/family/impersonate" 

+

147 family_stop_impersonation_path = "/v1/mgmt/family/impersonate/stop" 

+

148 family_settings_path = "/v1/mgmt/family/settings" 

+

149 family_load_custom_attributes_path = "/v1/mgmt/family/customattributes" 

+

150 family_create_custom_attributes_path = "/v1/mgmt/family/customattribute/create" 

+

151 family_delete_custom_attributes_path = "/v1/mgmt/family/customattribute/delete" 

+

152 

+

153 # sso application 

+

154 sso_application_oidc_create_path = "/v1/mgmt/sso/idp/app/oidc/create" 

+

155 sso_application_saml_create_path = "/v1/mgmt/sso/idp/app/saml/create" 

+

156 sso_application_oidc_update_path = "/v1/mgmt/sso/idp/app/oidc/update" 

+

157 sso_application_saml_update_path = "/v1/mgmt/sso/idp/app/saml/update" 

+

158 sso_application_delete_path = "/v1/mgmt/sso/idp/app/delete" 

+

159 sso_application_load_path = "/v1/mgmt/sso/idp/app/load" 

+

160 sso_application_load_all_path = "/v1/mgmt/sso/idp/apps/load" 

+

161 

+

162 # outbound application 

+

163 outbound_application_create_path = "/v1/mgmt/outbound/app/create" 

+

164 outbound_application_update_path = "/v1/mgmt/outbound/app/update" 

+

165 outbound_application_delete_path = "/v1/mgmt/outbound/app/delete" 

+

166 outbound_application_load_path = "/v1/mgmt/outbound/app" 

+

167 outbound_application_load_all_path = "/v1/mgmt/outbound/apps" 

+

168 outbound_application_fetch_token_by_scopes_path = "/v1/mgmt/outbound/app/user/token" 

+

169 outbound_application_fetch_token_path = "/v1/mgmt/outbound/app/user/token/latest" 

+

170 outbound_application_fetch_tenant_token_by_scopes_path = "/v1/mgmt/outbound/app/tenant/token" 

+

171 outbound_application_fetch_tenant_token_path = "/v1/mgmt/outbound/app/tenant/token/latest" 

+

172 outbound_application_delete_user_tokens_path = "/v1/mgmt/outbound/user/tokens" 

+

173 outbound_application_delete_token_path = "/v1/mgmt/outbound/token" 

+

174 outbound_application_list_apps_with_user_token_path = "/v1/mgmt/outbound/apps-with-user-token" 

+

175 outbound_application_upload_user_api_key_path = "/v1/mgmt/outbound/app/user/apikey/upload" 

+

176 outbound_application_upload_tenant_api_key_path = "/v1/mgmt/outbound/app/tenant/apikey/upload" 

+

177 outbound_application_upload_user_token_path = "/v1/mgmt/outbound/app/user/oauthtoken/upload" 

+

178 outbound_application_upload_tenant_token_path = "/v1/mgmt/outbound/app/tenant/oauthtoken/upload" 

+

179 outbound_application_batch_upload_user_tokens_path = "/v1/mgmt/outbound/app/user/oauthtoken/batch/upload" 

+

180 outbound_application_batch_upload_tenant_tokens_path = "/v1/mgmt/outbound/app/tenant/oauthtoken/batch/upload" 

+

181 

+

182 # outbound scim 

+

183 outbound_scim_create_path = "/v1/mgmt/outbound/scim/create" 

+

184 outbound_scim_update_path = "/v1/mgmt/outbound/scim/update" 

+

185 outbound_scim_delete_path = "/v1/mgmt/outbound/scim/delete" 

+

186 outbound_scim_load_path = "/v1/mgmt/outbound/scim" 

+

187 outbound_scim_set_enabled_path = "/v1/mgmt/outbound/scim/enabled/set" 

+

188 

+

189 # engine 

+

190 engine_create_path = "/v1/mgmt/engine/create" 

+

191 engine_update_path = "/v1/mgmt/engine/update" 

+

192 engine_delete_path = "/v1/mgmt/engine/delete" 

+

193 engine_load_path = "/v1/mgmt/engine/load" 

+

194 engine_load_all_path = "/v1/mgmt/engines/load" 

+

195 engine_rotate_secret_path = "/v1/mgmt/engine/rotate" 

+

196 

+

197 # user 

+

198 user_create_path = "/v1/mgmt/user/create" 

+

199 test_user_create_path = "/v1/mgmt/user/create/test" 

+

200 user_create_batch_path = "/v1/mgmt/user/create/batch" 

+

201 user_update_path = "/v1/mgmt/user/update" 

+

202 user_patch_path = "/v1/mgmt/user/patch" 

+

203 user_patch_batch_path = "/v1/mgmt/user/patch/batch" 

+

204 user_delete_path = "/v1/mgmt/user/delete" 

+

205 user_logout_path = "/v1/mgmt/user/logout" 

+

206 user_delete_all_test_users_path = "/v1/mgmt/user/test/delete/all" 

+

207 user_load_path = "/v1/mgmt/user" 

+

208 users_load_path = "/v1/mgmt/users/load" 

+

209 users_search_path = "/v2/mgmt/user/search" 

+

210 test_users_search_path = "/v2/mgmt/user/search/test" 

+

211 user_get_provider_token = "/v1/mgmt/user/provider/token" 

+

212 user_update_status_path = "/v1/mgmt/user/update/status" 

+

213 user_update_login_id_path = "/v1/mgmt/user/update/loginid" 

+

214 user_update_email_path = "/v1/mgmt/user/update/email" 

+

215 user_update_phone_path = "/v1/mgmt/user/update/phone" 

+

216 user_update_name_path = "/v1/mgmt/user/update/name" 

+

217 user_update_picture_path = "/v1/mgmt/user/update/picture" 

+

218 user_update_custom_attribute_path = "/v1/mgmt/user/update/customAttribute" 

+

219 user_set_role_path = "/v1/mgmt/user/update/role/set" 

+

220 user_add_role_path = "/v2/mgmt/user/update/role/add" 

+

221 user_remove_role_path = "/v1/mgmt/user/update/role/remove" 

+

222 user_add_sso_apps = "/v1/mgmt/user/update/ssoapp/add" 

+

223 user_set_sso_apps = "/v1/mgmt/user/update/ssoapp/set" 

+

224 user_remove_sso_apps = "/v1/mgmt/user/update/ssoapp/remove" 

+

225 user_set_password_path = "/v1/mgmt/user/password/set" # Deprecated 

+

226 user_set_temporary_password_path = "/v1/mgmt/user/password/set/temporary" 

+

227 user_set_active_password_path = "/v1/mgmt/user/password/set/active" 

+

228 user_expire_password_path = "/v1/mgmt/user/password/expire" 

+

229 user_remove_all_passkeys_path = "/v1/mgmt/user/passkeys/delete" 

+

230 user_remove_totp_seed_path = "/v1/mgmt/user/totp/delete" 

+

231 user_remove_recovery_codes_path = "/v1/mgmt/user/recovery-codes/delete" 

+

232 user_add_tenant_path = "/v1/mgmt/user/update/tenant/add" 

+

233 user_remove_tenant_path = "/v1/mgmt/user/update/tenant/remove" 

+

234 user_add_families_path = "/v1/mgmt/user/update/family/add" 

+

235 user_remove_families_path = "/v1/mgmt/user/update/family/remove" 

+

236 user_generate_otp_for_test_path = "/v1/mgmt/tests/generate/otp" 

+

237 user_generate_magic_link_for_test_path = "/v1/mgmt/tests/generate/magiclink" 

+

238 user_generate_enchanted_link_for_test_path = "/v1/mgmt/tests/generate/enchantedlink" 

+

239 user_generate_embedded_link_path = "/v1/mgmt/user/signin/embeddedlink" 

+

240 user_generate_sign_up_embedded_link_path = "/v1/mgmt/user/signup/embeddedlink" 

+

241 user_history_path = "/v1/mgmt/user/history" 

+

242 

+

243 # access key 

+

244 access_key_create_path = "/v1/mgmt/accesskey/create" 

+

245 access_key_load_path = "/v1/mgmt/accesskey" 

+

246 access_keys_search_path = "/v1/mgmt/accesskey/search" 

+

247 access_key_update_path = "/v1/mgmt/accesskey/update" 

+

248 access_key_deactivate_path = "/v1/mgmt/accesskey/deactivate" 

+

249 access_key_activate_path = "/v1/mgmt/accesskey/activate" 

+

250 access_key_delete_path = "/v1/mgmt/accesskey/delete" 

+

251 

+

252 # sso 

+

253 sso_settings_path = "/v1/mgmt/sso/settings" 

+

254 sso_metadata_path = "/v1/mgmt/sso/metadata" 

+

255 sso_mapping_path = "/v1/mgmt/sso/mapping" 

+

256 sso_recalculate_mappings_path = "/v1/mgmt/sso/recalculate-mappings" 

+

257 sso_load_settings_path = "/v2/mgmt/sso/settings" # v2 only 

+

258 sso_configure_oidc_settings = "/v1/mgmt/sso/oidc" 

+

259 sso_configure_saml_settings = "/v1/mgmt/sso/saml" 

+

260 sso_configure_saml_by_metadata_settings = "/v1/mgmt/sso/saml/metadata" 

+

261 sso_configure_xaa_settings = "/v1/mgmt/sso/xaa/settings" 

+

262 sso_load_all_xaa_settings = "/v1/mgmt/sso/xaa/settings/all" 

+

263 

+

264 # jwt 

+

265 update_jwt_path = "/v1/mgmt/jwt/update" 

+

266 impersonate_path = "/v1/mgmt/impersonate" 

+

267 stop_impersonation_path = "/v1/mgmt/stop/impersonation" 

+

268 mgmt_sign_in_path = "/v1/mgmt/auth/signin" 

+

269 mgmt_sign_up_path = "/v1/mgmt/auth/signup" 

+

270 mgmt_sign_up_or_in_path = "/v1/mgmt/auth/signup-in" 

+

271 anonymous_path = "/v1/mgmt/auth/anonymous" 

+

272 

+

273 # permission 

+

274 permission_create_path = "/v1/mgmt/permission/create" 

+

275 permission_create_batch_path = "/v1/mgmt/permission/create/batch" 

+

276 permission_update_path = "/v1/mgmt/permission/update" 

+

277 permission_update_batch_path = "/v1/mgmt/permission/update/batch" 

+

278 permission_delete_path = "/v1/mgmt/permission/delete" 

+

279 permission_delete_batch_path = "/v1/mgmt/permission/delete/batch" 

+

280 permission_load_all_path = "/v1/mgmt/permission/all" 

+

281 

+

282 # role 

+

283 role_create_path = "/v1/mgmt/role/create" 

+

284 role_create_batch_path = "/v1/mgmt/role/create/batch" 

+

285 role_update_path = "/v1/mgmt/role/update" 

+

286 role_update_batch_path = "/v1/mgmt/role/update/batch" 

+

287 role_delete_path = "/v1/mgmt/role/delete" 

+

288 role_delete_batch_path = "/v1/mgmt/role/delete/batch" 

+

289 role_load_all_path = "/v1/mgmt/role/all" 

+

290 role_search_path = "/v1/mgmt/role/search" 

+

291 

+

292 # flow 

+

293 flow_list_path = "/v1/mgmt/flow/list" 

+

294 flow_delete_path = "/v1/mgmt/flow/delete" 

+

295 flow_import_path = "/v1/mgmt/flow/import" 

+

296 flow_export_path = "/v1/mgmt/flow/export" 

+

297 flow_run_path = "/v1/mgmt/flow/run" 

+

298 flow_async_run_path = "/v1/mgmt/flow/async/run" 

+

299 flow_async_result_path = "/v1/mgmt/flow/async/result" 

+

300 

+

301 # theme 

+

302 theme_import_path = "/v1/mgmt/theme/import" 

+

303 theme_export_path = "/v1/mgmt/theme/export" 

+

304 

+

305 # group 

+

306 group_load_all_path = "/v1/mgmt/group/all" 

+

307 group_load_all_for_member_path = "/v1/mgmt/group/member/all" 

+

308 group_load_all_group_members_path = "/v1/mgmt/group/members" 

+

309 

+

310 # Audit 

+

311 audit_search = "/v1/mgmt/audit/search" 

+

312 audit_create_event = "/v1/mgmt/audit/event" 

+

313 

+

314 # Authz ReBAC 

+

315 authz_schema_save = "/v1/mgmt/authz/schema/save" 

+

316 authz_schema_delete = "/v1/mgmt/authz/schema/delete" 

+

317 authz_schema_load = "/v1/mgmt/authz/schema/load" 

+

318 authz_ns_save = "/v1/mgmt/authz/ns/save" 

+

319 authz_ns_delete = "/v1/mgmt/authz/ns/delete" 

+

320 authz_rd_save = "/v1/mgmt/authz/rd/save" 

+

321 authz_rd_delete = "/v1/mgmt/authz/rd/delete" 

+

322 authz_re_create = "/v1/mgmt/authz/re/create" 

+

323 authz_re_delete = "/v1/mgmt/authz/re/delete" 

+

324 authz_re_delete_resources = "/v1/mgmt/authz/re/deleteresources" 

+

325 authz_re_has_relations = "/v1/mgmt/authz/re/has" 

+

326 authz_re_who = "/v1/mgmt/authz/re/who" 

+

327 authz_re_resource = "/v1/mgmt/authz/re/resource" 

+

328 authz_re_targets = "/v1/mgmt/authz/re/targets" 

+

329 authz_re_target_all = "/v1/mgmt/authz/re/targetall" 

+

330 authz_re_target_with_relation = "/v1/mgmt/authz/re/targetwithrelation" 

+

331 authz_get_modified = "/v1/mgmt/authz/getmodified" 

+

332 

+

333 # FGA (new style Authz) 

+

334 fga_save_schema = "/v1/mgmt/fga/schema" 

+

335 fga_create_relations = "/v1/mgmt/fga/relations" 

+

336 fga_delete_relations = "/v1/mgmt/fga/relations/delete" 

+

337 fga_check = "/v1/mgmt/fga/check" 

+

338 fga_resources_load = "/v1/mgmt/fga/resources/load" 

+

339 fga_resources_save = "/v1/mgmt/fga/resources/save" 

+

340 

+

341 # Project 

+

342 project_update_name = "/v1/mgmt/project/update/name" 

+

343 project_update_tags = "/v1/mgmt/project/update/tags" 

+

344 project_clone = "/v1/mgmt/project/clone" 

+

345 project_export = "/v1/mgmt/project/export" 

+

346 project_import = "/v1/mgmt/project/import" 

+

347 project_list_projects = "/v1/mgmt/projects/list" 

+

348 

+

349 # Descoper 

+

350 descoper_create_path = "/v1/mgmt/descoper" 

+

351 descoper_update_path = "/v1/mgmt/descoper" 

+

352 descoper_load_path = "/v1/mgmt/descoper" 

+

353 descoper_delete_path = "/v1/mgmt/descoper" 

+

354 descoper_list_path = "/v1/mgmt/descoper/list" 

+

355 

+

356 # management key 

+

357 mgmt_key_create_path = "/v1/mgmt/managementkey" 

+

358 mgmt_key_update_path = "/v1/mgmt/managementkey" 

+

359 mgmt_key_load_path = "/v1/mgmt/managementkey" 

+

360 mgmt_key_delete_path = "/v1/mgmt/managementkey/delete" 

+

361 mgmt_key_search_path = "/v1/mgmt/managementkey/search" 

+

362 

+

363 # license 

+

364 license_get_path = "/v1/mgmt/license" 

+

365 

+

366 # access key (batch + rotate) 

+

367 access_key_activate_batch_path = "/v1/mgmt/accesskey/activate/batch" 

+

368 access_key_deactivate_batch_path = "/v1/mgmt/accesskey/deactivate/batch" 

+

369 access_key_delete_batch_path = "/v1/mgmt/accesskey/delete/batch" 

+

370 access_key_rotate_path = "/v1/mgmt/accesskey/rotate" 

+

371 

+

372 # analytics 

+

373 analytics_search_path = "/v1/mgmt/analytics/search" 

+

374 

+

375 # connector / audit webhook 

+

376 audit_webhook_set_path = "/v2/mgmt/connector/audit/web/set" 

+

377 

+

378 # fga (mappable + dryrun) 

+

379 fga_mappable_resources_path = "/v1/mgmt/fga/mappable/resources" 

+

380 fga_mappable_schema_path = "/v1/mgmt/fga/mappable/schema" 

+

381 fga_schema_dryrun_path = "/v1/mgmt/fga/schema/dryrun" 

+

382 

+

383 # jwt impersonate step-up 

+

384 impersonate_stepup_path = "/v1/mgmt/impersonate/stepup" 

+

385 

+

386 # list (deny / allow lists) 

+

387 list_path = "/v1/mgmt/list" 

+

388 list_all_path = "/v1/mgmt/list/all" 

+

389 list_clear_path = "/v1/mgmt/list/clear" 

+

390 list_delete_path = "/v1/mgmt/list/delete" 

+

391 list_import_path = "/v1/mgmt/list/import" 

+

392 list_ip_add_path = "/v1/mgmt/list/ip/add" 

+

393 list_ip_check_path = "/v1/mgmt/list/ip/check" 

+

394 list_ip_remove_path = "/v1/mgmt/list/ip/remove" 

+

395 list_name_path = "/v1/mgmt/list/name" 

+

396 list_text_add_path = "/v1/mgmt/list/text/add" 

+

397 list_text_check_path = "/v1/mgmt/list/text/check" 

+

398 list_text_remove_path = "/v1/mgmt/list/text/remove" 

+

399 list_update_path = "/v1/mgmt/list/update" 

+

400 

+

401 # password settings 

+

402 password_settings_path = "/v1/mgmt/password/settings" 

+

403 

+

404 # project (delete + snapshot) 

+

405 project_delete_path = "/v1/mgmt/project/delete" 

+

406 project_snapshot_export_path = "/v1/mgmt/project/snapshot/export" 

+

407 project_snapshot_import_path = "/v1/mgmt/project/snapshot/import" 

+

408 project_snapshot_validate_path = "/v1/mgmt/project/snapshot/validate" 

+

409 

+

410 # scope claim mapping 

+

411 scope_claim_mapping_get_path = "/v1/mgmt/scopeClaimMapping/get" 

+

412 scope_claim_mapping_set_path = "/v1/mgmt/scopeClaimMapping/set" 

+

413 scope_claim_mapping_delete_path = "/v1/mgmt/scopeClaimMapping/delete" 

+

414 

+

415 # sso application (rotate / secret / wsfed) 

+

416 sso_application_rotate_path = "/v1/mgmt/sso/idp/app/rotate" 

+

417 sso_application_secret_path = "/v1/mgmt/sso/idp/app/secret" 

+

418 sso_application_wsfed_create_path = "/v1/mgmt/sso/idp/app/wsfed/create" 

+

419 sso_application_wsfed_update_path = "/v1/mgmt/sso/idp/app/wsfed/update" 

+

420 

+

421 # sso settings (redirect / all / new) 

+

422 sso_redirect_path = "/v1/mgmt/sso/redirect" 

+

423 sso_load_all_settings_path = "/v2/mgmt/sso/settings/all" 

+

424 sso_new_settings_path = "/v1/mgmt/sso/settings/new" 

+

425 sso_configure_auth_type_path = "/v1/mgmt/sso/settings/authtype" 

+

426 

+

427 # tenant revoke sso config link 

+

428 tenant_revoke_sso_configuration_link_path = "/v1/mgmt/tenant/adminlinks/sso/revoke" 

+

429 

+

430 # third party application 

+

431 thirdparty_application_create_path = "/v1/mgmt/thirdparty/app/create" 

+

432 thirdparty_application_update_path = "/v1/mgmt/thirdparty/app/update" 

+

433 thirdparty_application_patch_path = "/v1/mgmt/thirdparty/app/patch" 

+

434 thirdparty_application_delete_path = "/v1/mgmt/thirdparty/app/delete" 

+

435 thirdparty_application_delete_batch_path = "/v1/mgmt/thirdparty/app/delete/batch" 

+

436 thirdparty_application_load_path = "/v1/mgmt/thirdparty/app/load" 

+

437 thirdparty_application_load_all_path = "/v1/mgmt/thirdparty/apps/load" 

+

438 thirdparty_application_rotate_path = "/v1/mgmt/thirdparty/app/rotate" 

+

439 thirdparty_application_secret_path = "/v1/mgmt/thirdparty/app/secret" 

+

440 thirdparty_consents_create_path = "/v1/mgmt/thirdparty/consents/create" 

+

441 thirdparty_consents_delete_path = "/v1/mgmt/thirdparty/consents/delete" 

+

442 thirdparty_consents_delete_tenant_path = "/v1/mgmt/thirdparty/consents/delete/tenant" 

+

443 thirdparty_consents_search_path = "/v1/mgmt/thirdparty/consents/search" 

+

444 

+

445 # user (gaps) 

+

446 user_create_custom_attribute_path = "/v1/mgmt/user/customattribute/create" 

+

447 user_delete_custom_attribute_path = "/v1/mgmt/user/customattribute/delete" 

+

448 user_load_custom_attributes_path = "/v1/mgmt/user/customattributes" 

+

449 user_load_family_scoped_custom_attributes_path = "/v1/mgmt/user/families/customattributes" 

+

450 user_create_family_scoped_custom_attributes_path = "/v1/mgmt/user/families/customattribute/create" 

+

451 user_delete_family_scoped_custom_attributes_path = "/v1/mgmt/user/families/customattribute/delete" 

+

452 user_delete_batch_path = "/v1/mgmt/user/delete/batch" 

+

453 user_import_path = "/v1/mgmt/user/import" 

+

454 user_delete_passkey_path = "/v1/mgmt/user/passkey/delete" 

+

455 user_list_passkeys_path = "/v1/mgmt/user/passkeys/list" 

+

456 user_list_trusted_devices_path = "/v1/mgmt/user/trusteddevices/list" 

+

457 user_remove_trusted_device_path = "/v1/mgmt/user/trusteddevices/remove" 

+

458 user_update_recovery_email_path = "/v1/mgmt/user/update/recovery/email" 

+

459 user_update_recovery_phone_path = "/v1/mgmt/user/update/recovery/phone" 

+

460 

+

461 

+

462class MgmtSignUpOptions: 

+

463 def __init__( 

+

464 self, 

+

465 custom_claims: Optional[dict] = None, 

+

466 refresh_duration: Optional[int] = None, 

+

467 ): 

+

468 self.custom_claims = custom_claims 

+

469 self.refresh_duration = refresh_duration 

+

470 

+

471 

+

472class FlowRunOptions: 

+

473 """ 

+

474 Options for running a flow. 

+

475 """ 

+

476 

+

477 def __init__( 

+

478 self, 

+

479 flow_input: Optional[Dict[str, Any]] = None, 

+

480 preview: Optional[bool] = None, 

+

481 tenant: Optional[str] = None, 

+

482 ): 

+

483 self.flow_input = flow_input 

+

484 self.preview = preview 

+

485 self.tenant = tenant 

+

486 

+

487 def to_dict(self) -> Dict[str, Any]: 

+

488 result: Dict[str, Any] = {} 

+

489 if self.flow_input is not None: 

+

490 result["input"] = self.flow_input 

+

491 if self.preview is not None: 

+

492 result["preview"] = self.preview 

+

493 if self.tenant is not None: 

+

494 result["tenant"] = self.tenant 

+

495 return result 

+

496 

+

497 @staticmethod 

+

498 def from_dict(options: Optional[dict]) -> Optional["FlowRunOptions"]: 

+

499 if options is None: 

+

500 return None 

+

501 return FlowRunOptions( 

+

502 flow_input=options.get("input"), 

+

503 preview=options.get("preview"), 

+

504 tenant=options.get("tenant"), 

+

505 ) 

+

506 

+

507 

+

508class MgmtLoginOptions: 

+

509 def __init__( 

+

510 self, 

+

511 stepup: bool = False, 

+

512 mfa: bool = False, 

+

513 revoke_other_sessions: Optional[bool] = None, 

+

514 custom_claims: Optional[dict] = None, 

+

515 jwt: Optional[str] = None, 

+

516 refresh_duration: Optional[int] = None, 

+

517 ): 

+

518 self.stepup = stepup 

+

519 self.custom_claims = custom_claims 

+

520 self.mfa = mfa 

+

521 self.revoke_other_sessions = revoke_other_sessions 

+

522 self.jwt = jwt 

+

523 self.refresh_duration = refresh_duration 

+

524 

+

525 

+

526def is_jwt_required(lgo: MgmtLoginOptions) -> bool: 

+

527 return lgo is not None and (lgo.stepup or lgo.mfa) 

+

528 

+

529 

+

530class MgmtUserRequest: 

+

531 def __init__( 

+

532 self, 

+

533 name: Optional[str] = None, 

+

534 given_name: Optional[str] = None, 

+

535 middle_name: Optional[str] = None, 

+

536 family_name: Optional[str] = None, 

+

537 phone: Optional[str] = None, 

+

538 email: Optional[str] = None, 

+

539 email_verified: Optional[bool] = None, 

+

540 phone_verified: Optional[bool] = None, 

+

541 sso_app_id: Optional[str] = None, 

+

542 ): 

+

543 self.name = name 

+

544 self.given_name = given_name 

+

545 self.middle_name = middle_name 

+

546 self.family_name = family_name 

+

547 self.phone = phone 

+

548 self.email = email 

+

549 self.email_verified = email_verified 

+

550 self.phone_verified = phone_verified 

+

551 self.sso_app_id = sso_app_id 

+

552 

+

553 def to_dict(self) -> dict: 

+

554 return { 

+

555 "name": self.name, 

+

556 "givenName": self.given_name, 

+

557 "middleName": self.middle_name, 

+

558 "familyName": self.family_name, 

+

559 "phone": self.phone, 

+

560 "email": self.email, 

+

561 "emailVerified": self.email_verified, 

+

562 "phoneVerified": self.phone_verified, 

+

563 "ssoAppId": self.sso_app_id, 

+

564 } 

+

565 

+

566 

+

567class AssociatedTenant: 

+

568 """ 

+

569 Represents a tenant association for a User or Access Key. The tenant_id is required to denote 

+

570 which tenant the user or access key belongs to. The role_names array is an optional list of 

+

571 roles for the user or access key in this specific tenant. 

+

572 """ 

+

573 

+

574 def __init__(self, tenant_id: str, role_names: Optional[List[str]] = None): 

+

575 self.tenant_id = tenant_id 

+

576 self.role_names = [] if role_names is None else role_names 

+

577 

+

578 

+

579def associated_tenants_to_dict(associated_tenants: List[AssociatedTenant]) -> list: 

+

580 associated_tenant_list = [] 

+

581 if associated_tenants: 

+

582 for associated_tenant in associated_tenants: 

+

583 associated_tenant_list.append( 

+

584 { 

+

585 "tenantId": associated_tenant.tenant_id, 

+

586 "roleNames": associated_tenant.role_names, 

+

587 } 

+

588 ) 

+

589 return associated_tenant_list 

+

590 

+

591 

+

592class AssociatedFamily: 

+

593 """ 

+

594 Represents a family association for a User. The family_id is required to denote which family 

+

595 the user belongs to. The role_names array is an optional list of the user's roles within that 

+

596 family, and family_scoped_attributes is an optional dict of the user's custom attribute values 

+

597 scoped to that family. When adding a user to a family they already belong to, omitting 

+

598 role_names or family_scoped_attributes leaves the existing values unchanged. 

+

599 """ 

+

600 

+

601 def __init__( 

+

602 self, 

+

603 family_id: str, 

+

604 role_names: Optional[List[str]] = None, 

+

605 family_scoped_attributes: Optional[dict] = None, 

+

606 ): 

+

607 self.family_id = family_id 

+

608 self.role_names = role_names 

+

609 self.family_scoped_attributes = family_scoped_attributes 

+

610 

+

611 def to_dict(self) -> dict: 

+

612 res: Dict[str, Any] = {"familyId": self.family_id} 

+

613 if self.role_names is not None: 

+

614 res["roleNames"] = self.role_names 

+

615 if self.family_scoped_attributes is not None: 

+

616 res["familyScopedAttributes"] = self.family_scoped_attributes 

+

617 return res 

+

618 

+

619 

+

620def associated_families_to_dict(associated_families: List[AssociatedFamily]) -> list: 

+

621 return [associated_family.to_dict() for associated_family in associated_families] 

+

622 

+

623 

+

624class CustomAttributeOption: 

+

625 """ 

+

626 Represents a selectable option of a single-select or multi-select custom attribute. 

+

627 """ 

+

628 

+

629 def __init__(self, value: str, label: str): 

+

630 self.value = value 

+

631 self.label = label 

+

632 

+

633 def to_dict(self) -> dict: 

+

634 return {"value": self.value, "label": self.label} 

+

635 

+

636 

+

637class CustomAttribute: 

+

638 """ 

+

639 Represents a custom attribute definition, as used by the family custom attributes 

+

640 and the family-scoped user custom attributes APIs. 

+

641 

+

642 The type is an integer: 1 = string, 2 = number, 3 = boolean, 4 = single select, 

+

643 5 = multi select, 6 = date, 7 = month and day. 

+

644 """ 

+

645 

+

646 def __init__( 

+

647 self, 

+

648 name: str, 

+

649 type: int, 

+

650 display_name: Optional[str] = None, 

+

651 options: Optional[List[CustomAttributeOption]] = None, 

+

652 default_value: Optional[Any] = None, 

+

653 view_permissions: Optional[List[str]] = None, 

+

654 edit_permissions: Optional[List[str]] = None, 

+

655 ): 

+

656 self.name = name 

+

657 self.type = type 

+

658 self.display_name = display_name 

+

659 self.options = options 

+

660 self.default_value = default_value 

+

661 self.view_permissions = view_permissions 

+

662 self.edit_permissions = edit_permissions 

+

663 

+

664 def to_dict(self) -> dict: 

+

665 res: Dict[str, Any] = {"name": self.name, "type": self.type} 

+

666 if self.display_name is not None: 

+

667 res["displayName"] = self.display_name 

+

668 if self.options is not None: 

+

669 res["options"] = [option.to_dict() for option in self.options] 

+

670 if self.default_value is not None: 

+

671 res["defaultValue"] = self.default_value 

+

672 if self.view_permissions is not None: 

+

673 res["viewPermissions"] = self.view_permissions 

+

674 if self.edit_permissions is not None: 

+

675 res["editPermissions"] = self.edit_permissions 

+

676 return res 

+

677 

+

678 

+

679def custom_attributes_to_dict(attributes: List[CustomAttribute]) -> list: 

+

680 return [attribute.to_dict() for attribute in attributes] 

+

681 

+

682 

+

683class SAMLIDPAttributeMappingInfo: 

+

684 """ 

+

685 Represents a SAML IDP attribute mapping object. use this class for mapping Descope attribute 

+

686 to the relevant SAML Assertion attributes matching your expected SP attributes names. 

+

687 """ 

+

688 

+

689 def __init__(self, name: str, type: str, value: str): 

+

690 self.name = name 

+

691 self.type = type 

+

692 self.value = value 

+

693 

+

694 

+

695def saml_idp_attribute_mapping_info_to_dict( 

+

696 attributes_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, 

+

697) -> list: 

+

698 attributes_mapping_list = [] 

+

699 if attributes_mapping: 

+

700 for attribute_mapping in attributes_mapping: 

+

701 attributes_mapping_list.append( 

+

702 { 

+

703 "name": attribute_mapping.name, 

+

704 "type": attribute_mapping.type, 

+

705 "value": attribute_mapping.value, 

+

706 } 

+

707 ) 

+

708 return attributes_mapping_list 

+

709 

+

710 

+

711class SAMLIDPRoleGroupMappingInfo: 

+

712 """ 

+

713 Represents a SAML IDP Role Group mapping object. 

+

714 """ 

+

715 

+

716 def __init__(self, id: str, name: str): 

+

717 self.id = id 

+

718 self.name = name 

+

719 

+

720 

+

721def saml_idp_role_group_mapping_info_to_dict( 

+

722 role_groups_mapping: Optional[List[SAMLIDPRoleGroupMappingInfo]] = None, 

+

723) -> list: 

+

724 role_groups_mapping_list = [] 

+

725 if role_groups_mapping: 

+

726 for group_mapping in role_groups_mapping: 

+

727 role_groups_mapping_list.append( 

+

728 { 

+

729 "id": group_mapping.id, 

+

730 "name": group_mapping.name, 

+

731 } 

+

732 ) 

+

733 return role_groups_mapping_list 

+

734 

+

735 

+

736class SAMLIDPGroupsMappingInfo: 

+

737 """ 

+

738 Represents a SAML IDP Descope roles to SP groups mapping object. use this class for mapping Descope roles 

+

739 to your SP groups. 

+

740 """ 

+

741 

+

742 def __init__( 

+

743 self, 

+

744 name: str, 

+

745 type: str, 

+

746 filter_type: str, 

+

747 value: str, 

+

748 roles: List[SAMLIDPRoleGroupMappingInfo], 

+

749 ): 

+

750 self.name = name 

+

751 self.type = type 

+

752 self.filter_type = filter_type 

+

753 self.value = value 

+

754 self.roles = roles 

+

755 

+

756 

+

757def saml_idp_groups_mapping_info_to_dict( 

+

758 groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, 

+

759) -> list: 

+

760 groups_mapping_list = [] 

+

761 if groups_mapping: 

+

762 for group_mapping in groups_mapping: 

+

763 groups_mapping_list.append( 

+

764 { 

+

765 "name": group_mapping.name, 

+

766 "type": group_mapping.type, 

+

767 "filterType": group_mapping.filter_type, 

+

768 "value": group_mapping.value, 

+

769 "roles": saml_idp_role_group_mapping_info_to_dict(group_mapping.roles), 

+

770 } 

+

771 ) 

+

772 return groups_mapping_list 

+

773 

+

774 

+

775class Sort: 

+

776 """ 

+

777 Represents a sort object. 

+

778 """ 

+

779 

+

780 def __init__(self, field: str, desc: Optional[bool] = False): 

+

781 self.field = field 

+

782 self.desc = desc 

+

783 

+

784 

+

785def sort_to_dict(sort: List[Sort]) -> list: 

+

786 sort_list = [] 

+

787 if sort: 

+

788 for s in sort: 

+

789 sort_list.append( 

+

790 { 

+

791 "field": s.field, 

+

792 "desc": s.desc, 

+

793 } 

+

794 ) 

+

795 return sort_list 

+

796 

+

797 

+

798class DescoperRole(Enum): 

+

799 """Represents a Descoper role.""" 

+

800 

+

801 ADMIN = "admin" 

+

802 DEVELOPER = "developer" 

+

803 SUPPORT = "support" 

+

804 AUDITOR = "auditor" 

+

805 

+

806 

+

807class DescoperAttributes: 

+

808 """ 

+

809 Represents Descoper attributes, such as name and email/phone. 

+

810 """ 

+

811 

+

812 def __init__( 

+

813 self, 

+

814 display_name: Optional[str] = None, 

+

815 email: Optional[str] = None, 

+

816 phone: Optional[str] = None, 

+

817 ): 

+

818 self.display_name = display_name 

+

819 self.email = email 

+

820 self.phone = phone 

+

821 

+

822 def to_dict(self) -> dict: 

+

823 return { 

+

824 "displayName": self.display_name, 

+

825 "email": self.email, 

+

826 "phone": self.phone, 

+

827 } 

+

828 

+

829 

+

830class DescoperTagRole: 

+

831 """ 

+

832 Represents a Descoper tags to role mapping. 

+

833 """ 

+

834 

+

835 def __init__( 

+

836 self, 

+

837 tags: Optional[List[str]] = None, 

+

838 role: Optional[DescoperRole] = None, 

+

839 ): 

+

840 self.tags = tags if tags is not None else [] 

+

841 self.role = role 

+

842 

+

843 def to_dict(self) -> dict: 

+

844 return { 

+

845 "tags": self.tags, 

+

846 "role": self.role.value if self.role else None, 

+

847 } 

+

848 

+

849 

+

850class DescoperProjectRole: 

+

851 """ 

+

852 Represents a Descoper projects to role mapping. 

+

853 """ 

+

854 

+

855 def __init__( 

+

856 self, 

+

857 project_ids: Optional[List[str]] = None, 

+

858 role: Optional[DescoperRole] = None, 

+

859 ): 

+

860 self.project_ids = project_ids if project_ids is not None else [] 

+

861 self.role = role 

+

862 

+

863 def to_dict(self) -> dict: 

+

864 return { 

+

865 "projectIds": self.project_ids, 

+

866 "role": self.role.value if self.role else None, 

+

867 } 

+

868 

+

869 

+

870class DescoperRBAC: 

+

871 """ 

+

872 Represents Descoper RBAC configuration. 

+

873 """ 

+

874 

+

875 def __init__( 

+

876 self, 

+

877 is_company_admin: bool = False, 

+

878 tags: Optional[List[DescoperTagRole]] = None, 

+

879 projects: Optional[List[DescoperProjectRole]] = None, 

+

880 ): 

+

881 self.is_company_admin = is_company_admin 

+

882 self.tags = tags if tags is not None else [] 

+

883 self.projects = projects if projects is not None else [] 

+

884 

+

885 def to_dict(self) -> dict: 

+

886 return { 

+

887 "isCompanyAdmin": self.is_company_admin, 

+

888 "tags": [t.to_dict() for t in self.tags], 

+

889 "projects": [p.to_dict() for p in self.projects], 

+

890 } 

+

891 

+

892 

+

893class DescoperCreate: 

+

894 """ 

+

895 Represents a Descoper to be created. 

+

896 """ 

+

897 

+

898 def __init__( 

+

899 self, 

+

900 login_id: str, 

+

901 attributes: Optional[DescoperAttributes] = None, 

+

902 send_invite: bool = False, 

+

903 rbac: Optional[DescoperRBAC] = None, 

+

904 ): 

+

905 self.login_id = login_id 

+

906 self.attributes = attributes 

+

907 self.send_invite = send_invite 

+

908 self.rbac = rbac 

+

909 

+

910 def to_dict(self) -> dict: 

+

911 return { 

+

912 "loginId": self.login_id, 

+

913 "attributes": self.attributes.to_dict() if self.attributes else None, 

+

914 "sendInvite": self.send_invite, 

+

915 "rbac": self.rbac.to_dict() if self.rbac else None, 

+

916 } 

+

917 

+

918 

+

919def descopers_to_dict(descopers: List[DescoperCreate]) -> list: 

+

920 return [d.to_dict() for d in descopers] 

+

921 

+

922 

+

923class MgmtKeyStatus(Enum): 

+

924 ACTIVE = "active" 

+

925 INACTIVE = "inactive" 

+

926 

+

927 

+

928class MgmtKeyProjectRole: 

+

929 def __init__(self, project_ids: List[str], roles: List[str]): 

+

930 self.project_ids = project_ids 

+

931 self.roles = roles 

+

932 

+

933 def to_dict(self) -> dict: 

+

934 return { 

+

935 "projectIds": self.project_ids, 

+

936 "roles": self.roles, 

+

937 } 

+

938 

+

939 

+

940class MgmtKeyTagRole: 

+

941 def __init__(self, tags: List[str], roles: List[str]): 

+

942 self.tags = tags 

+

943 self.roles = roles 

+

944 

+

945 def to_dict(self) -> dict: 

+

946 return { 

+

947 "tags": self.tags, 

+

948 "roles": self.roles, 

+

949 } 

+

950 

+

951 

+

952class MgmtKeyReBac: 

+

953 def __init__( 

+

954 self, 

+

955 company_roles: Optional[List[str]] = None, 

+

956 project_roles: Optional[List[MgmtKeyProjectRole]] = None, 

+

957 tag_roles: Optional[List[MgmtKeyTagRole]] = None, 

+

958 ): 

+

959 self.company_roles = company_roles 

+

960 self.project_roles = project_roles 

+

961 self.tag_roles = tag_roles 

+

962 

+

963 def to_dict(self) -> dict: 

+

964 res: dict = {} 

+

965 if self.company_roles is not None: 

+

966 res["companyRoles"] = self.company_roles 

+

967 if self.project_roles is not None: 

+

968 res["projectRoles"] = [pr.to_dict() for pr in self.project_roles] 

+

969 if self.tag_roles is not None: 

+

970 res["tagRoles"] = [tr.to_dict() for tr in self.tag_roles] 

+

971 return res 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_group_async_py.html b/htmlcov/d_f54762f9f1d5aa4d_group_async_py.html new file mode 100644 index 000000000..0d1f5565e --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_group_async_py.html @@ -0,0 +1,260 @@ + + + + + Coverage for descope/management/group_async.py: 92% + + + + + +
+
+

+ Coverage for descope/management/group_async.py: + 92% +

+ +

+ 25 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import List, Optional 

+

4 

+

5from descope._http_base import AsyncHTTPBase 

+

6from descope.management.common import MgmtV1 

+

7 

+

8 

+

9class GroupAsync(AsyncHTTPBase): 

+

10 """Async counterpart of Group — all HTTP calls are coroutines.""" 

+

11 

+

12 async def load_all_groups( 

+

13 self, 

+

14 tenant_id: str, 

+

15 sso_id: Optional[str] = None, 

+

16 ) -> dict: 

+

17 """ 

+

18 Load all groups for a specific tenant id. 

+

19 

+

20 Args: 

+

21 tenant_id (str): Tenant ID to load groups from. 

+

22 sso_id (str): Optional SSO configuration id (ssoId) to load only groups that came from 

+

23 that SSO configuration. Use the reserved id "default_ssoid" for the tenant's default 

+

24 SSO configuration. When omitted, all the tenant's groups are returned. 

+

25 

+

26 Return value (dict): 

+

27 Return dict in the format 

+

28 [ 

+

29 { 

+

30 "id": <group id>, 

+

31 "display": <display name>, 

+

32 "source": <"scim" or "jit">, 

+

33 "ssoId": <sso configuration id>, 

+

34 "members":[ 

+

35 { 

+

36 "loginId": <loginId>, 

+

37 "userId": <userId>, 

+

38 "display": <display name> 

+

39 } 

+

40 ] 

+

41 } 

+

42 ] 

+

43 Containing the loaded groups information. 

+

44 

+

45 Raise: 

+

46 AuthException: raised if load operation fails 

+

47 """ 

+

48 body = { 

+

49 "tenantId": tenant_id, 

+

50 } 

+

51 if sso_id is not None: 

+

52 body["ssoId"] = sso_id 

+

53 response = await self._http.post( 

+

54 MgmtV1.group_load_all_path, 

+

55 body=body, 

+

56 ) 

+

57 return response.json() 

+

58 

+

59 async def load_all_groups_for_members( 

+

60 self, 

+

61 tenant_id: str, 

+

62 user_ids: Optional[List[str]] = None, 

+

63 login_ids: Optional[List[str]] = None, 

+

64 sso_id: Optional[str] = None, 

+

65 ) -> dict: 

+

66 """ 

+

67 Load all groups for the provided user IDs or login IDs. 

+

68 

+

69 Args: 

+

70 tenant_id (str): Tenant ID to load groups from. 

+

71 user_ids (List[str]): Optional List of user IDs, with the format of "U2J5ES9S8TkvCgOvcrkpzUgVTEBM" (example), which can be found on the user's JWT. 

+

72 login_ids (List[str]): Optional List of login IDs, how the users identify when logging in. 

+

73 sso_id (str): Optional SSO configuration id (ssoId) to load only groups that came from 

+

74 that SSO configuration. Use the reserved id "default_ssoid" for the tenant's default 

+

75 SSO configuration. When omitted, all matching groups are returned. 

+

76 

+

77 Return value (dict): 

+

78 Return dict in the format 

+

79 [ 

+

80 { 

+

81 "id": <group id>, 

+

82 "display": <display name>, 

+

83 "source": <"scim" or "jit">, 

+

84 "ssoId": <sso configuration id>, 

+

85 "members":[ 

+

86 { 

+

87 "loginId": <loginId>, 

+

88 "userId": <userId>, 

+

89 "display": <display name> 

+

90 } 

+

91 ] 

+

92 } 

+

93 ] 

+

94 Containing the loaded groups information. 

+

95 

+

96 Raise: 

+

97 AuthException: raised if load operation fails 

+

98 """ 

+

99 user_ids = [] if user_ids is None else user_ids 

+

100 login_ids = [] if login_ids is None else login_ids 

+

101 

+

102 body = { 

+

103 "tenantId": tenant_id, 

+

104 "loginIds": login_ids, 

+

105 "userIds": user_ids, 

+

106 } 

+

107 if sso_id is not None: 

+

108 body["ssoId"] = sso_id 

+

109 response = await self._http.post( 

+

110 MgmtV1.group_load_all_for_member_path, 

+

111 body=body, 

+

112 ) 

+

113 return response.json() 

+

114 

+

115 async def load_all_group_members( 

+

116 self, 

+

117 tenant_id: str, 

+

118 group_id: str, 

+

119 sso_id: Optional[str] = None, 

+

120 ) -> dict: 

+

121 """ 

+

122 Load all members of the provided group id. 

+

123 

+

124 Args: 

+

125 tenant_id (str): Tenant ID to load groups from. 

+

126 group_id (str): Group ID to load members for. 

+

127 sso_id (str): Optional SSO configuration id (ssoId): return the group only if it came 

+

128 from that SSO configuration. Use the reserved id "default_ssoid" for the tenant's 

+

129 default SSO configuration. 

+

130 

+

131 Return value (dict): 

+

132 Return dict in the format 

+

133 [ 

+

134 { 

+

135 "id": <group id>, 

+

136 "display": <display name>, 

+

137 "source": <"scim" or "jit">, 

+

138 "ssoId": <sso configuration id>, 

+

139 "members":[ 

+

140 { 

+

141 "loginId": <loginId>, 

+

142 "userId": <userId>, 

+

143 "display": <display name> 

+

144 } 

+

145 ] 

+

146 } 

+

147 ] 

+

148 Containing the loaded groups information. 

+

149 

+

150 Raise: 

+

151 AuthException: raised if load operation fails 

+

152 """ 

+

153 body = { 

+

154 "tenantId": tenant_id, 

+

155 "groupId": group_id, 

+

156 } 

+

157 if sso_id is not None: 

+

158 body["ssoId"] = sso_id 

+

159 response = await self._http.post( 

+

160 MgmtV1.group_load_all_group_members_path, 

+

161 body=body, 

+

162 ) 

+

163 return response.json() 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_group_py.html b/htmlcov/d_f54762f9f1d5aa4d_group_py.html new file mode 100644 index 000000000..4cf5aac24 --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_group_py.html @@ -0,0 +1,256 @@ + + + + + Coverage for descope/management/group.py: 92% + + + + + +
+
+

+ Coverage for descope/management/group.py: + 92% +

+ +

+ 24 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from typing import List, Optional 

+

2 

+

3from descope._http_base import HTTPBase 

+

4from descope.management.common import MgmtV1 

+

5 

+

6 

+

7class Group(HTTPBase): 

+

8 def load_all_groups( 

+

9 self, 

+

10 tenant_id: str, 

+

11 sso_id: Optional[str] = None, 

+

12 ) -> dict: 

+

13 """ 

+

14 Load all groups for a specific tenant id. 

+

15 

+

16 Args: 

+

17 tenant_id (str): Tenant ID to load groups from. 

+

18 sso_id (str): Optional SSO configuration id (ssoId) to load only groups that came from 

+

19 that SSO configuration. Use the reserved id "default_ssoid" for the tenant's default 

+

20 SSO configuration. When omitted, all the tenant's groups are returned. 

+

21 

+

22 Return value (dict): 

+

23 Return dict in the format 

+

24 [ 

+

25 { 

+

26 "id": <group id>, 

+

27 "display": <display name>, 

+

28 "source": <"scim" or "jit">, 

+

29 "ssoId": <sso configuration id>, 

+

30 "members":[ 

+

31 { 

+

32 "loginId": <loginId>, 

+

33 "userId": <userId>, 

+

34 "display": <display name> 

+

35 } 

+

36 ] 

+

37 } 

+

38 ] 

+

39 Containing the loaded groups information. 

+

40 

+

41 Raise: 

+

42 AuthException: raised if load operation fails 

+

43 """ 

+

44 body = { 

+

45 "tenantId": tenant_id, 

+

46 } 

+

47 if sso_id is not None: 

+

48 body["ssoId"] = sso_id 

+

49 response = self._http.post( 

+

50 MgmtV1.group_load_all_path, 

+

51 body=body, 

+

52 ) 

+

53 return response.json() 

+

54 

+

55 def load_all_groups_for_members( 

+

56 self, 

+

57 tenant_id: str, 

+

58 user_ids: Optional[List[str]] = None, 

+

59 login_ids: Optional[List[str]] = None, 

+

60 sso_id: Optional[str] = None, 

+

61 ) -> dict: 

+

62 """ 

+

63 Load all groups for the provided user IDs or login IDs. 

+

64 

+

65 Args: 

+

66 tenant_id (str): Tenant ID to load groups from. 

+

67 user_ids (List[str]): Optional List of user IDs, with the format of "U2J5ES9S8TkvCgOvcrkpzUgVTEBM" (example), which can be found on the user's JWT. 

+

68 login_ids (List[str]): Optional List of login IDs, how the users identify when logging in. 

+

69 sso_id (str): Optional SSO configuration id (ssoId) to load only groups that came from 

+

70 that SSO configuration. Use the reserved id "default_ssoid" for the tenant's default 

+

71 SSO configuration. When omitted, all matching groups are returned. 

+

72 

+

73 Return value (dict): 

+

74 Return dict in the format 

+

75 [ 

+

76 { 

+

77 "id": <group id>, 

+

78 "display": <display name>, 

+

79 "source": <"scim" or "jit">, 

+

80 "ssoId": <sso configuration id>, 

+

81 "members":[ 

+

82 { 

+

83 "loginId": <loginId>, 

+

84 "userId": <userId>, 

+

85 "display": <display name> 

+

86 } 

+

87 ] 

+

88 } 

+

89 ] 

+

90 Containing the loaded groups information. 

+

91 

+

92 Raise: 

+

93 AuthException: raised if load operation fails 

+

94 """ 

+

95 user_ids = [] if user_ids is None else user_ids 

+

96 login_ids = [] if login_ids is None else login_ids 

+

97 

+

98 body = { 

+

99 "tenantId": tenant_id, 

+

100 "loginIds": login_ids, 

+

101 "userIds": user_ids, 

+

102 } 

+

103 if sso_id is not None: 

+

104 body["ssoId"] = sso_id 

+

105 response = self._http.post( 

+

106 MgmtV1.group_load_all_for_member_path, 

+

107 body=body, 

+

108 ) 

+

109 return response.json() 

+

110 

+

111 def load_all_group_members( 

+

112 self, 

+

113 tenant_id: str, 

+

114 group_id: str, 

+

115 sso_id: Optional[str] = None, 

+

116 ) -> dict: 

+

117 """ 

+

118 Load all members of the provided group id. 

+

119 

+

120 Args: 

+

121 tenant_id (str): Tenant ID to load groups from. 

+

122 group_id (str): Group ID to load members for. 

+

123 sso_id (str): Optional SSO configuration id (ssoId): return the group only if it came 

+

124 from that SSO configuration. Use the reserved id "default_ssoid" for the tenant's 

+

125 default SSO configuration. 

+

126 

+

127 Return value (dict): 

+

128 Return dict in the format 

+

129 [ 

+

130 { 

+

131 "id": <group id>, 

+

132 "display": <display name>, 

+

133 "source": <"scim" or "jit">, 

+

134 "ssoId": <sso configuration id>, 

+

135 "members":[ 

+

136 { 

+

137 "loginId": <loginId>, 

+

138 "userId": <userId>, 

+

139 "display": <display name> 

+

140 } 

+

141 ] 

+

142 } 

+

143 ] 

+

144 Containing the loaded groups information. 

+

145 

+

146 Raise: 

+

147 AuthException: raised if load operation fails 

+

148 """ 

+

149 body = { 

+

150 "tenantId": tenant_id, 

+

151 "groupId": group_id, 

+

152 } 

+

153 if sso_id is not None: 

+

154 body["ssoId"] = sso_id 

+

155 response = self._http.post( 

+

156 MgmtV1.group_load_all_group_members_path, 

+

157 body=body, 

+

158 ) 

+

159 return response.json() 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_outbound_application_async_py.html b/htmlcov/d_f54762f9f1d5aa4d_outbound_application_async_py.html new file mode 100644 index 000000000..6645817ab --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_outbound_application_async_py.html @@ -0,0 +1,988 @@ + + + + + Coverage for descope/management/outbound_application_async.py: 99% + + + + + +
+
+

+ Coverage for descope/management/outbound_application_async.py: + 99% +

+ +

+ 119 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import List, Optional 

+

4 

+

5from descope._http_base import AsyncHTTPBase 

+

6from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 

+

7from descope.http_client_async import HTTPClientAsync 

+

8from descope.management._outbound_application_base import OutboundApplicationBase 

+

9from descope.management.common import ( 

+

10 AccessType, 

+

11 MgmtV1, 

+

12 PromptType, 

+

13 URLParam, 

+

14) 

+

15 

+

16 

+

17class _OutboundApplicationTokenFetcherAsync: 

+

18 """Internal async helper class for shared token fetching logic.""" 

+

19 

+

20 @staticmethod 

+

21 async def fetch_token_by_scopes( 

+

22 *, 

+

23 http: HTTPClientAsync, 

+

24 token: Optional[str] = None, 

+

25 app_id: str, 

+

26 user_id: str, 

+

27 scopes: List[str], 

+

28 options: Optional[dict] = None, 

+

29 tenant_id: Optional[str] = None, 

+

30 ) -> dict: 

+

31 """Internal async implementation for fetching token by scopes.""" 

+

32 uri = MgmtV1.outbound_application_fetch_token_by_scopes_path 

+

33 response = await http.post( 

+

34 uri, 

+

35 body={ 

+

36 "appId": app_id, 

+

37 "userId": user_id, 

+

38 "scopes": scopes, 

+

39 "options": options, 

+

40 "tenantId": tenant_id, 

+

41 }, 

+

42 pswd=token, 

+

43 ) 

+

44 return response.json() 

+

45 

+

46 @staticmethod 

+

47 async def fetch_token( 

+

48 *, 

+

49 http: HTTPClientAsync, 

+

50 token: Optional[str] = None, 

+

51 app_id: str, 

+

52 user_id: str, 

+

53 tenant_id: Optional[str] = None, 

+

54 options: Optional[dict] = None, 

+

55 ) -> dict: 

+

56 """Internal async implementation for fetching token.""" 

+

57 uri = MgmtV1.outbound_application_fetch_token_path 

+

58 response = await http.post( 

+

59 uri, 

+

60 body={ 

+

61 "appId": app_id, 

+

62 "userId": user_id, 

+

63 "tenantId": tenant_id, 

+

64 "options": options, 

+

65 }, 

+

66 pswd=token, 

+

67 ) 

+

68 return response.json() 

+

69 

+

70 @staticmethod 

+

71 async def fetch_tenant_token_by_scopes( 

+

72 *, 

+

73 http: HTTPClientAsync, 

+

74 token: Optional[str] = None, 

+

75 app_id: str, 

+

76 tenant_id: str, 

+

77 scopes: List[str], 

+

78 options: Optional[dict] = None, 

+

79 ) -> dict: 

+

80 """Internal async implementation for fetching tenant token by scopes.""" 

+

81 uri = MgmtV1.outbound_application_fetch_tenant_token_by_scopes_path 

+

82 response = await http.post( 

+

83 uri, 

+

84 body={ 

+

85 "appId": app_id, 

+

86 "tenantId": tenant_id, 

+

87 "scopes": scopes, 

+

88 "options": options, 

+

89 }, 

+

90 pswd=token, 

+

91 ) 

+

92 return response.json() 

+

93 

+

94 @staticmethod 

+

95 async def fetch_tenant_token( 

+

96 *, 

+

97 http: HTTPClientAsync, 

+

98 token: Optional[str] = None, 

+

99 app_id: str, 

+

100 tenant_id: str, 

+

101 options: Optional[dict] = None, 

+

102 ) -> dict: 

+

103 """Internal async implementation for fetching tenant token.""" 

+

104 uri = MgmtV1.outbound_application_fetch_tenant_token_path 

+

105 response = await http.post( 

+

106 uri, 

+

107 body={ 

+

108 "appId": app_id, 

+

109 "tenantId": tenant_id, 

+

110 "options": options, 

+

111 }, 

+

112 pswd=token, 

+

113 ) 

+

114 return response.json() 

+

115 

+

116 

+

117class OutboundApplicationAsync(OutboundApplicationBase, AsyncHTTPBase): 

+

118 async def create_application( 

+

119 self, 

+

120 name: str, 

+

121 description: Optional[str] = None, 

+

122 logo: Optional[str] = None, 

+

123 id: Optional[str] = None, 

+

124 client_secret: Optional[str] = None, 

+

125 client_id: Optional[str] = None, 

+

126 discovery_url: Optional[str] = None, 

+

127 authorization_url: Optional[str] = None, 

+

128 authorization_url_params: Optional[List[URLParam]] = None, 

+

129 token_url: Optional[str] = None, 

+

130 token_url_params: Optional[List[URLParam]] = None, 

+

131 revocation_url: Optional[str] = None, 

+

132 default_scopes: Optional[List[str]] = None, 

+

133 default_redirect_url: Optional[str] = None, 

+

134 callback_domain: Optional[str] = None, 

+

135 pkce: Optional[bool] = None, 

+

136 access_type: Optional[AccessType] = None, 

+

137 prompt: Optional[List[PromptType]] = None, 

+

138 ) -> dict: 

+

139 """ 

+

140 Create a new outbound application with the given name. Outbound application IDs are provisioned automatically, but can be provided 

+

141 explicitly if needed. Both the name and ID must be unique per project. 

+

142 

+

143 Args: 

+

144 name (str): The outbound application's name. 

+

145 description (str): Optional outbound application description. 

+

146 logo (str): Optional outbound application logo. 

+

147 id (str): Optional outbound application ID. 

+

148 client_secret (str): Optional client secret for the application. 

+

149 client_id (str): Optional client ID for the application. 

+

150 discovery_url (str): Optional OAuth discovery URL. 

+

151 authorization_url (str): Optional OAuth authorization URL. 

+

152 authorization_url_params (List[URLParam]): Optional authorization URL parameters. 

+

153 token_url (str): Optional OAuth token URL. 

+

154 token_url_params (List[URLParam]): Optional token URL parameters. 

+

155 revocation_url (str): Optional OAuth token revocation URL. 

+

156 default_scopes (List[str]): Optional default OAuth scopes. 

+

157 default_redirect_url (str): Optional default redirect URL. 

+

158 callback_domain (str): Optional callback domain. 

+

159 pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. 

+

160 access_type (AccessType): Optional OAuth access type. 

+

161 prompt (List[PromptType]): Optional OAuth prompt parameters. 

+

162 

+

163 Return value (dict): 

+

164 Return dict in the format 

+

165 {"app": {"id": <id>, "name": <name>, "description": <description>, "logo": <logo>}} 

+

166 

+

167 Raise: 

+

168 AuthException: raised if create operation fails 

+

169 """ 

+

170 uri = MgmtV1.outbound_application_create_path 

+

171 response = await self._http.post( 

+

172 uri, 

+

173 body=OutboundApplicationBase._compose_create_update_body( 

+

174 name, 

+

175 description, 

+

176 logo, 

+

177 id, 

+

178 client_secret, 

+

179 client_id, 

+

180 discovery_url, 

+

181 authorization_url, 

+

182 authorization_url_params, 

+

183 token_url, 

+

184 token_url_params, 

+

185 revocation_url, 

+

186 default_scopes, 

+

187 default_redirect_url, 

+

188 callback_domain, 

+

189 pkce, 

+

190 access_type, 

+

191 prompt, 

+

192 ), 

+

193 ) 

+

194 return response.json() 

+

195 

+

196 async def update_application( 

+

197 self, 

+

198 id: str, 

+

199 name: str, 

+

200 description: Optional[str] = None, 

+

201 logo: Optional[str] = None, 

+

202 client_secret: Optional[str] = None, 

+

203 client_id: Optional[str] = None, 

+

204 discovery_url: Optional[str] = None, 

+

205 authorization_url: Optional[str] = None, 

+

206 authorization_url_params: Optional[List[URLParam]] = None, 

+

207 token_url: Optional[str] = None, 

+

208 token_url_params: Optional[List[URLParam]] = None, 

+

209 revocation_url: Optional[str] = None, 

+

210 default_scopes: Optional[List[str]] = None, 

+

211 default_redirect_url: Optional[str] = None, 

+

212 callback_domain: Optional[str] = None, 

+

213 pkce: Optional[bool] = None, 

+

214 access_type: Optional[AccessType] = None, 

+

215 prompt: Optional[List[PromptType]] = None, 

+

216 ) -> dict: 

+

217 """ 

+

218 Update an existing outbound application with the given parameters. IMPORTANT: All parameters are used as overrides 

+

219 to the existing outbound application. Empty fields will override populated fields. Use carefully. 

+

220 

+

221 Args: 

+

222 id (str): The ID of the outbound application to update. 

+

223 name (str): Updated outbound application name. 

+

224 description (str): Optional outbound application description. 

+

225 logo (str): Optional outbound application logo. 

+

226 client_secret (str): Optional client secret for the application. 

+

227 client_id (str): Optional client ID for the application. 

+

228 discovery_url (str): Optional OAuth discovery URL. 

+

229 authorization_url (str): Optional OAuth authorization URL. 

+

230 authorization_url_params (List[URLParam]): Optional authorization URL parameters. 

+

231 token_url (str): Optional OAuth token URL. 

+

232 token_url_params (List[URLParam]): Optional token URL parameters. 

+

233 revocation_url (str): Optional OAuth token revocation URL. 

+

234 default_scopes (List[str]): Optional default OAuth scopes. 

+

235 default_redirect_url (str): Optional default redirect URL. 

+

236 callback_domain (str): Optional callback domain. 

+

237 pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. 

+

238 access_type (AccessType): Optional OAuth access type. 

+

239 prompt (List[PromptType]): Optional OAuth prompt parameters. 

+

240 

+

241 Return value (dict): 

+

242 Return dict in the format 

+

243 {"app": {"id": <id>, "name": <name>, "description": <description>, "logo": <logo>}} 

+

244 

+

245 Raise: 

+

246 AuthException: raised if update operation fails 

+

247 """ 

+

248 uri = MgmtV1.outbound_application_update_path 

+

249 response = await self._http.post( 

+

250 uri, 

+

251 body={ 

+

252 "app": OutboundApplicationBase._compose_create_update_body( 

+

253 name, 

+

254 description, 

+

255 logo, 

+

256 id, 

+

257 client_secret, 

+

258 client_id, 

+

259 discovery_url, 

+

260 authorization_url, 

+

261 authorization_url_params, 

+

262 token_url, 

+

263 token_url_params, 

+

264 revocation_url, 

+

265 default_scopes, 

+

266 default_redirect_url, 

+

267 callback_domain, 

+

268 pkce, 

+

269 access_type, 

+

270 prompt, 

+

271 ) 

+

272 }, 

+

273 ) 

+

274 return response.json() 

+

275 

+

276 async def delete_application( 

+

277 self, 

+

278 id: str, 

+

279 ): 

+

280 """ 

+

281 Delete an existing outbound application. IMPORTANT: This action is irreversible. Use carefully. 

+

282 

+

283 Args: 

+

284 id (str): The ID of the outbound application that's to be deleted. 

+

285 

+

286 Raise: 

+

287 AuthException: raised if deletion operation fails 

+

288 """ 

+

289 uri = MgmtV1.outbound_application_delete_path 

+

290 await self._http.post(uri, body={"id": id}) 

+

291 

+

292 async def load_application( 

+

293 self, 

+

294 id: str, 

+

295 ) -> dict: 

+

296 """ 

+

297 Load outbound application by id. 

+

298 

+

299 Args: 

+

300 id (str): The ID of the outbound application to load. 

+

301 

+

302 Return value (dict): 

+

303 Return dict in the format 

+

304 {"app": {"id": <id>, "name": <name>, "description": <description>, "logo": <logo>}} 

+

305 Containing the loaded outbound application information. 

+

306 

+

307 Raise: 

+

308 AuthException: raised if load operation fails 

+

309 """ 

+

310 response = await self._http.get(f"{MgmtV1.outbound_application_load_path}/{id}") 

+

311 return response.json() 

+

312 

+

313 async def load_all_applications(self) -> dict: 

+

314 """ 

+

315 Load all outbound applications. 

+

316 

+

317 Return value (dict): 

+

318 Return dict in the format 

+

319 {"apps": [{"id": <id>, "name": <name>, "description": <description>, "logo": <logo>}, ...]} 

+

320 Containing the loaded outbound applications information. 

+

321 

+

322 Raise: 

+

323 AuthException: raised if load operation fails 

+

324 """ 

+

325 response = await self._http.get(MgmtV1.outbound_application_load_all_path) 

+

326 return response.json() 

+

327 

+

328 async def fetch_token_by_scopes( 

+

329 self, 

+

330 app_id: str, 

+

331 user_id: str, 

+

332 scopes: List[str], 

+

333 options: Optional[dict] = None, 

+

334 tenant_id: Optional[str] = None, 

+

335 ) -> dict: 

+

336 """ 

+

337 Fetch an outbound application token for a user with specific scopes. 

+

338 

+

339 Args: 

+

340 app_id (str): The ID of the outbound application. 

+

341 user_id (str): The ID of the user. 

+

342 scopes (List[str]): List of scopes to include in the token. 

+

343 options (dict): Optional token options. 

+

344 tenant_id (str): Optional tenant ID. 

+

345 

+

346 Return value (dict): 

+

347 Return dict in the format 

+

348 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

349 

+

350 Raise: 

+

351 AuthException: raised if fetch operation fails 

+

352 """ 

+

353 return await _OutboundApplicationTokenFetcherAsync.fetch_token_by_scopes( 

+

354 http=self._http, 

+

355 app_id=app_id, 

+

356 user_id=user_id, 

+

357 scopes=scopes, 

+

358 options=options, 

+

359 tenant_id=tenant_id, 

+

360 ) 

+

361 

+

362 async def fetch_token( 

+

363 self, 

+

364 app_id: str, 

+

365 user_id: str, 

+

366 tenant_id: Optional[str] = None, 

+

367 options: Optional[dict] = None, 

+

368 ) -> dict: 

+

369 """ 

+

370 Fetch an outbound application token for a user. 

+

371 

+

372 Args: 

+

373 app_id (str): The ID of the outbound application. 

+

374 user_id (str): The ID of the user. 

+

375 tenant_id (str): Optional tenant ID. 

+

376 options (dict): Optional token options. 

+

377 

+

378 Return value (dict): 

+

379 Return dict in the format 

+

380 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

381 

+

382 Raise: 

+

383 AuthException: raised if fetch operation fails 

+

384 """ 

+

385 return await _OutboundApplicationTokenFetcherAsync.fetch_token( 

+

386 http=self._http, 

+

387 app_id=app_id, 

+

388 user_id=user_id, 

+

389 tenant_id=tenant_id, 

+

390 options=options, 

+

391 ) 

+

392 

+

393 async def fetch_tenant_token_by_scopes( 

+

394 self, 

+

395 app_id: str, 

+

396 tenant_id: str, 

+

397 scopes: List[str], 

+

398 options: Optional[dict] = None, 

+

399 ) -> dict: 

+

400 """ 

+

401 Fetch an outbound application token for a tenant with specific scopes. 

+

402 

+

403 Args: 

+

404 app_id (str): The ID of the outbound application. 

+

405 tenant_id (str): The ID of the tenant. 

+

406 scopes (List[str]): List of scopes to include in the token. 

+

407 options (dict): Optional token options. 

+

408 

+

409 Return value (dict): 

+

410 Return dict in the format 

+

411 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

412 

+

413 Raise: 

+

414 AuthException: raised if fetch operation fails 

+

415 """ 

+

416 return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token_by_scopes( 

+

417 http=self._http, 

+

418 app_id=app_id, 

+

419 tenant_id=tenant_id, 

+

420 scopes=scopes, 

+

421 options=options, 

+

422 ) 

+

423 

+

424 async def fetch_tenant_token( 

+

425 self, 

+

426 app_id: str, 

+

427 tenant_id: str, 

+

428 options: Optional[dict] = None, 

+

429 ) -> dict: 

+

430 """ 

+

431 Fetch an outbound application token for a tenant. 

+

432 

+

433 Args: 

+

434 app_id (str): The ID of the outbound application. 

+

435 tenant_id (str): The ID of the tenant. 

+

436 options (dict): Optional token options. 

+

437 

+

438 Return value (dict): 

+

439 Return dict in the format 

+

440 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

441 

+

442 Raise: 

+

443 AuthException: raised if fetch operation fails 

+

444 """ 

+

445 return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token( 

+

446 http=self._http, 

+

447 app_id=app_id, 

+

448 tenant_id=tenant_id, 

+

449 options=options, 

+

450 ) 

+

451 

+

452 async def delete_user_tokens( 

+

453 self, 

+

454 app_id: Optional[str] = None, 

+

455 user_id: Optional[str] = None, 

+

456 ): 

+

457 """ 

+

458 Delete outbound application tokens by app ID and/or user ID. 

+

459 At least one of app_id or user_id must be provided. 

+

460 

+

461 Args: 

+

462 app_id (str): Optional ID of the outbound application. 

+

463 user_id (str): Optional ID of the user. 

+

464 

+

465 Raise: 

+

466 AuthException: raised if delete operation fails 

+

467 """ 

+

468 params = {} 

+

469 if app_id: 

+

470 params["appId"] = app_id 

+

471 if user_id: 

+

472 params["userId"] = user_id 

+

473 uri = MgmtV1.outbound_application_delete_user_tokens_path 

+

474 await self._http.delete(uri, params=params) 

+

475 

+

476 async def delete_token( 

+

477 self, 

+

478 token_id: str, 

+

479 ): 

+

480 """ 

+

481 Delete an outbound application token by its ID. 

+

482 

+

483 Args: 

+

484 token_id (str): The ID of the token to delete. 

+

485 

+

486 Raise: 

+

487 AuthException: raised if delete operation fails 

+

488 """ 

+

489 uri = MgmtV1.outbound_application_delete_token_path 

+

490 await self._http.delete(uri, params={"id": token_id}) 

+

491 

+

492 async def list_apps_with_user_token( 

+

493 self, 

+

494 user_id: str, 

+

495 tenant_id: Optional[str] = None, 

+

496 ) -> dict: 

+

497 """ 

+

498 List the IDs of the outbound applications the given user currently holds a valid token for. 

+

499 Use this for connection-status views instead of calling fetch_token once per application. 

+

500 

+

501 Args: 

+

502 user_id (str): The ID of the user. 

+

503 tenant_id (str): Optional tenant ID to scope the lookup to. 

+

504 

+

505 Return value (dict): 

+

506 Return dict in the format 

+

507 {"appIds": [<app_id>, ...]} 

+

508 

+

509 Raise: 

+

510 AuthException: raised if the operation fails 

+

511 """ 

+

512 params = {"userId": user_id} 

+

513 if tenant_id: 

+

514 params["tenantId"] = tenant_id 

+

515 response = await self._http.get( 

+

516 MgmtV1.outbound_application_list_apps_with_user_token_path, 

+

517 params=params, 

+

518 ) 

+

519 return response.json() 

+

520 

+

521 async def upload_user_api_key( 

+

522 self, 

+

523 app_id: str, 

+

524 user_id: str, 

+

525 api_key: str, 

+

526 tenant_id: Optional[str] = None, 

+

527 ) -> dict: 

+

528 """ 

+

529 Upload (set) a static API key for a user on an apikey-type outbound application. 

+

530 

+

531 Args: 

+

532 app_id (str): The ID of the outbound application. 

+

533 user_id (str): The ID of the user. 

+

534 api_key (str): The API key to store for the user. 

+

535 tenant_id (str): Optional tenant ID, used to associate different keys for the 

+

536 same user in different tenants. 

+

537 

+

538 Raise: 

+

539 AuthException: raised if the operation fails 

+

540 """ 

+

541 body: dict = {"appId": app_id, "userId": user_id, "apiKey": api_key} 

+

542 if tenant_id is not None: 

+

543 body["tenantId"] = tenant_id 

+

544 response = await self._http.post( 

+

545 MgmtV1.outbound_application_upload_user_api_key_path, 

+

546 body=body, 

+

547 ) 

+

548 return response.json() 

+

549 

+

550 async def upload_tenant_api_key( 

+

551 self, 

+

552 app_id: str, 

+

553 tenant_id: str, 

+

554 api_key: str, 

+

555 ) -> dict: 

+

556 """ 

+

557 Upload (set) a static API key for a tenant on an apikey-type outbound application. 

+

558 

+

559 Args: 

+

560 app_id (str): The ID of the outbound application. 

+

561 tenant_id (str): The ID of the tenant. 

+

562 api_key (str): The API key to store for the tenant. 

+

563 

+

564 Raise: 

+

565 AuthException: raised if the operation fails 

+

566 """ 

+

567 response = await self._http.post( 

+

568 MgmtV1.outbound_application_upload_tenant_api_key_path, 

+

569 body={"appId": app_id, "tenantId": tenant_id, "apiKey": api_key}, 

+

570 ) 

+

571 return response.json() 

+

572 

+

573 async def upload_user_token( 

+

574 self, 

+

575 app_id: str, 

+

576 user_id: str, 

+

577 tenant_id: Optional[str] = None, 

+

578 refresh_token: Optional[str] = None, 

+

579 access_token: Optional[str] = None, 

+

580 access_token_expiry: Optional[int] = None, 

+

581 access_token_type: Optional[str] = None, 

+

582 scopes: Optional[List[str]] = None, 

+

583 external_identifier: Optional[str] = None, 

+

584 id_token: Optional[str] = None, 

+

585 verify_refresh: Optional[bool] = None, 

+

586 granted_by: Optional[str] = None, 

+

587 ) -> dict: 

+

588 """ 

+

589 Upload (migrate) an existing OAuth token for a user on an oauth-type outbound application, 

+

590 without requiring the user to re-run the OAuth flow. At least one of refresh_token or 

+

591 access_token must be provided. 

+

592 

+

593 Args: 

+

594 app_id (str): The ID of the outbound application. 

+

595 user_id (str): The ID of the user. 

+

596 tenant_id (str): Optional tenant ID. 

+

597 refresh_token (str): Optional refresh token. 

+

598 access_token (str): Optional access token. 

+

599 access_token_expiry (int): Optional access token expiry, in epoch seconds. 

+

600 access_token_type (str): Optional token type (defaults to "Bearer"). 

+

601 scopes (List[str]): Optional scopes the token was granted for. 

+

602 external_identifier (str): Optional external identifier. 

+

603 id_token (str): Optional id token. 

+

604 verify_refresh (bool): If True, verify the refresh token against the provider before 

+

605 persisting; nothing is written if verification fails. 

+

606 granted_by (str): Optional attribution for who granted the token. 

+

607 

+

608 Raise: 

+

609 AuthException: raised if the operation fails 

+

610 """ 

+

611 base: dict = {"appId": app_id, "userId": user_id} 

+

612 if tenant_id is not None: 

+

613 base["tenantId"] = tenant_id 

+

614 body = OutboundApplicationBase._compose_oauth_upload_body( 

+

615 base, 

+

616 refresh_token, 

+

617 access_token, 

+

618 access_token_expiry, 

+

619 access_token_type, 

+

620 scopes, 

+

621 external_identifier, 

+

622 id_token, 

+

623 granted_by, 

+

624 verify_refresh, 

+

625 ) 

+

626 response = await self._http.post( 

+

627 MgmtV1.outbound_application_upload_user_token_path, 

+

628 body=body, 

+

629 ) 

+

630 return response.json() 

+

631 

+

632 async def upload_tenant_token( 

+

633 self, 

+

634 app_id: str, 

+

635 tenant_id: str, 

+

636 refresh_token: Optional[str] = None, 

+

637 access_token: Optional[str] = None, 

+

638 access_token_expiry: Optional[int] = None, 

+

639 access_token_type: Optional[str] = None, 

+

640 scopes: Optional[List[str]] = None, 

+

641 external_identifier: Optional[str] = None, 

+

642 id_token: Optional[str] = None, 

+

643 verify_refresh: Optional[bool] = None, 

+

644 granted_by: Optional[str] = None, 

+

645 ) -> dict: 

+

646 """ 

+

647 Upload (migrate) an existing OAuth token for a tenant on an oauth-type outbound application. 

+

648 At least one of refresh_token or access_token must be provided. 

+

649 

+

650 Args: 

+

651 app_id (str): The ID of the outbound application. 

+

652 tenant_id (str): The ID of the tenant. 

+

653 (remaining args): see upload_user_token. 

+

654 

+

655 Raise: 

+

656 AuthException: raised if the operation fails 

+

657 """ 

+

658 base: dict = {"appId": app_id, "tenantId": tenant_id} 

+

659 body = OutboundApplicationBase._compose_oauth_upload_body( 

+

660 base, 

+

661 refresh_token, 

+

662 access_token, 

+

663 access_token_expiry, 

+

664 access_token_type, 

+

665 scopes, 

+

666 external_identifier, 

+

667 id_token, 

+

668 granted_by, 

+

669 verify_refresh, 

+

670 ) 

+

671 response = await self._http.post( 

+

672 MgmtV1.outbound_application_upload_tenant_token_path, 

+

673 body=body, 

+

674 ) 

+

675 return response.json() 

+

676 

+

677 async def batch_upload_user_tokens(self, tokens: List[dict]) -> dict: 

+

678 """ 

+

679 Batch upload (migrate) existing OAuth tokens for users. All-or-nothing: if any item fails 

+

680 per-item validation, the returned failures are populated and no tokens are committed. 

+

681 

+

682 Args: 

+

683 tokens (List[dict]): A list of token dicts, each with at least appId and userId plus one of 

+

684 refreshToken / accessToken. See upload_user_token for the full set of fields 

+

685 (verifyRefresh is not supported for batch uploads). 

+

686 

+

687 Return value (dict): 

+

688 Return dict in the format 

+

689 {"failures": [{"appId": <id>, "userId": <id>, "errorCode": <code>, "reason": <reason>}, ...]} 

+

690 

+

691 Raise: 

+

692 AuthException: raised if the operation fails 

+

693 """ 

+

694 response = await self._http.post( 

+

695 MgmtV1.outbound_application_batch_upload_user_tokens_path, 

+

696 body={"tokens": tokens}, 

+

697 ) 

+

698 return response.json() 

+

699 

+

700 async def batch_upload_tenant_tokens(self, tokens: List[dict]) -> dict: 

+

701 """ 

+

702 Batch upload (migrate) existing OAuth tokens for tenants. All-or-nothing: if any item fails 

+

703 per-item validation, the returned failures are populated and no tokens are committed. 

+

704 

+

705 Args: 

+

706 tokens (List[dict]): A list of token dicts, each with at least appId and tenantId plus one of 

+

707 refreshToken / accessToken. 

+

708 

+

709 Return value (dict): 

+

710 Return dict in the format 

+

711 {"failures": [{"appId": <id>, "tenantId": <id>, "errorCode": <code>, "reason": <reason>}, ...]} 

+

712 

+

713 Raise: 

+

714 AuthException: raised if the operation fails 

+

715 """ 

+

716 response = await self._http.post( 

+

717 MgmtV1.outbound_application_batch_upload_tenant_tokens_path, 

+

718 body={"tokens": tokens}, 

+

719 ) 

+

720 return response.json() 

+

721 

+

722 

+

723class OutboundApplicationByTokenAsync(AsyncHTTPBase): 

+

724 def __init__(self, http_client: HTTPClientAsync): 

+

725 # This class expects the token to be passed for each call 

+

726 no_key_client = HTTPClientAsync( 

+

727 project_id=http_client.project_id, 

+

728 base_url=http_client.base_url, 

+

729 timeout_seconds=http_client.timeout_seconds, 

+

730 secure=http_client.secure, 

+

731 management_key=None, # Override the management key for this client 

+

732 verbose=http_client.verbose, 

+

733 last_response_store=http_client.last_response_store, 

+

734 ) 

+

735 super().__init__(no_key_client) 

+

736 

+

737 async def aclose(self) -> None: 

+

738 """Close the dedicated no-management-key HTTP client this instance owns.""" 

+

739 await self._http.aclose() 

+

740 

+

741 # Methods for fetching outbound application tokens using an inbound application token 

+

742 # that includes the "outbound.token.fetch" scope (no management key required) 

+

743 

+

744 def _check_inbound_app_token(self, token: str): 

+

745 """Check if inbound app token is available for the given property.""" 

+

746 if not token: 

+

747 raise AuthException( 

+

748 400, 

+

749 ERROR_TYPE_INVALID_ARGUMENT, 

+

750 "Inbound app token is required for perform this functionality", 

+

751 ) 

+

752 

+

753 async def fetch_token_by_scopes( 

+

754 self, 

+

755 token: str, 

+

756 app_id: str, 

+

757 user_id: str, 

+

758 scopes: List[str], 

+

759 options: Optional[dict] = None, 

+

760 tenant_id: Optional[str] = None, 

+

761 ) -> dict: 

+

762 """ 

+

763 Fetch an outbound application token for a user with specific scopes. 

+

764 

+

765 Args: 

+

766 token (str): The Inbound Application token to use for authentication. 

+

767 app_id (str): The ID of the outbound application. 

+

768 user_id (str): The ID of the user. 

+

769 scopes (List[str]): List of scopes to include in the token. 

+

770 options (dict): Optional token options. 

+

771 tenant_id (str): Optional tenant ID. 

+

772 

+

773 Return value (dict): 

+

774 Return dict in the format 

+

775 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

776 

+

777 Raise: 

+

778 AuthException: raised if fetch operation fails 

+

779 """ 

+

780 self._check_inbound_app_token(token) 

+

781 return await _OutboundApplicationTokenFetcherAsync.fetch_token_by_scopes( 

+

782 http=self._http, 

+

783 token=token, 

+

784 app_id=app_id, 

+

785 user_id=user_id, 

+

786 scopes=scopes, 

+

787 options=options, 

+

788 tenant_id=tenant_id, 

+

789 ) 

+

790 

+

791 async def fetch_token( 

+

792 self, 

+

793 token: str, 

+

794 app_id: str, 

+

795 user_id: str, 

+

796 tenant_id: Optional[str] = None, 

+

797 options: Optional[dict] = None, 

+

798 ) -> dict: 

+

799 """ 

+

800 Fetch an outbound application token for a user. 

+

801 

+

802 Args: 

+

803 token (str): The Inbound Application token to use for authentication. 

+

804 app_id (str): The ID of the outbound application. 

+

805 user_id (str): The ID of the user. 

+

806 tenant_id (str): Optional tenant ID. 

+

807 options (dict): Optional token options. 

+

808 

+

809 Return value (dict): 

+

810 Return dict in the format 

+

811 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

812 

+

813 Raise: 

+

814 AuthException: raised if fetch operation fails 

+

815 """ 

+

816 self._check_inbound_app_token(token) 

+

817 return await _OutboundApplicationTokenFetcherAsync.fetch_token( 

+

818 http=self._http, 

+

819 token=token, 

+

820 app_id=app_id, 

+

821 user_id=user_id, 

+

822 tenant_id=tenant_id, 

+

823 options=options, 

+

824 ) 

+

825 

+

826 async def fetch_tenant_token_by_scopes( 

+

827 self, 

+

828 token: str, 

+

829 app_id: str, 

+

830 tenant_id: str, 

+

831 scopes: List[str], 

+

832 options: Optional[dict] = None, 

+

833 ) -> dict: 

+

834 """ 

+

835 Fetch an outbound application token for a tenant with specific scopes. 

+

836 

+

837 Args: 

+

838 token (str): The Inbound Application token to use for authentication. 

+

839 app_id (str): The ID of the outbound application. 

+

840 tenant_id (str): The ID of the tenant. 

+

841 scopes (List[str]): List of scopes to include in the token. 

+

842 options (dict): Optional token options. 

+

843 

+

844 Return value (dict): 

+

845 Return dict in the format 

+

846 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

847 

+

848 Raise: 

+

849 AuthException: raised if fetch operation fails 

+

850 """ 

+

851 self._check_inbound_app_token(token) 

+

852 return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token_by_scopes( 

+

853 http=self._http, 

+

854 token=token, 

+

855 app_id=app_id, 

+

856 tenant_id=tenant_id, 

+

857 scopes=scopes, 

+

858 options=options, 

+

859 ) 

+

860 

+

861 async def fetch_tenant_token( 

+

862 self, 

+

863 token: str, 

+

864 app_id: str, 

+

865 tenant_id: str, 

+

866 options: Optional[dict] = None, 

+

867 ) -> dict: 

+

868 """ 

+

869 Fetch an outbound application token for a tenant. 

+

870 

+

871 Args: 

+

872 token (str): The Inbound Application token to use for authentication. 

+

873 app_id (str): The ID of the outbound application. 

+

874 tenant_id (str): The ID of the tenant. 

+

875 options (dict): Optional token options. 

+

876 

+

877 Return value (dict): 

+

878 Return dict in the format 

+

879 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

880 

+

881 Raise: 

+

882 AuthException: raised if fetch operation fails 

+

883 """ 

+

884 self._check_inbound_app_token(token) 

+

885 return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token( 

+

886 http=self._http, 

+

887 token=token, 

+

888 app_id=app_id, 

+

889 tenant_id=tenant_id, 

+

890 options=options, 

+

891 ) 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_outbound_application_py.html b/htmlcov/d_f54762f9f1d5aa4d_outbound_application_py.html new file mode 100644 index 000000000..1586019ae --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_outbound_application_py.html @@ -0,0 +1,978 @@ + + + + + Coverage for descope/management/outbound_application.py: 99% + + + + + +
+
+

+ Coverage for descope/management/outbound_application.py: + 99% +

+ +

+ 117 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import List, Optional 

+

4 

+

5from descope._http_base import HTTPBase 

+

6from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 

+

7from descope.http_client import HTTPClient 

+

8from descope.management._outbound_application_base import OutboundApplicationBase 

+

9from descope.management.common import ( 

+

10 AccessType, 

+

11 MgmtV1, 

+

12 PromptType, 

+

13 URLParam, 

+

14) 

+

15 

+

16 

+

17class _OutboundApplicationTokenFetcher: 

+

18 """Internal helper class for shared token fetching logic.""" 

+

19 

+

20 @staticmethod 

+

21 def fetch_token_by_scopes( 

+

22 *, 

+

23 http: HTTPClient, 

+

24 token: Optional[str] = None, 

+

25 app_id: str, 

+

26 user_id: str, 

+

27 scopes: List[str], 

+

28 options: Optional[dict] = None, 

+

29 tenant_id: Optional[str] = None, 

+

30 ) -> dict: 

+

31 """Internal implementation for fetching token by scopes.""" 

+

32 uri = MgmtV1.outbound_application_fetch_token_by_scopes_path 

+

33 response = http.post( 

+

34 uri, 

+

35 body={ 

+

36 "appId": app_id, 

+

37 "userId": user_id, 

+

38 "scopes": scopes, 

+

39 "options": options, 

+

40 "tenantId": tenant_id, 

+

41 }, 

+

42 pswd=token, 

+

43 ) 

+

44 return response.json() 

+

45 

+

46 @staticmethod 

+

47 def fetch_token( 

+

48 *, 

+

49 http: HTTPClient, 

+

50 token: Optional[str] = None, 

+

51 app_id: str, 

+

52 user_id: str, 

+

53 tenant_id: Optional[str] = None, 

+

54 options: Optional[dict] = None, 

+

55 ) -> dict: 

+

56 """Internal implementation for fetching token.""" 

+

57 uri = MgmtV1.outbound_application_fetch_token_path 

+

58 response = http.post( 

+

59 uri, 

+

60 body={ 

+

61 "appId": app_id, 

+

62 "userId": user_id, 

+

63 "tenantId": tenant_id, 

+

64 "options": options, 

+

65 }, 

+

66 pswd=token, 

+

67 ) 

+

68 return response.json() 

+

69 

+

70 @staticmethod 

+

71 def fetch_tenant_token_by_scopes( 

+

72 *, 

+

73 http: HTTPClient, 

+

74 token: Optional[str] = None, 

+

75 app_id: str, 

+

76 tenant_id: str, 

+

77 scopes: List[str], 

+

78 options: Optional[dict] = None, 

+

79 ) -> dict: 

+

80 """Internal implementation for fetching tenant token by scopes.""" 

+

81 uri = MgmtV1.outbound_application_fetch_tenant_token_by_scopes_path 

+

82 response = http.post( 

+

83 uri, 

+

84 body={ 

+

85 "appId": app_id, 

+

86 "tenantId": tenant_id, 

+

87 "scopes": scopes, 

+

88 "options": options, 

+

89 }, 

+

90 pswd=token, 

+

91 ) 

+

92 return response.json() 

+

93 

+

94 @staticmethod 

+

95 def fetch_tenant_token( 

+

96 *, 

+

97 http: HTTPClient, 

+

98 token: Optional[str] = None, 

+

99 app_id: str, 

+

100 tenant_id: str, 

+

101 options: Optional[dict] = None, 

+

102 ) -> dict: 

+

103 """Internal implementation for fetching tenant token.""" 

+

104 uri = MgmtV1.outbound_application_fetch_tenant_token_path 

+

105 response = http.post( 

+

106 uri, 

+

107 body={ 

+

108 "appId": app_id, 

+

109 "tenantId": tenant_id, 

+

110 "options": options, 

+

111 }, 

+

112 pswd=token, 

+

113 ) 

+

114 return response.json() 

+

115 

+

116 

+

117class OutboundApplication(OutboundApplicationBase, HTTPBase): 

+

118 def create_application( 

+

119 self, 

+

120 name: str, 

+

121 description: Optional[str] = None, 

+

122 logo: Optional[str] = None, 

+

123 id: Optional[str] = None, 

+

124 client_secret: Optional[str] = None, 

+

125 client_id: Optional[str] = None, 

+

126 discovery_url: Optional[str] = None, 

+

127 authorization_url: Optional[str] = None, 

+

128 authorization_url_params: Optional[List[URLParam]] = None, 

+

129 token_url: Optional[str] = None, 

+

130 token_url_params: Optional[List[URLParam]] = None, 

+

131 revocation_url: Optional[str] = None, 

+

132 default_scopes: Optional[List[str]] = None, 

+

133 default_redirect_url: Optional[str] = None, 

+

134 callback_domain: Optional[str] = None, 

+

135 pkce: Optional[bool] = None, 

+

136 access_type: Optional[AccessType] = None, 

+

137 prompt: Optional[List[PromptType]] = None, 

+

138 ) -> dict: 

+

139 """ 

+

140 Create a new outbound application with the given name. Outbound application IDs are provisioned automatically, but can be provided 

+

141 explicitly if needed. Both the name and ID must be unique per project. 

+

142 

+

143 Args: 

+

144 name (str): The outbound application's name. 

+

145 description (str): Optional outbound application description. 

+

146 logo (str): Optional outbound application logo. 

+

147 id (str): Optional outbound application ID. 

+

148 client_secret (str): Optional client secret for the application. 

+

149 client_id (str): Optional client ID for the application. 

+

150 discovery_url (str): Optional OAuth discovery URL. 

+

151 authorization_url (str): Optional OAuth authorization URL. 

+

152 authorization_url_params (List[URLParam]): Optional authorization URL parameters. 

+

153 token_url (str): Optional OAuth token URL. 

+

154 token_url_params (List[URLParam]): Optional token URL parameters. 

+

155 revocation_url (str): Optional OAuth token revocation URL. 

+

156 default_scopes (List[str]): Optional default OAuth scopes. 

+

157 default_redirect_url (str): Optional default redirect URL. 

+

158 callback_domain (str): Optional callback domain. 

+

159 pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. 

+

160 access_type (AccessType): Optional OAuth access type. 

+

161 prompt (List[PromptType]): Optional OAuth prompt parameters. 

+

162 

+

163 Return value (dict): 

+

164 Return dict in the format 

+

165 {"app": {"id": <id>, "name": <name>, "description": <description>, "logo": <logo>}} 

+

166 

+

167 Raise: 

+

168 AuthException: raised if create operation fails 

+

169 """ 

+

170 uri = MgmtV1.outbound_application_create_path 

+

171 response = self._http.post( 

+

172 uri, 

+

173 body=OutboundApplicationBase._compose_create_update_body( 

+

174 name, 

+

175 description, 

+

176 logo, 

+

177 id, 

+

178 client_secret, 

+

179 client_id, 

+

180 discovery_url, 

+

181 authorization_url, 

+

182 authorization_url_params, 

+

183 token_url, 

+

184 token_url_params, 

+

185 revocation_url, 

+

186 default_scopes, 

+

187 default_redirect_url, 

+

188 callback_domain, 

+

189 pkce, 

+

190 access_type, 

+

191 prompt, 

+

192 ), 

+

193 ) 

+

194 return response.json() 

+

195 

+

196 def update_application( 

+

197 self, 

+

198 id: str, 

+

199 name: str, 

+

200 description: Optional[str] = None, 

+

201 logo: Optional[str] = None, 

+

202 client_secret: Optional[str] = None, 

+

203 client_id: Optional[str] = None, 

+

204 discovery_url: Optional[str] = None, 

+

205 authorization_url: Optional[str] = None, 

+

206 authorization_url_params: Optional[List[URLParam]] = None, 

+

207 token_url: Optional[str] = None, 

+

208 token_url_params: Optional[List[URLParam]] = None, 

+

209 revocation_url: Optional[str] = None, 

+

210 default_scopes: Optional[List[str]] = None, 

+

211 default_redirect_url: Optional[str] = None, 

+

212 callback_domain: Optional[str] = None, 

+

213 pkce: Optional[bool] = None, 

+

214 access_type: Optional[AccessType] = None, 

+

215 prompt: Optional[List[PromptType]] = None, 

+

216 ) -> dict: 

+

217 """ 

+

218 Update an existing outbound application with the given parameters. IMPORTANT: All parameters are used as overrides 

+

219 to the existing outbound application. Empty fields will override populated fields. Use carefully. 

+

220 

+

221 Args: 

+

222 id (str): The ID of the outbound application to update. 

+

223 name (str): Updated outbound application name. 

+

224 description (str): Optional outbound application description. 

+

225 logo (str): Optional outbound application logo. 

+

226 client_secret (str): Optional client secret for the application. 

+

227 client_id (str): Optional client ID for the application. 

+

228 discovery_url (str): Optional OAuth discovery URL. 

+

229 authorization_url (str): Optional OAuth authorization URL. 

+

230 authorization_url_params (List[URLParam]): Optional authorization URL parameters. 

+

231 token_url (str): Optional OAuth token URL. 

+

232 token_url_params (List[URLParam]): Optional token URL parameters. 

+

233 revocation_url (str): Optional OAuth token revocation URL. 

+

234 default_scopes (List[str]): Optional default OAuth scopes. 

+

235 default_redirect_url (str): Optional default redirect URL. 

+

236 callback_domain (str): Optional callback domain. 

+

237 pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. 

+

238 access_type (AccessType): Optional OAuth access type. 

+

239 prompt (List[PromptType]): Optional OAuth prompt parameters. 

+

240 

+

241 Return value (dict): 

+

242 Return dict in the format 

+

243 {"app": {"id": <id>, "name": <name>, "description": <description>, "logo": <logo>}} 

+

244 

+

245 Raise: 

+

246 AuthException: raised if update operation fails 

+

247 """ 

+

248 uri = MgmtV1.outbound_application_update_path 

+

249 response = self._http.post( 

+

250 uri, 

+

251 body={ 

+

252 "app": OutboundApplicationBase._compose_create_update_body( 

+

253 name, 

+

254 description, 

+

255 logo, 

+

256 id, 

+

257 client_secret, 

+

258 client_id, 

+

259 discovery_url, 

+

260 authorization_url, 

+

261 authorization_url_params, 

+

262 token_url, 

+

263 token_url_params, 

+

264 revocation_url, 

+

265 default_scopes, 

+

266 default_redirect_url, 

+

267 callback_domain, 

+

268 pkce, 

+

269 access_type, 

+

270 prompt, 

+

271 ) 

+

272 }, 

+

273 ) 

+

274 return response.json() 

+

275 

+

276 def delete_application( 

+

277 self, 

+

278 id: str, 

+

279 ): 

+

280 """ 

+

281 Delete an existing outbound application. IMPORTANT: This action is irreversible. Use carefully. 

+

282 

+

283 Args: 

+

284 id (str): The ID of the outbound application that's to be deleted. 

+

285 

+

286 Raise: 

+

287 AuthException: raised if deletion operation fails 

+

288 """ 

+

289 uri = MgmtV1.outbound_application_delete_path 

+

290 self._http.post(uri, body={"id": id}) 

+

291 

+

292 def load_application( 

+

293 self, 

+

294 id: str, 

+

295 ) -> dict: 

+

296 """ 

+

297 Load outbound application by id. 

+

298 

+

299 Args: 

+

300 id (str): The ID of the outbound application to load. 

+

301 

+

302 Return value (dict): 

+

303 Return dict in the format 

+

304 {"app": {"id": <id>, "name": <name>, "description": <description>, "logo": <logo>}} 

+

305 Containing the loaded outbound application information. 

+

306 

+

307 Raise: 

+

308 AuthException: raised if load operation fails 

+

309 """ 

+

310 response = self._http.get(f"{MgmtV1.outbound_application_load_path}/{id}") 

+

311 return response.json() 

+

312 

+

313 def load_all_applications(self) -> dict: 

+

314 """ 

+

315 Load all outbound applications. 

+

316 

+

317 Return value (dict): 

+

318 Return dict in the format 

+

319 {"apps": [{"id": <id>, "name": <name>, "description": <description>, "logo": <logo>}, ...]} 

+

320 Containing the loaded outbound applications information. 

+

321 

+

322 Raise: 

+

323 AuthException: raised if load operation fails 

+

324 """ 

+

325 response = self._http.get(MgmtV1.outbound_application_load_all_path) 

+

326 return response.json() 

+

327 

+

328 def fetch_token_by_scopes( 

+

329 self, 

+

330 app_id: str, 

+

331 user_id: str, 

+

332 scopes: List[str], 

+

333 options: Optional[dict] = None, 

+

334 tenant_id: Optional[str] = None, 

+

335 ) -> dict: 

+

336 """ 

+

337 Fetch an outbound application token for a user with specific scopes. 

+

338 

+

339 Args: 

+

340 app_id (str): The ID of the outbound application. 

+

341 user_id (str): The ID of the user. 

+

342 scopes (List[str]): List of scopes to include in the token. 

+

343 options (dict): Optional token options. 

+

344 tenant_id (str): Optional tenant ID. 

+

345 

+

346 Return value (dict): 

+

347 Return dict in the format 

+

348 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

349 

+

350 Raise: 

+

351 AuthException: raised if fetch operation fails 

+

352 """ 

+

353 return _OutboundApplicationTokenFetcher.fetch_token_by_scopes( 

+

354 http=self._http, 

+

355 app_id=app_id, 

+

356 user_id=user_id, 

+

357 scopes=scopes, 

+

358 options=options, 

+

359 tenant_id=tenant_id, 

+

360 ) 

+

361 

+

362 def fetch_token( 

+

363 self, 

+

364 app_id: str, 

+

365 user_id: str, 

+

366 tenant_id: Optional[str] = None, 

+

367 options: Optional[dict] = None, 

+

368 ) -> dict: 

+

369 """ 

+

370 Fetch an outbound application token for a user. 

+

371 

+

372 Args: 

+

373 app_id (str): The ID of the outbound application. 

+

374 user_id (str): The ID of the user. 

+

375 tenant_id (str): Optional tenant ID. 

+

376 options (dict): Optional token options. 

+

377 

+

378 Return value (dict): 

+

379 Return dict in the format 

+

380 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

381 

+

382 Raise: 

+

383 AuthException: raised if fetch operation fails 

+

384 """ 

+

385 return _OutboundApplicationTokenFetcher.fetch_token( 

+

386 http=self._http, 

+

387 app_id=app_id, 

+

388 user_id=user_id, 

+

389 tenant_id=tenant_id, 

+

390 options=options, 

+

391 ) 

+

392 

+

393 def fetch_tenant_token_by_scopes( 

+

394 self, 

+

395 app_id: str, 

+

396 tenant_id: str, 

+

397 scopes: List[str], 

+

398 options: Optional[dict] = None, 

+

399 ) -> dict: 

+

400 """ 

+

401 Fetch an outbound application token for a tenant with specific scopes. 

+

402 

+

403 Args: 

+

404 app_id (str): The ID of the outbound application. 

+

405 tenant_id (str): The ID of the tenant. 

+

406 scopes (List[str]): List of scopes to include in the token. 

+

407 options (dict): Optional token options. 

+

408 

+

409 Return value (dict): 

+

410 Return dict in the format 

+

411 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

412 

+

413 Raise: 

+

414 AuthException: raised if fetch operation fails 

+

415 """ 

+

416 return _OutboundApplicationTokenFetcher.fetch_tenant_token_by_scopes( 

+

417 http=self._http, 

+

418 app_id=app_id, 

+

419 tenant_id=tenant_id, 

+

420 scopes=scopes, 

+

421 options=options, 

+

422 ) 

+

423 

+

424 def fetch_tenant_token( 

+

425 self, 

+

426 app_id: str, 

+

427 tenant_id: str, 

+

428 options: Optional[dict] = None, 

+

429 ) -> dict: 

+

430 """ 

+

431 Fetch an outbound application token for a tenant. 

+

432 

+

433 Args: 

+

434 app_id (str): The ID of the outbound application. 

+

435 tenant_id (str): The ID of the tenant. 

+

436 options (dict): Optional token options. 

+

437 

+

438 Return value (dict): 

+

439 Return dict in the format 

+

440 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

441 

+

442 Raise: 

+

443 AuthException: raised if fetch operation fails 

+

444 """ 

+

445 return _OutboundApplicationTokenFetcher.fetch_tenant_token( 

+

446 http=self._http, 

+

447 app_id=app_id, 

+

448 tenant_id=tenant_id, 

+

449 options=options, 

+

450 ) 

+

451 

+

452 def delete_user_tokens( 

+

453 self, 

+

454 app_id: Optional[str] = None, 

+

455 user_id: Optional[str] = None, 

+

456 ): 

+

457 """ 

+

458 Delete outbound application tokens by app ID and/or user ID. 

+

459 At least one of app_id or user_id must be provided. 

+

460 

+

461 Args: 

+

462 app_id (str): Optional ID of the outbound application. 

+

463 user_id (str): Optional ID of the user. 

+

464 

+

465 Raise: 

+

466 AuthException: raised if delete operation fails 

+

467 """ 

+

468 params = {} 

+

469 if app_id: 

+

470 params["appId"] = app_id 

+

471 if user_id: 

+

472 params["userId"] = user_id 

+

473 uri = MgmtV1.outbound_application_delete_user_tokens_path 

+

474 self._http.delete(uri, params=params) 

+

475 

+

476 def delete_token( 

+

477 self, 

+

478 token_id: str, 

+

479 ): 

+

480 """ 

+

481 Delete an outbound application token by its ID. 

+

482 

+

483 Args: 

+

484 token_id (str): The ID of the token to delete. 

+

485 

+

486 Raise: 

+

487 AuthException: raised if delete operation fails 

+

488 """ 

+

489 uri = MgmtV1.outbound_application_delete_token_path 

+

490 self._http.delete(uri, params={"id": token_id}) 

+

491 

+

492 def list_apps_with_user_token( 

+

493 self, 

+

494 user_id: str, 

+

495 tenant_id: Optional[str] = None, 

+

496 ) -> dict: 

+

497 """ 

+

498 List the IDs of the outbound applications the given user currently holds a valid token for. 

+

499 Use this for connection-status views instead of calling fetch_token once per application. 

+

500 

+

501 Args: 

+

502 user_id (str): The ID of the user. 

+

503 tenant_id (str): Optional tenant ID to scope the lookup to. 

+

504 

+

505 Return value (dict): 

+

506 Return dict in the format 

+

507 {"appIds": [<app_id>, ...]} 

+

508 

+

509 Raise: 

+

510 AuthException: raised if the operation fails 

+

511 """ 

+

512 params = {"userId": user_id} 

+

513 if tenant_id: 

+

514 params["tenantId"] = tenant_id 

+

515 response = self._http.get( 

+

516 MgmtV1.outbound_application_list_apps_with_user_token_path, 

+

517 params=params, 

+

518 ) 

+

519 return response.json() 

+

520 

+

521 def upload_user_api_key( 

+

522 self, 

+

523 app_id: str, 

+

524 user_id: str, 

+

525 api_key: str, 

+

526 tenant_id: Optional[str] = None, 

+

527 ) -> dict: 

+

528 """ 

+

529 Upload (set) a static API key for a user on an apikey-type outbound application. 

+

530 

+

531 Args: 

+

532 app_id (str): The ID of the outbound application. 

+

533 user_id (str): The ID of the user. 

+

534 api_key (str): The API key to store for the user. 

+

535 tenant_id (str): Optional tenant ID, used to associate different keys for the 

+

536 same user in different tenants. 

+

537 

+

538 Raise: 

+

539 AuthException: raised if the operation fails 

+

540 """ 

+

541 body: dict = {"appId": app_id, "userId": user_id, "apiKey": api_key} 

+

542 if tenant_id is not None: 

+

543 body["tenantId"] = tenant_id 

+

544 response = self._http.post( 

+

545 MgmtV1.outbound_application_upload_user_api_key_path, 

+

546 body=body, 

+

547 ) 

+

548 return response.json() 

+

549 

+

550 def upload_tenant_api_key( 

+

551 self, 

+

552 app_id: str, 

+

553 tenant_id: str, 

+

554 api_key: str, 

+

555 ) -> dict: 

+

556 """ 

+

557 Upload (set) a static API key for a tenant on an apikey-type outbound application. 

+

558 

+

559 Args: 

+

560 app_id (str): The ID of the outbound application. 

+

561 tenant_id (str): The ID of the tenant. 

+

562 api_key (str): The API key to store for the tenant. 

+

563 

+

564 Raise: 

+

565 AuthException: raised if the operation fails 

+

566 """ 

+

567 response = self._http.post( 

+

568 MgmtV1.outbound_application_upload_tenant_api_key_path, 

+

569 body={"appId": app_id, "tenantId": tenant_id, "apiKey": api_key}, 

+

570 ) 

+

571 return response.json() 

+

572 

+

573 def upload_user_token( 

+

574 self, 

+

575 app_id: str, 

+

576 user_id: str, 

+

577 tenant_id: Optional[str] = None, 

+

578 refresh_token: Optional[str] = None, 

+

579 access_token: Optional[str] = None, 

+

580 access_token_expiry: Optional[int] = None, 

+

581 access_token_type: Optional[str] = None, 

+

582 scopes: Optional[List[str]] = None, 

+

583 external_identifier: Optional[str] = None, 

+

584 id_token: Optional[str] = None, 

+

585 verify_refresh: Optional[bool] = None, 

+

586 granted_by: Optional[str] = None, 

+

587 ) -> dict: 

+

588 """ 

+

589 Upload (migrate) an existing OAuth token for a user on an oauth-type outbound application, 

+

590 without requiring the user to re-run the OAuth flow. At least one of refresh_token or 

+

591 access_token must be provided. 

+

592 

+

593 Args: 

+

594 app_id (str): The ID of the outbound application. 

+

595 user_id (str): The ID of the user. 

+

596 tenant_id (str): Optional tenant ID. 

+

597 refresh_token (str): Optional refresh token. 

+

598 access_token (str): Optional access token. 

+

599 access_token_expiry (int): Optional access token expiry, in epoch seconds. 

+

600 access_token_type (str): Optional token type (defaults to "Bearer"). 

+

601 scopes (List[str]): Optional scopes the token was granted for. 

+

602 external_identifier (str): Optional external identifier. 

+

603 id_token (str): Optional id token. 

+

604 verify_refresh (bool): If True, verify the refresh token against the provider before 

+

605 persisting; nothing is written if verification fails. 

+

606 granted_by (str): Optional attribution for who granted the token. 

+

607 

+

608 Raise: 

+

609 AuthException: raised if the operation fails 

+

610 """ 

+

611 base: dict = {"appId": app_id, "userId": user_id} 

+

612 if tenant_id is not None: 

+

613 base["tenantId"] = tenant_id 

+

614 body = OutboundApplicationBase._compose_oauth_upload_body( 

+

615 base, 

+

616 refresh_token, 

+

617 access_token, 

+

618 access_token_expiry, 

+

619 access_token_type, 

+

620 scopes, 

+

621 external_identifier, 

+

622 id_token, 

+

623 granted_by, 

+

624 verify_refresh, 

+

625 ) 

+

626 response = self._http.post( 

+

627 MgmtV1.outbound_application_upload_user_token_path, 

+

628 body=body, 

+

629 ) 

+

630 return response.json() 

+

631 

+

632 def upload_tenant_token( 

+

633 self, 

+

634 app_id: str, 

+

635 tenant_id: str, 

+

636 refresh_token: Optional[str] = None, 

+

637 access_token: Optional[str] = None, 

+

638 access_token_expiry: Optional[int] = None, 

+

639 access_token_type: Optional[str] = None, 

+

640 scopes: Optional[List[str]] = None, 

+

641 external_identifier: Optional[str] = None, 

+

642 id_token: Optional[str] = None, 

+

643 verify_refresh: Optional[bool] = None, 

+

644 granted_by: Optional[str] = None, 

+

645 ) -> dict: 

+

646 """ 

+

647 Upload (migrate) an existing OAuth token for a tenant on an oauth-type outbound application. 

+

648 At least one of refresh_token or access_token must be provided. 

+

649 

+

650 Args: 

+

651 app_id (str): The ID of the outbound application. 

+

652 tenant_id (str): The ID of the tenant. 

+

653 (remaining args): see upload_user_token. 

+

654 

+

655 Raise: 

+

656 AuthException: raised if the operation fails 

+

657 """ 

+

658 base: dict = {"appId": app_id, "tenantId": tenant_id} 

+

659 body = OutboundApplicationBase._compose_oauth_upload_body( 

+

660 base, 

+

661 refresh_token, 

+

662 access_token, 

+

663 access_token_expiry, 

+

664 access_token_type, 

+

665 scopes, 

+

666 external_identifier, 

+

667 id_token, 

+

668 granted_by, 

+

669 verify_refresh, 

+

670 ) 

+

671 response = self._http.post( 

+

672 MgmtV1.outbound_application_upload_tenant_token_path, 

+

673 body=body, 

+

674 ) 

+

675 return response.json() 

+

676 

+

677 def batch_upload_user_tokens(self, tokens: List[dict]) -> dict: 

+

678 """ 

+

679 Batch upload (migrate) existing OAuth tokens for users. All-or-nothing: if any item fails 

+

680 per-item validation, the returned failures are populated and no tokens are committed. 

+

681 

+

682 Args: 

+

683 tokens (List[dict]): A list of token dicts, each with at least appId and userId plus one of 

+

684 refreshToken / accessToken. See upload_user_token for the full set of fields 

+

685 (verifyRefresh is not supported for batch uploads). 

+

686 

+

687 Return value (dict): 

+

688 Return dict in the format 

+

689 {"failures": [{"appId": <id>, "userId": <id>, "errorCode": <code>, "reason": <reason>}, ...]} 

+

690 

+

691 Raise: 

+

692 AuthException: raised if the operation fails 

+

693 """ 

+

694 response = self._http.post( 

+

695 MgmtV1.outbound_application_batch_upload_user_tokens_path, 

+

696 body={"tokens": tokens}, 

+

697 ) 

+

698 return response.json() 

+

699 

+

700 def batch_upload_tenant_tokens(self, tokens: List[dict]) -> dict: 

+

701 """ 

+

702 Batch upload (migrate) existing OAuth tokens for tenants. All-or-nothing: if any item fails 

+

703 per-item validation, the returned failures are populated and no tokens are committed. 

+

704 

+

705 Args: 

+

706 tokens (List[dict]): A list of token dicts, each with at least appId and tenantId plus one of 

+

707 refreshToken / accessToken. 

+

708 

+

709 Return value (dict): 

+

710 Return dict in the format 

+

711 {"failures": [{"appId": <id>, "tenantId": <id>, "errorCode": <code>, "reason": <reason>}, ...]} 

+

712 

+

713 Raise: 

+

714 AuthException: raised if the operation fails 

+

715 """ 

+

716 response = self._http.post( 

+

717 MgmtV1.outbound_application_batch_upload_tenant_tokens_path, 

+

718 body={"tokens": tokens}, 

+

719 ) 

+

720 return response.json() 

+

721 

+

722 

+

723class OutboundApplicationByToken(HTTPBase): 

+

724 def __init__(self, http_client: HTTPClient): 

+

725 # This class expects the token to be passed for each call 

+

726 no_key_client = HTTPClient( 

+

727 project_id=http_client.project_id, 

+

728 base_url=http_client.base_url, 

+

729 timeout_seconds=http_client.timeout_seconds, 

+

730 secure=http_client.secure, 

+

731 management_key=None, # Override the management key for this client 

+

732 verbose=http_client.verbose, 

+

733 last_response_store=http_client.last_response_store, 

+

734 ) 

+

735 super().__init__(no_key_client) 

+

736 

+

737 # Methods for fetching outbound application tokens using an inbound application token 

+

738 # that includes the "outbound.token.fetch" scope (no management key required) 

+

739 

+

740 def _check_inbound_app_token(self, token: str): 

+

741 """Check if inbound app token is available for the given property.""" 

+

742 if not token: 

+

743 raise AuthException( 

+

744 400, 

+

745 ERROR_TYPE_INVALID_ARGUMENT, 

+

746 "Inbound app token is required for perform this functionality", 

+

747 ) 

+

748 

+

749 def fetch_token_by_scopes( 

+

750 self, 

+

751 token: str, 

+

752 app_id: str, 

+

753 user_id: str, 

+

754 scopes: List[str], 

+

755 options: Optional[dict] = None, 

+

756 tenant_id: Optional[str] = None, 

+

757 ) -> dict: 

+

758 """ 

+

759 Fetch an outbound application token for a user with specific scopes. 

+

760 

+

761 Args: 

+

762 token (str): The Inbound Application token to use for authentication. 

+

763 app_id (str): The ID of the outbound application. 

+

764 user_id (str): The ID of the user. 

+

765 scopes (List[str]): List of scopes to include in the token. 

+

766 options (dict): Optional token options. 

+

767 tenant_id (str): Optional tenant ID. 

+

768 

+

769 Return value (dict): 

+

770 Return dict in the format 

+

771 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

772 

+

773 Raise: 

+

774 AuthException: raised if fetch operation fails 

+

775 """ 

+

776 self._check_inbound_app_token(token) 

+

777 return _OutboundApplicationTokenFetcher.fetch_token_by_scopes( 

+

778 http=self._http, 

+

779 token=token, 

+

780 app_id=app_id, 

+

781 user_id=user_id, 

+

782 scopes=scopes, 

+

783 options=options, 

+

784 tenant_id=tenant_id, 

+

785 ) 

+

786 

+

787 def fetch_token( 

+

788 self, 

+

789 token: str, 

+

790 app_id: str, 

+

791 user_id: str, 

+

792 tenant_id: Optional[str] = None, 

+

793 options: Optional[dict] = None, 

+

794 ) -> dict: 

+

795 """ 

+

796 Fetch an outbound application token for a user. 

+

797 

+

798 Args: 

+

799 token (str): The Inbound Application token to use for authentication. 

+

800 app_id (str): The ID of the outbound application. 

+

801 user_id (str): The ID of the user. 

+

802 tenant_id (str): Optional tenant ID. 

+

803 options (dict): Optional token options. 

+

804 

+

805 Return value (dict): 

+

806 Return dict in the format 

+

807 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

808 

+

809 Raise: 

+

810 AuthException: raised if fetch operation fails 

+

811 """ 

+

812 self._check_inbound_app_token(token) 

+

813 return _OutboundApplicationTokenFetcher.fetch_token( 

+

814 http=self._http, 

+

815 token=token, 

+

816 app_id=app_id, 

+

817 user_id=user_id, 

+

818 tenant_id=tenant_id, 

+

819 options=options, 

+

820 ) 

+

821 

+

822 def fetch_tenant_token_by_scopes( 

+

823 self, 

+

824 token: str, 

+

825 app_id: str, 

+

826 tenant_id: str, 

+

827 scopes: List[str], 

+

828 options: Optional[dict] = None, 

+

829 ) -> dict: 

+

830 """ 

+

831 Fetch an outbound application token for a tenant with specific scopes. 

+

832 

+

833 Args: 

+

834 token (str): The Inbound Application token to use for authentication. 

+

835 app_id (str): The ID of the outbound application. 

+

836 tenant_id (str): The ID of the tenant. 

+

837 scopes (List[str]): List of scopes to include in the token. 

+

838 options (dict): Optional token options. 

+

839 

+

840 Return value (dict): 

+

841 Return dict in the format 

+

842 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

843 

+

844 Raise: 

+

845 AuthException: raised if fetch operation fails 

+

846 """ 

+

847 self._check_inbound_app_token(token) 

+

848 return _OutboundApplicationTokenFetcher.fetch_tenant_token_by_scopes( 

+

849 http=self._http, 

+

850 token=token, 

+

851 app_id=app_id, 

+

852 tenant_id=tenant_id, 

+

853 scopes=scopes, 

+

854 options=options, 

+

855 ) 

+

856 

+

857 def fetch_tenant_token(self, token: str, app_id: str, tenant_id: str, options: Optional[dict] = None) -> dict: 

+

858 """ 

+

859 Fetch an outbound application token for a tenant. 

+

860 

+

861 Args: 

+

862 token (str): The Inbound Application token to use for authentication. 

+

863 app_id (str): The ID of the outbound application. 

+

864 tenant_id (str): The ID of the tenant. 

+

865 options (dict): Optional token options. 

+

866 

+

867 Return value (dict): 

+

868 Return dict in the format 

+

869 {"token": {"token": <access_token>, "refreshToken": <refresh_token>, "expiresIn": <expires_in>, "tokenType": <token_type>, "scopes": <scopes>}} 

+

870 

+

871 Raise: 

+

872 AuthException: raised if fetch operation fails 

+

873 """ 

+

874 self._check_inbound_app_token(token) 

+

875 return _OutboundApplicationTokenFetcher.fetch_tenant_token( 

+

876 http=self._http, 

+

877 token=token, 

+

878 app_id=app_id, 

+

879 tenant_id=tenant_id, 

+

880 options=options, 

+

881 ) 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_role_async_py.html b/htmlcov/d_f54762f9f1d5aa4d_role_async_py.html new file mode 100644 index 000000000..22f144d14 --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_role_async_py.html @@ -0,0 +1,428 @@ + + + + + Coverage for descope/management/role_async.py: 98% + + + + + +
+
+

+ Coverage for descope/management/role_async.py: + 98% +

+ +

+ 47 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import List, Optional, Union 

+

4 

+

5from descope._http_base import AsyncHTTPBase 

+

6from descope.management.common import MgmtV1, role_delete_batch_body 

+

7 

+

8 

+

9class RoleAsync(AsyncHTTPBase): 

+

10 """Async counterpart of Role — all HTTP calls are coroutines.""" 

+

11 

+

12 async def create( 

+

13 self, 

+

14 name: str, 

+

15 description: Optional[str] = None, 

+

16 permission_names: Optional[List[str]] = None, 

+

17 tenant_id: Optional[str] = None, 

+

18 default: Optional[bool] = None, 

+

19 private: Optional[bool] = None, 

+

20 ): 

+

21 """ 

+

22 Create a new role. 

+

23 

+

24 Args: 

+

25 name (str): role name. 

+

26 description (str): Optional description to briefly explain what this role allows. 

+

27 permission_names (List[str]): Optional list of names of permissions this role grants. 

+

28 tenant_id (str): Optional tenant ID to create the role in. 

+

29 default (bool): Optional marks this role as default role. 

+

30 private (bool): Optional marks this role as private role. 

+

31 

+

32 Raise: 

+

33 AuthException: raised if creation operation fails 

+

34 """ 

+

35 permission_names = [] if permission_names is None else permission_names 

+

36 

+

37 await self._http.post( 

+

38 MgmtV1.role_create_path, 

+

39 body={ 

+

40 "name": name, 

+

41 "description": description, 

+

42 "permissionNames": permission_names, 

+

43 "tenantId": tenant_id, 

+

44 "default": default, 

+

45 "private": private, 

+

46 }, 

+

47 ) 

+

48 

+

49 async def create_batch( 

+

50 self, 

+

51 roles: List[dict], 

+

52 ): 

+

53 """ 

+

54 Create a batch of roles in a single atomic transaction. 

+

55 

+

56 Args: 

+

57 roles (List[dict]): List of role objects, each with: 

+

58 - name (str): role name. 

+

59 - description (str): Optional description. 

+

60 - permissionNames (List[str]): Optional list of permission names. 

+

61 - tenantId (str): Optional tenant ID. 

+

62 - default (bool): Optional default flag. 

+

63 - private (bool): Optional private flag. 

+

64 

+

65 Raise: 

+

66 AuthException: raised if creation operation fails 

+

67 """ 

+

68 await self._http.post( 

+

69 MgmtV1.role_create_batch_path, 

+

70 body={"roles": roles}, 

+

71 ) 

+

72 

+

73 async def update_batch( 

+

74 self, 

+

75 roles: List[dict], 

+

76 ): 

+

77 """ 

+

78 Update a batch of roles in a single atomic transaction. 

+

79 

+

80 Args: 

+

81 roles (List[dict]): List of role objects, each with: 

+

82 - name (str): current role name (or id (str): role ID, e.g. ROL...). 

+

83 - newName (str): new role name. 

+

84 - description (str): Optional new description. 

+

85 - permissionNames (List[str]): Optional list of permission names. 

+

86 - tenantId (str): Optional tenant ID. 

+

87 - default (bool): Optional default flag. 

+

88 - private (bool): Optional private flag. 

+

89 

+

90 Raise: 

+

91 AuthException: raised if update operation fails 

+

92 """ 

+

93 await self._http.post( 

+

94 MgmtV1.role_update_batch_path, 

+

95 body={"roles": roles}, 

+

96 ) 

+

97 

+

98 async def delete_batch( 

+

99 self, 

+

100 roles: List[Union[str, dict]], 

+

101 tenant_id: Optional[str] = None, 

+

102 ): 

+

103 """ 

+

104 Delete a batch of roles in a single atomic transaction. 

+

105 IMPORTANT: This action is irreversible. Use carefully. 

+

106 

+

107 Args: 

+

108 roles (List[Union[str, dict]]): List of role names to delete, or role objects each with: 

+

109 - name (str): role name. 

+

110 - tenantId (str): Optional tenant ID. 

+

111 tenant_id (str): Optional tenant ID the roles belong to. 

+

112 

+

113 Raise: 

+

114 ValueError: raised if roles is empty, holds an entry without a name, 

+

115 or spans more than one tenant 

+

116 AuthException: raised if deletion operation fails 

+

117 """ 

+

118 await self._http.post( 

+

119 MgmtV1.role_delete_batch_path, 

+

120 body=role_delete_batch_body(roles, tenant_id), 

+

121 ) 

+

122 

+

123 async def delete_batch_by_ids( 

+

124 self, 

+

125 role_ids: List[str], 

+

126 tenant_id: Optional[str] = None, 

+

127 ): 

+

128 """ 

+

129 Delete a batch of roles by their IDs in a single atomic transaction. 

+

130 IMPORTANT: This action is irreversible. Use carefully. 

+

131 

+

132 Args: 

+

133 role_ids (List[str]): List of role IDs to delete (e.g. ROL...). 

+

134 tenant_id (str): Optional tenant ID the roles belong to. 

+

135 

+

136 Raise: 

+

137 ValueError: raised if role_ids is empty 

+

138 AuthException: raised if deletion operation fails 

+

139 """ 

+

140 if not role_ids: 

+

141 raise ValueError("role_ids list cannot be empty") 

+

142 

+

143 await self._http.post( 

+

144 MgmtV1.role_delete_batch_path, 

+

145 body={"roleIds": role_ids, "tenantId": tenant_id}, 

+

146 ) 

+

147 

+

148 async def update( 

+

149 self, 

+

150 name: str, 

+

151 new_name: str, 

+

152 description: Optional[str] = None, 

+

153 permission_names: Optional[List[str]] = None, 

+

154 tenant_id: Optional[str] = None, 

+

155 default: Optional[bool] = None, 

+

156 private: Optional[bool] = None, 

+

157 ): 

+

158 """ 

+

159 Update an existing role with the given various fields. IMPORTANT: All parameters are used as overrides 

+

160 to the existing role. Empty fields will override populated fields. Use carefully. 

+

161 

+

162 Args: 

+

163 name (str): role name. 

+

164 new_name (str): role updated name. 

+

165 description (str): Optional description to briefly explain what this role allows. 

+

166 permission_names (List[str]): Optional list of names of permissions this role grants. 

+

167 tenant_id (str): Optional tenant ID to update the role in. 

+

168 default (bool): Optional marks this role as default role. 

+

169 private (bool): Optional marks this role as private role. 

+

170 

+

171 Raise: 

+

172 AuthException: raised if update operation fails 

+

173 """ 

+

174 permission_names = [] if permission_names is None else permission_names 

+

175 await self._http.post( 

+

176 MgmtV1.role_update_path, 

+

177 body={ 

+

178 "name": name, 

+

179 "newName": new_name, 

+

180 "description": description, 

+

181 "permissionNames": permission_names, 

+

182 "tenantId": tenant_id, 

+

183 "default": default, 

+

184 "private": private, 

+

185 }, 

+

186 ) 

+

187 

+

188 async def update_by_id( 

+

189 self, 

+

190 id: str, 

+

191 new_name: str, 

+

192 description: Optional[str] = None, 

+

193 permission_names: Optional[List[str]] = None, 

+

194 tenant_id: Optional[str] = None, 

+

195 default: Optional[bool] = None, 

+

196 private: Optional[bool] = None, 

+

197 ): 

+

198 """ 

+

199 Update an existing role identified by its ID. IMPORTANT: All parameters are used as overrides 

+

200 to the existing role. Empty fields will override populated fields. Use carefully. 

+

201 

+

202 Args: 

+

203 id (str): role ID (e.g. ROL...). 

+

204 new_name (str): role updated name. 

+

205 description (str): Optional description to briefly explain what this role allows. 

+

206 permission_names (List[str]): Optional list of names of permissions this role grants. 

+

207 tenant_id (str): Optional tenant ID to update the role in. 

+

208 default (bool): Optional marks this role as default role. 

+

209 private (bool): Optional marks this role as private role. 

+

210 

+

211 Raise: 

+

212 AuthException: raised if update operation fails 

+

213 """ 

+

214 permission_names = [] if permission_names is None else permission_names 

+

215 await self._http.post( 

+

216 MgmtV1.role_update_path, 

+

217 body={ 

+

218 "id": id, 

+

219 "newName": new_name, 

+

220 "description": description, 

+

221 "permissionNames": permission_names, 

+

222 "tenantId": tenant_id, 

+

223 "default": default, 

+

224 "private": private, 

+

225 }, 

+

226 ) 

+

227 

+

228 async def delete( 

+

229 self, 

+

230 name: str, 

+

231 tenant_id: Optional[str] = None, 

+

232 ): 

+

233 """ 

+

234 Delete an existing role. IMPORTANT: This action is irreversible. Use carefully. 

+

235 

+

236 Args: 

+

237 name (str): The name of the role to be deleted. 

+

238 

+

239 Raise: 

+

240 AuthException: raised if creation operation fails 

+

241 """ 

+

242 await self._http.post( 

+

243 MgmtV1.role_delete_path, 

+

244 body={"name": name, "tenantId": tenant_id}, 

+

245 ) 

+

246 

+

247 async def delete_by_id( 

+

248 self, 

+

249 id: str, 

+

250 tenant_id: Optional[str] = None, 

+

251 ): 

+

252 """ 

+

253 Delete an existing role by its ID. IMPORTANT: This action is irreversible. Use carefully. 

+

254 

+

255 Args: 

+

256 id (str): The ID of the role to be deleted (e.g. ROL...). 

+

257 tenant_id (str): Optional tenant ID the role belongs to. 

+

258 

+

259 Raise: 

+

260 AuthException: raised if deletion operation fails 

+

261 """ 

+

262 await self._http.post( 

+

263 MgmtV1.role_delete_path, 

+

264 body={"id": id, "tenantId": tenant_id}, 

+

265 ) 

+

266 

+

267 async def load_all( 

+

268 self, 

+

269 ) -> dict: 

+

270 """ 

+

271 Load all roles. 

+

272 

+

273 Return value (dict): 

+

274 Return dict in the format 

+

275 {"roles": [{"name": <name>, "description": <description>, "permissionNames":[]}] } 

+

276 Containing the loaded role information. 

+

277 

+

278 Raise: 

+

279 AuthException: raised if load operation fails 

+

280 """ 

+

281 response = await self._http.get( 

+

282 MgmtV1.role_load_all_path, 

+

283 ) 

+

284 return response.json() 

+

285 

+

286 async def search( 

+

287 self, 

+

288 tenant_ids: Optional[List[str]] = None, 

+

289 role_names: Optional[List[str]] = None, 

+

290 role_name_like: Optional[str] = None, 

+

291 permission_names: Optional[List[str]] = None, 

+

292 include_project_roles: Optional[bool] = None, 

+

293 role_ids: Optional[List[str]] = None, 

+

294 ) -> dict: 

+

295 """ 

+

296 Search roles based on the given filters. 

+

297 

+

298 Args: 

+

299 tenant_ids (List[str]): List of tenant ids to filter by 

+

300 role_names (List[str]): Only return matching roles to the given names 

+

301 role_name_like (str): Return roles that contain the given string ignoring case 

+

302 permission_names (List[str]): Only return roles that have the given permissions 

+

303 role_ids (List[str]): Only return roles matching the given IDs (e.g. ROL...) 

+

304 

+

305 Return value (dict): 

+

306 Return dict in the format 

+

307 {"roles": [{"id": <id>, "name": <name>, "description": <description>, "permissionNames":[]}] } 

+

308 Containing the loaded role information. 

+

309 

+

310 Raise: 

+

311 AuthException: raised if load operation fails 

+

312 """ 

+

313 body: dict[str, str | bool | List[str]] = {} 

+

314 if tenant_ids is not None: 

+

315 body["tenantIds"] = tenant_ids 

+

316 if role_names is not None: 

+

317 body["roleNames"] = role_names 

+

318 if role_name_like is not None: 

+

319 body["roleNameLike"] = role_name_like 

+

320 if permission_names is not None: 

+

321 body["permissionNames"] = permission_names 

+

322 if include_project_roles is not None: 

+

323 body["includeProjectRoles"] = include_project_roles 

+

324 if role_ids is not None: 

+

325 body["roleIds"] = role_ids 

+

326 

+

327 response = await self._http.post( 

+

328 MgmtV1.role_search_path, 

+

329 body=body, 

+

330 ) 

+

331 return response.json() 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_role_py.html b/htmlcov/d_f54762f9f1d5aa4d_role_py.html new file mode 100644 index 000000000..679cd585c --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_role_py.html @@ -0,0 +1,426 @@ + + + + + Coverage for descope/management/role.py: 98% + + + + + +
+
+

+ Coverage for descope/management/role.py: + 98% +

+ +

+ 47 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3from typing import List, Optional, Union 

+

4 

+

5from descope._http_base import HTTPBase 

+

6from descope.management.common import MgmtV1, role_delete_batch_body 

+

7 

+

8 

+

9class Role(HTTPBase): 

+

10 def create( 

+

11 self, 

+

12 name: str, 

+

13 description: Optional[str] = None, 

+

14 permission_names: Optional[List[str]] = None, 

+

15 tenant_id: Optional[str] = None, 

+

16 default: Optional[bool] = None, 

+

17 private: Optional[bool] = None, 

+

18 ): 

+

19 """ 

+

20 Create a new role. 

+

21 

+

22 Args: 

+

23 name (str): role name. 

+

24 description (str): Optional description to briefly explain what this role allows. 

+

25 permission_names (List[str]): Optional list of names of permissions this role grants. 

+

26 tenant_id (str): Optional tenant ID to create the role in. 

+

27 default (bool): Optional marks this role as default role. 

+

28 private (bool): Optional marks this role as private role. 

+

29 

+

30 Raise: 

+

31 AuthException: raised if creation operation fails 

+

32 """ 

+

33 permission_names = [] if permission_names is None else permission_names 

+

34 

+

35 self._http.post( 

+

36 MgmtV1.role_create_path, 

+

37 body={ 

+

38 "name": name, 

+

39 "description": description, 

+

40 "permissionNames": permission_names, 

+

41 "tenantId": tenant_id, 

+

42 "default": default, 

+

43 "private": private, 

+

44 }, 

+

45 ) 

+

46 

+

47 def create_batch( 

+

48 self, 

+

49 roles: List[dict], 

+

50 ): 

+

51 """ 

+

52 Create a batch of roles in a single atomic transaction. 

+

53 

+

54 Args: 

+

55 roles (List[dict]): List of role objects, each with: 

+

56 - name (str): role name. 

+

57 - description (str): Optional description. 

+

58 - permissionNames (List[str]): Optional list of permission names. 

+

59 - tenantId (str): Optional tenant ID. 

+

60 - default (bool): Optional default flag. 

+

61 - private (bool): Optional private flag. 

+

62 

+

63 Raise: 

+

64 AuthException: raised if creation operation fails 

+

65 """ 

+

66 self._http.post( 

+

67 MgmtV1.role_create_batch_path, 

+

68 body={"roles": roles}, 

+

69 ) 

+

70 

+

71 def update_batch( 

+

72 self, 

+

73 roles: List[dict], 

+

74 ): 

+

75 """ 

+

76 Update a batch of roles in a single atomic transaction. 

+

77 

+

78 Args: 

+

79 roles (List[dict]): List of role objects, each with: 

+

80 - name (str): current role name (or id (str): role ID, e.g. ROL...). 

+

81 - newName (str): new role name. 

+

82 - description (str): Optional new description. 

+

83 - permissionNames (List[str]): Optional list of permission names. 

+

84 - tenantId (str): Optional tenant ID. 

+

85 - default (bool): Optional default flag. 

+

86 - private (bool): Optional private flag. 

+

87 

+

88 Raise: 

+

89 AuthException: raised if update operation fails 

+

90 """ 

+

91 self._http.post( 

+

92 MgmtV1.role_update_batch_path, 

+

93 body={"roles": roles}, 

+

94 ) 

+

95 

+

96 def delete_batch( 

+

97 self, 

+

98 roles: List[Union[str, dict]], 

+

99 tenant_id: Optional[str] = None, 

+

100 ): 

+

101 """ 

+

102 Delete a batch of roles in a single atomic transaction. 

+

103 IMPORTANT: This action is irreversible. Use carefully. 

+

104 

+

105 Args: 

+

106 roles (List[Union[str, dict]]): List of role names to delete, or role objects each with: 

+

107 - name (str): role name. 

+

108 - tenantId (str): Optional tenant ID. 

+

109 tenant_id (str): Optional tenant ID the roles belong to. 

+

110 

+

111 Raise: 

+

112 ValueError: raised if roles is empty, holds an entry without a name, 

+

113 or spans more than one tenant 

+

114 AuthException: raised if deletion operation fails 

+

115 """ 

+

116 self._http.post( 

+

117 MgmtV1.role_delete_batch_path, 

+

118 body=role_delete_batch_body(roles, tenant_id), 

+

119 ) 

+

120 

+

121 def delete_batch_by_ids( 

+

122 self, 

+

123 role_ids: List[str], 

+

124 tenant_id: Optional[str] = None, 

+

125 ): 

+

126 """ 

+

127 Delete a batch of roles by their IDs in a single atomic transaction. 

+

128 IMPORTANT: This action is irreversible. Use carefully. 

+

129 

+

130 Args: 

+

131 role_ids (List[str]): List of role IDs to delete (e.g. ROL...). 

+

132 tenant_id (str): Optional tenant ID the roles belong to. 

+

133 

+

134 Raise: 

+

135 ValueError: raised if role_ids is empty 

+

136 AuthException: raised if deletion operation fails 

+

137 """ 

+

138 if not role_ids: 

+

139 raise ValueError("role_ids list cannot be empty") 

+

140 

+

141 self._http.post( 

+

142 MgmtV1.role_delete_batch_path, 

+

143 body={"roleIds": role_ids, "tenantId": tenant_id}, 

+

144 ) 

+

145 

+

146 def update( 

+

147 self, 

+

148 name: str, 

+

149 new_name: str, 

+

150 description: Optional[str] = None, 

+

151 permission_names: Optional[List[str]] = None, 

+

152 tenant_id: Optional[str] = None, 

+

153 default: Optional[bool] = None, 

+

154 private: Optional[bool] = None, 

+

155 ): 

+

156 """ 

+

157 Update an existing role with the given various fields. IMPORTANT: All parameters are used as overrides 

+

158 to the existing role. Empty fields will override populated fields. Use carefully. 

+

159 

+

160 Args: 

+

161 name (str): role name. 

+

162 new_name (str): role updated name. 

+

163 description (str): Optional description to briefly explain what this role allows. 

+

164 permission_names (List[str]): Optional list of names of permissions this role grants. 

+

165 tenant_id (str): Optional tenant ID to update the role in. 

+

166 default (bool): Optional marks this role as default role. 

+

167 private (bool): Optional marks this role as private role. 

+

168 

+

169 Raise: 

+

170 AuthException: raised if update operation fails 

+

171 """ 

+

172 permission_names = [] if permission_names is None else permission_names 

+

173 self._http.post( 

+

174 MgmtV1.role_update_path, 

+

175 body={ 

+

176 "name": name, 

+

177 "newName": new_name, 

+

178 "description": description, 

+

179 "permissionNames": permission_names, 

+

180 "tenantId": tenant_id, 

+

181 "default": default, 

+

182 "private": private, 

+

183 }, 

+

184 ) 

+

185 

+

186 def update_by_id( 

+

187 self, 

+

188 id: str, 

+

189 new_name: str, 

+

190 description: Optional[str] = None, 

+

191 permission_names: Optional[List[str]] = None, 

+

192 tenant_id: Optional[str] = None, 

+

193 default: Optional[bool] = None, 

+

194 private: Optional[bool] = None, 

+

195 ): 

+

196 """ 

+

197 Update an existing role identified by its ID. IMPORTANT: All parameters are used as overrides 

+

198 to the existing role. Empty fields will override populated fields. Use carefully. 

+

199 

+

200 Args: 

+

201 id (str): role ID (e.g. ROL...). 

+

202 new_name (str): role updated name. 

+

203 description (str): Optional description to briefly explain what this role allows. 

+

204 permission_names (List[str]): Optional list of names of permissions this role grants. 

+

205 tenant_id (str): Optional tenant ID to update the role in. 

+

206 default (bool): Optional marks this role as default role. 

+

207 private (bool): Optional marks this role as private role. 

+

208 

+

209 Raise: 

+

210 AuthException: raised if update operation fails 

+

211 """ 

+

212 permission_names = [] if permission_names is None else permission_names 

+

213 self._http.post( 

+

214 MgmtV1.role_update_path, 

+

215 body={ 

+

216 "id": id, 

+

217 "newName": new_name, 

+

218 "description": description, 

+

219 "permissionNames": permission_names, 

+

220 "tenantId": tenant_id, 

+

221 "default": default, 

+

222 "private": private, 

+

223 }, 

+

224 ) 

+

225 

+

226 def delete( 

+

227 self, 

+

228 name: str, 

+

229 tenant_id: Optional[str] = None, 

+

230 ): 

+

231 """ 

+

232 Delete an existing role. IMPORTANT: This action is irreversible. Use carefully. 

+

233 

+

234 Args: 

+

235 name (str): The name of the role to be deleted. 

+

236 

+

237 Raise: 

+

238 AuthException: raised if creation operation fails 

+

239 """ 

+

240 self._http.post( 

+

241 MgmtV1.role_delete_path, 

+

242 body={"name": name, "tenantId": tenant_id}, 

+

243 ) 

+

244 

+

245 def delete_by_id( 

+

246 self, 

+

247 id: str, 

+

248 tenant_id: Optional[str] = None, 

+

249 ): 

+

250 """ 

+

251 Delete an existing role by its ID. IMPORTANT: This action is irreversible. Use carefully. 

+

252 

+

253 Args: 

+

254 id (str): The ID of the role to be deleted (e.g. ROL...). 

+

255 tenant_id (str): Optional tenant ID the role belongs to. 

+

256 

+

257 Raise: 

+

258 AuthException: raised if deletion operation fails 

+

259 """ 

+

260 self._http.post( 

+

261 MgmtV1.role_delete_path, 

+

262 body={"id": id, "tenantId": tenant_id}, 

+

263 ) 

+

264 

+

265 def load_all( 

+

266 self, 

+

267 ) -> dict: 

+

268 """ 

+

269 Load all roles. 

+

270 

+

271 Return value (dict): 

+

272 Return dict in the format 

+

273 {"roles": [{"name": <name>, "description": <description>, "permissionNames":[]}] } 

+

274 Containing the loaded role information. 

+

275 

+

276 Raise: 

+

277 AuthException: raised if load operation fails 

+

278 """ 

+

279 response = self._http.get( 

+

280 MgmtV1.role_load_all_path, 

+

281 ) 

+

282 return response.json() 

+

283 

+

284 def search( 

+

285 self, 

+

286 tenant_ids: Optional[List[str]] = None, 

+

287 role_names: Optional[List[str]] = None, 

+

288 role_name_like: Optional[str] = None, 

+

289 permission_names: Optional[List[str]] = None, 

+

290 include_project_roles: Optional[bool] = None, 

+

291 role_ids: Optional[List[str]] = None, 

+

292 ) -> dict: 

+

293 """ 

+

294 Search roles based on the given filters. 

+

295 

+

296 Args: 

+

297 tenant_ids (List[str]): List of tenant ids to filter by 

+

298 role_names (List[str]): Only return matching roles to the given names 

+

299 role_name_like (str): Return roles that contain the given string ignoring case 

+

300 permission_names (List[str]): Only return roles that have the given permissions 

+

301 role_ids (List[str]): Only return roles matching the given IDs (e.g. ROL...) 

+

302 

+

303 Return value (dict): 

+

304 Return dict in the format 

+

305 {"roles": [{"id": <id>, "name": <name>, "description": <description>, "permissionNames":[]}] } 

+

306 Containing the loaded role information. 

+

307 

+

308 Raise: 

+

309 AuthException: raised if load operation fails 

+

310 """ 

+

311 body: dict[str, str | bool | List[str]] = {} 

+

312 if tenant_ids is not None: 

+

313 body["tenantIds"] = tenant_ids 

+

314 if role_names is not None: 

+

315 body["roleNames"] = role_names 

+

316 if role_name_like is not None: 

+

317 body["roleNameLike"] = role_name_like 

+

318 if permission_names is not None: 

+

319 body["permissionNames"] = permission_names 

+

320 if include_project_roles is not None: 

+

321 body["includeProjectRoles"] = include_project_roles 

+

322 if role_ids is not None: 

+

323 body["roleIds"] = role_ids 

+

324 

+

325 response = self._http.post( 

+

326 MgmtV1.role_search_path, 

+

327 body=body, 

+

328 ) 

+

329 return response.json() 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_user_async_py.html b/htmlcov/d_f54762f9f1d5aa4d_user_async_py.html new file mode 100644 index 000000000..7bbc9205a --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_user_async_py.html @@ -0,0 +1,2355 @@ + + + + + Coverage for descope/management/user_async.py: 99% + + + + + +
+
+

+ Coverage for descope/management/user_async.py: + 99% +

+ +

+ 319 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1from __future__ import annotations 

+

2 

+

3import base64 

+

4from typing import List, Optional, Union 

+

5 

+

6from descope._http_base import AsyncHTTPBase 

+

7from descope.common import DeliveryMethod, LoginOptions, get_method_string 

+

8from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

9from descope.management._user_base import CreateUserObj, UserBase, UserObj 

+

10from descope.management.common import ( 

+

11 AssociatedFamily, 

+

12 AssociatedTenant, 

+

13 CustomAttribute, 

+

14 MgmtV1, 

+

15 Sort, 

+

16 associated_families_to_dict, 

+

17 custom_attributes_to_dict, 

+

18 sort_to_dict, 

+

19) 

+

20 

+

21 

+

22class UserAsync(UserBase, AsyncHTTPBase): 

+

23 """Async counterpart of User — all HTTP calls are coroutines.""" 

+

24 

+

25 async def create( 

+

26 self, 

+

27 login_id: str, 

+

28 email: Optional[str] = None, 

+

29 phone: Optional[str] = None, 

+

30 display_name: Optional[str] = None, 

+

31 given_name: Optional[str] = None, 

+

32 middle_name: Optional[str] = None, 

+

33 family_name: Optional[str] = None, 

+

34 role_names: Optional[List[str]] = None, 

+

35 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

36 picture: Optional[str] = None, 

+

37 custom_attributes: Optional[dict] = None, 

+

38 verified_email: Optional[bool] = None, 

+

39 verified_phone: Optional[bool] = None, 

+

40 invite_url: Optional[str] = None, 

+

41 additional_login_ids: Optional[List[str]] = None, 

+

42 sso_app_ids: Optional[List[str]] = None, 

+

43 status: Optional[str] = None, 

+

44 family_associations: Optional[List[AssociatedFamily]] = None, 

+

45 ) -> dict: 

+

46 """ 

+

47 Create a new user. Users can have any number of optional fields, including email, phone number and authorization. 

+

48 

+

49 Args: 

+

50 login_id (str): user login ID. 

+

51 email (str): Optional user email address. 

+

52 phone (str): Optional user phone number. 

+

53 display_name (str): Optional user display name. 

+

54 role_names (List[str]): An optional list of the user's roles without tenant association. These roles are 

+

55 mutually exclusive with the `user_tenant` roles. 

+

56 user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are 

+

57 mutually exclusive with the general `role_names`. 

+

58 picture (str): Optional url for user picture 

+

59 custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app 

+

60 sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. 

+

61 status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". 

+

62 family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and 

+

63 family-scoped attribute values per family. 

+

64 

+

65 Return value (dict): 

+

66 Return dict in the format 

+

67 {"user": {}} 

+

68 Containing the created user information. 

+

69 

+

70 Raise: 

+

71 AuthException: raised if create operation fails 

+

72 """ 

+

73 UserBase._validate_status(status) 

+

74 role_names = [] if role_names is None else role_names 

+

75 user_tenants = [] if user_tenants is None else user_tenants 

+

76 

+

77 response = await self._http.post( 

+

78 MgmtV1.user_create_path, 

+

79 body=UserBase._compose_create_body( 

+

80 login_id, 

+

81 email, 

+

82 phone, 

+

83 display_name, 

+

84 given_name, 

+

85 middle_name, 

+

86 family_name, 

+

87 role_names, 

+

88 user_tenants, 

+

89 False, 

+

90 False, 

+

91 picture, 

+

92 custom_attributes, 

+

93 verified_email, 

+

94 verified_phone, 

+

95 invite_url, 

+

96 None, 

+

97 None, 

+

98 additional_login_ids, 

+

99 sso_app_ids, 

+

100 status=status, 

+

101 family_associations=family_associations, 

+

102 ), 

+

103 ) 

+

104 return response.json() 

+

105 

+

106 async def create_test_user( 

+

107 self, 

+

108 login_id: str, 

+

109 email: Optional[str] = None, 

+

110 phone: Optional[str] = None, 

+

111 display_name: Optional[str] = None, 

+

112 given_name: Optional[str] = None, 

+

113 middle_name: Optional[str] = None, 

+

114 family_name: Optional[str] = None, 

+

115 role_names: Optional[List[str]] = None, 

+

116 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

117 picture: Optional[str] = None, 

+

118 custom_attributes: Optional[dict] = None, 

+

119 verified_email: Optional[bool] = None, 

+

120 verified_phone: Optional[bool] = None, 

+

121 invite_url: Optional[str] = None, 

+

122 additional_login_ids: Optional[List[str]] = None, 

+

123 sso_app_ids: Optional[List[str]] = None, 

+

124 status: Optional[str] = None, 

+

125 family_associations: Optional[List[AssociatedFamily]] = None, 

+

126 ) -> dict: 

+

127 """ 

+

128 Create a new test user. 

+

129 The login_id is required and will determine what the user will use to sign in. 

+

130 Make sure the login id is unique for test. All other fields are optional. 

+

131 

+

132 Args: 

+

133 login_id (str): user login ID. 

+

134 email (str): Optional user email address. 

+

135 phone (str): Optional user phone number. 

+

136 display_name (str): Optional user display name. 

+

137 role_names (List[str]): An optional list of the user's roles without tenant association. These roles are 

+

138 mutually exclusive with the `user_tenant` roles. 

+

139 user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are 

+

140 mutually exclusive with the general `role_names`. 

+

141 picture (str): Optional url for user picture 

+

142 custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app 

+

143 sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. 

+

144 status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". 

+

145 family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and 

+

146 family-scoped attribute values per family. 

+

147 

+

148 Return value (dict): 

+

149 Return dict in the format 

+

150 {"user": {}} 

+

151 Containing the created test user information. 

+

152 

+

153 Raise: 

+

154 AuthException: raised if create operation fails 

+

155 """ 

+

156 UserBase._validate_status(status) 

+

157 role_names = [] if role_names is None else role_names 

+

158 user_tenants = [] if user_tenants is None else user_tenants 

+

159 

+

160 response = await self._http.post( 

+

161 MgmtV1.test_user_create_path, 

+

162 body=UserBase._compose_create_body( 

+

163 login_id, 

+

164 email, 

+

165 phone, 

+

166 display_name, 

+

167 given_name, 

+

168 middle_name, 

+

169 family_name, 

+

170 role_names, 

+

171 user_tenants, 

+

172 False, 

+

173 True, 

+

174 picture, 

+

175 custom_attributes, 

+

176 verified_email, 

+

177 verified_phone, 

+

178 invite_url, 

+

179 None, 

+

180 None, 

+

181 additional_login_ids, 

+

182 sso_app_ids, 

+

183 status=status, 

+

184 family_associations=family_associations, 

+

185 ), 

+

186 ) 

+

187 return response.json() 

+

188 

+

189 async def invite( 

+

190 self, 

+

191 login_id: str, 

+

192 email: Optional[str] = None, 

+

193 phone: Optional[str] = None, 

+

194 display_name: Optional[str] = None, 

+

195 given_name: Optional[str] = None, 

+

196 middle_name: Optional[str] = None, 

+

197 family_name: Optional[str] = None, 

+

198 role_names: Optional[List[str]] = None, 

+

199 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

200 picture: Optional[str] = None, 

+

201 custom_attributes: Optional[dict] = None, 

+

202 verified_email: Optional[bool] = None, 

+

203 verified_phone: Optional[bool] = None, 

+

204 invite_url: Optional[str] = None, 

+

205 send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings 

+

206 send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings 

+

207 additional_login_ids: Optional[List[str]] = None, 

+

208 sso_app_ids: Optional[List[str]] = None, 

+

209 template_id: str = "", 

+

210 test: bool = False, 

+

211 locale: Optional[str] = None, # locale for the invite message 

+

212 family_associations: Optional[List[AssociatedFamily]] = None, 

+

213 ) -> dict: 

+

214 """ 

+

215 Create a new user and invite them via an email / text message. 

+

216 

+

217 Functions exactly the same as the `create` function with the additional invitation 

+

218 behavior. See the documentation above for the general creation behavior. 

+

219 

+

220 IMPORTANT: Since the invitation is sent by email / phone, make sure either 

+

221 the email / phone is explicitly set, or the login_id itself is an email address / phone number. 

+

222 You must configure the invitation URL in the Descope console prior to 

+

223 calling the method. 

+

224 """ 

+

225 role_names = [] if role_names is None else role_names 

+

226 user_tenants = [] if user_tenants is None else user_tenants 

+

227 

+

228 response = await self._http.post( 

+

229 MgmtV1.user_create_path, 

+

230 body=UserBase._compose_create_body( 

+

231 login_id, 

+

232 email, 

+

233 phone, 

+

234 display_name, 

+

235 given_name, 

+

236 middle_name, 

+

237 family_name, 

+

238 role_names, 

+

239 user_tenants, 

+

240 True, 

+

241 test, 

+

242 picture, 

+

243 custom_attributes, 

+

244 verified_email, 

+

245 verified_phone, 

+

246 invite_url, 

+

247 send_mail, 

+

248 send_sms, 

+

249 additional_login_ids, 

+

250 sso_app_ids, 

+

251 template_id, 

+

252 locale, 

+

253 family_associations=family_associations, 

+

254 ), 

+

255 ) 

+

256 return response.json() 

+

257 

+

258 async def invite_batch( 

+

259 self, 

+

260 users: List[UserObj], 

+

261 invite_url: Optional[str] = None, 

+

262 send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings 

+

263 send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings 

+

264 locale: Optional[str] = None, # locale for the invite message 

+

265 template_options: Optional[dict] = None, # for providing messaging template options 

+

266 template_id: str = "", # for overriding the default messaging template 

+

267 ) -> dict: 

+

268 """ 

+

269 Create users in batch and invite them via an email / text message. 

+

270 

+

271 Functions exactly the same as the `create` function with the additional invitation 

+

272 behavior. See the documentation above for the general creation behavior. 

+

273 

+

274 IMPORTANT: Since the invitation is sent by email / phone, make sure either 

+

275 the email / phone is explicitly set, or the login_id itself is an email address / phone number. 

+

276 You must configure the invitation URL in the Descope console prior to 

+

277 calling the method. 

+

278 """ 

+

279 

+

280 response = await self._http.post( 

+

281 MgmtV1.user_create_batch_path, 

+

282 body=UserBase._compose_create_batch_body( 

+

283 users, 

+

284 invite_url, 

+

285 send_mail, 

+

286 send_sms, 

+

287 locale, 

+

288 template_options=template_options, 

+

289 template_id=template_id, 

+

290 ), 

+

291 ) 

+

292 return response.json() 

+

293 

+

294 async def update( 

+

295 self, 

+

296 login_id: str, 

+

297 email: Optional[str] = None, 

+

298 phone: Optional[str] = None, 

+

299 display_name: Optional[str] = None, 

+

300 given_name: Optional[str] = None, 

+

301 middle_name: Optional[str] = None, 

+

302 family_name: Optional[str] = None, 

+

303 role_names: Optional[List[str]] = None, 

+

304 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

305 picture: Optional[str] = None, 

+

306 custom_attributes: Optional[dict] = None, 

+

307 verified_email: Optional[bool] = None, 

+

308 verified_phone: Optional[bool] = None, 

+

309 additional_login_ids: Optional[List[str]] = None, 

+

310 sso_app_ids: Optional[List[str]] = None, 

+

311 test: bool = False, 

+

312 family_associations: Optional[List[AssociatedFamily]] = None, 

+

313 ) -> dict: 

+

314 """ 

+

315 Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides 

+

316 to the existing user. Empty fields will override populated fields, including family memberships: 

+

317 omitting family_associations removes the user from all of their families, and updating a dependent 

+

318 requires family_associations with the dependent's family. Use carefully. 

+

319 Use `patch` for partial updates instead. 

+

320 

+

321 Args: 

+

322 login_id (str): The login ID of the user to update. 

+

323 email (str): Optional user email address. 

+

324 phone (str): Optional user phone number. 

+

325 display_name (str): Optional user display name. 

+

326 given_name (str): Optional user given name. 

+

327 middle_name (str): Optional user middle name. 

+

328 family_name (str): Optional user family name. 

+

329 role_names (List[str]): An optional list of the user's roles without tenant association. These roles are 

+

330 mutually exclusive with the `user_tenant` roles. 

+

331 user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are 

+

332 mutually exclusive with the general `role_names`. 

+

333 picture (str): Optional url for user picture 

+

334 custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app 

+

335 sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. 

+

336 test (bool, optional): Set to True to update a test user. Defaults to False. 

+

337 family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and 

+

338 family-scoped attribute values per family. Replaces the user's family memberships, so None or an 

+

339 empty list removes the user from all families. 

+

340 

+

341 Return value (dict): 

+

342 Return dict in the format 

+

343 {"user": {}} 

+

344 Containing the updated user information. 

+

345 

+

346 Raise: 

+

347 AuthException: raised if update operation fails 

+

348 """ 

+

349 role_names = [] if role_names is None else role_names 

+

350 user_tenants = [] if user_tenants is None else user_tenants 

+

351 family_associations = [] if family_associations is None else family_associations 

+

352 

+

353 response = await self._http.post( 

+

354 MgmtV1.user_update_path, 

+

355 body=UserBase._compose_update_body( 

+

356 login_id, 

+

357 email, 

+

358 phone, 

+

359 display_name, 

+

360 given_name, 

+

361 middle_name, 

+

362 family_name, 

+

363 role_names, 

+

364 user_tenants, 

+

365 test, 

+

366 picture, 

+

367 custom_attributes, 

+

368 verified_email, 

+

369 verified_phone, 

+

370 additional_login_ids, 

+

371 sso_app_ids, 

+

372 None, 

+

373 family_associations=family_associations, 

+

374 ), 

+

375 ) 

+

376 return response.json() 

+

377 

+

378 async def patch( 

+

379 self, 

+

380 login_id: str, 

+

381 email: Optional[str] = None, 

+

382 phone: Optional[str] = None, 

+

383 display_name: Optional[str] = None, 

+

384 given_name: Optional[str] = None, 

+

385 middle_name: Optional[str] = None, 

+

386 family_name: Optional[str] = None, 

+

387 role_names: Optional[List[str]] = None, 

+

388 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

389 picture: Optional[str] = None, 

+

390 custom_attributes: Optional[dict] = None, 

+

391 verified_email: Optional[bool] = None, 

+

392 verified_phone: Optional[bool] = None, 

+

393 sso_app_ids: Optional[List[str]] = None, 

+

394 status: Optional[str] = None, 

+

395 test: bool = False, 

+

396 family_associations: Optional[List[AssociatedFamily]] = None, 

+

397 ) -> dict: 

+

398 """ 

+

399 Patches an existing user with the given various fields. Only the given fields will be used to update the user. 

+

400 

+

401 Args: 

+

402 login_id (str): The login ID of the user to update. 

+

403 email (str): Optional user email address. 

+

404 phone (str): Optional user phone number. 

+

405 display_name (str): Optional user display name. 

+

406 given_name (str): Optional user given name. 

+

407 middle_name (str): Optional user middle name. 

+

408 family_name (str): Optional user family name. 

+

409 role_names (List[str]): An optional list of the user's roles without tenant association. These roles are 

+

410 mutually exclusive with the `user_tenant` roles. 

+

411 user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are 

+

412 mutually exclusive with the general `role_names`. 

+

413 picture (str): Optional url for user picture 

+

414 custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app 

+

415 sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. 

+

416 status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". 

+

417 test (bool, optional): Set to True to update a test user. Defaults to False. 

+

418 family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and 

+

419 family-scoped attribute values per family. 

+

420 

+

421 Return value (dict): 

+

422 Return dict in the format 

+

423 {"user": {}} 

+

424 Containing the patched user information. 

+

425 

+

426 Raise: 

+

427 AuthException: raised if patch operation fails 

+

428 """ 

+

429 UserBase._validate_status(status) 

+

430 response = await self._http.patch( 

+

431 MgmtV1.user_patch_path, 

+

432 body=UserBase._compose_patch_body( 

+

433 login_id, 

+

434 email, 

+

435 phone, 

+

436 display_name, 

+

437 given_name, 

+

438 middle_name, 

+

439 family_name, 

+

440 role_names, 

+

441 user_tenants, 

+

442 picture, 

+

443 custom_attributes, 

+

444 verified_email, 

+

445 verified_phone, 

+

446 sso_app_ids, 

+

447 status, 

+

448 test, 

+

449 family_associations=family_associations, 

+

450 ), 

+

451 ) 

+

452 return response.json() 

+

453 

+

454 async def patch_batch( 

+

455 self, 

+

456 users: List[UserObj], 

+

457 test: bool = False, 

+

458 ) -> dict: 

+

459 """ 

+

460 Patch users in batch. Only the provided fields will be updated for each user. 

+

461 

+

462 Args: 

+

463 users (List[UserObj]): A list of UserObj instances representing users to be patched. 

+

464 Each UserObj should have a login_id and the fields to be updated. 

+

465 test (bool, optional): Set to True to patch test users. Defaults to False. 

+

466 

+

467 Return value (dict): 

+

468 Return dict in the format 

+

469 {"patchedUsers": [...], "failedUsers": [...]} 

+

470 "patchedUsers" contains successfully patched users, 

+

471 "failedUsers" contains users that failed to be patched with error details. 

+

472 

+

473 Raise: 

+

474 AuthException: raised if patch batch operation fails 

+

475 """ 

+

476 # Validate status fields for all users 

+

477 for user in users: 

+

478 UserBase._validate_status(user.status, user.login_id) 

+

479 

+

480 response = await self._http.patch( 

+

481 MgmtV1.user_patch_batch_path, 

+

482 body=UserBase._compose_patch_batch_body(users, test), 

+

483 ) 

+

484 return response.json() 

+

485 

+

486 async def delete( 

+

487 self, 

+

488 login_id: str, 

+

489 ): 

+

490 """ 

+

491 Delete an existing user. IMPORTANT: This action is irreversible. Use carefully. 

+

492 

+

493 Args: 

+

494 login_id (str): The login ID of the user to be deleted. 

+

495 

+

496 Raise: 

+

497 AuthException: raised if delete operation fails 

+

498 """ 

+

499 await self._http.post( 

+

500 MgmtV1.user_delete_path, 

+

501 body={"loginId": login_id}, 

+

502 ) 

+

503 

+

504 async def delete_by_user_id( 

+

505 self, 

+

506 user_id: str, 

+

507 ): 

+

508 """ 

+

509 Delete an existing user by user ID. IMPORTANT: This action is irreversible. Use carefully. 

+

510 

+

511 Args: 

+

512 user_id (str): The user ID from the user's JWT. 

+

513 

+

514 Raise: 

+

515 AuthException: raised if delete operation fails 

+

516 """ 

+

517 await self._http.post( 

+

518 MgmtV1.user_delete_path, 

+

519 body={"userId": user_id}, 

+

520 ) 

+

521 

+

522 async def delete_all_test_users( 

+

523 self, 

+

524 ): 

+

525 """ 

+

526 Delete all test users in the project. IMPORTANT: This action is irreversible. Use carefully. 

+

527 

+

528 Raise: 

+

529 AuthException: raised if delete operation fails 

+

530 """ 

+

531 await self._http.delete( 

+

532 MgmtV1.user_delete_all_test_users_path, 

+

533 ) 

+

534 

+

535 async def load( 

+

536 self, 

+

537 login_id: str, 

+

538 ) -> dict: 

+

539 """ 

+

540 Load an existing user. 

+

541 

+

542 Args: 

+

543 login_id (str): The login ID of the user to be loaded. 

+

544 

+

545 Return value (dict): 

+

546 Return dict in the format 

+

547 {"user": {}} 

+

548 Containing the loaded user information. 

+

549 

+

550 Raise: 

+

551 AuthException: raised if load operation fails 

+

552 """ 

+

553 response = await self._http.get( 

+

554 uri=MgmtV1.user_load_path, 

+

555 params={"loginId": login_id}, 

+

556 ) 

+

557 return response.json() 

+

558 

+

559 async def load_by_user_id( 

+

560 self, 

+

561 user_id: str, 

+

562 ) -> dict: 

+

563 """ 

+

564 Load an existing user by user ID. 

+

565 The user ID can be found on the user's JWT. 

+

566 

+

567 Args: 

+

568 user_id (str): The user ID from the user's JWT. 

+

569 

+

570 Return value (dict): 

+

571 Return dict in the format 

+

572 {"user": {}} 

+

573 Containing the loaded user information. 

+

574 

+

575 Raise: 

+

576 AuthException: raised if load operation fails 

+

577 """ 

+

578 response = await self._http.get( 

+

579 uri=MgmtV1.user_load_path, 

+

580 params={"userId": user_id}, 

+

581 ) 

+

582 return response.json() 

+

583 

+

584 async def logout_user( 

+

585 self, 

+

586 login_id: str, 

+

587 ): 

+

588 """ 

+

589 Logout a user from all devices. 

+

590 

+

591 Args: 

+

592 login_id (str): The login ID of the user to be logged out. 

+

593 

+

594 Raise: 

+

595 AuthException: raised if logout operation fails 

+

596 """ 

+

597 await self._http.post( 

+

598 MgmtV1.user_logout_path, 

+

599 body={"loginId": login_id}, 

+

600 ) 

+

601 

+

602 async def logout_user_by_user_id( 

+

603 self, 

+

604 user_id: str, 

+

605 ): 

+

606 """ 

+

607 Logout a user from all devices. 

+

608 

+

609 Args: 

+

610 user_id (str): The login ID of the user to be logged out. 

+

611 

+

612 Raise: 

+

613 AuthException: raised if logout operation fails 

+

614 """ 

+

615 await self._http.post( 

+

616 MgmtV1.user_logout_path, 

+

617 body={"userId": user_id}, 

+

618 ) 

+

619 

+

620 async def load_users( 

+

621 self, 

+

622 user_ids: List[str], 

+

623 include_invalid_users: Optional[bool] = None, 

+

624 ) -> dict: 

+

625 """ 

+

626 Load users by their user IDs. 

+

627 

+

628 Args: 

+

629 user_ids (List[str]): Optional list of user IDs to filter by 

+

630 include_invalid_users (bool): Optional flag to include invalid users in the response 

+

631 

+

632 Return value (dict): 

+

633 Return dict in the format 

+

634 {"users": []} 

+

635 "users" contains a list of all of the found users and their information 

+

636 

+

637 Raise: 

+

638 AuthException: raised if search operation fails 

+

639 """ 

+

640 if user_ids is None or len(user_ids) == 0: 

+

641 raise AuthException( 

+

642 400, 

+

643 ERROR_TYPE_INVALID_ARGUMENT, 

+

644 "At least one user id needs to be supplied", 

+

645 ) 

+

646 

+

647 body: dict[str, Union[List[str], bool]] = { 

+

648 "userIds": user_ids, 

+

649 } 

+

650 

+

651 if include_invalid_users is not None: 

+

652 body["includeInvalidUsers"] = include_invalid_users 

+

653 

+

654 response = await self._http.post( 

+

655 MgmtV1.users_load_path, 

+

656 body=body, 

+

657 ) 

+

658 return response.json() 

+

659 

+

660 async def search_all( 

+

661 self, 

+

662 tenant_ids: Optional[List[str]] = None, 

+

663 role_names: Optional[List[str]] = None, 

+

664 limit: int = 0, 

+

665 page: int = 0, 

+

666 test_users_only: bool = False, 

+

667 with_test_user: bool = False, 

+

668 custom_attributes: Optional[dict] = None, 

+

669 statuses: Optional[List[str]] = None, 

+

670 emails: Optional[List[str]] = None, 

+

671 phones: Optional[List[str]] = None, 

+

672 sso_app_ids: Optional[List[str]] = None, 

+

673 sort: Optional[List[Sort]] = None, 

+

674 text: Optional[str] = None, 

+

675 login_ids: Optional[List[str]] = None, 

+

676 from_created_time: Optional[int] = None, 

+

677 to_created_time: Optional[int] = None, 

+

678 from_modified_time: Optional[int] = None, 

+

679 to_modified_time: Optional[int] = None, 

+

680 user_ids: Optional[List[str]] = None, 

+

681 tenant_role_ids: Optional[dict] = None, 

+

682 tenant_role_names: Optional[dict] = None, 

+

683 family_ids: Optional[List[str]] = None, 

+

684 dependent: Optional[bool] = None, 

+

685 lock_reasons: Optional[List[str]] = None, 

+

686 temp_lock_reasons: Optional[List[str]] = None, 

+

687 ) -> dict: 

+

688 """ 

+

689 Search all users. 

+

690 

+

691 Args: 

+

692 tenant_ids (List[str]): Optional list of tenant IDs to filter by 

+

693 role_names (List[str]): Optional list of role names to filter by 

+

694 limit (int): Optional limit of the number of users returned. Leave empty for default. 

+

695 page (int): Optional pagination control. Pages start at 0 and must be non-negative. 

+

696 test_users_only (bool): Optional filter only test users. 

+

697 with_test_user (bool): Optional include test users in search. 

+

698 custom_attributes (dict): Optional search for a attribute with a given value 

+

699 statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") 

+

700 emails (List[str]): Optional list of emails to search for 

+

701 phones (List[str]): Optional list of phones to search for 

+

702 sso_app_ids (List[str]): Optional list of SSO application IDs to filter by 

+

703 text (str): Optional string, allows free text search among all user's attributes. 

+

704 login_ids (List[str]): Optional list of login ids 

+

705 sort (List[Sort]): Optional List[dict], allows to sort by fields. 

+

706 from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) 

+

707 to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) 

+

708 from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) 

+

709 to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) 

+

710 user_ids (List[str]): Optional list of user IDs to filter by 

+

711 tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. 

+

712 Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). 

+

713 tenant_role_names (dict): Optional mapping of tenant ID to list of role names. 

+

714 Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). 

+

715 family_ids (List[str]): Optional list of family IDs. Only users that are members of at least one of these families are returned. 

+

716 dependent (bool): Optional, filter by whether the user is a family dependent (a user with no login credentials of their own). 

+

717 lock_reasons (List[str]): Optional list of lock reasons. Only users disabled by brute-force protection with one of these 

+

718 reasons are returned. Allowed values: "password", "totp", "recovery_codes", "security_questions". 

+

719 temp_lock_reasons (List[str]): Optional list of lock reasons. Only users currently temporarily locked with one of these 

+

720 reasons are returned. Allowed values: "password", "recovery_codes", "security_questions" (TOTP has no temporary lock). 

+

721 A user is temporarily locked while its tempLockExpiration (unix seconds, 0 when none) is greater than now. 

+

722 

+

723 Return value (dict): 

+

724 Return dict in the format 

+

725 {"users": []} 

+

726 "users" contains a list of all of the found users and their information 

+

727 

+

728 Raise: 

+

729 AuthException: raised if search operation fails 

+

730 """ 

+

731 tenant_ids = [] if tenant_ids is None else tenant_ids 

+

732 role_names = [] if role_names is None else role_names 

+

733 

+

734 self._validate_search_pagination(limit, page) 

+

735 body = { 

+

736 "tenantIds": tenant_ids, 

+

737 "roleNames": role_names, 

+

738 "limit": limit, 

+

739 "page": page, 

+

740 "testUsersOnly": test_users_only, 

+

741 "withTestUser": with_test_user, 

+

742 } 

+

743 if statuses is not None: 

+

744 body["statuses"] = statuses 

+

745 

+

746 if emails is not None: 

+

747 body["emails"] = emails 

+

748 

+

749 if phones is not None: 

+

750 body["phones"] = phones 

+

751 

+

752 if custom_attributes is not None: 

+

753 body["customAttributes"] = custom_attributes 

+

754 

+

755 if sso_app_ids is not None: 

+

756 body["ssoAppIds"] = sso_app_ids 

+

757 

+

758 if login_ids is not None: 

+

759 body["loginIds"] = login_ids 

+

760 

+

761 if user_ids is not None: 

+

762 body["userIds"] = user_ids 

+

763 

+

764 if text is not None: 

+

765 body["text"] = text 

+

766 

+

767 if sort is not None: 

+

768 body["sort"] = sort_to_dict(sort) 

+

769 

+

770 if from_created_time is not None: 

+

771 body["fromCreatedTime"] = from_created_time 

+

772 if to_created_time is not None: 

+

773 body["toCreatedTime"] = to_created_time 

+

774 if from_modified_time is not None: 

+

775 body["fromModifiedTime"] = from_modified_time 

+

776 if to_modified_time is not None: 

+

777 body["toModifiedTime"] = to_modified_time 

+

778 

+

779 if tenant_role_ids is not None: 

+

780 body["tenantRoleIds"] = tenant_role_ids 

+

781 if tenant_role_names is not None: 

+

782 body["tenantRoleNames"] = tenant_role_names 

+

783 if family_ids is not None: 

+

784 body["familyIds"] = family_ids 

+

785 if dependent is not None: 

+

786 body["dependent"] = dependent 

+

787 if lock_reasons is not None: 

+

788 body["lockReasons"] = lock_reasons 

+

789 if temp_lock_reasons is not None: 

+

790 body["tempLockReasons"] = temp_lock_reasons 

+

791 

+

792 response = await self._http.post( 

+

793 MgmtV1.users_search_path, 

+

794 body=body, 

+

795 ) 

+

796 return response.json() 

+

797 

+

798 async def search_all_test_users( 

+

799 self, 

+

800 tenant_ids: Optional[List[str]] = None, 

+

801 role_names: Optional[List[str]] = None, 

+

802 limit: int = 0, 

+

803 page: int = 0, 

+

804 custom_attributes: Optional[dict] = None, 

+

805 statuses: Optional[List[str]] = None, 

+

806 emails: Optional[List[str]] = None, 

+

807 phones: Optional[List[str]] = None, 

+

808 sso_app_ids: Optional[List[str]] = None, 

+

809 sort: Optional[List[Sort]] = None, 

+

810 text: Optional[str] = None, 

+

811 login_ids: Optional[List[str]] = None, 

+

812 from_created_time: Optional[int] = None, 

+

813 to_created_time: Optional[int] = None, 

+

814 from_modified_time: Optional[int] = None, 

+

815 to_modified_time: Optional[int] = None, 

+

816 tenant_role_ids: Optional[dict] = None, 

+

817 tenant_role_names: Optional[dict] = None, 

+

818 family_ids: Optional[List[str]] = None, 

+

819 dependent: Optional[bool] = None, 

+

820 lock_reasons: Optional[List[str]] = None, 

+

821 temp_lock_reasons: Optional[List[str]] = None, 

+

822 ) -> dict: 

+

823 """ 

+

824 Search all test users. 

+

825 

+

826 Args: 

+

827 tenant_ids (List[str]): Optional list of tenant IDs to filter by 

+

828 role_names (List[str]): Optional list of role names to filter by 

+

829 limit (int): Optional limit of the number of users returned. Leave empty for default. 

+

830 page (int): Optional pagination control. Pages start at 0 and must be non-negative. 

+

831 custom_attributes (dict): Optional search for a attribute with a given value 

+

832 statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") 

+

833 emails (List[str]): Optional list of emails to search for 

+

834 phones (List[str]): Optional list of phones to search for 

+

835 sso_app_ids (List[str]): Optional list of SSO application IDs to filter by 

+

836 text (str): Optional string, allows free text search among all user's attributes. 

+

837 login_ids (List[str]): Optional list of login ids 

+

838 sort (List[Sort]): Optional List[dict], allows to sort by fields. 

+

839 from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) 

+

840 to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) 

+

841 from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) 

+

842 to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) 

+

843 tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. 

+

844 Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). 

+

845 tenant_role_names (dict): Optional mapping of tenant ID to list of role names. 

+

846 Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). 

+

847 family_ids (List[str]): Optional list of family IDs. Only users that are members of at least one of these families are returned. 

+

848 dependent (bool): Optional, filter by whether the user is a family dependent (a user with no login credentials of their own). 

+

849 lock_reasons (List[str]): Optional list of lock reasons. Only users disabled by brute-force protection with one of these 

+

850 reasons are returned. Allowed values: "password", "totp", "recovery_codes", "security_questions". 

+

851 temp_lock_reasons (List[str]): Optional list of lock reasons. Only users currently temporarily locked with one of these 

+

852 reasons are returned. Allowed values: "password", "recovery_codes", "security_questions" (TOTP has no temporary lock). 

+

853 A user is temporarily locked while its tempLockExpiration (unix seconds, 0 when none) is greater than now. 

+

854 

+

855 Return value (dict): 

+

856 Return dict in the format 

+

857 {"users": []} 

+

858 "users" contains a list of all of the found users and their information 

+

859 

+

860 Raise: 

+

861 AuthException: raised if search operation fails 

+

862 """ 

+

863 tenant_ids = [] if tenant_ids is None else tenant_ids 

+

864 role_names = [] if role_names is None else role_names 

+

865 

+

866 self._validate_search_pagination(limit, page) 

+

867 body = { 

+

868 "tenantIds": tenant_ids, 

+

869 "roleNames": role_names, 

+

870 "limit": limit, 

+

871 "page": page, 

+

872 "testUsersOnly": True, 

+

873 "withTestUser": True, 

+

874 } 

+

875 if statuses is not None: 

+

876 body["statuses"] = statuses 

+

877 

+

878 if emails is not None: 

+

879 body["emails"] = emails 

+

880 

+

881 if phones is not None: 

+

882 body["phones"] = phones 

+

883 

+

884 if custom_attributes is not None: 

+

885 body["customAttributes"] = custom_attributes 

+

886 

+

887 if sso_app_ids is not None: 

+

888 body["ssoAppIds"] = sso_app_ids 

+

889 

+

890 if login_ids is not None: 

+

891 body["loginIds"] = login_ids 

+

892 

+

893 if text is not None: 

+

894 body["text"] = text 

+

895 

+

896 if sort is not None: 

+

897 body["sort"] = sort_to_dict(sort) 

+

898 

+

899 if from_created_time is not None: 

+

900 body["fromCreatedTime"] = from_created_time 

+

901 if to_created_time is not None: 

+

902 body["toCreatedTime"] = to_created_time 

+

903 if from_modified_time is not None: 

+

904 body["fromModifiedTime"] = from_modified_time 

+

905 if to_modified_time is not None: 

+

906 body["toModifiedTime"] = to_modified_time 

+

907 

+

908 if tenant_role_ids is not None: 

+

909 body["tenantRoleIds"] = tenant_role_ids 

+

910 if tenant_role_names is not None: 

+

911 body["tenantRoleNames"] = tenant_role_names 

+

912 if family_ids is not None: 

+

913 body["familyIds"] = family_ids 

+

914 if dependent is not None: 

+

915 body["dependent"] = dependent 

+

916 if lock_reasons is not None: 

+

917 body["lockReasons"] = lock_reasons 

+

918 if temp_lock_reasons is not None: 

+

919 body["tempLockReasons"] = temp_lock_reasons 

+

920 

+

921 response = await self._http.post( 

+

922 MgmtV1.test_users_search_path, 

+

923 body=body, 

+

924 ) 

+

925 return response.json() 

+

926 

+

927 async def get_provider_token( 

+

928 self, 

+

929 login_id: str, 

+

930 provider: str, 

+

931 withRefreshToken: Optional[bool] = False, 

+

932 forceRefresh: Optional[bool] = False, 

+

933 ) -> dict: 

+

934 """ 

+

935 Get the provider token for the given login ID. 

+

936 Only users that sign-in using social providers will have token. 

+

937 Note: The 'Manage tokens from provider' setting must be enabled. 

+

938 

+

939 Args: 

+

940 login_id (str): The login ID of the user. 

+

941 provider (str): The provider name (google, facebook, etc'). 

+

942 withRefreshToken (bool): Optional, set to true to get also the refresh token. 

+

943 forceRefresh (bool): Optional, set to true to force refresh the token. 

+

944 

+

945 Return value (dict): 

+

946 Return dict in the format 

+

947 {"provider": "", "providerUserId": "", "accessToken": "", "expiration": "", "scopes": "[]"} 

+

948 Containing the provider token of the given user and provider. 

+

949 

+

950 Raise: 

+

951 AuthException: raised if the operation fails 

+

952 """ 

+

953 response = await self._http.get( 

+

954 MgmtV1.user_get_provider_token, 

+

955 params={ 

+

956 "loginId": login_id, 

+

957 "provider": provider, 

+

958 "withRefreshToken": withRefreshToken, 

+

959 "forceRefresh": forceRefresh, 

+

960 }, 

+

961 ) 

+

962 return response.json() 

+

963 

+

964 async def activate( 

+

965 self, 

+

966 login_id: str, 

+

967 ) -> dict: 

+

968 """ 

+

969 Activate an existing user. 

+

970 

+

971 Args: 

+

972 login_id (str): The login ID of the user to be activated. 

+

973 

+

974 Return value (dict): 

+

975 Return dict in the format 

+

976 {"user": {}} 

+

977 Containing the updated user information. 

+

978 

+

979 Raise: 

+

980 AuthException: raised if activate operation fails 

+

981 """ 

+

982 response = await self._http.post( 

+

983 MgmtV1.user_update_status_path, 

+

984 body={"loginId": login_id, "status": "enabled"}, 

+

985 ) 

+

986 return response.json() 

+

987 

+

988 async def deactivate( 

+

989 self, 

+

990 login_id: str, 

+

991 ) -> dict: 

+

992 """ 

+

993 Deactivate an existing user. 

+

994 

+

995 Args: 

+

996 login_id (str): The login ID of the user to be deactivated. 

+

997 

+

998 Return value (dict): 

+

999 Return dict in the format 

+

1000 {"user": {}} 

+

1001 Containing the updated user information. 

+

1002 

+

1003 Raise: 

+

1004 AuthException: raised if deactivate operation fails 

+

1005 """ 

+

1006 response = await self._http.post( 

+

1007 MgmtV1.user_update_status_path, 

+

1008 body={"loginId": login_id, "status": "disabled"}, 

+

1009 ) 

+

1010 return response.json() 

+

1011 

+

1012 async def update_login_id( 

+

1013 self, 

+

1014 login_id: str, 

+

1015 new_login_id: Optional[str] = None, 

+

1016 ) -> dict: 

+

1017 """ 

+

1018 Update login id of user, leave new login empty to remove the ID. 

+

1019 A user must have at least one login ID. Trying to remove the last one will fail. 

+

1020 

+

1021 Args: 

+

1022 login_id (str): The login ID of the user to update. 

+

1023 new_login_id (str): New login ID to set for the user. 

+

1024 

+

1025 Return value (dict): 

+

1026 Return dict in the format 

+

1027 {"user": {}} 

+

1028 Containing the updated user information. 

+

1029 

+

1030 Raise: 

+

1031 AuthException: raised if the update operation fails 

+

1032 """ 

+

1033 response = await self._http.post( 

+

1034 MgmtV1.user_update_login_id_path, 

+

1035 body={"loginId": login_id, "newLoginId": new_login_id}, 

+

1036 ) 

+

1037 return response.json() 

+

1038 

+

1039 async def update_email( 

+

1040 self, 

+

1041 login_id: str, 

+

1042 email: Optional[str] = None, 

+

1043 verified: Optional[bool] = None, 

+

1044 fail_on_conflict: Optional[bool] = None, 

+

1045 ) -> dict: 

+

1046 """ 

+

1047 Update the email address for an existing user. 

+

1048 

+

1049 Args: 

+

1050 login_id (str): The login ID of the user to update the email for. 

+

1051 email (str): The user email address. Leave empty to remove. 

+

1052 verified (bool): Set to true for the user to be able to login with the email address. 

+

1053 fail_on_conflict (bool): Set to true to raise an error if the email is used as a login id and already exists. 

+

1054 

+

1055 Return value (dict): 

+

1056 Return dict in the format 

+

1057 {"user": {}} 

+

1058 Containing the updated user information. 

+

1059 

+

1060 Raise: 

+

1061 AuthException: raised if the update operation fails 

+

1062 """ 

+

1063 response = await self._http.post( 

+

1064 MgmtV1.user_update_email_path, 

+

1065 body={ 

+

1066 "loginId": login_id, 

+

1067 "email": email, 

+

1068 "verified": verified, 

+

1069 "failOnConflict": fail_on_conflict, 

+

1070 }, 

+

1071 ) 

+

1072 return response.json() 

+

1073 

+

1074 async def update_phone( 

+

1075 self, 

+

1076 login_id: str, 

+

1077 phone: Optional[str] = None, 

+

1078 verified: Optional[bool] = None, 

+

1079 fail_on_conflict: Optional[bool] = None, 

+

1080 ) -> dict: 

+

1081 """ 

+

1082 Update the phone number for an existing user. 

+

1083 

+

1084 Args: 

+

1085 login_id (str): The login ID of the user to update the phone for. 

+

1086 phone (str): The user phone number. Leave empty to remove. 

+

1087 verified (bool): Set to true for the user to be able to login with the phone number. 

+

1088 fail_on_conflict (bool): Set to true to raise an error if the phone is used as a login id and already exists. 

+

1089 

+

1090 Return value (dict): 

+

1091 Return dict in the format 

+

1092 {"user": {}} 

+

1093 Containing the updated user information. 

+

1094 

+

1095 Raise: 

+

1096 AuthException: raised if the update operation fails 

+

1097 """ 

+

1098 response = await self._http.post( 

+

1099 MgmtV1.user_update_phone_path, 

+

1100 body={ 

+

1101 "loginId": login_id, 

+

1102 "phone": phone, 

+

1103 "verified": verified, 

+

1104 "failOnConflict": fail_on_conflict, 

+

1105 }, 

+

1106 ) 

+

1107 return response.json() 

+

1108 

+

1109 async def update_display_name( 

+

1110 self, 

+

1111 login_id: str, 

+

1112 display_name: Optional[str] = None, 

+

1113 given_name: Optional[str] = None, 

+

1114 middle_name: Optional[str] = None, 

+

1115 family_name: Optional[str] = None, 

+

1116 ) -> dict: 

+

1117 """ 

+

1118 Update the display name for an existing user. 

+

1119 

+

1120 Args: 

+

1121 login_id (str): The login ID of the user to update. 

+

1122 display_name (str): Optional user display name. Leave empty to remove. 

+

1123 

+

1124 Return value (dict): 

+

1125 Return dict in the format 

+

1126 {"user": {}} 

+

1127 Containing the updated user information. 

+

1128 

+

1129 Raise: 

+

1130 AuthException: raised if the update operation fails 

+

1131 """ 

+

1132 bdy = {"loginId": login_id} 

+

1133 if display_name is not None: 

+

1134 bdy["displayName"] = display_name 

+

1135 if given_name is not None: 

+

1136 bdy["givenName"] = given_name 

+

1137 if middle_name is not None: 

+

1138 bdy["middleName"] = middle_name 

+

1139 if family_name is not None: 

+

1140 bdy["familyName"] = family_name 

+

1141 response = await self._http.post( 

+

1142 MgmtV1.user_update_name_path, 

+

1143 body=bdy, 

+

1144 ) 

+

1145 return response.json() 

+

1146 

+

1147 async def update_picture( 

+

1148 self, 

+

1149 login_id: str, 

+

1150 picture: Optional[str] = None, 

+

1151 ) -> dict: 

+

1152 """ 

+

1153 Update the picture for an existing user. 

+

1154 

+

1155 Args: 

+

1156 login_id (str): The login ID of the user to update. 

+

1157 picture (str): Optional url to user avatar. Leave empty to remove. 

+

1158 

+

1159 Return value (dict): 

+

1160 Return dict in the format 

+

1161 {"user": {}} 

+

1162 Containing the updated user information. 

+

1163 

+

1164 Raise: 

+

1165 AuthException: raised if the update operation fails 

+

1166 """ 

+

1167 response = await self._http.post( 

+

1168 MgmtV1.user_update_picture_path, 

+

1169 body={"loginId": login_id, "picture": picture}, 

+

1170 ) 

+

1171 return response.json() 

+

1172 

+

1173 async def update_custom_attribute( 

+

1174 self, login_id: str, attribute_key: str, attribute_val: Union[str, int, bool] 

+

1175 ) -> dict: 

+

1176 """ 

+

1177 Update a custom attribute of an existing user. 

+

1178 

+

1179 Args: 

+

1180 login_id (str): The login ID of the user to update. 

+

1181 attribute_key (str): The custom attribute that needs to be updated, this attribute needs to exists in Descope console app 

+

1182 attribute_val: The value to be updated 

+

1183 

+

1184 Return value (dict): 

+

1185 Return dict in the format 

+

1186 {"user": {}} 

+

1187 Containing the updated user information. 

+

1188 

+

1189 Raise: 

+

1190 AuthException: raised if the update operation fails 

+

1191 """ 

+

1192 response = await self._http.post( 

+

1193 MgmtV1.user_update_custom_attribute_path, 

+

1194 body={ 

+

1195 "loginId": login_id, 

+

1196 "attributeKey": attribute_key, 

+

1197 "attributeValue": attribute_val, 

+

1198 }, 

+

1199 ) 

+

1200 return response.json() 

+

1201 

+

1202 async def set_roles( 

+

1203 self, 

+

1204 login_id: str, 

+

1205 role_names: List[str], 

+

1206 ) -> dict: 

+

1207 """ 

+

1208 Set roles to a user without tenant association. Use set_tenant_roles 

+

1209 for users that are part of a multi-tenant project. 

+

1210 

+

1211 Args: 

+

1212 login_id (str): The login ID of the user to update. 

+

1213 role_names (List[str]): A list of roles to set to a user without tenant association. 

+

1214 

+

1215 Return value (dict): 

+

1216 Return dict in the format 

+

1217 {"user": {}} 

+

1218 Containing the updated user information. 

+

1219 

+

1220 Raise: 

+

1221 AuthException: raised if the operation fails 

+

1222 """ 

+

1223 response = await self._http.post( 

+

1224 MgmtV1.user_set_role_path, 

+

1225 body={"loginId": login_id, "roleNames": role_names}, 

+

1226 ) 

+

1227 return response.json() 

+

1228 

+

1229 async def add_roles( 

+

1230 self, 

+

1231 login_id: str, 

+

1232 role_names: List[str], 

+

1233 ) -> dict: 

+

1234 """ 

+

1235 Add roles to a user without tenant association. Use add_tenant_roles 

+

1236 for users that are part of a multi-tenant project. 

+

1237 

+

1238 Args: 

+

1239 login_id (str): The login ID of the user to update. 

+

1240 role_names (List[str]): A list of roles to add to a user without tenant association. 

+

1241 

+

1242 Return value (dict): 

+

1243 Return dict in the format 

+

1244 {"user": {}} 

+

1245 Containing the updated user information. 

+

1246 

+

1247 Raise: 

+

1248 AuthException: raised if the operation fails 

+

1249 """ 

+

1250 response = await self._http.post( 

+

1251 MgmtV1.user_add_role_path, 

+

1252 body={"loginId": login_id, "roleNames": role_names}, 

+

1253 ) 

+

1254 return response.json() 

+

1255 

+

1256 async def remove_roles( 

+

1257 self, 

+

1258 login_id: str, 

+

1259 role_names: List[str], 

+

1260 ) -> dict: 

+

1261 """ 

+

1262 Remove roles from a user without tenant association. Use remove_tenant_roles 

+

1263 for users that are part of a multi-tenant project. 

+

1264 

+

1265 Args: 

+

1266 login_id (str): The login ID of the user to update. 

+

1267 role_names (List[str]): A list of roles to remove from a user without tenant association. 

+

1268 

+

1269 Return value (dict): 

+

1270 Return dict in the format 

+

1271 {"user": {}} 

+

1272 Containing the updated user information. 

+

1273 

+

1274 Raise: 

+

1275 AuthException: raised if the operation fails 

+

1276 """ 

+

1277 response = await self._http.post( 

+

1278 MgmtV1.user_remove_role_path, 

+

1279 body={"loginId": login_id, "roleNames": role_names}, 

+

1280 ) 

+

1281 return response.json() 

+

1282 

+

1283 async def set_sso_apps( 

+

1284 self, 

+

1285 login_id: str, 

+

1286 sso_app_ids: List[str], 

+

1287 ) -> dict: 

+

1288 """ 

+

1289 Set SSO applications association to a user. 

+

1290 

+

1291 Args: 

+

1292 login_id (str): The login ID of the user to update. 

+

1293 sso_app_ids (List[str]): A list of sso applications ids for associate with a user. 

+

1294 

+

1295 Return value (dict): 

+

1296 Return dict in the format 

+

1297 {"user": {}} 

+

1298 Containing the updated user information. 

+

1299 

+

1300 Raise: 

+

1301 AuthException: raised if the operation fails 

+

1302 """ 

+

1303 response = await self._http.post( 

+

1304 MgmtV1.user_set_sso_apps, 

+

1305 body={"loginId": login_id, "ssoAppIds": sso_app_ids}, 

+

1306 ) 

+

1307 return response.json() 

+

1308 

+

1309 async def add_sso_apps( 

+

1310 self, 

+

1311 login_id: str, 

+

1312 sso_app_ids: List[str], 

+

1313 ) -> dict: 

+

1314 """ 

+

1315 Add SSO applications association to a user. 

+

1316 

+

1317 Args: 

+

1318 login_id (str): The login ID of the user to update. 

+

1319 sso_app_ids (List[str]): A list of sso applications ids for associate with a user. 

+

1320 

+

1321 Return value (dict): 

+

1322 Return dict in the format 

+

1323 {"user": {}} 

+

1324 Containing the updated user information. 

+

1325 

+

1326 Raise: 

+

1327 AuthException: raised if the operation fails 

+

1328 """ 

+

1329 response = await self._http.post( 

+

1330 MgmtV1.user_add_sso_apps, 

+

1331 body={"loginId": login_id, "ssoAppIds": sso_app_ids}, 

+

1332 ) 

+

1333 return response.json() 

+

1334 

+

1335 async def remove_sso_apps( 

+

1336 self, 

+

1337 login_id: str, 

+

1338 sso_app_ids: List[str], 

+

1339 ) -> dict: 

+

1340 """ 

+

1341 Remove SSO applications association from a user. 

+

1342 

+

1343 Args: 

+

1344 login_id (str): The login ID of the user to update. 

+

1345 sso_app_ids (List[str]): A list of sso applications ids to remove association from a user. 

+

1346 

+

1347 Return value (dict): 

+

1348 Return dict in the format 

+

1349 {"user": {}} 

+

1350 Containing the updated user information. 

+

1351 

+

1352 Raise: 

+

1353 AuthException: raised if the operation fails 

+

1354 """ 

+

1355 response = await self._http.post( 

+

1356 MgmtV1.user_remove_sso_apps, 

+

1357 body={"loginId": login_id, "ssoAppIds": sso_app_ids}, 

+

1358 ) 

+

1359 return response.json() 

+

1360 

+

1361 async def add_tenant( 

+

1362 self, 

+

1363 login_id: str, 

+

1364 tenant_id: str, 

+

1365 ) -> dict: 

+

1366 """ 

+

1367 Add a tenant association to an existing user. 

+

1368 

+

1369 Args: 

+

1370 login_id (str): The login ID of the user to update. 

+

1371 tenant_id (str): The ID of the tenant to add to the user. 

+

1372 

+

1373 Return value (dict): 

+

1374 Return dict in the format 

+

1375 {"user": {}} 

+

1376 Containing the updated user information. 

+

1377 

+

1378 Raise: 

+

1379 AuthException: raised if the operation fails 

+

1380 """ 

+

1381 response = await self._http.post( 

+

1382 MgmtV1.user_add_tenant_path, 

+

1383 body={"loginId": login_id, "tenantId": tenant_id}, 

+

1384 ) 

+

1385 return response.json() 

+

1386 

+

1387 async def remove_tenant( 

+

1388 self, 

+

1389 login_id: str, 

+

1390 tenant_id: str, 

+

1391 ) -> dict: 

+

1392 """ 

+

1393 Remove a tenant association from an existing user. 

+

1394 

+

1395 Args: 

+

1396 login_id (str): The login ID of the user to update. 

+

1397 tenant_id (str): The ID of the tenant to add to the user. 

+

1398 

+

1399 Return value (dict): 

+

1400 Return dict in the format 

+

1401 {"user": {}} 

+

1402 Containing the updated user information. 

+

1403 

+

1404 Raise: 

+

1405 AuthException: raised if the operation fails 

+

1406 """ 

+

1407 response = await self._http.post( 

+

1408 MgmtV1.user_remove_tenant_path, 

+

1409 body={"loginId": login_id, "tenantId": tenant_id}, 

+

1410 ) 

+

1411 return response.json() 

+

1412 

+

1413 async def add_families( 

+

1414 self, 

+

1415 login_id: str, 

+

1416 family_associations: List[AssociatedFamily], 

+

1417 ) -> dict: 

+

1418 """ 

+

1419 Add a user to one or more families. Each association may also set the user's roles and 

+

1420 family-scoped attribute values in that family. Adding a family the user already belongs to 

+

1421 merges: given role names are granted on top of the existing ones, given family-scoped 

+

1422 attributes are merged into the stored ones, and omitting either leaves it unchanged. 

+

1423 

+

1424 Args: 

+

1425 login_id (str): The login ID of the user to update. 

+

1426 family_associations (List[AssociatedFamily]): The families to add the user to (at least one). 

+

1427 

+

1428 Return value (dict): 

+

1429 Return dict in the format 

+

1430 {"user": {}} 

+

1431 Containing the updated user information. 

+

1432 

+

1433 Raise: 

+

1434 AuthException: raised if the operation fails 

+

1435 """ 

+

1436 response = await self._http.post( 

+

1437 MgmtV1.user_add_families_path, 

+

1438 body={ 

+

1439 "loginId": login_id, 

+

1440 "familyAssociations": associated_families_to_dict(family_associations), 

+

1441 }, 

+

1442 ) 

+

1443 return response.json() 

+

1444 

+

1445 async def remove_families( 

+

1446 self, 

+

1447 login_id: str, 

+

1448 family_ids: List[str], 

+

1449 ) -> dict: 

+

1450 """ 

+

1451 Remove a user from one or more families. 

+

1452 

+

1453 Args: 

+

1454 login_id (str): The login ID of the user to update. 

+

1455 family_ids (List[str]): The IDs of the families to remove the user from (at least one). 

+

1456 

+

1457 Return value (dict): 

+

1458 Return dict in the format 

+

1459 {"user": {}} 

+

1460 Containing the updated user information. 

+

1461 

+

1462 Raise: 

+

1463 AuthException: raised if the operation fails 

+

1464 """ 

+

1465 response = await self._http.post( 

+

1466 MgmtV1.user_remove_families_path, 

+

1467 body={"loginId": login_id, "familyIds": family_ids}, 

+

1468 ) 

+

1469 return response.json() 

+

1470 

+

1471 async def set_tenant_roles( 

+

1472 self, 

+

1473 login_id: str, 

+

1474 tenant_id: str, 

+

1475 role_names: List[str], 

+

1476 ) -> dict: 

+

1477 """ 

+

1478 Set roles to a user in a specific tenant. 

+

1479 

+

1480 Args: 

+

1481 login_id (str): The login ID of the user to update. 

+

1482 tenant_id (str): The ID of the user's tenant. 

+

1483 role_names (List[str]): A list of roles to set on the user. 

+

1484 

+

1485 Return value (dict): 

+

1486 Return dict in the format 

+

1487 {"user": {}} 

+

1488 Containing the updated user information. 

+

1489 

+

1490 Raise: 

+

1491 AuthException: raised if the operation fails 

+

1492 """ 

+

1493 response = await self._http.post( 

+

1494 MgmtV1.user_set_role_path, 

+

1495 body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, 

+

1496 ) 

+

1497 return response.json() 

+

1498 

+

1499 async def add_tenant_roles( 

+

1500 self, 

+

1501 login_id: str, 

+

1502 tenant_id: str, 

+

1503 role_names: List[str], 

+

1504 ) -> dict: 

+

1505 """ 

+

1506 Add roles to a user in a specific tenant. 

+

1507 

+

1508 Args: 

+

1509 login_id (str): The login ID of the user to update. 

+

1510 tenant_id (str): The ID of the user's tenant. 

+

1511 role_names (List[str]): A list of roles to add to the user. 

+

1512 

+

1513 Return value (dict): 

+

1514 Return dict in the format 

+

1515 {"user": {}} 

+

1516 Containing the updated user information. 

+

1517 

+

1518 Raise: 

+

1519 AuthException: raised if the operation fails 

+

1520 """ 

+

1521 response = await self._http.post( 

+

1522 MgmtV1.user_add_role_path, 

+

1523 body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, 

+

1524 ) 

+

1525 return response.json() 

+

1526 

+

1527 async def remove_tenant_roles( 

+

1528 self, 

+

1529 login_id: str, 

+

1530 tenant_id: str, 

+

1531 role_names: List[str], 

+

1532 ) -> dict: 

+

1533 """ 

+

1534 Remove roles from a user in a specific tenant. 

+

1535 

+

1536 Args: 

+

1537 login_id (str): The login ID of the user to update. 

+

1538 tenant_id (str): The ID of the user's tenant. 

+

1539 role_names (List[str]): A list of roles to remove from the user. 

+

1540 

+

1541 Return value (dict): 

+

1542 Return dict in the format 

+

1543 {"user": {}} 

+

1544 Containing the updated user information. 

+

1545 

+

1546 Raise: 

+

1547 AuthException: raised if the operation fails 

+

1548 """ 

+

1549 response = await self._http.post( 

+

1550 MgmtV1.user_remove_role_path, 

+

1551 body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, 

+

1552 ) 

+

1553 return response.json() 

+

1554 

+

1555 async def set_temporary_password( 

+

1556 self, 

+

1557 login_id: str, 

+

1558 password: str, 

+

1559 ) -> None: 

+

1560 """ 

+

1561 Set the temporary password for the given login ID. 

+

1562 Note: The password will automatically be set as expired. 

+

1563 The user will not be able to log-in with this password, and will be required to replace it on next login. 

+

1564 See also: expire_password 

+

1565 

+

1566 Args: 

+

1567 login_id (str): The login ID of the user to set the password to. 

+

1568 password (str): The new password to set to the user. 

+

1569 

+

1570 Raise: 

+

1571 AuthException: raised if the operation fails 

+

1572 """ 

+

1573 await self._http.post( 

+

1574 MgmtV1.user_set_temporary_password_path, 

+

1575 body={ 

+

1576 "loginId": login_id, 

+

1577 "password": password, 

+

1578 "setActive": False, 

+

1579 }, 

+

1580 ) 

+

1581 return 

+

1582 

+

1583 async def set_active_password( 

+

1584 self, 

+

1585 login_id: str, 

+

1586 password: str, 

+

1587 ) -> None: 

+

1588 """ 

+

1589 Set the password for the given login ID. 

+

1590 

+

1591 Args: 

+

1592 login_id (str): The login ID of the user to set the password to. 

+

1593 password (str): The new password to set to the user. 

+

1594 

+

1595 Raise: 

+

1596 AuthException: raised if the operation fails 

+

1597 """ 

+

1598 await self._http.post( 

+

1599 MgmtV1.user_set_active_password_path, 

+

1600 body={ 

+

1601 "loginId": login_id, 

+

1602 "password": password, 

+

1603 "setActive": True, 

+

1604 }, 

+

1605 ) 

+

1606 return 

+

1607 

+

1608 # Deprecated (use set_temporary_password instead) 

+

1609 async def set_password( 

+

1610 self, 

+

1611 login_id: str, 

+

1612 password: str, 

+

1613 ) -> None: 

+

1614 """ 

+

1615 Set the password for the given login ID. 

+

1616 Note: The password will automatically be set as expired. 

+

1617 The user will not be able to log-in with this password, and will be required to replace it on next login. 

+

1618 See also: expire_password 

+

1619 

+

1620 Args: 

+

1621 login_id (str): The login ID of the user to set the password to. 

+

1622 password (str): The new password to set to the user. 

+

1623 

+

1624 Raise: 

+

1625 AuthException: raised if the operation fails 

+

1626 """ 

+

1627 await self._http.post( 

+

1628 MgmtV1.user_set_password_path, 

+

1629 body={ 

+

1630 "loginId": login_id, 

+

1631 "password": password, 

+

1632 }, 

+

1633 ) 

+

1634 return 

+

1635 

+

1636 async def expire_password( 

+

1637 self, 

+

1638 login_id: str, 

+

1639 ) -> None: 

+

1640 """ 

+

1641 Expires the password for the given login ID. 

+

1642 Note: user sign-in with an expired password, the user will get an error with code. 

+

1643 Use the `password.send_reset` or `password.replace` methods to reset/replace the password. 

+

1644 

+

1645 Args: 

+

1646 login_id (str): The login ID of the user to expire the password to. 

+

1647 

+

1648 Raise: 

+

1649 AuthException: raised if the operation fails 

+

1650 """ 

+

1651 await self._http.post( 

+

1652 MgmtV1.user_expire_password_path, 

+

1653 body={"loginId": login_id}, 

+

1654 ) 

+

1655 return 

+

1656 

+

1657 async def remove_all_passkeys( 

+

1658 self, 

+

1659 login_id: str, 

+

1660 ) -> None: 

+

1661 """ 

+

1662 Removes all registered passkeys (WebAuthn devices) for the user with the given login ID. 

+

1663 Note: The user might not be able to login anymore if they have no other authentication 

+

1664 methods or a verified email/phone. 

+

1665 

+

1666 Args: 

+

1667 login_id (str): The login ID of the user to remove passkeys for. 

+

1668 

+

1669 Raise: 

+

1670 AuthException: raised if the operation fails 

+

1671 """ 

+

1672 await self._http.post( 

+

1673 MgmtV1.user_remove_all_passkeys_path, 

+

1674 body={"loginId": login_id}, 

+

1675 ) 

+

1676 return 

+

1677 

+

1678 async def remove_totp_seed( 

+

1679 self, 

+

1680 login_id: str, 

+

1681 ) -> None: 

+

1682 """ 

+

1683 Removes TOTP seed for the user with the given login ID. 

+

1684 Note: The user might not be able to login anymore if they have no other authentication 

+

1685 methods or a verified email/phone. 

+

1686 

+

1687 Args: 

+

1688 login_id (str): The login ID of the user to remove totp seed for. 

+

1689 

+

1690 Raise: 

+

1691 AuthException: raised if the operation fails 

+

1692 """ 

+

1693 await self._http.post( 

+

1694 MgmtV1.user_remove_totp_seed_path, 

+

1695 body={"loginId": login_id}, 

+

1696 ) 

+

1697 return 

+

1698 

+

1699 async def remove_recovery_codes( 

+

1700 self, 

+

1701 login_id: str, 

+

1702 ) -> None: 

+

1703 """ 

+

1704 Removes all recovery codes for the user with the given login ID or user ID. 

+

1705 

+

1706 Args: 

+

1707 login_id (str): The login ID or user ID of the user to remove recovery codes for. 

+

1708 

+

1709 Raise: 

+

1710 AuthException: raised if the operation fails 

+

1711 """ 

+

1712 await self._http.post( 

+

1713 MgmtV1.user_remove_recovery_codes_path, 

+

1714 body={"loginId": login_id}, 

+

1715 ) 

+

1716 return 

+

1717 

+

1718 async def generate_otp_for_test_user( 

+

1719 self, 

+

1720 method: DeliveryMethod, 

+

1721 login_id: str, 

+

1722 login_options: Optional[LoginOptions] = None, 

+

1723 ) -> dict: 

+

1724 """ 

+

1725 Generate OTP for the given login ID of a test user. 

+

1726 This is useful when running tests and don't want to use 3rd party messaging services. 

+

1727 

+

1728 Args: 

+

1729 method (DeliveryMethod): The method to use for "delivering" the OTP verification code to the user, for example 

+

1730 EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED 

+

1731 login_id (str): The login ID of the test user being validated. 

+

1732 login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. 

+

1733 

+

1734 Return value (dict): 

+

1735 Return dict in the format 

+

1736 {"code": "", "loginId": ""} 

+

1737 Containing the code for the login (exactly as it sent via Email or Phone messaging). 

+

1738 

+

1739 Raise: 

+

1740 AuthException: raised if the operation fails 

+

1741 """ 

+

1742 response = await self._http.post( 

+

1743 MgmtV1.user_generate_otp_for_test_path, 

+

1744 body={ 

+

1745 "loginId": login_id, 

+

1746 "deliveryMethod": get_method_string(method), 

+

1747 "loginOptions": login_options.__dict__ if login_options else {}, 

+

1748 }, 

+

1749 ) 

+

1750 return response.json() 

+

1751 

+

1752 async def generate_magic_link_for_test_user( 

+

1753 self, 

+

1754 method: DeliveryMethod, 

+

1755 login_id: str, 

+

1756 uri: str, 

+

1757 login_options: Optional[LoginOptions] = None, 

+

1758 ) -> dict: 

+

1759 """ 

+

1760 Generate Magic Link for the given login ID of a test user. 

+

1761 This is useful when running tests and don't want to use 3rd party messaging services. 

+

1762 

+

1763 Args: 

+

1764 method (DeliveryMethod): The method to use for "delivering" the verification magic link to the user, for example 

+

1765 EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED 

+

1766 login_id (str): The login ID of the test user being validated. 

+

1767 uri (str): Optional redirect uri which will be used instead of any global configuration. 

+

1768 login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. 

+

1769 

+

1770 Return value (dict): 

+

1771 Return dict in the format 

+

1772 {"link": "", "loginId": ""} 

+

1773 Containing the magic link for the login (exactly as it sent via Email or Phone messaging). 

+

1774 

+

1775 Raise: 

+

1776 AuthException: raised if the operation fails 

+

1777 """ 

+

1778 response = await self._http.post( 

+

1779 MgmtV1.user_generate_magic_link_for_test_path, 

+

1780 body={ 

+

1781 "loginId": login_id, 

+

1782 "deliveryMethod": get_method_string(method), 

+

1783 "URI": uri, 

+

1784 "loginOptions": login_options.__dict__ if login_options else {}, 

+

1785 }, 

+

1786 ) 

+

1787 return response.json() 

+

1788 

+

1789 async def generate_enchanted_link_for_test_user( 

+

1790 self, 

+

1791 login_id: str, 

+

1792 uri: str, 

+

1793 login_options: Optional[LoginOptions] = None, 

+

1794 ) -> dict: 

+

1795 """ 

+

1796 Generate Enchanted Link for the given login ID of a test user. 

+

1797 This is useful when running tests and don't want to use 3rd party messaging services. 

+

1798 

+

1799 Args: 

+

1800 login_id (str): The login ID of the test user being validated. 

+

1801 uri (str): Optional redirect uri which will be used instead of any global configuration. 

+

1802 login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. 

+

1803 

+

1804 Return value (dict): 

+

1805 Return dict in the format 

+

1806 {"link": "", "loginId": "", "pendingRef": ""} 

+

1807 Containing the enchanted link for the login (exactly as it sent via Email or Phone messaging) and pendingRef. 

+

1808 

+

1809 Raise: 

+

1810 AuthException: raised if the operation fails 

+

1811 """ 

+

1812 response = await self._http.post( 

+

1813 MgmtV1.user_generate_enchanted_link_for_test_path, 

+

1814 body={ 

+

1815 "loginId": login_id, 

+

1816 "URI": uri, 

+

1817 "loginOptions": login_options.__dict__ if login_options else {}, 

+

1818 }, 

+

1819 ) 

+

1820 return response.json() 

+

1821 

+

1822 async def generate_embedded_link( 

+

1823 self, login_id: str, custom_claims: Optional[dict] = None, timeout: int = 0 

+

1824 ) -> str: 

+

1825 """ 

+

1826 Generate Embedded Link for the given user login ID. 

+

1827 The return value is a token that can be verified via magic link, or using flows 

+

1828 

+

1829 Args: 

+

1830 login_id (str): The login ID of the user to authenticate with. 

+

1831 custom_claims (dict): Additional claims to place on the jwt after verification 

+

1832 

+

1833 Return value (str): 

+

1834 Return the token to be used in verification process 

+

1835 

+

1836 Raise: 

+

1837 AuthException: raised if the operation fails 

+

1838 """ 

+

1839 response = await self._http.post( 

+

1840 MgmtV1.user_generate_embedded_link_path, 

+

1841 body={ 

+

1842 "loginId": login_id, 

+

1843 "customClaims": custom_claims, 

+

1844 "timeout": timeout, 

+

1845 }, 

+

1846 ) 

+

1847 return response.json()["token"] 

+

1848 

+

1849 async def generate_sign_up_embedded_link( 

+

1850 self, 

+

1851 login_id: str, 

+

1852 user: Optional[CreateUserObj] = None, 

+

1853 email_verified: bool = False, 

+

1854 phone_verified: bool = False, 

+

1855 login_options: Optional[LoginOptions] = None, 

+

1856 timeout: int = 0, 

+

1857 ) -> str: 

+

1858 """ 

+

1859 Generate sign up Embedded Link for the given user login ID. 

+

1860 The return value is a token that can be verified via magic link, or using flows 

+

1861 

+

1862 Args: 

+

1863 login_id (str): The login ID of the user to authenticate with. 

+

1864 user (CreateUserObj): Optional user object to create the user with 

+

1865 email_verified (bool): Optional, set to true if the email is verified 

+

1866 phone_verified (bool): Optional, set to true if the phone is verified 

+

1867 login_options (LoginOptions): Optional login options to customize the link 

+

1868 timeout (int): Optional, the timeout in seconds for the link to be valid 

+

1869 

+

1870 Return value (str): 

+

1871 Return the token to be used in verification process 

+

1872 

+

1873 Raise: 

+

1874 AuthException: raised if the operation fails 

+

1875 """ 

+

1876 response = await self._http.post( 

+

1877 MgmtV1.user_generate_sign_up_embedded_link_path, 

+

1878 body={ 

+

1879 "loginId": login_id, 

+

1880 "user": user.__dict__ if user else {}, 

+

1881 "loginOptions": login_options.__dict__ if login_options else {}, 

+

1882 "emailVerified": email_verified, 

+

1883 "phoneVerified": phone_verified, 

+

1884 "timeout": timeout, 

+

1885 }, 

+

1886 ) 

+

1887 return response.json()["token"] 

+

1888 

+

1889 async def history(self, user_ids: List[str]) -> List[dict]: 

+

1890 """ 

+

1891 Retrieve users' authentication history, by the given user's ids. 

+

1892 

+

1893 Args: 

+

1894 login_ids (List[str]): List of Users' IDs. 

+

1895 

+

1896 Return value (List[dict]): 

+

1897 Return List in the format 

+

1898 [ 

+

1899 { 

+

1900 "userId": "User's ID", 

+

1901 "loginTime": "User'sLogin time", 

+

1902 "city": "User's city", 

+

1903 "country": "User's country", 

+

1904 "ip": User's IP 

+

1905 } 

+

1906 ] 

+

1907 

+

1908 Raise: 

+

1909 AuthException: raised if the operation fails 

+

1910 """ 

+

1911 response = await self._http.post( 

+

1912 MgmtV1.user_history_path, 

+

1913 body=user_ids, 

+

1914 ) 

+

1915 return response.json() 

+

1916 

+

1917 async def create_custom_attribute( 

+

1918 self, 

+

1919 name: str, 

+

1920 display_name: str, 

+

1921 type: str, 

+

1922 required: Optional[bool] = None, 

+

1923 options: Optional[List[str]] = None, 

+

1924 ) -> dict: 

+

1925 """ 

+

1926 Create a new custom attribute for users. 

+

1927 

+

1928 Args: 

+

1929 name (str): The name of the custom attribute. 

+

1930 display_name (str): The display name of the custom attribute. 

+

1931 type (str): The type of the custom attribute (e.g., "string", "number", "boolean"). 

+

1932 required (bool): Optional, whether the custom attribute is required. 

+

1933 options (List[str]): Optional, list of options for the custom attribute. 

+

1934 

+

1935 Return value (dict): 

+

1936 Return dict containing the custom attributes list. 

+

1937 

+

1938 Raise: 

+

1939 AuthException: raised if the operation fails 

+

1940 """ 

+

1941 body: dict = { 

+

1942 "attributes": [ 

+

1943 { 

+

1944 "name": name, 

+

1945 "displayName": display_name, 

+

1946 "type": type, 

+

1947 } 

+

1948 ] 

+

1949 } 

+

1950 if required is not None: 

+

1951 body["attributes"][0]["required"] = required 

+

1952 if options is not None: 

+

1953 body["attributes"][0]["options"] = options 

+

1954 

+

1955 response = await self._http.post( 

+

1956 MgmtV1.user_create_custom_attribute_path, 

+

1957 body=body, 

+

1958 ) 

+

1959 return response.json() 

+

1960 

+

1961 async def delete_custom_attribute( 

+

1962 self, 

+

1963 name: str, 

+

1964 ) -> dict: 

+

1965 """ 

+

1966 Delete a custom attribute for users. 

+

1967 

+

1968 Args: 

+

1969 name (str): The name of the custom attribute to delete. 

+

1970 

+

1971 Return value (dict): 

+

1972 Return dict containing the remaining custom attributes list. 

+

1973 

+

1974 Raise: 

+

1975 AuthException: raised if the operation fails 

+

1976 """ 

+

1977 response = await self._http.post( 

+

1978 MgmtV1.user_delete_custom_attribute_path, 

+

1979 body={"names": [name]}, 

+

1980 ) 

+

1981 return response.json() 

+

1982 

+

1983 async def load_custom_attributes(self) -> dict: 

+

1984 """ 

+

1985 Load all custom attributes for users. 

+

1986 

+

1987 Return value (dict): 

+

1988 Return dict containing the custom attributes list. 

+

1989 

+

1990 Raise: 

+

1991 AuthException: raised if the operation fails 

+

1992 """ 

+

1993 response = await self._http.get( 

+

1994 MgmtV1.user_load_custom_attributes_path, 

+

1995 params={}, 

+

1996 ) 

+

1997 return response.json() 

+

1998 

+

1999 async def load_family_scoped_custom_attributes(self) -> dict: 

+

2000 """ 

+

2001 Load the family-scoped user custom attribute definitions. These are user attributes whose 

+

2002 values are held per family membership (see `AssociatedFamily.family_scoped_attributes`), 

+

2003 and are a separate set from the plain user custom attributes. 

+

2004 

+

2005 Return value (dict): 

+

2006 Return dict in the format 

+

2007 {"data": [{"name": <name>, "type": <int>, "displayName": <str>, ...}]} 

+

2008 

+

2009 Raise: 

+

2010 AuthException: raised if the operation fails 

+

2011 """ 

+

2012 response = await self._http.get(MgmtV1.user_load_family_scoped_custom_attributes_path) 

+

2013 return response.json() 

+

2014 

+

2015 async def create_family_scoped_custom_attributes(self, attributes: List[CustomAttribute]) -> dict: 

+

2016 """ 

+

2017 Create family-scoped user custom attribute definitions. 

+

2018 

+

2019 Args: 

+

2020 attributes (List[CustomAttribute]): The custom attribute definitions to create. 

+

2021 

+

2022 Return value (dict): 

+

2023 Return dict in the format 

+

2024 {"data": [...]} 

+

2025 Containing the updated family-scoped custom attribute definitions. 

+

2026 

+

2027 Raise: 

+

2028 AuthException: raised if the operation fails 

+

2029 """ 

+

2030 response = await self._http.post( 

+

2031 MgmtV1.user_create_family_scoped_custom_attributes_path, 

+

2032 body={"attributes": custom_attributes_to_dict(attributes)}, 

+

2033 ) 

+

2034 return response.json() 

+

2035 

+

2036 async def delete_family_scoped_custom_attributes(self, names: List[str]) -> dict: 

+

2037 """ 

+

2038 Delete family-scoped user custom attribute definitions by name. 

+

2039 

+

2040 Args: 

+

2041 names (List[str]): The names of the custom attributes to delete. 

+

2042 

+

2043 Return value (dict): 

+

2044 Return dict in the format 

+

2045 {"data": [...]} 

+

2046 Containing the remaining family-scoped custom attribute definitions. 

+

2047 

+

2048 Raise: 

+

2049 AuthException: raised if the operation fails 

+

2050 """ 

+

2051 response = await self._http.post( 

+

2052 MgmtV1.user_delete_family_scoped_custom_attributes_path, 

+

2053 body={"names": names}, 

+

2054 ) 

+

2055 return response.json() 

+

2056 

+

2057 async def delete_batch( 

+

2058 self, 

+

2059 user_ids: List[str], 

+

2060 ): 

+

2061 """ 

+

2062 Delete multiple users by their user IDs. IMPORTANT: This action is irreversible. Use carefully. 

+

2063 

+

2064 Args: 

+

2065 user_ids (List[str]): List of user IDs to delete. 

+

2066 

+

2067 Raise: 

+

2068 AuthException: raised if the operation fails 

+

2069 """ 

+

2070 await self._http.post( 

+

2071 MgmtV1.user_delete_batch_path, 

+

2072 body={"userIds": user_ids}, 

+

2073 ) 

+

2074 

+

2075 async def import_users( 

+

2076 self, 

+

2077 source: str, 

+

2078 users: Optional[bytes] = None, 

+

2079 hashes: Optional[bytes] = None, 

+

2080 dryrun: bool = False, 

+

2081 ) -> dict: 

+

2082 """ 

+

2083 Import users from an external source. 

+

2084 

+

2085 Args: 

+

2086 source (str): The source of the users (e.g., "auth0", "firebase"). 

+

2087 users (bytes): Optional, JSON bytes containing the users to import. 

+

2088 hashes (bytes): Optional, JSON bytes containing password hashes. 

+

2089 dryrun (bool): Optional, if True, perform a dry run without actually importing. 

+

2090 

+

2091 Return value (dict): 

+

2092 Return dict containing the import results. 

+

2093 

+

2094 Raise: 

+

2095 AuthException: raised if the operation fails 

+

2096 """ 

+

2097 body = { 

+

2098 "source": source, 

+

2099 "dryrun": dryrun, 

+

2100 } 

+

2101 if users is not None: 

+

2102 body["users"] = base64.b64encode(users).decode("utf-8") 

+

2103 if hashes is not None: 

+

2104 body["hashes"] = base64.b64encode(hashes).decode("utf-8") 

+

2105 

+

2106 response = await self._http.post( 

+

2107 MgmtV1.user_import_path, 

+

2108 body=body, 

+

2109 ) 

+

2110 return response.json() 

+

2111 

+

2112 async def delete_passkey( 

+

2113 self, 

+

2114 login_id: str, 

+

2115 credential_id: str, 

+

2116 ): 

+

2117 """ 

+

2118 Delete a specific passkey (WebAuthn device) for a user. 

+

2119 

+

2120 Args: 

+

2121 login_id (str): The login ID of the user. 

+

2122 credential_id (str): The credential ID of the passkey to delete. 

+

2123 

+

2124 Raise: 

+

2125 AuthException: raised if the operation fails 

+

2126 """ 

+

2127 await self._http.post( 

+

2128 MgmtV1.user_delete_passkey_path, 

+

2129 body={"loginId": login_id, "credentialId": credential_id}, 

+

2130 ) 

+

2131 

+

2132 async def list_passkeys( 

+

2133 self, 

+

2134 login_id: str, 

+

2135 ) -> dict: 

+

2136 """ 

+

2137 List all passkeys (WebAuthn devices) registered for a user. 

+

2138 

+

2139 Args: 

+

2140 login_id (str): The login ID of the user. 

+

2141 

+

2142 Return value (dict): 

+

2143 Return dict containing the list of passkeys. 

+

2144 

+

2145 Raise: 

+

2146 AuthException: raised if the operation fails 

+

2147 """ 

+

2148 response = await self._http.post( 

+

2149 MgmtV1.user_list_passkeys_path, 

+

2150 body={"loginId": login_id}, 

+

2151 ) 

+

2152 return response.json() 

+

2153 

+

2154 async def list_trusted_devices( 

+

2155 self, 

+

2156 identifiers: List[str], 

+

2157 ) -> dict: 

+

2158 """ 

+

2159 List all trusted devices for the specified users. 

+

2160 

+

2161 Args: 

+

2162 identifiers (List[str]): List of login IDs or user IDs. 

+

2163 

+

2164 Return value (dict): 

+

2165 Return dict containing the list of trusted devices. 

+

2166 

+

2167 Raise: 

+

2168 AuthException: raised if the operation fails 

+

2169 """ 

+

2170 response = await self._http.post( 

+

2171 MgmtV1.user_list_trusted_devices_path, 

+

2172 body={"identifiers": identifiers}, 

+

2173 ) 

+

2174 return response.json() 

+

2175 

+

2176 async def remove_trusted_device( 

+

2177 self, 

+

2178 identifier: str, 

+

2179 device_ids: List[str], 

+

2180 ): 

+

2181 """ 

+

2182 Remove trusted devices for a user. 

+

2183 

+

2184 Args: 

+

2185 identifier (str): The login ID or user ID. 

+

2186 device_ids (List[str]): List of device IDs to remove. 

+

2187 

+

2188 Raise: 

+

2189 AuthException: raised if the operation fails 

+

2190 """ 

+

2191 await self._http.post( 

+

2192 MgmtV1.user_remove_trusted_device_path, 

+

2193 body={"identifier": identifier, "deviceIds": device_ids}, 

+

2194 ) 

+

2195 

+

2196 async def update_recovery_email( 

+

2197 self, 

+

2198 login_id: str, 

+

2199 recovery_email: str, 

+

2200 verified: bool = False, 

+

2201 ) -> dict: 

+

2202 """ 

+

2203 Update the recovery email for a user. 

+

2204 

+

2205 Args: 

+

2206 login_id (str): The login ID of the user. 

+

2207 recovery_email (str): The recovery email address. 

+

2208 verified (bool): Whether the recovery email is verified. 

+

2209 

+

2210 Return value (dict): 

+

2211 Return dict in the format 

+

2212 {"user": {}} 

+

2213 Containing the updated user information. 

+

2214 

+

2215 Raise: 

+

2216 AuthException: raised if the operation fails 

+

2217 """ 

+

2218 response = await self._http.post( 

+

2219 MgmtV1.user_update_recovery_email_path, 

+

2220 body={ 

+

2221 "loginId": login_id, 

+

2222 "recoveryEmail": recovery_email, 

+

2223 "verified": verified, 

+

2224 }, 

+

2225 ) 

+

2226 return response.json() 

+

2227 

+

2228 async def update_recovery_phone( 

+

2229 self, 

+

2230 login_id: str, 

+

2231 recovery_phone: str, 

+

2232 verified: bool = False, 

+

2233 ) -> dict: 

+

2234 """ 

+

2235 Update the recovery phone number for a user. 

+

2236 

+

2237 Args: 

+

2238 login_id (str): The login ID of the user. 

+

2239 recovery_phone (str): The recovery phone number. 

+

2240 verified (bool): Whether the recovery phone is verified. 

+

2241 

+

2242 Return value (dict): 

+

2243 Return dict in the format 

+

2244 {"user": {}} 

+

2245 Containing the updated user information. 

+

2246 

+

2247 Raise: 

+

2248 AuthException: raised if the operation fails 

+

2249 """ 

+

2250 response = await self._http.post( 

+

2251 MgmtV1.user_update_recovery_phone_path, 

+

2252 body={ 

+

2253 "loginId": login_id, 

+

2254 "recoveryPhone": recovery_phone, 

+

2255 "verified": verified, 

+

2256 }, 

+

2257 ) 

+

2258 return response.json() 

+
+ + + diff --git a/htmlcov/d_f54762f9f1d5aa4d_user_py.html b/htmlcov/d_f54762f9f1d5aa4d_user_py.html new file mode 100644 index 000000000..d79c39c02 --- /dev/null +++ b/htmlcov/d_f54762f9f1d5aa4d_user_py.html @@ -0,0 +1,2346 @@ + + + + + Coverage for descope/management/user.py: 99% + + + + + +
+
+

+ Coverage for descope/management/user.py: + 99% +

+ +

+ 318 statements   + + + +

+

+ « prev     + ^ index     + » next +       + coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+ +
+
+
+

1import base64 

+

2from typing import List, Optional, Union 

+

3 

+

4from descope._http_base import HTTPBase 

+

5from descope.common import DeliveryMethod, LoginOptions, get_method_string 

+

6from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException 

+

7from descope.management._user_base import CreateUserObj, UserBase, UserObj 

+

8from descope.management.common import ( 

+

9 AssociatedFamily, 

+

10 AssociatedTenant, 

+

11 CustomAttribute, 

+

12 MgmtV1, 

+

13 Sort, 

+

14 associated_families_to_dict, 

+

15 custom_attributes_to_dict, 

+

16 sort_to_dict, 

+

17) 

+

18 

+

19 

+

20class User(UserBase, HTTPBase): 

+

21 def create( 

+

22 self, 

+

23 login_id: str, 

+

24 email: Optional[str] = None, 

+

25 phone: Optional[str] = None, 

+

26 display_name: Optional[str] = None, 

+

27 given_name: Optional[str] = None, 

+

28 middle_name: Optional[str] = None, 

+

29 family_name: Optional[str] = None, 

+

30 role_names: Optional[List[str]] = None, 

+

31 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

32 picture: Optional[str] = None, 

+

33 custom_attributes: Optional[dict] = None, 

+

34 verified_email: Optional[bool] = None, 

+

35 verified_phone: Optional[bool] = None, 

+

36 invite_url: Optional[str] = None, 

+

37 additional_login_ids: Optional[List[str]] = None, 

+

38 sso_app_ids: Optional[List[str]] = None, 

+

39 status: Optional[str] = None, 

+

40 family_associations: Optional[List[AssociatedFamily]] = None, 

+

41 ) -> dict: 

+

42 """ 

+

43 Create a new user. Users can have any number of optional fields, including email, phone number and authorization. 

+

44 

+

45 Args: 

+

46 login_id (str): user login ID. 

+

47 email (str): Optional user email address. 

+

48 phone (str): Optional user phone number. 

+

49 display_name (str): Optional user display name. 

+

50 role_names (List[str]): An optional list of the user's roles without tenant association. These roles are 

+

51 mutually exclusive with the `user_tenant` roles. 

+

52 user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are 

+

53 mutually exclusive with the general `role_names`. 

+

54 picture (str): Optional url for user picture 

+

55 custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app 

+

56 sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. 

+

57 status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". 

+

58 family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and 

+

59 family-scoped attribute values per family. 

+

60 

+

61 Return value (dict): 

+

62 Return dict in the format 

+

63 {"user": {}} 

+

64 Containing the created user information. 

+

65 

+

66 Raise: 

+

67 AuthException: raised if create operation fails 

+

68 """ 

+

69 UserBase._validate_status(status) 

+

70 role_names = [] if role_names is None else role_names 

+

71 user_tenants = [] if user_tenants is None else user_tenants 

+

72 

+

73 response = self._http.post( 

+

74 MgmtV1.user_create_path, 

+

75 body=UserBase._compose_create_body( 

+

76 login_id, 

+

77 email, 

+

78 phone, 

+

79 display_name, 

+

80 given_name, 

+

81 middle_name, 

+

82 family_name, 

+

83 role_names, 

+

84 user_tenants, 

+

85 False, 

+

86 False, 

+

87 picture, 

+

88 custom_attributes, 

+

89 verified_email, 

+

90 verified_phone, 

+

91 invite_url, 

+

92 None, 

+

93 None, 

+

94 additional_login_ids, 

+

95 sso_app_ids, 

+

96 status=status, 

+

97 family_associations=family_associations, 

+

98 ), 

+

99 ) 

+

100 return response.json() 

+

101 

+

102 def create_test_user( 

+

103 self, 

+

104 login_id: str, 

+

105 email: Optional[str] = None, 

+

106 phone: Optional[str] = None, 

+

107 display_name: Optional[str] = None, 

+

108 given_name: Optional[str] = None, 

+

109 middle_name: Optional[str] = None, 

+

110 family_name: Optional[str] = None, 

+

111 role_names: Optional[List[str]] = None, 

+

112 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

113 picture: Optional[str] = None, 

+

114 custom_attributes: Optional[dict] = None, 

+

115 verified_email: Optional[bool] = None, 

+

116 verified_phone: Optional[bool] = None, 

+

117 invite_url: Optional[str] = None, 

+

118 additional_login_ids: Optional[List[str]] = None, 

+

119 sso_app_ids: Optional[List[str]] = None, 

+

120 status: Optional[str] = None, 

+

121 family_associations: Optional[List[AssociatedFamily]] = None, 

+

122 ) -> dict: 

+

123 """ 

+

124 Create a new test user. 

+

125 The login_id is required and will determine what the user will use to sign in. 

+

126 Make sure the login id is unique for test. All other fields are optional. 

+

127 

+

128 Args: 

+

129 login_id (str): user login ID. 

+

130 email (str): Optional user email address. 

+

131 phone (str): Optional user phone number. 

+

132 display_name (str): Optional user display name. 

+

133 role_names (List[str]): An optional list of the user's roles without tenant association. These roles are 

+

134 mutually exclusive with the `user_tenant` roles. 

+

135 user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are 

+

136 mutually exclusive with the general `role_names`. 

+

137 picture (str): Optional url for user picture 

+

138 custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app 

+

139 sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. 

+

140 status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". 

+

141 family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and 

+

142 family-scoped attribute values per family. 

+

143 

+

144 Return value (dict): 

+

145 Return dict in the format 

+

146 {"user": {}} 

+

147 Containing the created test user information. 

+

148 

+

149 Raise: 

+

150 AuthException: raised if create operation fails 

+

151 """ 

+

152 UserBase._validate_status(status) 

+

153 role_names = [] if role_names is None else role_names 

+

154 user_tenants = [] if user_tenants is None else user_tenants 

+

155 

+

156 response = self._http.post( 

+

157 MgmtV1.test_user_create_path, 

+

158 body=UserBase._compose_create_body( 

+

159 login_id, 

+

160 email, 

+

161 phone, 

+

162 display_name, 

+

163 given_name, 

+

164 middle_name, 

+

165 family_name, 

+

166 role_names, 

+

167 user_tenants, 

+

168 False, 

+

169 True, 

+

170 picture, 

+

171 custom_attributes, 

+

172 verified_email, 

+

173 verified_phone, 

+

174 invite_url, 

+

175 None, 

+

176 None, 

+

177 additional_login_ids, 

+

178 sso_app_ids, 

+

179 status=status, 

+

180 family_associations=family_associations, 

+

181 ), 

+

182 ) 

+

183 return response.json() 

+

184 

+

185 def invite( 

+

186 self, 

+

187 login_id: str, 

+

188 email: Optional[str] = None, 

+

189 phone: Optional[str] = None, 

+

190 display_name: Optional[str] = None, 

+

191 given_name: Optional[str] = None, 

+

192 middle_name: Optional[str] = None, 

+

193 family_name: Optional[str] = None, 

+

194 role_names: Optional[List[str]] = None, 

+

195 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

196 picture: Optional[str] = None, 

+

197 custom_attributes: Optional[dict] = None, 

+

198 verified_email: Optional[bool] = None, 

+

199 verified_phone: Optional[bool] = None, 

+

200 invite_url: Optional[str] = None, 

+

201 send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings 

+

202 send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings 

+

203 additional_login_ids: Optional[List[str]] = None, 

+

204 sso_app_ids: Optional[List[str]] = None, 

+

205 template_id: str = "", 

+

206 test: bool = False, 

+

207 locale: Optional[str] = None, # locale for the invite message 

+

208 family_associations: Optional[List[AssociatedFamily]] = None, 

+

209 ) -> dict: 

+

210 """ 

+

211 Create a new user and invite them via an email / text message. 

+

212 

+

213 Functions exactly the same as the `create` function with the additional invitation 

+

214 behavior. See the documentation above for the general creation behavior. 

+

215 

+

216 IMPORTANT: Since the invitation is sent by email / phone, make sure either 

+

217 the email / phone is explicitly set, or the login_id itself is an email address / phone number. 

+

218 You must configure the invitation URL in the Descope console prior to 

+

219 calling the method. 

+

220 """ 

+

221 role_names = [] if role_names is None else role_names 

+

222 user_tenants = [] if user_tenants is None else user_tenants 

+

223 

+

224 response = self._http.post( 

+

225 MgmtV1.user_create_path, 

+

226 body=UserBase._compose_create_body( 

+

227 login_id, 

+

228 email, 

+

229 phone, 

+

230 display_name, 

+

231 given_name, 

+

232 middle_name, 

+

233 family_name, 

+

234 role_names, 

+

235 user_tenants, 

+

236 True, 

+

237 test, 

+

238 picture, 

+

239 custom_attributes, 

+

240 verified_email, 

+

241 verified_phone, 

+

242 invite_url, 

+

243 send_mail, 

+

244 send_sms, 

+

245 additional_login_ids, 

+

246 sso_app_ids, 

+

247 template_id, 

+

248 locale, 

+

249 family_associations=family_associations, 

+

250 ), 

+

251 ) 

+

252 return response.json() 

+

253 

+

254 def invite_batch( 

+

255 self, 

+

256 users: List[UserObj], 

+

257 invite_url: Optional[str] = None, 

+

258 send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings 

+

259 send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings 

+

260 locale: Optional[str] = None, # locale for the invite message 

+

261 template_options: Optional[dict] = None, # for providing messaging template options 

+

262 template_id: str = "", # for overriding the default messaging template 

+

263 ) -> dict: 

+

264 """ 

+

265 Create users in batch and invite them via an email / text message. 

+

266 

+

267 Functions exactly the same as the `create` function with the additional invitation 

+

268 behavior. See the documentation above for the general creation behavior. 

+

269 

+

270 IMPORTANT: Since the invitation is sent by email / phone, make sure either 

+

271 the email / phone is explicitly set, or the login_id itself is an email address / phone number. 

+

272 You must configure the invitation URL in the Descope console prior to 

+

273 calling the method. 

+

274 """ 

+

275 

+

276 response = self._http.post( 

+

277 MgmtV1.user_create_batch_path, 

+

278 body=UserBase._compose_create_batch_body( 

+

279 users, 

+

280 invite_url, 

+

281 send_mail, 

+

282 send_sms, 

+

283 locale, 

+

284 template_options=template_options, 

+

285 template_id=template_id, 

+

286 ), 

+

287 ) 

+

288 return response.json() 

+

289 

+

290 def update( 

+

291 self, 

+

292 login_id: str, 

+

293 email: Optional[str] = None, 

+

294 phone: Optional[str] = None, 

+

295 display_name: Optional[str] = None, 

+

296 given_name: Optional[str] = None, 

+

297 middle_name: Optional[str] = None, 

+

298 family_name: Optional[str] = None, 

+

299 role_names: Optional[List[str]] = None, 

+

300 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

301 picture: Optional[str] = None, 

+

302 custom_attributes: Optional[dict] = None, 

+

303 verified_email: Optional[bool] = None, 

+

304 verified_phone: Optional[bool] = None, 

+

305 additional_login_ids: Optional[List[str]] = None, 

+

306 sso_app_ids: Optional[List[str]] = None, 

+

307 test: bool = False, 

+

308 family_associations: Optional[List[AssociatedFamily]] = None, 

+

309 ) -> dict: 

+

310 """ 

+

311 Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides 

+

312 to the existing user. Empty fields will override populated fields, including family memberships: 

+

313 omitting family_associations removes the user from all of their families, and updating a dependent 

+

314 requires family_associations with the dependent's family. Use carefully. 

+

315 Use `patch` for partial updates instead. 

+

316 

+

317 Args: 

+

318 login_id (str): The login ID of the user to update. 

+

319 email (str): Optional user email address. 

+

320 phone (str): Optional user phone number. 

+

321 display_name (str): Optional user display name. 

+

322 given_name (str): Optional user given name. 

+

323 middle_name (str): Optional user middle name. 

+

324 family_name (str): Optional user family name. 

+

325 role_names (List[str]): An optional list of the user's roles without tenant association. These roles are 

+

326 mutually exclusive with the `user_tenant` roles. 

+

327 user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are 

+

328 mutually exclusive with the general `role_names`. 

+

329 picture (str): Optional url for user picture 

+

330 custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app 

+

331 sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. 

+

332 test (bool, optional): Set to True to update a test user. Defaults to False. 

+

333 family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and 

+

334 family-scoped attribute values per family. Replaces the user's family memberships, so None or an 

+

335 empty list removes the user from all families. 

+

336 

+

337 Return value (dict): 

+

338 Return dict in the format 

+

339 {"user": {}} 

+

340 Containing the updated user information. 

+

341 

+

342 Raise: 

+

343 AuthException: raised if update operation fails 

+

344 """ 

+

345 role_names = [] if role_names is None else role_names 

+

346 user_tenants = [] if user_tenants is None else user_tenants 

+

347 family_associations = [] if family_associations is None else family_associations 

+

348 

+

349 response = self._http.post( 

+

350 MgmtV1.user_update_path, 

+

351 body=UserBase._compose_update_body( 

+

352 login_id, 

+

353 email, 

+

354 phone, 

+

355 display_name, 

+

356 given_name, 

+

357 middle_name, 

+

358 family_name, 

+

359 role_names, 

+

360 user_tenants, 

+

361 test, 

+

362 picture, 

+

363 custom_attributes, 

+

364 verified_email, 

+

365 verified_phone, 

+

366 additional_login_ids, 

+

367 sso_app_ids, 

+

368 None, 

+

369 family_associations=family_associations, 

+

370 ), 

+

371 ) 

+

372 return response.json() 

+

373 

+

374 def patch( 

+

375 self, 

+

376 login_id: str, 

+

377 email: Optional[str] = None, 

+

378 phone: Optional[str] = None, 

+

379 display_name: Optional[str] = None, 

+

380 given_name: Optional[str] = None, 

+

381 middle_name: Optional[str] = None, 

+

382 family_name: Optional[str] = None, 

+

383 role_names: Optional[List[str]] = None, 

+

384 user_tenants: Optional[List[AssociatedTenant]] = None, 

+

385 picture: Optional[str] = None, 

+

386 custom_attributes: Optional[dict] = None, 

+

387 verified_email: Optional[bool] = None, 

+

388 verified_phone: Optional[bool] = None, 

+

389 sso_app_ids: Optional[List[str]] = None, 

+

390 status: Optional[str] = None, 

+

391 test: bool = False, 

+

392 family_associations: Optional[List[AssociatedFamily]] = None, 

+

393 ) -> dict: 

+

394 """ 

+

395 Patches an existing user with the given various fields. Only the given fields will be used to update the user. 

+

396 

+

397 Args: 

+

398 login_id (str): The login ID of the user to update. 

+

399 email (str): Optional user email address. 

+

400 phone (str): Optional user phone number. 

+

401 display_name (str): Optional user display name. 

+

402 given_name (str): Optional user given name. 

+

403 middle_name (str): Optional user middle name. 

+

404 family_name (str): Optional user family name. 

+

405 role_names (List[str]): An optional list of the user's roles without tenant association. These roles are 

+

406 mutually exclusive with the `user_tenant` roles. 

+

407 user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are 

+

408 mutually exclusive with the general `role_names`. 

+

409 picture (str): Optional url for user picture 

+

410 custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app 

+

411 sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. 

+

412 status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". 

+

413 test (bool, optional): Set to True to update a test user. Defaults to False. 

+

414 family_associations (List[AssociatedFamily]): Optional list of the user's families, and optionally, their roles and 

+

415 family-scoped attribute values per family. 

+

416 

+

417 Return value (dict): 

+

418 Return dict in the format 

+

419 {"user": {}} 

+

420 Containing the patched user information. 

+

421 

+

422 Raise: 

+

423 AuthException: raised if patch operation fails 

+

424 """ 

+

425 UserBase._validate_status(status) 

+

426 response = self._http.patch( 

+

427 MgmtV1.user_patch_path, 

+

428 body=UserBase._compose_patch_body( 

+

429 login_id, 

+

430 email, 

+

431 phone, 

+

432 display_name, 

+

433 given_name, 

+

434 middle_name, 

+

435 family_name, 

+

436 role_names, 

+

437 user_tenants, 

+

438 picture, 

+

439 custom_attributes, 

+

440 verified_email, 

+

441 verified_phone, 

+

442 sso_app_ids, 

+

443 status, 

+

444 test, 

+

445 family_associations=family_associations, 

+

446 ), 

+

447 ) 

+

448 return response.json() 

+

449 

+

450 def patch_batch( 

+

451 self, 

+

452 users: List[UserObj], 

+

453 test: bool = False, 

+

454 ) -> dict: 

+

455 """ 

+

456 Patch users in batch. Only the provided fields will be updated for each user. 

+

457 

+

458 Args: 

+

459 users (List[UserObj]): A list of UserObj instances representing users to be patched. 

+

460 Each UserObj should have a login_id and the fields to be updated. 

+

461 test (bool, optional): Set to True to patch test users. Defaults to False. 

+

462 

+

463 Return value (dict): 

+

464 Return dict in the format 

+

465 {"patchedUsers": [...], "failedUsers": [...]} 

+

466 "patchedUsers" contains successfully patched users, 

+

467 "failedUsers" contains users that failed to be patched with error details. 

+

468 

+

469 Raise: 

+

470 AuthException: raised if patch batch operation fails 

+

471 """ 

+

472 for user in users: 

+

473 UserBase._validate_status(user.status, user.login_id) 

+

474 

+

475 response = self._http.patch( 

+

476 MgmtV1.user_patch_batch_path, 

+

477 body=UserBase._compose_patch_batch_body(users, test), 

+

478 ) 

+

479 return response.json() 

+

480 

+

481 def delete( 

+

482 self, 

+

483 login_id: str, 

+

484 ): 

+

485 """ 

+

486 Delete an existing user. IMPORTANT: This action is irreversible. Use carefully. 

+

487 

+

488 Args: 

+

489 login_id (str): The login ID of the user to be deleted. 

+

490 

+

491 Raise: 

+

492 AuthException: raised if delete operation fails 

+

493 """ 

+

494 self._http.post( 

+

495 MgmtV1.user_delete_path, 

+

496 body={"loginId": login_id}, 

+

497 ) 

+

498 

+

499 def delete_by_user_id( 

+

500 self, 

+

501 user_id: str, 

+

502 ): 

+

503 """ 

+

504 Delete an existing user by user ID. IMPORTANT: This action is irreversible. Use carefully. 

+

505 

+

506 Args: 

+

507 user_id (str): The user ID from the user's JWT. 

+

508 

+

509 Raise: 

+

510 AuthException: raised if delete operation fails 

+

511 """ 

+

512 self._http.post( 

+

513 MgmtV1.user_delete_path, 

+

514 body={"userId": user_id}, 

+

515 ) 

+

516 

+

517 def delete_all_test_users( 

+

518 self, 

+

519 ): 

+

520 """ 

+

521 Delete all test users in the project. IMPORTANT: This action is irreversible. Use carefully. 

+

522 

+

523 Raise: 

+

524 AuthException: raised if delete operation fails 

+

525 """ 

+

526 self._http.delete( 

+

527 MgmtV1.user_delete_all_test_users_path, 

+

528 ) 

+

529 

+

530 def load( 

+

531 self, 

+

532 login_id: str, 

+

533 ) -> dict: 

+

534 """ 

+

535 Load an existing user. 

+

536 

+

537 Args: 

+

538 login_id (str): The login ID of the user to be loaded. 

+

539 

+

540 Return value (dict): 

+

541 Return dict in the format 

+

542 {"user": {}} 

+

543 Containing the loaded user information. 

+

544 

+

545 Raise: 

+

546 AuthException: raised if load operation fails 

+

547 """ 

+

548 response = self._http.get( 

+

549 uri=MgmtV1.user_load_path, 

+

550 params={"loginId": login_id}, 

+

551 ) 

+

552 return response.json() 

+

553 

+

554 def load_by_user_id( 

+

555 self, 

+

556 user_id: str, 

+

557 ) -> dict: 

+

558 """ 

+

559 Load an existing user by user ID. 

+

560 The user ID can be found on the user's JWT. 

+

561 

+

562 Args: 

+

563 user_id (str): The user ID from the user's JWT. 

+

564 

+

565 Return value (dict): 

+

566 Return dict in the format 

+

567 {"user": {}} 

+

568 Containing the loaded user information. 

+

569 

+

570 Raise: 

+

571 AuthException: raised if load operation fails 

+

572 """ 

+

573 response = self._http.get( 

+

574 uri=MgmtV1.user_load_path, 

+

575 params={"userId": user_id}, 

+

576 ) 

+

577 return response.json() 

+

578 

+

579 def logout_user( 

+

580 self, 

+

581 login_id: str, 

+

582 ): 

+

583 """ 

+

584 Logout a user from all devices. 

+

585 

+

586 Args: 

+

587 login_id (str): The login ID of the user to be logged out. 

+

588 

+

589 Raise: 

+

590 AuthException: raised if logout operation fails 

+

591 """ 

+

592 self._http.post( 

+

593 MgmtV1.user_logout_path, 

+

594 body={"loginId": login_id}, 

+

595 ) 

+

596 

+

597 def logout_user_by_user_id( 

+

598 self, 

+

599 user_id: str, 

+

600 ): 

+

601 """ 

+

602 Logout a user from all devices. 

+

603 

+

604 Args: 

+

605 user_id (str): The login ID of the user to be logged out. 

+

606 

+

607 Raise: 

+

608 AuthException: raised if logout operation fails 

+

609 """ 

+

610 self._http.post( 

+

611 MgmtV1.user_logout_path, 

+

612 body={"userId": user_id}, 

+

613 ) 

+

614 

+

615 def load_users( 

+

616 self, 

+

617 user_ids: List[str], 

+

618 include_invalid_users: Optional[bool] = None, 

+

619 ) -> dict: 

+

620 """ 

+

621 Load users by their user IDs. 

+

622 

+

623 Args: 

+

624 user_ids (List[str]): Optional list of user IDs to filter by 

+

625 include_invalid_users (bool): Optional flag to include invalid users in the response 

+

626 

+

627 Return value (dict): 

+

628 Return dict in the format 

+

629 {"users": []} 

+

630 "users" contains a list of all of the found users and their information 

+

631 

+

632 Raise: 

+

633 AuthException: raised if search operation fails 

+

634 """ 

+

635 if user_ids is None or len(user_ids) == 0: 

+

636 raise AuthException( 

+

637 400, 

+

638 ERROR_TYPE_INVALID_ARGUMENT, 

+

639 "At least one user id needs to be supplied", 

+

640 ) 

+

641 

+

642 body: dict[str, Union[List[str], bool]] = { 

+

643 "userIds": user_ids, 

+

644 } 

+

645 

+

646 if include_invalid_users is not None: 

+

647 body["includeInvalidUsers"] = include_invalid_users 

+

648 

+

649 response = self._http.post( 

+

650 MgmtV1.users_load_path, 

+

651 body=body, 

+

652 ) 

+

653 return response.json() 

+

654 

+

655 def search_all( 

+

656 self, 

+

657 tenant_ids: Optional[List[str]] = None, 

+

658 role_names: Optional[List[str]] = None, 

+

659 limit: int = 0, 

+

660 page: int = 0, 

+

661 test_users_only: bool = False, 

+

662 with_test_user: bool = False, 

+

663 custom_attributes: Optional[dict] = None, 

+

664 statuses: Optional[List[str]] = None, 

+

665 emails: Optional[List[str]] = None, 

+

666 phones: Optional[List[str]] = None, 

+

667 sso_app_ids: Optional[List[str]] = None, 

+

668 sort: Optional[List[Sort]] = None, 

+

669 text: Optional[str] = None, 

+

670 login_ids: Optional[List[str]] = None, 

+

671 from_created_time: Optional[int] = None, 

+

672 to_created_time: Optional[int] = None, 

+

673 from_modified_time: Optional[int] = None, 

+

674 to_modified_time: Optional[int] = None, 

+

675 user_ids: Optional[List[str]] = None, 

+

676 tenant_role_ids: Optional[dict] = None, 

+

677 tenant_role_names: Optional[dict] = None, 

+

678 family_ids: Optional[List[str]] = None, 

+

679 dependent: Optional[bool] = None, 

+

680 lock_reasons: Optional[List[str]] = None, 

+

681 temp_lock_reasons: Optional[List[str]] = None, 

+

682 ) -> dict: 

+

683 """ 

+

684 Search all users. 

+

685 

+

686 Args: 

+

687 tenant_ids (List[str]): Optional list of tenant IDs to filter by 

+

688 role_names (List[str]): Optional list of role names to filter by 

+

689 limit (int): Optional limit of the number of users returned. Leave empty for default. 

+

690 page (int): Optional pagination control. Pages start at 0 and must be non-negative. 

+

691 test_users_only (bool): Optional filter only test users. 

+

692 with_test_user (bool): Optional include test users in search. 

+

693 custom_attributes (dict): Optional search for a attribute with a given value 

+

694 statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") 

+

695 emails (List[str]): Optional list of emails to search for 

+

696 phones (List[str]): Optional list of phones to search for 

+

697 sso_app_ids (List[str]): Optional list of SSO application IDs to filter by 

+

698 text (str): Optional string, allows free text search among all user's attributes. 

+

699 login_ids (List[str]): Optional list of login ids 

+

700 sort (List[Sort]): Optional List[dict], allows to sort by fields. 

+

701 from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) 

+

702 to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) 

+

703 from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) 

+

704 to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) 

+

705 user_ids (List[str]): Optional list of user IDs to filter by 

+

706 tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. 

+

707 Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). 

+

708 tenant_role_names (dict): Optional mapping of tenant ID to list of role names. 

+

709 Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). 

+

710 family_ids (List[str]): Optional list of family IDs. Only users that are members of at least one of these families are returned. 

+

711 dependent (bool): Optional, filter by whether the user is a family dependent (a user with no login credentials of their own). 

+

712 lock_reasons (List[str]): Optional list of lock reasons. Only users disabled by brute-force protection with one of these 

+

713 reasons are returned. Allowed values: "password", "totp", "recovery_codes", "security_questions". 

+

714 temp_lock_reasons (List[str]): Optional list of lock reasons. Only users currently temporarily locked with one of these 

+

715 reasons are returned. Allowed values: "password", "recovery_codes", "security_questions" (TOTP has no temporary lock). 

+

716 A user is temporarily locked while its tempLockExpiration (unix seconds, 0 when none) is greater than now. 

+

717 

+

718 Return value (dict): 

+

719 Return dict in the format 

+

720 {"users": []} 

+

721 "users" contains a list of all of the found users and their information 

+

722 

+

723 Raise: 

+

724 AuthException: raised if search operation fails 

+

725 """ 

+

726 tenant_ids = [] if tenant_ids is None else tenant_ids 

+

727 role_names = [] if role_names is None else role_names 

+

728 

+

729 self._validate_search_pagination(limit, page) 

+

730 body = { 

+

731 "tenantIds": tenant_ids, 

+

732 "roleNames": role_names, 

+

733 "limit": limit, 

+

734 "page": page, 

+

735 "testUsersOnly": test_users_only, 

+

736 "withTestUser": with_test_user, 

+

737 } 

+

738 if statuses is not None: 

+

739 body["statuses"] = statuses 

+

740 

+

741 if emails is not None: 

+

742 body["emails"] = emails 

+

743 

+

744 if phones is not None: 

+

745 body["phones"] = phones 

+

746 

+

747 if custom_attributes is not None: 

+

748 body["customAttributes"] = custom_attributes 

+

749 

+

750 if sso_app_ids is not None: 

+

751 body["ssoAppIds"] = sso_app_ids 

+

752 

+

753 if login_ids is not None: 

+

754 body["loginIds"] = login_ids 

+

755 

+

756 if user_ids is not None: 

+

757 body["userIds"] = user_ids 

+

758 

+

759 if text is not None: 

+

760 body["text"] = text 

+

761 

+

762 if sort is not None: 

+

763 body["sort"] = sort_to_dict(sort) 

+

764 

+

765 if from_created_time is not None: 

+

766 body["fromCreatedTime"] = from_created_time 

+

767 if to_created_time is not None: 

+

768 body["toCreatedTime"] = to_created_time 

+

769 if from_modified_time is not None: 

+

770 body["fromModifiedTime"] = from_modified_time 

+

771 if to_modified_time is not None: 

+

772 body["toModifiedTime"] = to_modified_time 

+

773 

+

774 if tenant_role_ids is not None: 

+

775 body["tenantRoleIds"] = tenant_role_ids 

+

776 if tenant_role_names is not None: 

+

777 body["tenantRoleNames"] = tenant_role_names 

+

778 if family_ids is not None: 

+

779 body["familyIds"] = family_ids 

+

780 if dependent is not None: 

+

781 body["dependent"] = dependent 

+

782 if lock_reasons is not None: 

+

783 body["lockReasons"] = lock_reasons 

+

784 if temp_lock_reasons is not None: 

+

785 body["tempLockReasons"] = temp_lock_reasons 

+

786 

+

787 response = self._http.post( 

+

788 MgmtV1.users_search_path, 

+

789 body=body, 

+

790 ) 

+

791 return response.json() 

+

792 

+

793 def search_all_test_users( 

+

794 self, 

+

795 tenant_ids: Optional[List[str]] = None, 

+

796 role_names: Optional[List[str]] = None, 

+

797 limit: int = 0, 

+

798 page: int = 0, 

+

799 custom_attributes: Optional[dict] = None, 

+

800 statuses: Optional[List[str]] = None, 

+

801 emails: Optional[List[str]] = None, 

+

802 phones: Optional[List[str]] = None, 

+

803 sso_app_ids: Optional[List[str]] = None, 

+

804 sort: Optional[List[Sort]] = None, 

+

805 text: Optional[str] = None, 

+

806 login_ids: Optional[List[str]] = None, 

+

807 from_created_time: Optional[int] = None, 

+

808 to_created_time: Optional[int] = None, 

+

809 from_modified_time: Optional[int] = None, 

+

810 to_modified_time: Optional[int] = None, 

+

811 tenant_role_ids: Optional[dict] = None, 

+

812 tenant_role_names: Optional[dict] = None, 

+

813 family_ids: Optional[List[str]] = None, 

+

814 dependent: Optional[bool] = None, 

+

815 lock_reasons: Optional[List[str]] = None, 

+

816 temp_lock_reasons: Optional[List[str]] = None, 

+

817 ) -> dict: 

+

818 """ 

+

819 Search all test users. 

+

820 

+

821 Args: 

+

822 tenant_ids (List[str]): Optional list of tenant IDs to filter by 

+

823 role_names (List[str]): Optional list of role names to filter by 

+

824 limit (int): Optional limit of the number of users returned. Leave empty for default. 

+

825 page (int): Optional pagination control. Pages start at 0 and must be non-negative. 

+

826 custom_attributes (dict): Optional search for a attribute with a given value 

+

827 statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") 

+

828 emails (List[str]): Optional list of emails to search for 

+

829 phones (List[str]): Optional list of phones to search for 

+

830 sso_app_ids (List[str]): Optional list of SSO application IDs to filter by 

+

831 text (str): Optional string, allows free text search among all user's attributes. 

+

832 login_ids (List[str]): Optional list of login ids 

+

833 sort (List[Sort]): Optional List[dict], allows to sort by fields. 

+

834 from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) 

+

835 to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) 

+

836 from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) 

+

837 to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) 

+

838 tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. 

+

839 Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). 

+

840 tenant_role_names (dict): Optional mapping of tenant ID to list of role names. 

+

841 Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). 

+

842 family_ids (List[str]): Optional list of family IDs. Only users that are members of at least one of these families are returned. 

+

843 dependent (bool): Optional, filter by whether the user is a family dependent (a user with no login credentials of their own). 

+

844 lock_reasons (List[str]): Optional list of lock reasons. Only users disabled by brute-force protection with one of these 

+

845 reasons are returned. Allowed values: "password", "totp", "recovery_codes", "security_questions". 

+

846 temp_lock_reasons (List[str]): Optional list of lock reasons. Only users currently temporarily locked with one of these 

+

847 reasons are returned. Allowed values: "password", "recovery_codes", "security_questions" (TOTP has no temporary lock). 

+

848 A user is temporarily locked while its tempLockExpiration (unix seconds, 0 when none) is greater than now. 

+

849 

+

850 Return value (dict): 

+

851 Return dict in the format 

+

852 {"users": []} 

+

853 "users" contains a list of all of the found users and their information 

+

854 

+

855 Raise: 

+

856 AuthException: raised if search operation fails 

+

857 """ 

+

858 tenant_ids = [] if tenant_ids is None else tenant_ids 

+

859 role_names = [] if role_names is None else role_names 

+

860 

+

861 self._validate_search_pagination(limit, page) 

+

862 body = { 

+

863 "tenantIds": tenant_ids, 

+

864 "roleNames": role_names, 

+

865 "limit": limit, 

+

866 "page": page, 

+

867 "testUsersOnly": True, 

+

868 "withTestUser": True, 

+

869 } 

+

870 if statuses is not None: 

+

871 body["statuses"] = statuses 

+

872 

+

873 if emails is not None: 

+

874 body["emails"] = emails 

+

875 

+

876 if phones is not None: 

+

877 body["phones"] = phones 

+

878 

+

879 if custom_attributes is not None: 

+

880 body["customAttributes"] = custom_attributes 

+

881 

+

882 if sso_app_ids is not None: 

+

883 body["ssoAppIds"] = sso_app_ids 

+

884 

+

885 if login_ids is not None: 

+

886 body["loginIds"] = login_ids 

+

887 

+

888 if text is not None: 

+

889 body["text"] = text 

+

890 

+

891 if sort is not None: 

+

892 body["sort"] = sort_to_dict(sort) 

+

893 

+

894 if from_created_time is not None: 

+

895 body["fromCreatedTime"] = from_created_time 

+

896 if to_created_time is not None: 

+

897 body["toCreatedTime"] = to_created_time 

+

898 if from_modified_time is not None: 

+

899 body["fromModifiedTime"] = from_modified_time 

+

900 if to_modified_time is not None: 

+

901 body["toModifiedTime"] = to_modified_time 

+

902 

+

903 if tenant_role_ids is not None: 

+

904 body["tenantRoleIds"] = tenant_role_ids 

+

905 if tenant_role_names is not None: 

+

906 body["tenantRoleNames"] = tenant_role_names 

+

907 if family_ids is not None: 

+

908 body["familyIds"] = family_ids 

+

909 if dependent is not None: 

+

910 body["dependent"] = dependent 

+

911 if lock_reasons is not None: 

+

912 body["lockReasons"] = lock_reasons 

+

913 if temp_lock_reasons is not None: 

+

914 body["tempLockReasons"] = temp_lock_reasons 

+

915 

+

916 response = self._http.post( 

+

917 MgmtV1.test_users_search_path, 

+

918 body=body, 

+

919 ) 

+

920 return response.json() 

+

921 

+

922 def get_provider_token( 

+

923 self, 

+

924 login_id: str, 

+

925 provider: str, 

+

926 withRefreshToken: Optional[bool] = False, 

+

927 forceRefresh: Optional[bool] = False, 

+

928 ) -> dict: 

+

929 """ 

+

930 Get the provider token for the given login ID. 

+

931 Only users that sign-in using social providers will have token. 

+

932 Note: The 'Manage tokens from provider' setting must be enabled. 

+

933 

+

934 Args: 

+

935 login_id (str): The login ID of the user. 

+

936 provider (str): The provider name (google, facebook, etc'). 

+

937 withRefreshToken (bool): Optional, set to true to get also the refresh token. 

+

938 forceRefresh (bool): Optional, set to true to force refresh the token. 

+

939 

+

940 Return value (dict): 

+

941 Return dict in the format 

+

942 {"provider": "", "providerUserId": "", "accessToken": "", "expiration": "", "scopes": "[]"} 

+

943 Containing the provider token of the given user and provider. 

+

944 

+

945 Raise: 

+

946 AuthException: raised if the operation fails 

+

947 """ 

+

948 response = self._http.get( 

+

949 MgmtV1.user_get_provider_token, 

+

950 params={ 

+

951 "loginId": login_id, 

+

952 "provider": provider, 

+

953 "withRefreshToken": withRefreshToken, 

+

954 "forceRefresh": forceRefresh, 

+

955 }, 

+

956 ) 

+

957 return response.json() 

+

958 

+

959 def activate( 

+

960 self, 

+

961 login_id: str, 

+

962 ) -> dict: 

+

963 """ 

+

964 Activate an existing user. 

+

965 

+

966 Args: 

+

967 login_id (str): The login ID of the user to be activated. 

+

968 

+

969 Return value (dict): 

+

970 Return dict in the format 

+

971 {"user": {}} 

+

972 Containing the updated user information. 

+

973 

+

974 Raise: 

+

975 AuthException: raised if activate operation fails 

+

976 """ 

+

977 response = self._http.post( 

+

978 MgmtV1.user_update_status_path, 

+

979 body={"loginId": login_id, "status": "enabled"}, 

+

980 ) 

+

981 return response.json() 

+

982 

+

983 def deactivate( 

+

984 self, 

+

985 login_id: str, 

+

986 ) -> dict: 

+

987 """ 

+

988 Deactivate an existing user. 

+

989 

+

990 Args: 

+

991 login_id (str): The login ID of the user to be deactivated. 

+

992 

+

993 Return value (dict): 

+

994 Return dict in the format 

+

995 {"user": {}} 

+

996 Containing the updated user information. 

+

997 

+

998 Raise: 

+

999 AuthException: raised if deactivate operation fails 

+

1000 """ 

+

1001 response = self._http.post( 

+

1002 MgmtV1.user_update_status_path, 

+

1003 body={"loginId": login_id, "status": "disabled"}, 

+

1004 ) 

+

1005 return response.json() 

+

1006 

+

1007 def update_login_id( 

+

1008 self, 

+

1009 login_id: str, 

+

1010 new_login_id: Optional[str] = None, 

+

1011 ) -> dict: 

+

1012 """ 

+

1013 Update login id of user, leave new login empty to remove the ID. 

+

1014 A user must have at least one login ID. Trying to remove the last one will fail. 

+

1015 

+

1016 Args: 

+

1017 login_id (str): The login ID of the user to update. 

+

1018 new_login_id (str): New login ID to set for the user. 

+

1019 

+

1020 Return value (dict): 

+

1021 Return dict in the format 

+

1022 {"user": {}} 

+

1023 Containing the updated user information. 

+

1024 

+

1025 Raise: 

+

1026 AuthException: raised if the update operation fails 

+

1027 """ 

+

1028 response = self._http.post( 

+

1029 MgmtV1.user_update_login_id_path, 

+

1030 body={"loginId": login_id, "newLoginId": new_login_id}, 

+

1031 ) 

+

1032 return response.json() 

+

1033 

+

1034 def update_email( 

+

1035 self, 

+

1036 login_id: str, 

+

1037 email: Optional[str] = None, 

+

1038 verified: Optional[bool] = None, 

+

1039 fail_on_conflict: Optional[bool] = None, 

+

1040 ) -> dict: 

+

1041 """ 

+

1042 Update the email address for an existing user. 

+

1043 

+

1044 Args: 

+

1045 login_id (str): The login ID of the user to update the email for. 

+

1046 email (str): The user email address. Leave empty to remove. 

+

1047 verified (bool): Set to true for the user to be able to login with the email address. 

+

1048 fail_on_conflict (bool): Set to true to raise an error if the email is used as a login id and already exists. 

+

1049 

+

1050 Return value (dict): 

+

1051 Return dict in the format 

+

1052 {"user": {}} 

+

1053 Containing the updated user information. 

+

1054 

+

1055 Raise: 

+

1056 AuthException: raised if the update operation fails 

+

1057 """ 

+

1058 response = self._http.post( 

+

1059 MgmtV1.user_update_email_path, 

+

1060 body={ 

+

1061 "loginId": login_id, 

+

1062 "email": email, 

+

1063 "verified": verified, 

+

1064 "failOnConflict": fail_on_conflict, 

+

1065 }, 

+

1066 ) 

+

1067 return response.json() 

+

1068 

+

1069 def update_phone( 

+

1070 self, 

+

1071 login_id: str, 

+

1072 phone: Optional[str] = None, 

+

1073 verified: Optional[bool] = None, 

+

1074 fail_on_conflict: Optional[bool] = None, 

+

1075 ) -> dict: 

+

1076 """ 

+

1077 Update the phone number for an existing user. 

+

1078 

+

1079 Args: 

+

1080 login_id (str): The login ID of the user to update the phone for. 

+

1081 phone (str): The user phone number. Leave empty to remove. 

+

1082 verified (bool): Set to true for the user to be able to login with the phone number. 

+

1083 fail_on_conflict (bool): Set to true to raise an error if the phone is used as a login id and already exists. 

+

1084 

+

1085 Return value (dict): 

+

1086 Return dict in the format 

+

1087 {"user": {}} 

+

1088 Containing the updated user information. 

+

1089 

+

1090 Raise: 

+

1091 AuthException: raised if the update operation fails 

+

1092 """ 

+

1093 response = self._http.post( 

+

1094 MgmtV1.user_update_phone_path, 

+

1095 body={ 

+

1096 "loginId": login_id, 

+

1097 "phone": phone, 

+

1098 "verified": verified, 

+

1099 "failOnConflict": fail_on_conflict, 

+

1100 }, 

+

1101 ) 

+

1102 return response.json() 

+

1103 

+

1104 def update_display_name( 

+

1105 self, 

+

1106 login_id: str, 

+

1107 display_name: Optional[str] = None, 

+

1108 given_name: Optional[str] = None, 

+

1109 middle_name: Optional[str] = None, 

+

1110 family_name: Optional[str] = None, 

+

1111 ) -> dict: 

+

1112 """ 

+

1113 Update the display name for an existing user. 

+

1114 

+

1115 Args: 

+

1116 login_id (str): The login ID of the user to update. 

+

1117 display_name (str): Optional user display name. Leave empty to remove. 

+

1118 

+

1119 Return value (dict): 

+

1120 Return dict in the format 

+

1121 {"user": {}} 

+

1122 Containing the updated user information. 

+

1123 

+

1124 Raise: 

+

1125 AuthException: raised if the update operation fails 

+

1126 """ 

+

1127 bdy = {"loginId": login_id} 

+

1128 if display_name is not None: 

+

1129 bdy["displayName"] = display_name 

+

1130 if given_name is not None: 

+

1131 bdy["givenName"] = given_name 

+

1132 if middle_name is not None: 

+

1133 bdy["middleName"] = middle_name 

+

1134 if family_name is not None: 

+

1135 bdy["familyName"] = family_name 

+

1136 response = self._http.post( 

+

1137 MgmtV1.user_update_name_path, 

+

1138 body=bdy, 

+

1139 ) 

+

1140 return response.json() 

+

1141 

+

1142 def update_picture( 

+

1143 self, 

+

1144 login_id: str, 

+

1145 picture: Optional[str] = None, 

+

1146 ) -> dict: 

+

1147 """ 

+

1148 Update the picture for an existing user. 

+

1149 

+

1150 Args: 

+

1151 login_id (str): The login ID of the user to update. 

+

1152 picture (str): Optional url to user avatar. Leave empty to remove. 

+

1153 

+

1154 Return value (dict): 

+

1155 Return dict in the format 

+

1156 {"user": {}} 

+

1157 Containing the updated user information. 

+

1158 

+

1159 Raise: 

+

1160 AuthException: raised if the update operation fails 

+

1161 """ 

+

1162 response = self._http.post( 

+

1163 MgmtV1.user_update_picture_path, 

+

1164 body={"loginId": login_id, "picture": picture}, 

+

1165 ) 

+

1166 return response.json() 

+

1167 

+

1168 def update_custom_attribute(self, login_id: str, attribute_key: str, attribute_val: Union[str, int, bool]) -> dict: 

+

1169 """ 

+

1170 Update a custom attribute of an existing user. 

+

1171 

+

1172 Args: 

+

1173 login_id (str): The login ID of the user to update. 

+

1174 attribute_key (str): The custom attribute that needs to be updated, this attribute needs to exists in Descope console app 

+

1175 attribute_val: The value to be updated 

+

1176 

+

1177 Return value (dict): 

+

1178 Return dict in the format 

+

1179 {"user": {}} 

+

1180 Containing the updated user information. 

+

1181 

+

1182 Raise: 

+

1183 AuthException: raised if the update operation fails 

+

1184 """ 

+

1185 response = self._http.post( 

+

1186 MgmtV1.user_update_custom_attribute_path, 

+

1187 body={ 

+

1188 "loginId": login_id, 

+

1189 "attributeKey": attribute_key, 

+

1190 "attributeValue": attribute_val, 

+

1191 }, 

+

1192 ) 

+

1193 return response.json() 

+

1194 

+

1195 def set_roles( 

+

1196 self, 

+

1197 login_id: str, 

+

1198 role_names: List[str], 

+

1199 ) -> dict: 

+

1200 """ 

+

1201 Set roles to a user without tenant association. Use set_tenant_roles 

+

1202 for users that are part of a multi-tenant project. 

+

1203 

+

1204 Args: 

+

1205 login_id (str): The login ID of the user to update. 

+

1206 role_names (List[str]): A list of roles to set to a user without tenant association. 

+

1207 

+

1208 Return value (dict): 

+

1209 Return dict in the format 

+

1210 {"user": {}} 

+

1211 Containing the updated user information. 

+

1212 

+

1213 Raise: 

+

1214 AuthException: raised if the operation fails 

+

1215 """ 

+

1216 response = self._http.post( 

+

1217 MgmtV1.user_set_role_path, 

+

1218 body={"loginId": login_id, "roleNames": role_names}, 

+

1219 ) 

+

1220 return response.json() 

+

1221 

+

1222 def add_roles( 

+

1223 self, 

+

1224 login_id: str, 

+

1225 role_names: List[str], 

+

1226 ) -> dict: 

+

1227 """ 

+

1228 Add roles to a user without tenant association. Use add_tenant_roles 

+

1229 for users that are part of a multi-tenant project. 

+

1230 

+

1231 Args: 

+

1232 login_id (str): The login ID of the user to update. 

+

1233 role_names (List[str]): A list of roles to add to a user without tenant association. 

+

1234 

+

1235 Return value (dict): 

+

1236 Return dict in the format 

+

1237 {"user": {}} 

+

1238 Containing the updated user information. 

+

1239 

+

1240 Raise: 

+

1241 AuthException: raised if the operation fails 

+

1242 """ 

+

1243 response = self._http.post( 

+

1244 MgmtV1.user_add_role_path, 

+

1245 body={"loginId": login_id, "roleNames": role_names}, 

+

1246 ) 

+

1247 return response.json() 

+

1248 

+

1249 def remove_roles( 

+

1250 self, 

+

1251 login_id: str, 

+

1252 role_names: List[str], 

+

1253 ) -> dict: 

+

1254 """ 

+

1255 Remove roles from a user without tenant association. Use remove_tenant_roles 

+

1256 for users that are part of a multi-tenant project. 

+

1257 

+

1258 Args: 

+

1259 login_id (str): The login ID of the user to update. 

+

1260 role_names (List[str]): A list of roles to remove from a user without tenant association. 

+

1261 

+

1262 Return value (dict): 

+

1263 Return dict in the format 

+

1264 {"user": {}} 

+

1265 Containing the updated user information. 

+

1266 

+

1267 Raise: 

+

1268 AuthException: raised if the operation fails 

+

1269 """ 

+

1270 response = self._http.post( 

+

1271 MgmtV1.user_remove_role_path, 

+

1272 body={"loginId": login_id, "roleNames": role_names}, 

+

1273 ) 

+

1274 return response.json() 

+

1275 

+

1276 def set_sso_apps( 

+

1277 self, 

+

1278 login_id: str, 

+

1279 sso_app_ids: List[str], 

+

1280 ) -> dict: 

+

1281 """ 

+

1282 Set SSO applications association to a user. 

+

1283 

+

1284 Args: 

+

1285 login_id (str): The login ID of the user to update. 

+

1286 sso_app_ids (List[str]): A list of sso applications ids for associate with a user. 

+

1287 

+

1288 Return value (dict): 

+

1289 Return dict in the format 

+

1290 {"user": {}} 

+

1291 Containing the updated user information. 

+

1292 

+

1293 Raise: 

+

1294 AuthException: raised if the operation fails 

+

1295 """ 

+

1296 response = self._http.post( 

+

1297 MgmtV1.user_set_sso_apps, 

+

1298 body={"loginId": login_id, "ssoAppIds": sso_app_ids}, 

+

1299 ) 

+

1300 return response.json() 

+

1301 

+

1302 def add_sso_apps( 

+

1303 self, 

+

1304 login_id: str, 

+

1305 sso_app_ids: List[str], 

+

1306 ) -> dict: 

+

1307 """ 

+

1308 Add SSO applications association to a user. 

+

1309 

+

1310 Args: 

+

1311 login_id (str): The login ID of the user to update. 

+

1312 sso_app_ids (List[str]): A list of sso applications ids for associate with a user. 

+

1313 

+

1314 Return value (dict): 

+

1315 Return dict in the format 

+

1316 {"user": {}} 

+

1317 Containing the updated user information. 

+

1318 

+

1319 Raise: 

+

1320 AuthException: raised if the operation fails 

+

1321 """ 

+

1322 response = self._http.post( 

+

1323 MgmtV1.user_add_sso_apps, 

+

1324 body={"loginId": login_id, "ssoAppIds": sso_app_ids}, 

+

1325 ) 

+

1326 return response.json() 

+

1327 

+

1328 def remove_sso_apps( 

+

1329 self, 

+

1330 login_id: str, 

+

1331 sso_app_ids: List[str], 

+

1332 ) -> dict: 

+

1333 """ 

+

1334 Remove SSO applications association from a user. 

+

1335 

+

1336 Args: 

+

1337 login_id (str): The login ID of the user to update. 

+

1338 sso_app_ids (List[str]): A list of sso applications ids to remove association from a user. 

+

1339 

+

1340 Return value (dict): 

+

1341 Return dict in the format 

+

1342 {"user": {}} 

+

1343 Containing the updated user information. 

+

1344 

+

1345 Raise: 

+

1346 AuthException: raised if the operation fails 

+

1347 """ 

+

1348 response = self._http.post( 

+

1349 MgmtV1.user_remove_sso_apps, 

+

1350 body={"loginId": login_id, "ssoAppIds": sso_app_ids}, 

+

1351 ) 

+

1352 return response.json() 

+

1353 

+

1354 def add_tenant( 

+

1355 self, 

+

1356 login_id: str, 

+

1357 tenant_id: str, 

+

1358 ) -> dict: 

+

1359 """ 

+

1360 Add a tenant association to an existing user. 

+

1361 

+

1362 Args: 

+

1363 login_id (str): The login ID of the user to update. 

+

1364 tenant_id (str): The ID of the tenant to add to the user. 

+

1365 

+

1366 Return value (dict): 

+

1367 Return dict in the format 

+

1368 {"user": {}} 

+

1369 Containing the updated user information. 

+

1370 

+

1371 Raise: 

+

1372 AuthException: raised if the operation fails 

+

1373 """ 

+

1374 response = self._http.post( 

+

1375 MgmtV1.user_add_tenant_path, 

+

1376 body={"loginId": login_id, "tenantId": tenant_id}, 

+

1377 ) 

+

1378 return response.json() 

+

1379 

+

1380 def remove_tenant( 

+

1381 self, 

+

1382 login_id: str, 

+

1383 tenant_id: str, 

+

1384 ) -> dict: 

+

1385 """ 

+

1386 Remove a tenant association from an existing user. 

+

1387 

+

1388 Args: 

+

1389 login_id (str): The login ID of the user to update. 

+

1390 tenant_id (str): The ID of the tenant to add to the user. 

+

1391 

+

1392 Return value (dict): 

+

1393 Return dict in the format 

+

1394 {"user": {}} 

+

1395 Containing the updated user information. 

+

1396 

+

1397 Raise: 

+

1398 AuthException: raised if the operation fails 

+

1399 """ 

+

1400 response = self._http.post( 

+

1401 MgmtV1.user_remove_tenant_path, 

+

1402 body={"loginId": login_id, "tenantId": tenant_id}, 

+

1403 ) 

+

1404 return response.json() 

+

1405 

+

1406 def add_families( 

+

1407 self, 

+

1408 login_id: str, 

+

1409 family_associations: List[AssociatedFamily], 

+

1410 ) -> dict: 

+

1411 """ 

+

1412 Add a user to one or more families. Each association may also set the user's roles and 

+

1413 family-scoped attribute values in that family. Adding a family the user already belongs to 

+

1414 merges: given role names are granted on top of the existing ones, given family-scoped 

+

1415 attributes are merged into the stored ones, and omitting either leaves it unchanged. 

+

1416 

+

1417 Args: 

+

1418 login_id (str): The login ID of the user to update. 

+

1419 family_associations (List[AssociatedFamily]): The families to add the user to (at least one). 

+

1420 

+

1421 Return value (dict): 

+

1422 Return dict in the format 

+

1423 {"user": {}} 

+

1424 Containing the updated user information. 

+

1425 

+

1426 Raise: 

+

1427 AuthException: raised if the operation fails 

+

1428 """ 

+

1429 response = self._http.post( 

+

1430 MgmtV1.user_add_families_path, 

+

1431 body={ 

+

1432 "loginId": login_id, 

+

1433 "familyAssociations": associated_families_to_dict(family_associations), 

+

1434 }, 

+

1435 ) 

+

1436 return response.json() 

+

1437 

+

1438 def remove_families( 

+

1439 self, 

+

1440 login_id: str, 

+

1441 family_ids: List[str], 

+

1442 ) -> dict: 

+

1443 """ 

+

1444 Remove a user from one or more families. 

+

1445 

+

1446 Args: 

+

1447 login_id (str): The login ID of the user to update. 

+

1448 family_ids (List[str]): The IDs of the families to remove the user from (at least one). 

+

1449 

+

1450 Return value (dict): 

+

1451 Return dict in the format 

+

1452 {"user": {}} 

+

1453 Containing the updated user information. 

+

1454 

+

1455 Raise: 

+

1456 AuthException: raised if the operation fails 

+

1457 """ 

+

1458 response = self._http.post( 

+

1459 MgmtV1.user_remove_families_path, 

+

1460 body={"loginId": login_id, "familyIds": family_ids}, 

+

1461 ) 

+

1462 return response.json() 

+

1463 

+

1464 def set_tenant_roles( 

+

1465 self, 

+

1466 login_id: str, 

+

1467 tenant_id: str, 

+

1468 role_names: List[str], 

+

1469 ) -> dict: 

+

1470 """ 

+

1471 Set roles to a user in a specific tenant. 

+

1472 

+

1473 Args: 

+

1474 login_id (str): The login ID of the user to update. 

+

1475 tenant_id (str): The ID of the user's tenant. 

+

1476 role_names (List[str]): A list of roles to set on the user. 

+

1477 

+

1478 Return value (dict): 

+

1479 Return dict in the format 

+

1480 {"user": {}} 

+

1481 Containing the updated user information. 

+

1482 

+

1483 Raise: 

+

1484 AuthException: raised if the operation fails 

+

1485 """ 

+

1486 response = self._http.post( 

+

1487 MgmtV1.user_set_role_path, 

+

1488 body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, 

+

1489 ) 

+

1490 return response.json() 

+

1491 

+

1492 def add_tenant_roles( 

+

1493 self, 

+

1494 login_id: str, 

+

1495 tenant_id: str, 

+

1496 role_names: List[str], 

+

1497 ) -> dict: 

+

1498 """ 

+

1499 Add roles to a user in a specific tenant. 

+

1500 

+

1501 Args: 

+

1502 login_id (str): The login ID of the user to update. 

+

1503 tenant_id (str): The ID of the user's tenant. 

+

1504 role_names (List[str]): A list of roles to add to the user. 

+

1505 

+

1506 Return value (dict): 

+

1507 Return dict in the format 

+

1508 {"user": {}} 

+

1509 Containing the updated user information. 

+

1510 

+

1511 Raise: 

+

1512 AuthException: raised if the operation fails 

+

1513 """ 

+

1514 response = self._http.post( 

+

1515 MgmtV1.user_add_role_path, 

+

1516 body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, 

+

1517 ) 

+

1518 return response.json() 

+

1519 

+

1520 def remove_tenant_roles( 

+

1521 self, 

+

1522 login_id: str, 

+

1523 tenant_id: str, 

+

1524 role_names: List[str], 

+

1525 ) -> dict: 

+

1526 """ 

+

1527 Remove roles from a user in a specific tenant. 

+

1528 

+

1529 Args: 

+

1530 login_id (str): The login ID of the user to update. 

+

1531 tenant_id (str): The ID of the user's tenant. 

+

1532 role_names (List[str]): A list of roles to remove from the user. 

+

1533 

+

1534 Return value (dict): 

+

1535 Return dict in the format 

+

1536 {"user": {}} 

+

1537 Containing the updated user information. 

+

1538 

+

1539 Raise: 

+

1540 AuthException: raised if the operation fails 

+

1541 """ 

+

1542 response = self._http.post( 

+

1543 MgmtV1.user_remove_role_path, 

+

1544 body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, 

+

1545 ) 

+

1546 return response.json() 

+

1547 

+

1548 def set_temporary_password( 

+

1549 self, 

+

1550 login_id: str, 

+

1551 password: str, 

+

1552 ) -> None: 

+

1553 """ 

+

1554 Set the temporary password for the given login ID. 

+

1555 Note: The password will automatically be set as expired. 

+

1556 The user will not be able to log-in with this password, and will be required to replace it on next login. 

+

1557 See also: expire_password 

+

1558 

+

1559 Args: 

+

1560 login_id (str): The login ID of the user to set the password to. 

+

1561 password (str): The new password to set to the user. 

+

1562 

+

1563 Raise: 

+

1564 AuthException: raised if the operation fails 

+

1565 """ 

+

1566 self._http.post( 

+

1567 MgmtV1.user_set_temporary_password_path, 

+

1568 body={ 

+

1569 "loginId": login_id, 

+

1570 "password": password, 

+

1571 "setActive": False, 

+

1572 }, 

+

1573 ) 

+

1574 return 

+

1575 

+

1576 def set_active_password( 

+

1577 self, 

+

1578 login_id: str, 

+

1579 password: str, 

+

1580 ) -> None: 

+

1581 """ 

+

1582 Set the password for the given login ID. 

+

1583 

+

1584 Args: 

+

1585 login_id (str): The login ID of the user to set the password to. 

+

1586 password (str): The new password to set to the user. 

+

1587 

+

1588 Raise: 

+

1589 AuthException: raised if the operation fails 

+

1590 """ 

+

1591 self._http.post( 

+

1592 MgmtV1.user_set_active_password_path, 

+

1593 body={ 

+

1594 "loginId": login_id, 

+

1595 "password": password, 

+

1596 "setActive": True, 

+

1597 }, 

+

1598 ) 

+

1599 return 

+

1600 

+

1601 # Deprecated (use set_temporary_password instead) 

+

1602 def set_password( 

+

1603 self, 

+

1604 login_id: str, 

+

1605 password: str, 

+

1606 ) -> None: 

+

1607 """ 

+

1608 Set the password for the given login ID. 

+

1609 Note: The password will automatically be set as expired. 

+

1610 The user will not be able to log-in with this password, and will be required to replace it on next login. 

+

1611 See also: expire_password 

+

1612 

+

1613 Args: 

+

1614 login_id (str): The login ID of the user to set the password to. 

+

1615 password (str): The new password to set to the user. 

+

1616 

+

1617 Raise: 

+

1618 AuthException: raised if the operation fails 

+

1619 """ 

+

1620 self._http.post( 

+

1621 MgmtV1.user_set_password_path, 

+

1622 body={ 

+

1623 "loginId": login_id, 

+

1624 "password": password, 

+

1625 }, 

+

1626 ) 

+

1627 return 

+

1628 

+

1629 def expire_password( 

+

1630 self, 

+

1631 login_id: str, 

+

1632 ) -> None: 

+

1633 """ 

+

1634 Expires the password for the given login ID. 

+

1635 Note: user sign-in with an expired password, the user will get an error with code. 

+

1636 Use the `password.send_reset` or `password.replace` methods to reset/replace the password. 

+

1637 

+

1638 Args: 

+

1639 login_id (str): The login ID of the user to expire the password to. 

+

1640 

+

1641 Raise: 

+

1642 AuthException: raised if the operation fails 

+

1643 """ 

+

1644 self._http.post( 

+

1645 MgmtV1.user_expire_password_path, 

+

1646 body={"loginId": login_id}, 

+

1647 ) 

+

1648 return 

+

1649 

+

1650 def remove_all_passkeys( 

+

1651 self, 

+

1652 login_id: str, 

+

1653 ) -> None: 

+

1654 """ 

+

1655 Removes all registered passkeys (WebAuthn devices) for the user with the given login ID. 

+

1656 Note: The user might not be able to login anymore if they have no other authentication 

+

1657 methods or a verified email/phone. 

+

1658 

+

1659 Args: 

+

1660 login_id (str): The login ID of the user to remove passkeys for. 

+

1661 

+

1662 Raise: 

+

1663 AuthException: raised if the operation fails 

+

1664 """ 

+

1665 self._http.post( 

+

1666 MgmtV1.user_remove_all_passkeys_path, 

+

1667 body={"loginId": login_id}, 

+

1668 ) 

+

1669 return 

+

1670 

+

1671 def remove_totp_seed( 

+

1672 self, 

+

1673 login_id: str, 

+

1674 ) -> None: 

+

1675 """ 

+

1676 Removes TOTP seed for the user with the given login ID. 

+

1677 Note: The user might not be able to login anymore if they have no other authentication 

+

1678 methods or a verified email/phone. 

+

1679 

+

1680 Args: 

+

1681 login_id (str): The login ID of the user to remove totp seed for. 

+

1682 

+

1683 Raise: 

+

1684 AuthException: raised if the operation fails 

+

1685 """ 

+

1686 self._http.post( 

+

1687 MgmtV1.user_remove_totp_seed_path, 

+

1688 body={"loginId": login_id}, 

+

1689 ) 

+

1690 return 

+

1691 

+

1692 def remove_recovery_codes( 

+

1693 self, 

+

1694 login_id: str, 

+

1695 ) -> None: 

+

1696 """ 

+

1697 Removes all recovery codes for the user with the given login ID or user ID. 

+

1698 

+

1699 Args: 

+

1700 login_id (str): The login ID or user ID of the user to remove recovery codes for. 

+

1701 

+

1702 Raise: 

+

1703 AuthException: raised if the operation fails 

+

1704 """ 

+

1705 self._http.post( 

+

1706 MgmtV1.user_remove_recovery_codes_path, 

+

1707 body={"loginId": login_id}, 

+

1708 ) 

+

1709 return 

+

1710 

+

1711 def generate_otp_for_test_user( 

+

1712 self, 

+

1713 method: DeliveryMethod, 

+

1714 login_id: str, 

+

1715 login_options: Optional[LoginOptions] = None, 

+

1716 ) -> dict: 

+

1717 """ 

+

1718 Generate OTP for the given login ID of a test user. 

+

1719 This is useful when running tests and don't want to use 3rd party messaging services. 

+

1720 

+

1721 Args: 

+

1722 method (DeliveryMethod): The method to use for "delivering" the OTP verification code to the user, for example 

+

1723 EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED 

+

1724 login_id (str): The login ID of the test user being validated. 

+

1725 login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. 

+

1726 

+

1727 Return value (dict): 

+

1728 Return dict in the format 

+

1729 {"code": "", "loginId": ""} 

+

1730 Containing the code for the login (exactly as it sent via Email or Phone messaging). 

+

1731 

+

1732 Raise: 

+

1733 AuthException: raised if the operation fails 

+

1734 """ 

+

1735 response = self._http.post( 

+

1736 MgmtV1.user_generate_otp_for_test_path, 

+

1737 body={ 

+

1738 "loginId": login_id, 

+

1739 "deliveryMethod": get_method_string(method), 

+

1740 "loginOptions": login_options.__dict__ if login_options else {}, 

+

1741 }, 

+

1742 ) 

+

1743 return response.json() 

+

1744 

+

1745 def generate_magic_link_for_test_user( 

+

1746 self, 

+

1747 method: DeliveryMethod, 

+

1748 login_id: str, 

+

1749 uri: str, 

+

1750 login_options: Optional[LoginOptions] = None, 

+

1751 ) -> dict: 

+

1752 """ 

+

1753 Generate Magic Link for the given login ID of a test user. 

+

1754 This is useful when running tests and don't want to use 3rd party messaging services. 

+

1755 

+

1756 Args: 

+

1757 method (DeliveryMethod): The method to use for "delivering" the verification magic link to the user, for example 

+

1758 EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED 

+

1759 login_id (str): The login ID of the test user being validated. 

+

1760 uri (str): Optional redirect uri which will be used instead of any global configuration. 

+

1761 login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. 

+

1762 

+

1763 Return value (dict): 

+

1764 Return dict in the format 

+

1765 {"link": "", "loginId": ""} 

+

1766 Containing the magic link for the login (exactly as it sent via Email or Phone messaging). 

+

1767 

+

1768 Raise: 

+

1769 AuthException: raised if the operation fails 

+

1770 """ 

+

1771 response = self._http.post( 

+

1772 MgmtV1.user_generate_magic_link_for_test_path, 

+

1773 body={ 

+

1774 "loginId": login_id, 

+

1775 "deliveryMethod": get_method_string(method), 

+

1776 "URI": uri, 

+

1777 "loginOptions": login_options.__dict__ if login_options else {}, 

+

1778 }, 

+

1779 ) 

+

1780 return response.json() 

+

1781 

+

1782 def generate_enchanted_link_for_test_user( 

+

1783 self, 

+

1784 login_id: str, 

+

1785 uri: str, 

+

1786 login_options: Optional[LoginOptions] = None, 

+

1787 ) -> dict: 

+

1788 """ 

+

1789 Generate Enchanted Link for the given login ID of a test user. 

+

1790 This is useful when running tests and don't want to use 3rd party messaging services. 

+

1791 

+

1792 Args: 

+

1793 login_id (str): The login ID of the test user being validated. 

+

1794 uri (str): Optional redirect uri which will be used instead of any global configuration. 

+

1795 login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. 

+

1796 

+

1797 Return value (dict): 

+

1798 Return dict in the format 

+

1799 {"link": "", "loginId": "", "pendingRef": ""} 

+

1800 Containing the enchanted link for the login (exactly as it sent via Email or Phone messaging) and pendingRef. 

+

1801 

+

1802 Raise: 

+

1803 AuthException: raised if the operation fails 

+

1804 """ 

+

1805 response = self._http.post( 

+

1806 MgmtV1.user_generate_enchanted_link_for_test_path, 

+

1807 body={ 

+

1808 "loginId": login_id, 

+

1809 "URI": uri, 

+

1810 "loginOptions": login_options.__dict__ if login_options else {}, 

+

1811 }, 

+

1812 ) 

+

1813 return response.json() 

+

1814 

+

1815 def generate_embedded_link(self, login_id: str, custom_claims: Optional[dict] = None, timeout: int = 0) -> str: 

+

1816 """ 

+

1817 Generate Embedded Link for the given user login ID. 

+

1818 The return value is a token that can be verified via magic link, or using flows 

+

1819 

+

1820 Args: 

+

1821 login_id (str): The login ID of the user to authenticate with. 

+

1822 custom_claims (dict): Additional claims to place on the jwt after verification 

+

1823 

+

1824 Return value (str): 

+

1825 Return the token to be used in verification process 

+

1826 

+

1827 Raise: 

+

1828 AuthException: raised if the operation fails 

+

1829 """ 

+

1830 response = self._http.post( 

+

1831 MgmtV1.user_generate_embedded_link_path, 

+

1832 body={ 

+

1833 "loginId": login_id, 

+

1834 "customClaims": custom_claims, 

+

1835 "timeout": timeout, 

+

1836 }, 

+

1837 ) 

+

1838 return response.json()["token"] 

+

1839 

+

1840 def generate_sign_up_embedded_link( 

+

1841 self, 

+

1842 login_id: str, 

+

1843 user: Optional[CreateUserObj] = None, 

+

1844 email_verified: bool = False, 

+

1845 phone_verified: bool = False, 

+

1846 login_options: Optional[LoginOptions] = None, 

+

1847 timeout: int = 0, 

+

1848 ) -> str: 

+

1849 """ 

+

1850 Generate sign up Embedded Link for the given user login ID. 

+

1851 The return value is a token that can be verified via magic link, or using flows 

+

1852 

+

1853 Args: 

+

1854 login_id (str): The login ID of the user to authenticate with. 

+

1855 user (CreateUserObj): Optional user object to create the user with 

+

1856 email_verified (bool): Optional, set to true if the email is verified 

+

1857 phone_verified (bool): Optional, set to true if the phone is verified 

+

1858 login_options (LoginOptions): Optional login options to customize the link 

+

1859 timeout (int): Optional, the timeout in seconds for the link to be valid 

+

1860 

+

1861 Return value (str): 

+

1862 Return the token to be used in verification process 

+

1863 

+

1864 Raise: 

+

1865 AuthException: raised if the operation fails 

+

1866 """ 

+

1867 response = self._http.post( 

+

1868 MgmtV1.user_generate_sign_up_embedded_link_path, 

+

1869 body={ 

+

1870 "loginId": login_id, 

+

1871 "user": user.__dict__ if user else {}, 

+

1872 "loginOptions": login_options.__dict__ if login_options else {}, 

+

1873 "emailVerified": email_verified, 

+

1874 "phoneVerified": phone_verified, 

+

1875 "timeout": timeout, 

+

1876 }, 

+

1877 ) 

+

1878 return response.json()["token"] 

+

1879 

+

1880 def history(self, user_ids: List[str]) -> List[dict]: 

+

1881 """ 

+

1882 Retrieve users' authentication history, by the given user's ids. 

+

1883 

+

1884 Args: 

+

1885 login_ids (List[str]): List of Users' IDs. 

+

1886 

+

1887 Return value (List[dict]): 

+

1888 Return List in the format 

+

1889 [ 

+

1890 { 

+

1891 "userId": "User's ID", 

+

1892 "loginTime": "User'sLogin time", 

+

1893 "city": "User's city", 

+

1894 "country": "User's country", 

+

1895 "ip": User's IP 

+

1896 } 

+

1897 ] 

+

1898 

+

1899 Raise: 

+

1900 AuthException: raised if the operation fails 

+

1901 """ 

+

1902 response = self._http.post( 

+

1903 MgmtV1.user_history_path, 

+

1904 body=user_ids, 

+

1905 ) 

+

1906 return response.json() 

+

1907 

+

1908 def create_custom_attribute( 

+

1909 self, 

+

1910 name: str, 

+

1911 display_name: str, 

+

1912 type: str, 

+

1913 required: Optional[bool] = None, 

+

1914 options: Optional[List[str]] = None, 

+

1915 ) -> dict: 

+

1916 """ 

+

1917 Create a new custom attribute for users. 

+

1918 

+

1919 Args: 

+

1920 name (str): The name of the custom attribute. 

+

1921 display_name (str): The display name of the custom attribute. 

+

1922 type (str): The type of the custom attribute (e.g., "string", "number", "boolean"). 

+

1923 required (bool): Optional, whether the custom attribute is required. 

+

1924 options (List[str]): Optional, list of options for the custom attribute. 

+

1925 

+

1926 Return value (dict): 

+

1927 Return dict containing the custom attributes list. 

+

1928 

+

1929 Raise: 

+

1930 AuthException: raised if the operation fails 

+

1931 """ 

+

1932 body: dict = { 

+

1933 "attributes": [ 

+

1934 { 

+

1935 "name": name, 

+

1936 "displayName": display_name, 

+

1937 "type": type, 

+

1938 } 

+

1939 ] 

+

1940 } 

+

1941 if required is not None: 

+

1942 body["attributes"][0]["required"] = required 

+

1943 if options is not None: 

+

1944 body["attributes"][0]["options"] = options 

+

1945 

+

1946 response = self._http.post( 

+

1947 MgmtV1.user_create_custom_attribute_path, 

+

1948 body=body, 

+

1949 ) 

+

1950 return response.json() 

+

1951 

+

1952 def delete_custom_attribute( 

+

1953 self, 

+

1954 name: str, 

+

1955 ) -> dict: 

+

1956 """ 

+

1957 Delete a custom attribute for users. 

+

1958 

+

1959 Args: 

+

1960 name (str): The name of the custom attribute to delete. 

+

1961 

+

1962 Return value (dict): 

+

1963 Return dict containing the remaining custom attributes list. 

+

1964 

+

1965 Raise: 

+

1966 AuthException: raised if the operation fails 

+

1967 """ 

+

1968 response = self._http.post( 

+

1969 MgmtV1.user_delete_custom_attribute_path, 

+

1970 body={"names": [name]}, 

+

1971 ) 

+

1972 return response.json() 

+

1973 

+

1974 def load_custom_attributes(self) -> dict: 

+

1975 """ 

+

1976 Load all custom attributes for users. 

+

1977 

+

1978 Return value (dict): 

+

1979 Return dict containing the custom attributes list. 

+

1980 

+

1981 Raise: 

+

1982 AuthException: raised if the operation fails 

+

1983 """ 

+

1984 response = self._http.get( 

+

1985 MgmtV1.user_load_custom_attributes_path, 

+

1986 params={}, 

+

1987 ) 

+

1988 return response.json() 

+

1989 

+

1990 def load_family_scoped_custom_attributes(self) -> dict: 

+

1991 """ 

+

1992 Load the family-scoped user custom attribute definitions. These are user attributes whose 

+

1993 values are held per family membership (see `AssociatedFamily.family_scoped_attributes`), 

+

1994 and are a separate set from the plain user custom attributes. 

+

1995 

+

1996 Return value (dict): 

+

1997 Return dict in the format 

+

1998 {"data": [{"name": <name>, "type": <int>, "displayName": <str>, ...}]} 

+

1999 

+

2000 Raise: 

+

2001 AuthException: raised if the operation fails 

+

2002 """ 

+

2003 response = self._http.get(MgmtV1.user_load_family_scoped_custom_attributes_path) 

+

2004 return response.json() 

+

2005 

+

2006 def create_family_scoped_custom_attributes(self, attributes: List[CustomAttribute]) -> dict: 

+

2007 """ 

+

2008 Create family-scoped user custom attribute definitions. 

+

2009 

+

2010 Args: 

+

2011 attributes (List[CustomAttribute]): The custom attribute definitions to create. 

+

2012 

+

2013 Return value (dict): 

+

2014 Return dict in the format 

+

2015 {"data": [...]} 

+

2016 Containing the updated family-scoped custom attribute definitions. 

+

2017 

+

2018 Raise: 

+

2019 AuthException: raised if the operation fails 

+

2020 """ 

+

2021 response = self._http.post( 

+

2022 MgmtV1.user_create_family_scoped_custom_attributes_path, 

+

2023 body={"attributes": custom_attributes_to_dict(attributes)}, 

+

2024 ) 

+

2025 return response.json() 

+

2026 

+

2027 def delete_family_scoped_custom_attributes(self, names: List[str]) -> dict: 

+

2028 """ 

+

2029 Delete family-scoped user custom attribute definitions by name. 

+

2030 

+

2031 Args: 

+

2032 names (List[str]): The names of the custom attributes to delete. 

+

2033 

+

2034 Return value (dict): 

+

2035 Return dict in the format 

+

2036 {"data": [...]} 

+

2037 Containing the remaining family-scoped custom attribute definitions. 

+

2038 

+

2039 Raise: 

+

2040 AuthException: raised if the operation fails 

+

2041 """ 

+

2042 response = self._http.post( 

+

2043 MgmtV1.user_delete_family_scoped_custom_attributes_path, 

+

2044 body={"names": names}, 

+

2045 ) 

+

2046 return response.json() 

+

2047 

+

2048 def delete_batch( 

+

2049 self, 

+

2050 user_ids: List[str], 

+

2051 ): 

+

2052 """ 

+

2053 Delete multiple users by their user IDs. IMPORTANT: This action is irreversible. Use carefully. 

+

2054 

+

2055 Args: 

+

2056 user_ids (List[str]): List of user IDs to delete. 

+

2057 

+

2058 Raise: 

+

2059 AuthException: raised if the operation fails 

+

2060 """ 

+

2061 self._http.post( 

+

2062 MgmtV1.user_delete_batch_path, 

+

2063 body={"userIds": user_ids}, 

+

2064 ) 

+

2065 

+

2066 def import_users( 

+

2067 self, 

+

2068 source: str, 

+

2069 users: Optional[bytes] = None, 

+

2070 hashes: Optional[bytes] = None, 

+

2071 dryrun: bool = False, 

+

2072 ) -> dict: 

+

2073 """ 

+

2074 Import users from an external source. 

+

2075 

+

2076 Args: 

+

2077 source (str): The source of the users (e.g., "auth0", "firebase"). 

+

2078 users (bytes): Optional, JSON bytes containing the users to import. 

+

2079 hashes (bytes): Optional, JSON bytes containing password hashes. 

+

2080 dryrun (bool): Optional, if True, perform a dry run without actually importing. 

+

2081 

+

2082 Return value (dict): 

+

2083 Return dict containing the import results. 

+

2084 

+

2085 Raise: 

+

2086 AuthException: raised if the operation fails 

+

2087 """ 

+

2088 body = { 

+

2089 "source": source, 

+

2090 "dryrun": dryrun, 

+

2091 } 

+

2092 if users is not None: 

+

2093 body["users"] = base64.b64encode(users).decode("utf-8") 

+

2094 if hashes is not None: 

+

2095 body["hashes"] = base64.b64encode(hashes).decode("utf-8") 

+

2096 

+

2097 response = self._http.post( 

+

2098 MgmtV1.user_import_path, 

+

2099 body=body, 

+

2100 ) 

+

2101 return response.json() 

+

2102 

+

2103 def delete_passkey( 

+

2104 self, 

+

2105 login_id: str, 

+

2106 credential_id: str, 

+

2107 ): 

+

2108 """ 

+

2109 Delete a specific passkey (WebAuthn device) for a user. 

+

2110 

+

2111 Args: 

+

2112 login_id (str): The login ID of the user. 

+

2113 credential_id (str): The credential ID of the passkey to delete. 

+

2114 

+

2115 Raise: 

+

2116 AuthException: raised if the operation fails 

+

2117 """ 

+

2118 self._http.post( 

+

2119 MgmtV1.user_delete_passkey_path, 

+

2120 body={"loginId": login_id, "credentialId": credential_id}, 

+

2121 ) 

+

2122 

+

2123 def list_passkeys( 

+

2124 self, 

+

2125 login_id: str, 

+

2126 ) -> dict: 

+

2127 """ 

+

2128 List all passkeys (WebAuthn devices) registered for a user. 

+

2129 

+

2130 Args: 

+

2131 login_id (str): The login ID of the user. 

+

2132 

+

2133 Return value (dict): 

+

2134 Return dict containing the list of passkeys. 

+

2135 

+

2136 Raise: 

+

2137 AuthException: raised if the operation fails 

+

2138 """ 

+

2139 response = self._http.post( 

+

2140 MgmtV1.user_list_passkeys_path, 

+

2141 body={"loginId": login_id}, 

+

2142 ) 

+

2143 return response.json() 

+

2144 

+

2145 def list_trusted_devices( 

+

2146 self, 

+

2147 identifiers: List[str], 

+

2148 ) -> dict: 

+

2149 """ 

+

2150 List all trusted devices for the specified users. 

+

2151 

+

2152 Args: 

+

2153 identifiers (List[str]): List of login IDs or user IDs. 

+

2154 

+

2155 Return value (dict): 

+

2156 Return dict containing the list of trusted devices. 

+

2157 

+

2158 Raise: 

+

2159 AuthException: raised if the operation fails 

+

2160 """ 

+

2161 response = self._http.post( 

+

2162 MgmtV1.user_list_trusted_devices_path, 

+

2163 body={"identifiers": identifiers}, 

+

2164 ) 

+

2165 return response.json() 

+

2166 

+

2167 def remove_trusted_device( 

+

2168 self, 

+

2169 identifier: str, 

+

2170 device_ids: List[str], 

+

2171 ): 

+

2172 """ 

+

2173 Remove trusted devices for a user. 

+

2174 

+

2175 Args: 

+

2176 identifier (str): The login ID or user ID. 

+

2177 device_ids (List[str]): List of device IDs to remove. 

+

2178 

+

2179 Raise: 

+

2180 AuthException: raised if the operation fails 

+

2181 """ 

+

2182 self._http.post( 

+

2183 MgmtV1.user_remove_trusted_device_path, 

+

2184 body={"identifier": identifier, "deviceIds": device_ids}, 

+

2185 ) 

+

2186 

+

2187 def update_recovery_email( 

+

2188 self, 

+

2189 login_id: str, 

+

2190 recovery_email: str, 

+

2191 verified: bool = False, 

+

2192 ) -> dict: 

+

2193 """ 

+

2194 Update the recovery email for a user. 

+

2195 

+

2196 Args: 

+

2197 login_id (str): The login ID of the user. 

+

2198 recovery_email (str): The recovery email address. 

+

2199 verified (bool): Whether the recovery email is verified. 

+

2200 

+

2201 Return value (dict): 

+

2202 Return dict in the format 

+

2203 {"user": {}} 

+

2204 Containing the updated user information. 

+

2205 

+

2206 Raise: 

+

2207 AuthException: raised if the operation fails 

+

2208 """ 

+

2209 response = self._http.post( 

+

2210 MgmtV1.user_update_recovery_email_path, 

+

2211 body={ 

+

2212 "loginId": login_id, 

+

2213 "recoveryEmail": recovery_email, 

+

2214 "verified": verified, 

+

2215 }, 

+

2216 ) 

+

2217 return response.json() 

+

2218 

+

2219 def update_recovery_phone( 

+

2220 self, 

+

2221 login_id: str, 

+

2222 recovery_phone: str, 

+

2223 verified: bool = False, 

+

2224 ) -> dict: 

+

2225 """ 

+

2226 Update the recovery phone number for a user. 

+

2227 

+

2228 Args: 

+

2229 login_id (str): The login ID of the user. 

+

2230 recovery_phone (str): The recovery phone number. 

+

2231 verified (bool): Whether the recovery phone is verified. 

+

2232 

+

2233 Return value (dict): 

+

2234 Return dict in the format 

+

2235 {"user": {}} 

+

2236 Containing the updated user information. 

+

2237 

+

2238 Raise: 

+

2239 AuthException: raised if the operation fails 

+

2240 """ 

+

2241 response = self._http.post( 

+

2242 MgmtV1.user_update_recovery_phone_path, 

+

2243 body={ 

+

2244 "loginId": login_id, 

+

2245 "recoveryPhone": recovery_phone, 

+

2246 "verified": verified, 

+

2247 }, 

+

2248 ) 

+

2249 return response.json() 

+
+ + + diff --git a/htmlcov/favicon_32.png b/htmlcov/favicon_32.png new file mode 100644 index 000000000..8649f0475 Binary files /dev/null and b/htmlcov/favicon_32.png differ diff --git a/htmlcov/index.html b/htmlcov/index.html new file mode 100644 index 000000000..f9c55f1d3 --- /dev/null +++ b/htmlcov/index.html @@ -0,0 +1,334 @@ + + + + + Coverage report + + + + + +
+
+

Coverage report: + 98% +

+ +
+ +
+

+ coverage.py v7.3.1, + created at 2026-11-05 07:36 +0000 +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Modulestatementsmissingexcludedcoverage
descope/_auth_base.py1851099%
descope/_authmethod_base.py131092%
descope/_client_base.py893097%
descope/_http_base.py111091%
descope/auth.py905094%
descope/auth_async.py1271099%
descope/authmethod/_enchantedlink_base.py647089%
descope/authmethod/_magiclink_base.py617089%
descope/authmethod/_otp_base.py647089%
descope/authmethod/enchantedlink.py792097%
descope/authmethod/enchantedlink_async.py782097%
descope/authmethod/magiclink.py531098%
descope/authmethod/magiclink_async.py511098%
descope/authmethod/password.py491098%
descope/authmethod/password_async.py431098%
descope/descope_client.py1263098%
descope/descope_client_async.py1541099%
descope/jwt_common.py514092%
descope/management/_lists_base.py431098%
descope/management/_outbound_application_base.py584093%
descope/management/_sso_settings_base.py981099%
descope/management/_third_party_application_base.py264085%
descope/management/_user_base.py16211093%
descope/management/audit.py4911078%
descope/management/audit_async.py4911078%
descope/management/common.py5451099%
descope/management/group.py242092%
descope/management/group_async.py252092%
descope/management/outbound_application.py1171099%
descope/management/outbound_application_async.py1191099%
descope/management/role.py471098%
descope/management/role_async.py471098%
descope/management/user.py3184099%
descope/management/user_async.py3194099%
Total6804109198%
+

+ No items found using the specified filter. +

+

78 files skipped due to complete coverage.

+
+ + + diff --git a/htmlcov/keybd_closed.png b/htmlcov/keybd_closed.png new file mode 100644 index 000000000..ba119c47d Binary files /dev/null and b/htmlcov/keybd_closed.png differ diff --git a/htmlcov/keybd_open.png b/htmlcov/keybd_open.png new file mode 100644 index 000000000..a8bac6c9d Binary files /dev/null and b/htmlcov/keybd_open.png differ diff --git a/htmlcov/status.json b/htmlcov/status.json new file mode 100644 index 000000000..2d280b8d0 --- /dev/null +++ b/htmlcov/status.json @@ -0,0 +1 @@ +{"format":2,"version":"7.3.1","globals":"29860ac21c6484b9dd239ce5728742f1","files":{"d_ed9d64cdfac9d9b5__auth_base_py":{"hash":"4c2c8c08637235cd3be6c2b023936eeb","index":{"nums":[0,1,185,0,1,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5__auth_base_py.html","relative_filename":"descope/_auth_base.py"}},"d_ed9d64cdfac9d9b5__authmethod_base_py":{"hash":"c71ade34430c18eca4d52a88871fd7a2","index":{"nums":[0,1,13,0,1,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5__authmethod_base_py.html","relative_filename":"descope/_authmethod_base.py"}},"d_ed9d64cdfac9d9b5__client_base_py":{"hash":"145070505117563c180d22bdb729c8d2","index":{"nums":[0,1,89,0,3,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5__client_base_py.html","relative_filename":"descope/_client_base.py"}},"d_ed9d64cdfac9d9b5__http_base_py":{"hash":"5937824fbd8d896bb764824fc219c832","index":{"nums":[0,1,11,0,1,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5__http_base_py.html","relative_filename":"descope/_http_base.py"}},"d_ed9d64cdfac9d9b5_auth_py":{"hash":"a56a9ef624c015eb1955f307ec23e7b9","index":{"nums":[0,1,90,0,5,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5_auth_py.html","relative_filename":"descope/auth.py"}},"d_ed9d64cdfac9d9b5_auth_async_py":{"hash":"474ccda5647826d42b4dd94447c7d92b","index":{"nums":[0,1,127,0,1,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5_auth_async_py.html","relative_filename":"descope/auth_async.py"}},"d_2a85bba178da7a61__enchantedlink_base_py":{"hash":"f3c2487fab05c4e866807ecf78c4d76c","index":{"nums":[0,1,64,0,7,0,0,0],"html_filename":"d_2a85bba178da7a61__enchantedlink_base_py.html","relative_filename":"descope/authmethod/_enchantedlink_base.py"}},"d_2a85bba178da7a61__magiclink_base_py":{"hash":"c9f4a1e1a18bbdc5ad861f8a7fa2f4d9","index":{"nums":[0,1,61,0,7,0,0,0],"html_filename":"d_2a85bba178da7a61__magiclink_base_py.html","relative_filename":"descope/authmethod/_magiclink_base.py"}},"d_2a85bba178da7a61__otp_base_py":{"hash":"5bd30c5fceb6578b5daf1c0e5fc5d3a2","index":{"nums":[0,1,64,0,7,0,0,0],"html_filename":"d_2a85bba178da7a61__otp_base_py.html","relative_filename":"descope/authmethod/_otp_base.py"}},"d_2a85bba178da7a61_enchantedlink_py":{"hash":"d9a5e5d5044569e64c5b7a4389b86c4d","index":{"nums":[0,1,79,0,2,0,0,0],"html_filename":"d_2a85bba178da7a61_enchantedlink_py.html","relative_filename":"descope/authmethod/enchantedlink.py"}},"d_2a85bba178da7a61_enchantedlink_async_py":{"hash":"3228f3c73b0aa54d7029e07141b89eae","index":{"nums":[0,1,78,0,2,0,0,0],"html_filename":"d_2a85bba178da7a61_enchantedlink_async_py.html","relative_filename":"descope/authmethod/enchantedlink_async.py"}},"d_2a85bba178da7a61_magiclink_py":{"hash":"45aaf380738b2095f0878058089ccb17","index":{"nums":[0,1,53,0,1,0,0,0],"html_filename":"d_2a85bba178da7a61_magiclink_py.html","relative_filename":"descope/authmethod/magiclink.py"}},"d_2a85bba178da7a61_magiclink_async_py":{"hash":"00a274a21f85a2d62408344d866cc634","index":{"nums":[0,1,51,0,1,0,0,0],"html_filename":"d_2a85bba178da7a61_magiclink_async_py.html","relative_filename":"descope/authmethod/magiclink_async.py"}},"d_2a85bba178da7a61_password_py":{"hash":"96c9d7abe960f0009e59b9e3c1ee2c27","index":{"nums":[0,1,49,0,1,0,0,0],"html_filename":"d_2a85bba178da7a61_password_py.html","relative_filename":"descope/authmethod/password.py"}},"d_2a85bba178da7a61_password_async_py":{"hash":"d9d52e6cd41fe725604217d8a7e0adef","index":{"nums":[0,1,43,0,1,0,0,0],"html_filename":"d_2a85bba178da7a61_password_async_py.html","relative_filename":"descope/authmethod/password_async.py"}},"d_ed9d64cdfac9d9b5_descope_client_py":{"hash":"f7233bffaebb6b8bb41db3577cbf9f30","index":{"nums":[0,1,126,0,3,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5_descope_client_py.html","relative_filename":"descope/descope_client.py"}},"d_ed9d64cdfac9d9b5_descope_client_async_py":{"hash":"96b53408986d28726240d803bd1ef493","index":{"nums":[0,1,154,0,1,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5_descope_client_async_py.html","relative_filename":"descope/descope_client_async.py"}},"d_ed9d64cdfac9d9b5_jwt_common_py":{"hash":"60fd2f12d35bd23b272621fd9ef2cb57","index":{"nums":[0,1,51,0,4,0,0,0],"html_filename":"d_ed9d64cdfac9d9b5_jwt_common_py.html","relative_filename":"descope/jwt_common.py"}},"d_f54762f9f1d5aa4d__lists_base_py":{"hash":"42eeaa5fb676bc1d99b2c2084bad7747","index":{"nums":[0,1,43,0,1,0,0,0],"html_filename":"d_f54762f9f1d5aa4d__lists_base_py.html","relative_filename":"descope/management/_lists_base.py"}},"d_f54762f9f1d5aa4d__outbound_application_base_py":{"hash":"0d04091a4f716394578eeb0cdb12fe91","index":{"nums":[0,1,58,0,4,0,0,0],"html_filename":"d_f54762f9f1d5aa4d__outbound_application_base_py.html","relative_filename":"descope/management/_outbound_application_base.py"}},"d_f54762f9f1d5aa4d__sso_settings_base_py":{"hash":"59ff0a29d38ec1c0c2f4776715afbc3b","index":{"nums":[0,1,98,0,1,0,0,0],"html_filename":"d_f54762f9f1d5aa4d__sso_settings_base_py.html","relative_filename":"descope/management/_sso_settings_base.py"}},"d_f54762f9f1d5aa4d__third_party_application_base_py":{"hash":"72d2df74e4aabde706a72ed662110632","index":{"nums":[0,1,26,0,4,0,0,0],"html_filename":"d_f54762f9f1d5aa4d__third_party_application_base_py.html","relative_filename":"descope/management/_third_party_application_base.py"}},"d_f54762f9f1d5aa4d__user_base_py":{"hash":"8e4cf37852ad827efb5530e02e1a8968","index":{"nums":[0,1,162,0,11,0,0,0],"html_filename":"d_f54762f9f1d5aa4d__user_base_py.html","relative_filename":"descope/management/_user_base.py"}},"d_f54762f9f1d5aa4d_audit_py":{"hash":"258c55f82ecc29e54a73b371c23243e0","index":{"nums":[0,1,49,0,11,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_audit_py.html","relative_filename":"descope/management/audit.py"}},"d_f54762f9f1d5aa4d_audit_async_py":{"hash":"643dfe498bae547a4780d6217b253cb4","index":{"nums":[0,1,49,0,11,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_audit_async_py.html","relative_filename":"descope/management/audit_async.py"}},"d_f54762f9f1d5aa4d_common_py":{"hash":"567e20fe1f793e3da44cb9e6fb0750b9","index":{"nums":[0,1,545,0,1,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_common_py.html","relative_filename":"descope/management/common.py"}},"d_f54762f9f1d5aa4d_group_py":{"hash":"a3e83aa71776e820a54facae94f3bfb8","index":{"nums":[0,1,24,0,2,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_group_py.html","relative_filename":"descope/management/group.py"}},"d_f54762f9f1d5aa4d_group_async_py":{"hash":"c0e1c863da7ae15356aea350b2ccfc5a","index":{"nums":[0,1,25,0,2,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_group_async_py.html","relative_filename":"descope/management/group_async.py"}},"d_f54762f9f1d5aa4d_outbound_application_py":{"hash":"4d9a844452cd7acf7f64b40b94bc3c44","index":{"nums":[0,1,117,0,1,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_outbound_application_py.html","relative_filename":"descope/management/outbound_application.py"}},"d_f54762f9f1d5aa4d_outbound_application_async_py":{"hash":"d161e5dd217616aa4d1d043802330c8d","index":{"nums":[0,1,119,0,1,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_outbound_application_async_py.html","relative_filename":"descope/management/outbound_application_async.py"}},"d_f54762f9f1d5aa4d_role_py":{"hash":"e872be1510c78fe4640e737f094f80fc","index":{"nums":[0,1,47,0,1,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_role_py.html","relative_filename":"descope/management/role.py"}},"d_f54762f9f1d5aa4d_role_async_py":{"hash":"c69c05ab01ab1ed189ae4a1cf1813f5f","index":{"nums":[0,1,47,0,1,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_role_async_py.html","relative_filename":"descope/management/role_async.py"}},"d_f54762f9f1d5aa4d_user_py":{"hash":"b4f22ca480c957460ed134cca3d499ab","index":{"nums":[0,1,318,0,4,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_user_py.html","relative_filename":"descope/management/user.py"}},"d_f54762f9f1d5aa4d_user_async_py":{"hash":"8916e4661025522569735b7be73328ca","index":{"nums":[0,1,319,0,4,0,0,0],"html_filename":"d_f54762f9f1d5aa4d_user_async_py.html","relative_filename":"descope/management/user_async.py"}}}} \ No newline at end of file diff --git a/htmlcov/style.css b/htmlcov/style.css new file mode 100644 index 000000000..11b24c4e7 --- /dev/null +++ b/htmlcov/style.css @@ -0,0 +1,309 @@ +@charset "UTF-8"; +/* Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 */ +/* For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt */ +/* Don't edit this .css file. Edit the .scss file instead! */ +html, body, h1, h2, h3, p, table, td, th { margin: 0; padding: 0; border: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; } + +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 1em; background: #fff; color: #000; } + +@media (prefers-color-scheme: dark) { body { background: #1e1e1e; } } + +@media (prefers-color-scheme: dark) { body { color: #eee; } } + +html > body { font-size: 16px; } + +a:active, a:focus { outline: 2px dashed #007acc; } + +p { font-size: .875em; line-height: 1.4em; } + +table { border-collapse: collapse; } + +td { vertical-align: top; } + +table tr.hidden { display: none !important; } + +p#no_rows { display: none; font-size: 1.2em; } + +a.nav { text-decoration: none; color: inherit; } + +a.nav:hover { text-decoration: underline; color: inherit; } + +.hidden { display: none; } + +header { background: #f8f8f8; width: 100%; z-index: 2; border-bottom: 1px solid #ccc; } + +@media (prefers-color-scheme: dark) { header { background: black; } } + +@media (prefers-color-scheme: dark) { header { border-color: #333; } } + +header .content { padding: 1rem 3.5rem; } + +header h2 { margin-top: .5em; font-size: 1em; } + +header p.text { margin: .5em 0 -.5em; color: #666; font-style: italic; } + +@media (prefers-color-scheme: dark) { header p.text { color: #aaa; } } + +header.sticky { position: fixed; left: 0; right: 0; height: 2.5em; } + +header.sticky .text { display: none; } + +header.sticky h1, header.sticky h2 { font-size: 1em; margin-top: 0; display: inline-block; } + +header.sticky .content { padding: 0.5rem 3.5rem; } + +header.sticky .content p { font-size: 1em; } + +header.sticky ~ #source { padding-top: 6.5em; } + +main { position: relative; z-index: 1; } + +footer { margin: 1rem 3.5rem; } + +footer .content { padding: 0; color: #666; font-style: italic; } + +@media (prefers-color-scheme: dark) { footer .content { color: #aaa; } } + +#index { margin: 1rem 0 0 3.5rem; } + +h1 { font-size: 1.25em; display: inline-block; } + +#filter_container { float: right; margin: 0 2em 0 0; } + +#filter_container input { width: 10em; padding: 0.2em 0.5em; border: 2px solid #ccc; background: #fff; color: #000; } + +@media (prefers-color-scheme: dark) { #filter_container input { border-color: #444; } } + +@media (prefers-color-scheme: dark) { #filter_container input { background: #1e1e1e; } } + +@media (prefers-color-scheme: dark) { #filter_container input { color: #eee; } } + +#filter_container input:focus { border-color: #007acc; } + +header button { font-family: inherit; font-size: inherit; border: 1px solid; border-radius: .2em; color: inherit; padding: .1em .5em; margin: 1px calc(.1em + 1px); cursor: pointer; border-color: #ccc; } + +@media (prefers-color-scheme: dark) { header button { border-color: #444; } } + +header button:active, header button:focus { outline: 2px dashed #007acc; } + +header button.run { background: #eeffee; } + +@media (prefers-color-scheme: dark) { header button.run { background: #373d29; } } + +header button.run.show_run { background: #dfd; border: 2px solid #00dd00; margin: 0 .1em; } + +@media (prefers-color-scheme: dark) { header button.run.show_run { background: #373d29; } } + +header button.mis { background: #ffeeee; } + +@media (prefers-color-scheme: dark) { header button.mis { background: #4b1818; } } + +header button.mis.show_mis { background: #fdd; border: 2px solid #ff0000; margin: 0 .1em; } + +@media (prefers-color-scheme: dark) { header button.mis.show_mis { background: #4b1818; } } + +header button.exc { background: #f7f7f7; } + +@media (prefers-color-scheme: dark) { header button.exc { background: #333; } } + +header button.exc.show_exc { background: #eee; border: 2px solid #808080; margin: 0 .1em; } + +@media (prefers-color-scheme: dark) { header button.exc.show_exc { background: #333; } } + +header button.par { background: #ffffd5; } + +@media (prefers-color-scheme: dark) { header button.par { background: #650; } } + +header button.par.show_par { background: #ffa; border: 2px solid #bbbb00; margin: 0 .1em; } + +@media (prefers-color-scheme: dark) { header button.par.show_par { background: #650; } } + +#help_panel, #source p .annotate.long { display: none; position: absolute; z-index: 999; background: #ffffcc; border: 1px solid #888; border-radius: .2em; color: #333; padding: .25em .5em; } + +#source p .annotate.long { white-space: normal; float: right; top: 1.75em; right: 1em; height: auto; } + +#help_panel_wrapper { float: right; position: relative; } + +#keyboard_icon { margin: 5px; } + +#help_panel_state { display: none; } + +#help_panel { top: 25px; right: 0; padding: .75em; border: 1px solid #883; color: #333; } + +#help_panel .keyhelp p { margin-top: .75em; } + +#help_panel .legend { font-style: italic; margin-bottom: 1em; } + +.indexfile #help_panel { width: 25em; } + +.pyfile #help_panel { width: 18em; } + +#help_panel_state:checked ~ #help_panel { display: block; } + +kbd { border: 1px solid black; border-color: #888 #333 #333 #888; padding: .1em .35em; font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: bold; background: #eee; border-radius: 3px; } + +#source { padding: 1em 0 1em 3.5rem; font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; } + +#source p { position: relative; white-space: pre; } + +#source p * { box-sizing: border-box; } + +#source p .n { float: left; text-align: right; width: 3.5rem; box-sizing: border-box; margin-left: -3.5rem; padding-right: 1em; color: #999; } + +@media (prefers-color-scheme: dark) { #source p .n { color: #777; } } + +#source p .n.highlight { background: #ffdd00; } + +#source p .n a { margin-top: -4em; padding-top: 4em; text-decoration: none; color: #999; } + +@media (prefers-color-scheme: dark) { #source p .n a { color: #777; } } + +#source p .n a:hover { text-decoration: underline; color: #999; } + +@media (prefers-color-scheme: dark) { #source p .n a:hover { color: #777; } } + +#source p .t { display: inline-block; width: 100%; box-sizing: border-box; margin-left: -.5em; padding-left: 0.3em; border-left: 0.2em solid #fff; } + +@media (prefers-color-scheme: dark) { #source p .t { border-color: #1e1e1e; } } + +#source p .t:hover { background: #f2f2f2; } + +@media (prefers-color-scheme: dark) { #source p .t:hover { background: #282828; } } + +#source p .t:hover ~ .r .annotate.long { display: block; } + +#source p .t .com { color: #008000; font-style: italic; line-height: 1px; } + +@media (prefers-color-scheme: dark) { #source p .t .com { color: #6a9955; } } + +#source p .t .key { font-weight: bold; line-height: 1px; } + +#source p .t .str { color: #0451a5; } + +@media (prefers-color-scheme: dark) { #source p .t .str { color: #9cdcfe; } } + +#source p.mis .t { border-left: 0.2em solid #ff0000; } + +#source p.mis.show_mis .t { background: #fdd; } + +@media (prefers-color-scheme: dark) { #source p.mis.show_mis .t { background: #4b1818; } } + +#source p.mis.show_mis .t:hover { background: #f2d2d2; } + +@media (prefers-color-scheme: dark) { #source p.mis.show_mis .t:hover { background: #532323; } } + +#source p.run .t { border-left: 0.2em solid #00dd00; } + +#source p.run.show_run .t { background: #dfd; } + +@media (prefers-color-scheme: dark) { #source p.run.show_run .t { background: #373d29; } } + +#source p.run.show_run .t:hover { background: #d2f2d2; } + +@media (prefers-color-scheme: dark) { #source p.run.show_run .t:hover { background: #404633; } } + +#source p.exc .t { border-left: 0.2em solid #808080; } + +#source p.exc.show_exc .t { background: #eee; } + +@media (prefers-color-scheme: dark) { #source p.exc.show_exc .t { background: #333; } } + +#source p.exc.show_exc .t:hover { background: #e2e2e2; } + +@media (prefers-color-scheme: dark) { #source p.exc.show_exc .t:hover { background: #3c3c3c; } } + +#source p.par .t { border-left: 0.2em solid #bbbb00; } + +#source p.par.show_par .t { background: #ffa; } + +@media (prefers-color-scheme: dark) { #source p.par.show_par .t { background: #650; } } + +#source p.par.show_par .t:hover { background: #f2f2a2; } + +@media (prefers-color-scheme: dark) { #source p.par.show_par .t:hover { background: #6d5d0c; } } + +#source p .r { position: absolute; top: 0; right: 2.5em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } + +#source p .annotate { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; color: #666; padding-right: .5em; } + +@media (prefers-color-scheme: dark) { #source p .annotate { color: #ddd; } } + +#source p .annotate.short:hover ~ .long { display: block; } + +#source p .annotate.long { width: 30em; right: 2.5em; } + +#source p input { display: none; } + +#source p input ~ .r label.ctx { cursor: pointer; border-radius: .25em; } + +#source p input ~ .r label.ctx::before { content: "▶ "; } + +#source p input ~ .r label.ctx:hover { background: #e8f4ff; color: #666; } + +@media (prefers-color-scheme: dark) { #source p input ~ .r label.ctx:hover { background: #0f3a42; } } + +@media (prefers-color-scheme: dark) { #source p input ~ .r label.ctx:hover { color: #aaa; } } + +#source p input:checked ~ .r label.ctx { background: #d0e8ff; color: #666; border-radius: .75em .75em 0 0; padding: 0 .5em; margin: -.25em 0; } + +@media (prefers-color-scheme: dark) { #source p input:checked ~ .r label.ctx { background: #056; } } + +@media (prefers-color-scheme: dark) { #source p input:checked ~ .r label.ctx { color: #aaa; } } + +#source p input:checked ~ .r label.ctx::before { content: "▼ "; } + +#source p input:checked ~ .ctxs { padding: .25em .5em; overflow-y: scroll; max-height: 10.5em; } + +#source p label.ctx { color: #999; display: inline-block; padding: 0 .5em; font-size: .8333em; } + +@media (prefers-color-scheme: dark) { #source p label.ctx { color: #777; } } + +#source p .ctxs { display: block; max-height: 0; overflow-y: hidden; transition: all .2s; padding: 0 .5em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; white-space: nowrap; background: #d0e8ff; border-radius: .25em; margin-right: 1.75em; text-align: right; } + +@media (prefers-color-scheme: dark) { #source p .ctxs { background: #056; } } + +#index { font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.875em; } + +#index table.index { margin-left: -.5em; } + +#index td, #index th { text-align: right; width: 5em; padding: .25em .5em; border-bottom: 1px solid #eee; } + +@media (prefers-color-scheme: dark) { #index td, #index th { border-color: #333; } } + +#index td.name, #index th.name { text-align: left; width: auto; } + +#index th { font-style: italic; color: #333; cursor: pointer; } + +@media (prefers-color-scheme: dark) { #index th { color: #ddd; } } + +#index th:hover { background: #eee; } + +@media (prefers-color-scheme: dark) { #index th:hover { background: #333; } } + +#index th[aria-sort="ascending"], #index th[aria-sort="descending"] { white-space: nowrap; background: #eee; padding-left: .5em; } + +@media (prefers-color-scheme: dark) { #index th[aria-sort="ascending"], #index th[aria-sort="descending"] { background: #333; } } + +#index th[aria-sort="ascending"]::after { font-family: sans-serif; content: " ↑"; } + +#index th[aria-sort="descending"]::after { font-family: sans-serif; content: " ↓"; } + +#index td.name a { text-decoration: none; color: inherit; } + +#index tr.total td, #index tr.total_dynamic td { font-weight: bold; border-top: 1px solid #ccc; border-bottom: none; } + +#index tr.file:hover { background: #eee; } + +@media (prefers-color-scheme: dark) { #index tr.file:hover { background: #333; } } + +#index tr.file:hover td.name { text-decoration: underline; color: inherit; } + +#scroll_marker { position: fixed; z-index: 3; right: 0; top: 0; width: 16px; height: 100%; background: #fff; border-left: 1px solid #eee; will-change: transform; } + +@media (prefers-color-scheme: dark) { #scroll_marker { background: #1e1e1e; } } + +@media (prefers-color-scheme: dark) { #scroll_marker { border-color: #333; } } + +#scroll_marker .marker { background: #ccc; position: absolute; min-height: 3px; width: 100%; } + +@media (prefers-color-scheme: dark) { #scroll_marker .marker { background: #444; } } diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index b0241fabe..000000000 --- a/pyproject.toml +++ /dev/null @@ -1,153 +0,0 @@ -[project] -name = "descope" -version = "2.17.0" -description = "Descope Python SDK" -authors = [{ name = "Descope", email = "info@descope.com" }] -requires-python = ">=3.9,<4.0" -readme = "README.md" -license = "MIT" -license-files = ["LICENSE"] -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Typing :: Typed", - "Topic :: Software Development :: Libraries :: Python Modules", -] -dependencies = [ - "httpx>=0.28,<0.29", - "certifi>=2026.10.19", - "pyjwt[crypto]>=2.4.0", - "email-validator>=2,<3", -] - -[project.optional-dependencies] -Flask = ["Flask>=2"] - -[project.urls] -Homepage = "https://descope.com/" -Repository = "https://github.com/descope/python-sdk" -Documentation = "https://docs.descope.com" -"Bug Tracker" = "https://github.com/descope/python-sdk/issues" - -[dependency-groups] -dev = [ - "pre-commit==3.8.0", - "python-dotenv>=1.2.1", - "ruff==0.17.0", -] -types = [ - # mypy 1.12+ requires Python 3.10+; on 3.9 we stay on the last 1.11.x line. - # mypy is only run in the lint job (Python 3.13) so 3.9 never installs it in CI. - # The `<1.12` upper bound on the 3.9 entry is REQUIRED — without it renovate - # bumps the pin past 1.12 (e.g. to 2.x) and produces an unsatisfiable lock. Keep it. - "mypy>=1.20.1; python_version >= '3.10'", - "mypy>=1.11.2,<1.12; python_version < '3.10'", -] -tests = [ - # pytest 9 requires Python 3.10+; on 3.9 we stay on the last 8.x line. - # The `<9` upper bound on the 3.9 entry is REQUIRED — without it renovate - # bumps the pin past 9.0 and produces an unsatisfiable lock. Keep it. - # CVE-2025-71176 (pytest <9.0.3 tmp-dir predictability) is dev-only and - # not fixable on 3.9 — risk is bounded to local CI runners. - "pytest>=9.0.3; python_version >= '3.10'", - "pytest>=8.4,<9; python_version < '3.10'", - "pytest-cov>=5", - # pytest-asyncio 1.4.0 dropped Python 3.9 support; on 3.9 we stay on the last - # release that still supports it (1.2.0). Bumping this to 1.4.0 (renovate, #1584) - # made the locked resolution unsatisfiable for the 3.9 split and broke CI's - # install step. Keep ==1.2.0 on the 3.9 entry. - "pytest-asyncio==1.2.0; python_version < '3.10'", - "pytest-asyncio==1.4.0; python_version >= '3.10'", - "coverage[toml]>=7.3.1,<8", -] - -[tool.uv] -default-groups = "all" - -[tool.uv.build-backend] -module-name = "descope" -module-root = "" - -[build-system] -requires = ["uv_build>=0.13.0,<0.14.0"] -build-backend = "uv_build" - -[tool.pytest.ini_options] -asyncio_mode = "auto" -markers = [ - "e2e: end-to-end tests against a real Descope backend (require DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY)", -] - -[tool.coverage.run] -relative_files = true -source = ["descope"] -omit = ["descope/flask/*"] - -[tool.coverage.report] -fail_under = 98 -skip_covered = true -skip_empty = true - -[tool.ruff] -line-length = 120 - -[tool.ruff.lint] -# Replaces flake8 (E/W/F), isort (I), flake8-bugbear (B), pep8-naming (N), pyupgrade (UP) -select = ["E", "W", "F", "I", "B", "N", "UP"] -ignore = [ - "E501", # line length is enforced by the formatter - "N818", # public exception class names predate the *Error suffix convention - "B904", # exception chaining cleanup tracked separately - # PEP 585/604 syntax requires Python 3.10+ at runtime; we still support 3.9 - "UP006", - "UP007", - "UP035", - "UP045", - "UP046", - "UP047", -] - -[tool.ruff.lint.per-file-ignores] -"__init__.py" = ["F401"] -# Public management APIs intentionally mirror the backend JSON schema (camelCase) -"descope/management/tenant.py" = ["N803"] -"descope/management/tenant_async.py" = ["N803"] -"descope/management/user.py" = ["N803", "N806"] -"descope/management/user_async.py" = ["N803", "N806"] -# Tests intentionally use blind exception checks and raw attribute access -"tests/**" = ["B017", "B018"] -# e2e test classes use a TestE2E_ naming scheme for readable grouping -"tests/e2e/**" = ["N801"] - -[tool.pylic] -# License names match the long-form `License` metadata field that pylic reads -# from installed package METADATA. SPDX short names rarely match. -safe_licenses = [ - "MIT License", - "MIT", - "ISC License (ISCL)", - "Apache-2.0", - "BSD License", - "BSD-2-Clause", - "BSD-3-Clause", - "Python Software Foundation License", - "PSF-2.0", -] -# Per-package allowances for non-standard licenses -# (certifi/MPL-2.0, email-validator/Unlicense — both pre-approved in the old liccheck policy; -# pathspec/MPL-2.0 is a transitive mypy dependency). -unsafe_packages = [ - "certifi", - "email-validator", - "pathspec", -] diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index 24c75bf5e..000000000 --- a/release-please-config.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "bootstrap-sha": "9ad71ffd7cdb956850a292073a3492669231936c", - "release-type": "python", - "packages": { - ".": { - "package-name": "descope", - "changelog-path": "CHANGELOG.md", - "extra-files": [ - { - "type": "toml", - "path": "pyproject.toml", - "jsonpath": "$.project.version" - }, - { - "type": "toml", - "path": "uv.lock", - "jsonpath": "$.package[?(@.name.value=='descope')].version" - } - ] - } - }, - "include-v-in-tag": false, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, - "sequential-calls": false, - "pull-request-title-pattern": "chore: release ${version}", - "changelog-sections": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "perf", "section": "Performance Improvements" }, - { "type": "revert", "section": "Reverts" }, - { "type": "docs", "section": "Documentation" }, - { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, - { "type": "refactor", "section": "Code Refactoring", "hidden": true }, - { "type": "test", "section": "Tests", "hidden": true }, - { "type": "build", "section": "Build System", "hidden": true }, - { "type": "ci", "section": "Continuous Integration", "hidden": true } - ] -} diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 12c42a9b9..000000000 --- a/renovate.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["local>descope/renovate-config"], - "lockFileMaintenance": { "enabled": true, "automerge": true }, - "pre-commit": { - "enabled": true - } -} diff --git a/samples/__init__.py b/samples/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/access_key_sample_app.py b/samples/access_key_sample_app.py deleted file mode 100644 index 3ae9fc9a1..000000000 --- a/samples/access_key_sample_app.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging - -from descope import AccessKeyLoginOptions, AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - access_key = "" - - try: - descope_client = DescopeClient(project_id=project_id) - logging.info("Going to login by using access key ...") - if not access_key: - access_key = input("Please insert the access key\n") - - try: - loc = AccessKeyLoginOptions(custom_claims={"k1": "v1"}) - jwt_response = descope_client.exchange_access_key(access_key=access_key, login_options=loc) - logging.info("exchange access key successfully") - logging.info(f"jwt_response: {jwt_response}") - - permission_name = "TestPermission" - permission_presented = descope_client.validate_permissions(jwt_response, [permission_name]) - logging.info(f"{permission_name} presented on the jwt: [{permission_presented}]") - role_name = "TestRole" - role_presented = descope_client.validate_roles(jwt_response, [role_name]) - logging.info(f"{role_name} presented on the jwt: [{role_presented}]") - except AuthException as e: - logging.info(f"Failed to exchange access key {e}") - raise - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/enchantedlink_sample_app.py b/samples/enchantedlink_sample_app.py deleted file mode 100644 index d56e67a17..000000000 --- a/samples/enchantedlink_sample_app.py +++ /dev/null @@ -1,69 +0,0 @@ -import logging -import sys -from threading import Thread -from time import sleep - -from descope import REFRESH_SESSION_TOKEN_NAME, AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - - try: - descope_client = DescopeClient(project_id=project_id) - - def verify(): - token = input("Please insert the token you received by email:\n") - try: - descope_client.enchantedlink.verify(token=token) - logging.info("Token is valid") - except AuthException as e: - logging.info(f"Invalid Token {e}") - raise - - logging.info("Going to signup / signin using Enchanted Link ...") - email = input("Please insert email to signup / signin:\n") - resp = descope_client.enchantedlink.sign_up_or_in( - login_id=email, - uri="http://test.me", - ) - - link_identifier = resp["linkId"] - masked_email = resp["maskedEmail"] - logging.info(f"we have sent you an email to {masked_email}") - logging.info(f"Please click the link with the identifier {link_identifier}") - pending_ref = resp["pendingRef"] - - done = False - - # open thread to get input - new_thread = Thread(target=verify) - new_thread.start() - - i = 0 - while not done: - try: - i = i + 1 - sys.stdout.write(f"\r Sleeping {i}...") - sys.stdout.flush() - sleep(4) - jwt_response = descope_client.enchantedlink.get_session(pending_ref) - done = True - except AuthException as e: - if e.status_code != 401: - logging.info(f"Failed pending session, err: {e}") - done = True - - if jwt_response: - refresh_token = jwt_response.get(REFRESH_SESSION_TOKEN_NAME).get("jwt") - descope_client.logout(refresh_token) - logging.info("User logged out") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/flask_authentication.py b/samples/flask_authentication.py deleted file mode 100644 index 246ef7a0b..000000000 --- a/samples/flask_authentication.py +++ /dev/null @@ -1,46 +0,0 @@ -from flask import Flask, Response - -from descope import DescopeClient -from descope.flask import descope_full_login, descope_logout, descope_validate_auth - -APP = Flask(__name__) -PROJECT_ID = "" # Can be set also by environment variable - -# init the DescopeClient -descope_client = DescopeClient(PROJECT_ID) - - -@APP.route("/login", methods=["GET"]) -@descope_full_login( - project_id=PROJECT_ID, - flow_id="sign-up-or-in", - success_redirect_url="http://dev.localhost:9010/private", -) -def login(): - # Nothing to do! this is the MAGIC! - pass - - -# This needs authentication -@APP.route("/private") -@descope_validate_auth(descope_client) # Can add permissions=["Perm 1"], roles=["Role 1"], tenant="t1" conditions -def private(): - return Response("

Restricted page, authentication needed.

") - - -@APP.route("/logout") -@descope_logout(descope_client) -def logout(): - return Response("

Goodbye, logged out.

") - - -# This doesn't need authentication -@APP.route("/") -def home(): - return Response("

Hello, public page!

") - - -if __name__ == "__main__": - APP.run( - host="dev.localhost", port=9010 - ) # cannot run on localhost as cookie will not work (just add it to your /etc/hosts file) diff --git a/samples/magiclink_sample_app.py b/samples/magiclink_sample_app.py deleted file mode 100644 index 944f4c237..000000000 --- a/samples/magiclink_sample_app.py +++ /dev/null @@ -1,78 +0,0 @@ -import logging - -from descope import ( - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, - AuthException, - DeliveryMethod, - DescopeClient, -) - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - - try: - descope_client = DescopeClient(project_id=project_id) - - logging.info("Going to signup / signin using Magic Link ...") - email = input("Please insert email to signup / signin:\n") - masked_mail = descope_client.magiclink.sign_up_or_in( - method=DeliveryMethod.EMAIL, - login_id=email, - uri="http://test.me", - ) - - token = input(f"Please insert the token you received by email ({masked_mail}):\n") - try: - jwt_response = descope_client.magiclink.verify(token=token) - logging.info("Token is valid") - refresh_token = jwt_response.get(REFRESH_SESSION_TOKEN_NAME).get("jwt") - logging.info(f"jwt_response: {jwt_response}") - except AuthException as e: - logging.info(f"Invalid Token {e}") - raise - - try: - logging.info("Going to logout after sign-in / sign-up") - descope_client.logout(refresh_token) - logging.info("User logged out after sign-in / sign-up") - except AuthException as e: - logging.info(f"Failed to logged after sign-in / sign-up, err: {e}") - - logging.info("Going to sign in same user again...") - descope_client.magiclink.sign_in(method=DeliveryMethod.EMAIL, login_id=email, uri="http://test.me") - - token = input("Please insert the Token you received by email:\n") - try: - jwt_response = descope_client.magiclink.verify(token=token) - logging.info("Token is valid") - session_token_1 = jwt_response.get(SESSION_TOKEN_NAME).get("jwt") - refresh_token_1 = jwt_response.get(REFRESH_SESSION_TOKEN_NAME).get("jwt") - logging.info(f"jwt_response: {jwt_response}") - except AuthException as e: - logging.info(f"Invalid Token {e}") - raise - - try: - logging.info(f"going to validate session...{session_token_1}") - descope_client.validate_and_refresh_session(session_token_1, refresh_token_1) - logging.info("Session is valid and all is OK") - except AuthException as e: - logging.info(f"Session is not valid {e}") - - try: - logging.info(f"Going to logout at the second time\nrefresh_token: {refresh_token_1}") - descope_client.logout(refresh_token_1) - logging.info("User logged out") - except AuthException as e: - logging.info(f"Failed to logged out user, err: {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/magiclink_web_sample_app.py b/samples/magiclink_web_sample_app.py deleted file mode 100644 index 2d030929f..000000000 --- a/samples/magiclink_web_sample_app.py +++ /dev/null @@ -1,144 +0,0 @@ -from flask import Flask, Response, g, jsonify, request - -from descope import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, - AuthException, - DeliveryMethod, - DescopeClient, -) -from descope.flask import ( - descope_logout, - descope_validate_auth, - descope_verify_magiclink_token, - set_cookie_on_response, -) - -APP = Flask(__name__) - -PROJECT_ID = "" -URI = "http://127.0.0.1:9000/api/verify_by_decorator" - -# init the DescopeClient -descope_client = DescopeClient(PROJECT_ID) - - -class Error(Exception): - def __init__(self, error, status_code): - self.error = error - self.status_code = status_code - - -@APP.errorhandler(Error) -def handle_auth_error(ex): - response = jsonify(ex.error) - response.status_code = ex.status_code - return response - - -@APP.route("/api/signup", methods=["POST"]) -def sign_up(): - data = request.get_json(force=True) - email = data.get("email", None) - user = data.get("user", None) - if not email or not user: - return Response("Unauthorized", 401) - - try: - usr = {"username": "dummy", "name": "", "phone": "", "email": ""} - descope_client.magiclink.sign_up(DeliveryMethod.EMAIL, email, URI, usr) - except AuthException: - return Response("Unauthorized", 401) - - response = "This is sign up API handling" - return jsonify(message=response) - - -@APP.route("/api/signin", methods=["POST"]) -def sign_in(): - data = request.get_json(force=True) - email = data.get("email", None) - if not email: - return Response("Unauthorized, missing email", 401) - - try: - descope_client.magiclink.sign_in(DeliveryMethod.EMAIL, email, URI) - except AuthException: - return Response("Unauthorized, something went wrong when sending email", 401) - - response = "This is sign in API handling" - return jsonify(message=response) - - -@APP.route("/api/sign-up-or-in", methods=["POST"]) -def sign_up_or_in(): - data = request.get_json(force=True) - email = data.get("email", None) - if not email: - return Response("Unauthorized, missing email", 401) - - try: - descope_client.magiclink.sign_up_or_in(DeliveryMethod.EMAIL, email, URI) - except AuthException: - return Response("Unauthorized, something went wrong when sending email", 401) - - response = "This is sign up or in API handling" - return jsonify(message=response) - - -@APP.route("/api/verify", methods=["POST"]) -def verify(): - data = request.get_json(force=True) - token = data.get("token", None) - if not token: - return Response("Unauthorized", 401) - - try: - jwt_response = descope_client.magiclink.verify(token) - except AuthException: - return Response("Unauthorized", 401) - - response = Response("Token verified", 200) - - set_cookie_on_response(response, jwt_response[SESSION_TOKEN_NAME], jwt_response[COOKIE_DATA_NAME]) - set_cookie_on_response( - response, - jwt_response[REFRESH_SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - - return response - - -@APP.route("/api/verify_by_decorator", methods=["GET"]) -@descope_verify_magiclink_token(descope_client) -def verify_by_decorator(*args, **kwargs): - claims = g.claims - response = f"This is a code verification API, claims are: {claims}" - return jsonify(message=response) - - -# This needs authentication -@APP.route("/api/private") -@descope_validate_auth(descope_client, permissions=["Perm 1"]) -def private(): - response = "This is a private API and you must be authenticated to see this" - return jsonify(message=response) - - -@APP.route("/api/logout") -@descope_logout(descope_client) -def logout(): - response = "Logged out" - return jsonify(message=response) - - -# This doesn't need authentication -@APP.route("/") -def home(): - return "OK" - - -if __name__ == "__main__": - APP.run(host="127.0.0.1", port=9000) diff --git a/samples/management/__init__.py b/samples/management/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/management/access_key_sample_app.py b/samples/management/access_key_sample_app.py deleted file mode 100755 index 1c57a70c0..000000000 --- a/samples/management/access_key_sample_app.py +++ /dev/null @@ -1,94 +0,0 @@ -import datetime -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - key_id = "" - - try: - logging.info("Going to create a new access key") - now = datetime.datetime.now() - t = now + datetime.timedelta(minutes=10) - exp_time = int(t.timestamp()) - cc = {"k1": "v1"} - access_key_resp = descope_client.mgmt.access_key.create( - name="key-name", expire_time=exp_time, custom_claims=cc - ) - cleartext = access_key_resp["cleartext"] - access_key = access_key_resp["key"] - key_id = access_key["id"] - logging.info(f"Create: created access key {access_key}") - - except AuthException as e: - logging.info(f"Access key creation failed {e}") - - try: - logging.info("Searching for created access key") - access_key_resp = descope_client.mgmt.access_key.load(key_id) - access_key = access_key_resp["key"] - logging.info(f"Load: found access key {access_key}") - - except AuthException as e: - logging.info(f"Access key load failed {e}") - - try: - logging.info("Searching all access keys") - users_resp = descope_client.mgmt.access_key.search_all_access_keys() - access_keys = users_resp["keys"] - for key in access_keys: - logging.info(f"Search Found access key {key}") - - except AuthException as e: - logging.info(f"Access key load failed {e}") - - try: - logging.info("Updating newly created access key") - # update overrides all fields, must provide the entire entity - # we mean to update. - descope_client.mgmt.access_key.update(key_id, "New key name") - - except AuthException as e: - logging.info(f"Access key update failed {e}") - - try: - resp = descope_client.exchange_access_key(cleartext, None) - logging.info(f"Exchange: session token: {resp}") - except AuthException as e: - logging.info(f"Access key exchange failed {e}") - - try: - logging.info("Deactivating newly created access key") - descope_client.mgmt.access_key.deactivate(key_id) - - except AuthException as e: - logging.info(f"Access key deactivate failed {e}") - - try: - logging.info("Activating newly created access key") - descope_client.mgmt.access_key.activate(key_id) - - except AuthException as e: - logging.info(f"Access key activate failed {e}") - - try: - logging.info("Deleting newly created access key") - descope_client.mgmt.access_key.delete(key_id) - - except AuthException as e: - logging.info(f"Access key deletion failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/audit_sample_app.py b/samples/management/audit_sample_app.py deleted file mode 100755 index a2f4e6f2b..000000000 --- a/samples/management/audit_sample_app.py +++ /dev/null @@ -1,50 +0,0 @@ -import logging -import sys -from datetime import datetime - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - # Either specify here or read from env - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - try: - logging.info("Going to create audit event") - descope_client.mgmt.audit.create_event( - action="pencil.created", - user_id="user-id", - actor_id="actor-id", - tenant_id="tenant-id", - type="info", - data={"some": "data"}, - ) - logging.info("Create: created audit event") - - except AuthException as e: - logging.info(f"Audit event creation failed {e}") - - try: - logging.info("Going to search audit") - text = None - if len(sys.argv) > 1: - text = sys.argv[1] - from_ts = None - if len(sys.argv) > 2: - from_ts = datetime.fromisoformat(sys.argv[2]) - logging.info(descope_client.mgmt.audit.search(text=text, from_ts=from_ts)) - - except AuthException as e: - logging.info(f"Audit search failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/authz_sample_app.py b/samples/management/authz_sample_app.py deleted file mode 100755 index c83a326f3..000000000 --- a/samples/management/authz_sample_app.py +++ /dev/null @@ -1,141 +0,0 @@ -import json -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - # Either specify here or read from env - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - try: - logging.info("Creating test authz schema if different name") - schema = descope_client.mgmt.authz.load_schema() - with open("samples/management/files.json") as f: - schema_from_file = json.load(f) - if schema["name"] != schema_from_file["name"]: - logging.info("Schema is different, upgrading...") - descope_client.mgmt.authz.save_schema(schema_from_file, True) - descope_client.mgmt.authz.create_relations( - [ - { - "resource": "Dev", - "relationDefinition": "parent", - "namespace": "org", - "target": "Descope", - }, - { - "resource": "Sales", - "relationDefinition": "parent", - "namespace": "org", - "target": "Descope", - }, - { - "resource": "Dev", - "relationDefinition": "member", - "namespace": "org", - "target": "u1", - }, - { - "resource": "Dev", - "relationDefinition": "member", - "namespace": "org", - "target": "u3", - }, - { - "resource": "Sales", - "relationDefinition": "member", - "namespace": "org", - "target": "u2", - }, - { - "resource": "Presentations", - "relationDefinition": "parent", - "namespace": "folder", - "target": "Internal", - }, - { - "resource": "roadmap.ppt", - "relationDefinition": "parent", - "namespace": "doc", - "target": "Presentations", - }, - { - "resource": "roadmap.ppt", - "relationDefinition": "owner", - "namespace": "doc", - "target": "u1", - }, - { - "resource": "Internal", - "relationDefinition": "viewer", - "namespace": "folder", - "targetSetResource": "Descope", - "targetSetRelationDefinition": "member", - "targetSetRelationDefinitionNamespace": "org", - }, - { - "resource": "Presentations", - "relationDefinition": "editor", - "namespace": "folder", - "targetSetResource": "Sales", - "targetSetRelationDefinition": "member", - "targetSetRelationDefinitionNamespace": "org", - }, - ] - ) - res = descope_client.mgmt.authz.has_relations( - [ - { - "resource": "roadmap.ppt", - "relationDefinition": "owner", - "namespace": "doc", - "target": "u1", - }, - { - "resource": "roadmap.ppt", - "relationDefinition": "editor", - "namespace": "doc", - "target": "u1", - }, - { - "resource": "roadmap.ppt", - "relationDefinition": "viewer", - "namespace": "doc", - "target": "u1", - }, - { - "resource": "roadmap.ppt", - "relationDefinition": "viewer", - "namespace": "doc", - "target": "u3", - }, - { - "resource": "roadmap.ppt", - "relationDefinition": "editor", - "namespace": "doc", - "target": "u3", - }, - { - "resource": "roadmap.ppt", - "relationDefinition": "editor", - "namespace": "doc", - "target": "u2", - }, - ] - ) - logging.info(f"Checking existing relations: {res}") - except AuthException as e: - logging.info(f"Checking existing relations failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/family_sample_app.py b/samples/management/family_sample_app.py deleted file mode 100644 index 6c8576b43..000000000 --- a/samples/management/family_sample_app.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -End-to-end walkthrough of the family account management API. - -Creates throwaway attribute definitions, a family, a guardian and a dependent, exercises every -family endpoint, then deletes everything it created and restores the original family settings. - -Run: - DESCOPE_PROJECT_ID=... DESCOPE_MANAGEMENT_KEY=... uv run python samples/management/family_sample_app.py - -Optional environment variables: - DESCOPE_BASE_URL - override the Descope API base URL (e.g. for a custom domain). - SKIP_CLEANUP=1 - keep everything the run created (and the guardian's membership) for inspection. - FAMILY_ROLE - the guardian's role in the family. Defaults to "Family Admin", the default family - role Descope creates when family accounts are enabled. A different role needs the - "Family Impersonate Dependents" permission for the impersonation step to pass. - -The script exits with a non-zero status if any step, including a cleanup step, fails. -""" - -import base64 -import json -import logging -import os -import sys -import time -from typing import Any, Callable, List, Optional - -from descope import AssociatedFamily, CustomAttribute, DescopeClient - -logging.basicConfig(level=logging.INFO, format="%(message)s") -logger = logging.getLogger(__name__) - -# Custom attribute type 1 is a string attribute -STRING_ATTRIBUTE = 1 - - -def step(name: str, call: Callable[[], Any], log_result: bool = True) -> Any: - """Run a single step, logging its result. Exceptions propagate to the caller.""" - result = call() - logger.info(f"\n[ok] {name}") - if log_result and result is not None: - logger.info(json.dumps(result, indent=2, default=str)) - return result - - -def log_session_claims(jwt: str) -> None: - """Log the identity claims of a session JWT: the subject, the acting user (set while impersonating) - and the selected family. The token itself is a live session credential, so it is never logged. The - payload is decoded without verifying the signature, which is fine for display only - validate tokens - with the SDK before trusting them.""" - payload = jwt.split(".")[1] - claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) - logger.info(json.dumps({k: claims.get(k) for k in ("sub", "act", "dcf")}, indent=2)) - - -def main() -> int: - project_id = os.getenv("DESCOPE_PROJECT_ID") - management_key = os.getenv("DESCOPE_MANAGEMENT_KEY") - base_url = os.getenv("DESCOPE_BASE_URL") or None - guardian_role = os.getenv("FAMILY_ROLE") or "Family Admin" - skip_cleanup = os.getenv("SKIP_CLEANUP") == "1" - - if not project_id or not management_key: - logger.error("Missing DESCOPE_PROJECT_ID or DESCOPE_MANAGEMENT_KEY environment variables") - return 1 - - descope_client = DescopeClient(project_id=project_id, management_key=management_key, base_url=base_url) - family = descope_client.mgmt.family - user = descope_client.mgmt.user - - run = format(int(time.time() * 1000), "x") - family_attr = f"plan_{run}" - family_scoped_attr = f"nickname_{run}" - guardian_login_id = f"guardian-{run}@example.com" - - # --- Settings ----------------------------------------------------------------------------- - original_settings = step("family.get_settings", family.get_settings) - step( - "family.configure_settings (enable families)", - lambda: family.configure_settings(enabled=True, allow_multiple_families_users=True), - ) - - family_id: Optional[str] = None - dependent_user_id: Optional[str] = None - guardian_created = False - family_attr_created = False - family_scoped_attr_created = False - failed = False - - try: - # --- Attribute definitions ------------------------------------------------------------ - step( - "family.create_custom_attributes", - lambda: family.create_custom_attributes( - [CustomAttribute(family_attr, STRING_ATTRIBUTE, display_name="Plan")] - ), - ) - family_attr_created = True - step("family.load_custom_attributes", family.load_custom_attributes) - - step( - "user.create_family_scoped_custom_attributes", - lambda: user.create_family_scoped_custom_attributes( - [CustomAttribute(family_scoped_attr, STRING_ATTRIBUTE, display_name="Nickname")] - ), - ) - family_scoped_attr_created = True - step("user.load_family_scoped_custom_attributes", user.load_family_scoped_custom_attributes) - - # --- Family CRUD ---------------------------------------------------------------------- - created = step( - "family.create", - lambda: family.create(f"Demo Family {run}", custom_attributes={family_attr: "free"}), - ) - family_id = created["family"]["id"] - fid: str = family_id - - step( - "family.update (rename + change attribute)", - lambda: family.update(fid, name=f"Demo Family {run} (renamed)", custom_attributes={family_attr: "premium"}), - ) - step("family.search_all by id", lambda: family.search_all(ids=[fid])) - step( - "family.search_all by custom attribute", - lambda: family.search_all(custom_attributes={family_attr: "premium"}), - ) - - # --- Guardian (regular member) -------------------------------------------------------- - step( - "user.create (guardian, created into the family)", - lambda: user.create( - guardian_login_id, - email=guardian_login_id, - display_name="Demo Guardian", - family_associations=[ - AssociatedFamily( - fid, role_names=[guardian_role], family_scoped_attributes={family_scoped_attr: "Mom"} - ) - ], - ), - ) - guardian_created = True - - guardian = step( - "user.add_families (update family-scoped attribute)", - lambda: user.add_families( - guardian_login_id, - [AssociatedFamily(fid, family_scoped_attributes={family_scoped_attr: "Mommy"})], - ), - ) - logger.info(f" guardian userFamilies -> {guardian['user'].get('userFamilies')}") - - # --- Dependent (no login credentials of their own) ------------------------------------ - dependent = step( - "family.create_dependent", - lambda: family.create_dependent( - fid, - name=f"Demo Kid {run}", - given_name="Demo", - family_scoped_attributes={family_scoped_attr: "Kiddo"}, - ), - )["user"] - dependent_user_id = dependent["userId"] - logger.info(f" dependent.dependent -> {dependent.get('dependent')}") - - # --- Search users by family ----------------------------------------------------------- - members = step("user.search_all (all family members)", lambda: user.search_all(family_ids=[fid])) - logger.info(f" member login IDs -> {[u.get('loginIds') for u in members['users']]}") - step( - "user.search_all (dependents only)", - lambda: user.search_all(family_ids=[fid], dependent=True), - ) - - # --- Impersonation -------------------------------------------------------------------- - # The guardian acts as family admin through guardian_role's impersonate-dependents permission - impersonation_jwt = step( - "family.impersonate_dependent", - lambda: family.impersonate_dependent(guardian_login_id, dependent["loginIds"][0], fid), - log_result=False, # keep session tokens out of the logs - ) - # sub is the dependent, act is the guardian acting on their behalf, dcf is the selected family - log_session_claims(impersonation_jwt) - guardian_jwt = step( - "family.stop_impersonation", lambda: family.stop_impersonation(impersonation_jwt), log_result=False - ) - # Back to the guardian's own session: sub is the guardian and act is gone - log_session_claims(guardian_jwt) - - # --- Membership removal --------------------------------------------------------------- - # Kept when skipping cleanup, so the family shows both the guardian and the dependent - if not skip_cleanup: - step( - "user.remove_families (guardian)", - lambda: user.remove_families(guardian_login_id, [fid]), - ) - except Exception as e: - failed = True - logger.error(f"\n[failed] {e}") - finally: - if skip_cleanup: - logger.info("\n--- SKIP_CLEANUP=1, left in place ---") - logger.info( - json.dumps( - { - "familyId": family_id, - "familyAttribute": family_attr if family_attr_created else None, - "familyScopedUserAttribute": family_scoped_attr if family_scoped_attr_created else None, - "guardianLoginId": guardian_login_id if guardian_created else None, - "dependentUserId": dependent_user_id, - "originalSettings": original_settings, - }, - indent=2, - ) - ) - else: - # --- Cleanup, reverse order ------------------------------------------------------- - # Every cleanup step is attempted even if an earlier one fails, and any failure makes - # the script exit non-zero. - logger.info("\n--- cleanup ---") - cleanup: List[tuple] = [] - if dependent_user_id: - dep_id: str = dependent_user_id - cleanup.append(("family.delete_dependent", lambda: family.delete_dependent(dep_id))) - if guardian_created: - cleanup.append(("user.delete (guardian)", lambda: user.delete(guardian_login_id))) - if family_id: - del_id: str = family_id - cleanup.append(("family.delete", lambda: family.delete(del_id))) - if family_scoped_attr_created: - cleanup.append( - ( - "user.delete_family_scoped_custom_attributes", - lambda: user.delete_family_scoped_custom_attributes([family_scoped_attr]), - ) - ) - if family_attr_created: - cleanup.append( - ("family.delete_custom_attributes", lambda: family.delete_custom_attributes([family_attr])) - ) - max_members = original_settings.get("maxFamilyMembers") - cleanup.append( - ( - "family.configure_settings (restore original)", - lambda: family.configure_settings( - enabled=original_settings.get("enabled", False), - max_family_members=max_members if max_members else None, - allow_multiple_families_users=original_settings.get("allowMultipleFamiliesUsers", False), - ), - ) - ) - for name, call in cleanup: - try: - step(name, call) - except Exception as e: - failed = True - logger.error(f"\n[failed] {name}: {e}") - - return 1 if failed else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/samples/management/files.json b/samples/management/files.json deleted file mode 100644 index 783d668ec..000000000 --- a/samples/management/files.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "namespaces": [ - { - "name": "doc", - "relationDefinitions": [ - { - "name": "parent", - "complexDefinition": null - }, - { - "name": "editor", - "complexDefinition": { - "nType": "union", - "children": [ - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - } - ], - "expression": null - } - }, - { - "name": "viewer", - "complexDefinition": { - "nType": "union", - "children": [ - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - } - ], - "expression": null - } - }, - { - "name": "owner", - "complexDefinition": { - "nType": "union", - "children": [ - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - } - ], - "expression": null - } - } - ] - }, - { - "name": "folder", - "relationDefinitions": [ - { - "name": "editor", - "complexDefinition": { - "nType": "union", - "children": [ - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - } - ], - "expression": null - } - }, - { - "name": "parent", - "complexDefinition": null - }, - { - "name": "owner", - "complexDefinition": { - "nType": "union", - "children": [ - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - } - ], - "expression": null - } - }, - { - "name": "viewer", - "complexDefinition": { - "nType": "union", - "children": [ - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - } - ], - "expression": null - } - } - ] - }, - { - "name": "org", - "relationDefinitions": [ - { - "name": "parent", - "complexDefinition": null - }, - { - "name": "member", - "complexDefinition": { - "nType": "union", - "children": [ - { - "nType": "child", - "children": [], - "expression": null - }, - { - "nType": "child", - "children": [], - "expression": null - } - ], - "expression": null - } - } - ] - } - ], - "name": "Files" -} diff --git a/samples/management/flow_sample_app.py b/samples/management/flow_sample_app.py deleted file mode 100755 index 6c6905c74..000000000 --- a/samples/management/flow_sample_app.py +++ /dev/null @@ -1,50 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - name = "sign-up-or-in" - - try: - logging.info("Going get 'sign-up-or-in' flow") - res = descope_client.mgmt.flow.export_flow(name) - - except AuthException as e: - logging.info(f"Export flow failed {e}") - - try: - logging.info("Importing sign-up-or-in flow and change name") - res["flow"]["name"] = "Importing from SDK" - res = descope_client.mgmt.flow.import_flow("sign-up-or-in", res["flow"], res["screens"]) - - except AuthException as e: - logging.info(f"Importing flow failed {e}") - - try: - logging.info("Going get the project theme") - res = descope_client.mgmt.flow.import_theme() - - except AuthException as e: - logging.info(f"Export theme failed {e}") - - try: - logging.info("Importing theme back") - res = descope_client.mgmt.flow.import_theme("sign-up-or-in", res) - - except AuthException as e: - logging.info(f"Importing theme failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/group_sample_app.py b/samples/management/group_sample_app.py deleted file mode 100755 index 82990e359..000000000 --- a/samples/management/group_sample_app.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - tenant_id = "" # tenant id to search groups in - - try: - logging.info("Going to load all groups for tenant") - groups_resp = descope_client.mgmt.group.load_all_groups(tenant_id=tenant_id) - for group in groups_resp: - logging.info(f"Search Found group {group}") - login_id = groups_resp[0]["members"][0]["loginId"] - user_id = groups_resp[0]["members"][0]["userId"] - group_id = groups_resp[0]["id"] - except AuthException as e: - logging.info(f"Groups load failed {e}") - - try: - logging.info("Going to load all groups for members - using login ID") - groups_resp = descope_client.mgmt.group.load_all_groups_for_members( - tenant_id=tenant_id, - login_ids=[login_id], - ) - for group in groups_resp: - logging.info(f"Search Found group {group}") - except AuthException as e: - logging.info(f"Groups load failed {e}") - - try: - logging.info("Going to load all groups for members - using user IDs") - groups_resp = descope_client.mgmt.group.load_all_groups_for_members( - tenant_id=tenant_id, - user_ids=[user_id], - ) - for group in groups_resp: - logging.info(f"Search Found group {group}") - except AuthException as e: - logging.info(f"Groups load failed {e}") - - try: - logging.info("Going to load all members for group") - groups_resp = descope_client.mgmt.group.load_all_group_members( - tenant_id=tenant_id, - group_id=group_id, - ) - for group in groups_resp: - logging.info(f"Search Found group {group}") - except AuthException as e: - logging.info(f"Groups load failed {e}") - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/outbound_application_sample_app.py b/samples/management/outbound_application_sample_app.py deleted file mode 100644 index 46a0cf8f9..000000000 --- a/samples/management/outbound_application_sample_app.py +++ /dev/null @@ -1,169 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - outbound_app_id = "" - - # CREATE OUTBOUND APPLICATION - - try: - logging.info("Going to create a new outbound application") - resp = descope_client.mgmt.outbound_application.create_application( - "My first outbound application", - description="This is a test outbound application", - client_secret="shhh..", - ) - outbound_app_id = resp["app"]["id"] - logging.info(f"Outbound application creation response: {resp}") - - except AuthException as e: - logging.info(f"Outbound application creation failed {e}") - - # LOAD OUTBOUND APPLICATION - - try: - logging.info("Loading outbound application by id") - outbound_app_resp = descope_client.mgmt.outbound_application.load_application(outbound_app_id) - logging.info(f"Found outbound application {outbound_app_resp}") - - except AuthException as e: - logging.info(f"Outbound application load failed {e}") - - # LOAD ALL OUTBOUND APPLICATIONS - - try: - logging.info("Loading all outbound applications") - outbound_app_resp = descope_client.mgmt.outbound_application.load_all_applications() - apps = outbound_app_resp["apps"] - for app in apps: - logging.info(f"LoadAll Found outbound application: {app}") - - except AuthException as e: - logging.info(f"Outbound application load all failed {e}") - - # UPDATE OUTBOUND APPLICATION - - try: - logging.info("Going to update the outbound application") - descope_client.mgmt.outbound_application.update_application( - outbound_app_id, - "Updated outbound application name", - description="Updated description", - logo="https://example.com/logo.png", - client_secret="new-secret", - ) - logging.info("Outbound application updated successfully") - - except AuthException as e: - logging.info(f"Outbound application update failed {e}") - - # FETCH TOKEN BY SCOPES - - try: - logging.info("Going to fetch token by scopes") - token_resp = descope_client.mgmt.outbound_application.fetch_token_by_scopes( - outbound_app_id, - "user123", - ["read", "write"], - options={"refreshToken": True}, - tenant_id="tenant456", - ) - logging.info(f"Token fetch response: {token_resp}") - - except AuthException as e: - logging.info(f"Token fetch by scopes failed {e}") - - # FETCH TOKEN - - try: - logging.info("Going to fetch token") - token_resp = descope_client.mgmt.outbound_application.fetch_token( - outbound_app_id, - "user123", - tenant_id="tenant456", - options={"forceRefresh": True}, - ) - logging.info(f"Token fetch response: {token_resp}") - - except AuthException as e: - logging.info(f"Token fetch failed {e}") - - # FETCH TENANT TOKEN BY SCOPES - - try: - logging.info("Going to fetch tenant token by scopes") - token_resp = descope_client.mgmt.outbound_application.fetch_tenant_token_by_scopes( - outbound_app_id, - "tenant456", - ["read", "write"], - options={"refreshToken": True}, - ) - logging.info(f"Tenant token fetch response: {token_resp}") - - except AuthException as e: - logging.info(f"Tenant token fetch by scopes failed {e}") - - # FETCH TENANT TOKEN - - try: - logging.info("Going to fetch tenant token") - token_resp = descope_client.mgmt.outbound_application.fetch_tenant_token( - outbound_app_id, - "tenant456", - options={"forceRefresh": True}, - ) - logging.info(f"Tenant token fetch response: {token_resp}") - - except AuthException as e: - logging.info(f"Tenant token fetch failed {e}") - - # DELETE USER TOKENS - - try: - logging.info("Going to delete user tokens by app and user id") - descope_client.mgmt.outbound_application.delete_user_tokens( - app_id=outbound_app_id, - user_id="user123", - ) - logging.info("User tokens deleted successfully") - - except AuthException as e: - logging.info(f"User tokens deletion failed {e}") - - # DELETE TOKEN BY ID - - try: - logging.info("Going to delete token by id") - descope_client.mgmt.outbound_application.delete_token( - token_id="token-id-123", - ) - logging.info("Token deleted successfully") - - except AuthException as e: - logging.info(f"Token deletion failed {e}") - - # DELETE OUTBOUND APPLICATION - - try: - logging.info("Going to delete the outbound application") - descope_client.mgmt.outbound_application.delete_application(outbound_app_id) - logging.info("Outbound application deleted successfully") - - except AuthException as e: - logging.info(f"Outbound application deletion failed {e}") - - except AuthException as e: - logging.info(f"Failed to initialize client: {e}") - - -if __name__ == "__main__": - main() diff --git a/samples/management/permission_sample_app.py b/samples/management/permission_sample_app.py deleted file mode 100755 index d17f01479..000000000 --- a/samples/management/permission_sample_app.py +++ /dev/null @@ -1,94 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - name = "My Permission" - - try: - logging.info("Going to create a new permission") - descope_client.mgmt.permission.create(name, "Allowed to test :)") - - except AuthException as e: - logging.info(f"Permission creation failed {e}") - - try: - logging.info("Going to create a batch of permissions") - descope_client.mgmt.permission.create_batch( - [ - { - "name": "Batch Permission 1", - "description": "First batch permission", - }, - { - "name": "Batch Permission 2", - "description": "Second batch permission", - }, - ] - ) - - except AuthException as e: - logging.info(f"Permission batch creation failed {e}") - - try: - logging.info("Loading all permissions") - permissions_resp = descope_client.mgmt.permission.load_all() - permissions = permissions_resp["permissions"] - for permission in permissions: - logging.info(f"Search Found permission {permission}") - - except AuthException as e: - logging.info(f"Permission load failed {e}") - - try: - logging.info("Updating newly created permission") - # update overrides all fields, must provide the entire entity - # we mean to update. - descope_client.mgmt.permission.update(name, new_name="My Updated Permission", description="New Description") - - except AuthException as e: - logging.info(f"Permission update failed {e}") - - try: - logging.info("Updating a batch of permissions") - descope_client.mgmt.permission.update_batch( - [ - { - "name": "Batch Permission 1", - "newName": "Updated Batch Permission 1", - "description": "Updated description", - }, - ] - ) - - except AuthException as e: - logging.info(f"Permission batch update failed {e}") - - try: - logging.info("Deleting newly created permission") - descope_client.mgmt.permission.delete("My Updated Permission") - - except AuthException as e: - logging.info(f"Permission deletion failed {e}") - - try: - logging.info("Deleting a batch of permissions") - descope_client.mgmt.permission.delete_batch(["Updated Batch Permission 1", "Batch Permission 2"]) - - except AuthException as e: - logging.info(f"Permission batch deletion failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/role_sample_app.py b/samples/management/role_sample_app.py deleted file mode 100755 index 08059c684..000000000 --- a/samples/management/role_sample_app.py +++ /dev/null @@ -1,127 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - name = "My Role" - - try: - logging.info("Going to create a new role") - descope_client.mgmt.role.create( - name, - "Allowed to test :)", - permission_names=["SSO Admin"], - default=False, # Optional, marks this role as default role - private=False, # Optional, marks this role as private role - ) - - except AuthException as e: - logging.info(f"Role creation failed {e}") - - try: - logging.info("Going to create a batch of roles") - descope_client.mgmt.role.create_batch( - [ - { - "name": "Batch Role 1", - "description": "First batch role", - "permissionNames": ["SSO Admin"], - }, - { - "name": "Batch Role 2", - "description": "Second batch role", - "default": True, - }, - ] - ) - - except AuthException as e: - logging.info(f"Role batch creation failed {e}") - - try: - logging.info("Loading all roles") - roles_resp = descope_client.mgmt.role.load_all() - roles = roles_resp["roles"] - for role in roles: - logging.info(f"Search Found role {role}") - - except AuthException as e: - logging.info(f"Role load failed {e}") - - try: - logging.info("Searching roles by tenant and name filters") - roles_resp = descope_client.mgmt.role.search( - tenant_ids=["t1", "t2"], - role_names=["My Role"], - ) - roles = roles_resp["roles"] - for role in roles: - logging.info(f"Search Found role {role}") - - except AuthException as e: - logging.info(f"Role search failed {e}") - - try: - logging.info("Updating newly created role") - # update overrides all fields, must provide the entire entity - # we mean to update. - descope_client.mgmt.role.update( - name, - new_name="My Updated Role", - description="New Description", - permission_names=["User Admin"], - default=False, # Optional, marks this role as default role - private=False, # Optional, marks this role as private role - ) - - except AuthException as e: - logging.info(f"Role update failed {e}") - - try: - logging.info("Updating a batch of roles") - descope_client.mgmt.role.update_batch( - [ - { - "name": "Batch Role 1", - "newName": "Updated Batch Role 1", - "description": "Updated description", - "permissionNames": ["User Admin"], - }, - ] - ) - - except AuthException as e: - logging.info(f"Role batch update failed {e}") - - try: - logging.info("Deleting a batch of roles") - descope_client.mgmt.role.delete_batch( - [ - "Updated Batch Role 1", - ] - ) - - except AuthException as e: - logging.info(f"Role batch deletion failed {e}") - - try: - logging.info("Deleting newly created role") - descope_client.mgmt.role.delete("My Updated Role") - - except AuthException as e: - logging.info(f"Role deletion failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/sso_application_sample_app.py b/samples/management/sso_application_sample_app.py deleted file mode 100755 index 9d5e3a948..000000000 --- a/samples/management/sso_application_sample_app.py +++ /dev/null @@ -1,150 +0,0 @@ -import logging - -from descope import ( - AuthException, - DescopeClient, - SAMLIDPAttributeMappingInfo, - SAMLIDPGroupsMappingInfo, - SAMLIDPRoleGroupMappingInfo, -) - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - sso_app_id = "" - - # OIDC APP - - try: - logging.info("Going to create a new OIDC sso application") - resp = descope_client.mgmt.sso_application.create_oidc_application( - "My first OIDC sso application", "http://dummy.com" - ) - sso_app_id = resp["id"] - logging.info(f"SSO application creation response: {resp}") - - except AuthException as e: - logging.info(f"SSO application creation failed {e}") - - try: - logging.info("Loading SSO application by id") - sso_app_resp = descope_client.mgmt.sso_application.load(sso_app_id) - logging.info(f"Found sso application {sso_app_resp}") - - except AuthException as e: - logging.info(f"Permission load failed {e}") - - try: - logging.info("Loading all sso applications") - sso_app_resp = descope_client.mgmt.sso_application.load_all() - apps = sso_app_resp["apps"] - for app in apps: - logging.info(f"LoadAll Found sso application: {app}") - - except AuthException as e: - logging.info(f"Permission load failed {e}") - - try: - logging.info("Updating newly created OIDC sso application") - # update overrides all fields, must provide the entire entity - # we mean to update. - descope_client.mgmt.sso_application.update_oidc_application( - id=sso_app_id, - name="My First OIDC sso application", - login_page_url="http://newdummy.com", - enabled=False, - ) - - except AuthException as e: - logging.info(f"sso application update failed {e}") - - try: - logging.info("Deleting newly created OIDC sso application") - descope_client.mgmt.sso_application.delete(sso_app_id) - - except AuthException as e: - logging.info(f"sso application deletion failed {e}") - - # SAML APP - - try: - logging.info("Going to create a new SAML sso application") - resp = descope_client.mgmt.sso_application.create_saml_application( - name="My first SAML sso application", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="http://dummy.com/metadata", - attribute_mapping=[SAMLIDPAttributeMappingInfo("email", "", "attrVal1")], - groups_mapping=[ - SAMLIDPGroupsMappingInfo( - "grp1", - "", - "roles", - "", - [SAMLIDPRoleGroupMappingInfo("myRoleId", "myRole")], - ) - ], - subject_name_id_type="email", - ) - sso_app_id = resp["id"] - logging.info(f"SSO application creation response: {resp}") - - except AuthException as e: - logging.info(f"SSO application creation failed {e}") - - try: - logging.info("Loading SSO application by id") - sso_app_resp = descope_client.mgmt.sso_application.load(sso_app_id) - logging.info(f"Found sso application {sso_app_resp}") - - except AuthException as e: - logging.info(f"Permission load failed {e}") - - try: - logging.info("Loading all sso applications") - sso_app_resp = descope_client.mgmt.sso_application.load_all() - apps = sso_app_resp["apps"] - for app in apps: - logging.info(f"LoadAll Found sso application: {app}") - - except AuthException as e: - logging.info(f"Permission load failed {e}") - - try: - logging.info("Updating newly created SAML sso application") - # update overrides all fields, must provide the entire entity - # we mean to update. - descope_client.mgmt.sso_application.update_saml_application( - id=sso_app_id, - name="My First SAML sso application", - login_page_url="http://newdummy.com", - enabled=False, - use_metadata_info=False, - entity_id="entity1234", - acs_url="http://dummy.com/acs", - certificate="my cert", - subject_name_id_type="", - ) - - except AuthException as e: - logging.info(f"sso application update failed {e}") - - try: - logging.info("Deleting newly created SAML sso application") - descope_client.mgmt.sso_application.delete(sso_app_id) - - except AuthException as e: - logging.info(f"sso application deletion failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/sso_sample_app.py b/samples/management/sso_sample_app.py deleted file mode 100755 index 8edb7f37f..000000000 --- a/samples/management/sso_sample_app.py +++ /dev/null @@ -1,186 +0,0 @@ -import logging - -from descope import ( - AttributeMapping, - AuthException, - DescopeClient, - OIDCAttributeMapping, - RoleMapping, - SSOOIDCSettings, - SSOSAMLSettings, - SSOSAMLSettingsByMetadata, -) - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - tenant_id = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - - try: - logging.info("Configuring tenant with OIDC settings") - settings = SSOOIDCSettings( - name="myProvider", - client_id="iddd", - client_secret="secret", - auth_url="https://dummy.com/auth", - token_url="https://dummy.com/token", - user_data_url="https://dummy.com/userInfo", - scope=["openid", "profile", "email"], - attribute_mapping=OIDCAttributeMapping( - login_id="subject", - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - email="email", - verified_email="verifiedEmail", - username="username", - phone_number="phoneNumber", - verified_phone="verifiedPhone", - picture="picture", - ), - groups_priority=["admin_group", "user_group"], - ) - descope_client.mgmt.sso.configure_oidc_settings(tenant_id, settings) - except AuthException as e: - logging.info(f"Configure tenant OIDC settings failed {e}") - - try: - logging.info("Load SSO settings for tenant - OIDC") - settings_res = descope_client.mgmt.sso.load_settings(tenant_id) - logging.info(f"SSO settings for tenant: {settings_res}") - except AuthException as e: - logging.info(f"Load SSO settings failed {e}") - - try: - logging.info("Configuring tenant with SAML settings") - settings = SSOSAMLSettings( - idp_url="https://dummy.com/saml", - idp_entity_id="entity1234", - idp_cert="my certificate", - attribute_mapping=AttributeMapping( - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - picture="picture", - email="email", - phone_number="phoneNumber", - group="groups", - ), - role_mappings=[RoleMapping(groups=["grp1"], role_name="rl1")], - groups_priority=["admin_group", "user_group"], - ) - descope_client.mgmt.sso.configure_saml_settings(tenant_id, settings) - except AuthException as e: - logging.info(f"Configure tenant SAML settings failed {e}") - - try: - logging.info("Load SSO settings for tenant - SAML") - settings_res = descope_client.mgmt.sso.load_settings(tenant_id) - logging.info(f"SSO settings for tenant: {settings_res}") - except AuthException as e: - logging.info(f"Load SSO settings failed {e}") - - try: - logging.info("Configuring tenant with SAML settings by metadata") - settings = SSOSAMLSettingsByMetadata( - idp_metadata_url="https://dummy.com/metadata", - attribute_mapping=AttributeMapping( - name="myName", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - picture="picture", - email="email", - phone_number="phoneNumber", - group="groups", - ), - role_mappings=[RoleMapping(groups=["grp1"], role_name="rl1")], - groups_priority=["admin_group", "user_group"], - ) - descope_client.mgmt.sso.configure_saml_settings_by_metadata(tenant_id, settings, domains=["kuki.com"]) - except AuthException as e: - logging.info(f"Configure tenant SAML settings by metadata failed {e}") - - try: - logging.info("Load SSO settings for tenant - SAML by metadata") - settings_res = descope_client.mgmt.sso.load_settings(tenant_id) - logging.info(f"SSO settings for tenant: {settings_res}") - except AuthException as e: - logging.info(f"Load SSO settings failed {e}") - - try: - logging.info("Recalculating SSO mappings for tenant") - descope_client.mgmt.sso.recalculate_sso_mappings(tenant_id) - except AuthException as e: - logging.info(f"Recalculate SSO mappings failed {e}") - - # All the following code is DEPRECATED (keeping just for backward compatibility) - try: - logging.info("Get SSO settings for tenant") - settings_res = descope_client.mgmt.sso.get_settings(tenant_id) - sso_tenant_domains = settings_res["domains"] - logging.info(f"SSO domains for tenant: {sso_tenant_domains}") - - except AuthException as e: - logging.info(f"Get SSO settings failed {e}") - - idp_url = "" - entity_id = "" - idp_cert = "" - idp_metadata_url = "" - redirect_url = "" - domains = [] - role_mappings = [RoleMapping(["a"], "Tenant Admin")] - attribute_mapping = AttributeMapping(name="MyName") - - try: - logging.info("Configure SSO for tenant") - descope_client.mgmt.sso.configure( - tenant_id, - idp_url=idp_url, - entity_id=entity_id, - idp_cert=idp_cert, - redirect_url=redirect_url, - domains=domains, - ) - - except AuthException as e: - logging.info(f"SSO configuration failed {e}") - - try: - logging.info("Configure SSO for tenant via metadata") - descope_client.mgmt.sso.configure_via_metadata( - tenant_id, - idp_metadata_url=idp_metadata_url, - redirect_url=redirect_url, - domains=domains, - ) - - except AuthException as e: - logging.info(f"SSO configuration failed via metadata {e}") - - try: - logging.info("Update tenant role mappings") - descope_client.mgmt.sso.mapping( - tenant_id, - role_mappings=role_mappings, - attribute_mapping=attribute_mapping, - ) - - except AuthException as e: - logging.info(f"SSO role mapping failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/tenant_sample_app.py b/samples/management/tenant_sample_app.py deleted file mode 100755 index 96b553d49..000000000 --- a/samples/management/tenant_sample_app.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - tenant_id = "" - - try: - logging.info("Going to create a new tenant") - resp = descope_client.mgmt.tenant.create("My First Tenant") - tenant_id = resp["id"] - logging.info(f"Tenant creation response: {resp}") - - except AuthException as e: - logging.info(f"Tenant creation failed {e}") - - try: - logging.info("Loading tenant by id") - tenant_resp = descope_client.mgmt.tenant.load(tenant_id) - logging.info(f"Found tenant {tenant_resp}") - - except AuthException as e: - logging.info(f"Permission load failed {e}") - - try: - logging.info("Loading all tenants") - tenants_resp = descope_client.mgmt.tenant.load_all() - tenants = tenants_resp["tenants"] - for tenant in tenants: - logging.info(f"Search Found tenant {tenant}") - - except AuthException as e: - logging.info(f"Permission load failed {e}") - - try: - logging.info("Updating newly created tenant") - # update overrides all fields, must provide the entire entity - # we mean to update. - descope_client.mgmt.tenant.update(tenant_id, "My First Tenant", ["mydomain.com"]) - - except AuthException as e: - logging.info(f"Tenant update failed {e}") - - try: - logging.info("Deleting newly created tenant") - descope_client.mgmt.tenant.delete(tenant_id) - - except AuthException as e: - logging.info(f"Tenant deletion failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/user_sample_app.py b/samples/management/user_sample_app.py deleted file mode 100755 index cb60b5829..000000000 --- a/samples/management/user_sample_app.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - user_login_id = "des@copeland.com" - - try: - logging.info("Going to create a new user") - descope_client.mgmt.user.create(user_login_id) - - except AuthException as e: - logging.info(f"User creation failed {e}") - - try: - logging.info("Searching for created user") - user_resp = descope_client.mgmt.user.load(user_login_id) - user = user_resp["user"] - logging.info(f"Load: found user {user}") - - except AuthException as e: - logging.info(f"User load failed {e}") - - try: - logging.info("Searching all users created user") - users_resp = descope_client.mgmt.user.search_all() - users = users_resp["users"] - for user in users: - logging.info(f"Search Found user {user}") - - except AuthException as e: - logging.info(f"User load failed {e}") - - try: - logging.info("Updating newly created user") - # update overrides all fields, must provide the entire entity - # we mean to update. - descope_client.mgmt.user.update(user_login_id, display_name="Desmond Copeland") - - except AuthException as e: - logging.info(f"User update failed {e}") - - try: - logging.info("Deleting newly created user") - descope_client.mgmt.user.delete(user_login_id) - - except AuthException as e: - logging.info(f"User deletion failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/management/xaa_sample_app.py b/samples/management/xaa_sample_app.py deleted file mode 100644 index 820fdff92..000000000 --- a/samples/management/xaa_sample_app.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging - -from descope import ( - AttributeMapping, - AuthException, - DescopeClient, - RoleMapping, - XAAIssuerSettings, - XAAJWTBearerSettings, - XAASettings, -) - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - management_key = "" - - try: - descope_client = DescopeClient(project_id=project_id, management_key=management_key) - tenant_id = "" - # sso_id addresses a single SSO configuration of the tenant. Leave it empty ("") to target - # the tenant's default SSO configuration. - sso_id = "" - - # Cross-App Access (XAA / ID-JAG) trust is configured per SSO configuration of a tenant, through - # the SSO management API. The trusted issuers are set together with the config-level shared - # group/role mapping (shared across SAML / OIDC / SCIM / XAA for the sso_id). - try: - logging.info("Configuring XAA (ID-JAG) trusted issuers + shared mapping") - descope_client.mgmt.sso.configure_xaa_settings( - tenant_id, - XAASettings( - enabled=True, - settings=XAAJWTBearerSettings( - issuers={ - # Key is the trusted issuer URL. - "https://issuer.example.com": XAAIssuerSettings( - jwks_uri="https://issuer.example.com/.well-known/jwks.json", - sign_algorithm="RS256", - user_info_uri="https://issuer.example.com/userinfo", - # assertion claim used as the login id - external_id_field_name="sub", - # JIT provisioning on: create/update the user from the assertion - jit_disabled=False, - attribute_mapping=AttributeMapping( - email="email", - name="name", - # assertion claim that carries the user's groups. - # NOTE: how those group names resolve to roles is defined once, - # per SSO configuration, via the shared mapping below. - group="groups", - ), - ), - }, - ), - # Config-level shared group/role mapping (shared across SAML / OIDC / SCIM / XAA). - role_mappings=[RoleMapping(groups=["admins"], role_name="Tenant Admin")], - default_sso_roles=["Member"], - ), - sso_id=sso_id, - ) - logging.info("XAA settings configured") - - except AuthException as e: - logging.info(f"XAA configure failed {e}") - - try: - logging.info("Loading the XAA settings for a single SSO configuration") - xaa = descope_client.mgmt.sso.load_xaa_settings(tenant_id, sso_id) - logging.info(f"Loaded XAA settings: {xaa}") - - except AuthException as e: - logging.info(f"XAA load failed {e}") - - try: - logging.info("Loading the XAA settings for every SSO configuration of the tenant") - all_xaa = descope_client.mgmt.sso.load_all_xaa_settings(tenant_id) - logging.info(f"Loaded all XAA settings: {all_xaa}") - - except AuthException as e: - logging.info(f"XAA load-all failed {e}") - - try: - logging.info("Deleting the XAA settings of a single SSO configuration") - # Removes the configuration's trusted issuers from the tenant's ID-JAG runtime index. - descope_client.mgmt.sso.delete_xaa_settings(tenant_id, sso_id) - - except AuthException as e: - logging.info(f"XAA delete failed {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/oauth_sample_app.py b/samples/oauth_sample_app.py deleted file mode 100644 index bd00f6517..000000000 --- a/samples/oauth_sample_app.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging - -from descope import REFRESH_SESSION_TOKEN_NAME, AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - try: - descope_client = DescopeClient(project_id=project_id) - - logging.info("Going to login with Oauth auth method ...") - resp = descope_client.oauth.start("facebook", "https://www.google.com") - logging.info(f"oauth response: {resp}") - - code = input("Please insert the code you received from redirect URI:\n") - - jwt_response = descope_client.oauth.exchange_token(code) - logging.info("oauth code valid") - refresh_token = jwt_response.get(REFRESH_SESSION_TOKEN_NAME).get("jwt") - descope_client.logout(refresh_token) - logging.info("User logged out") - - except AuthException as e: - logging.info(f"Failed to start oauth: {e}") - - -if __name__ == "__main__": - main() diff --git a/samples/oauth_web_sample_app.py b/samples/oauth_web_sample_app.py deleted file mode 100644 index 6d4ea44fe..000000000 --- a/samples/oauth_web_sample_app.py +++ /dev/null @@ -1,55 +0,0 @@ -from flask import Flask, jsonify - -from descope import DescopeClient -from descope.flask import descope_logout, descope_oauth, descope_validate_auth - -APP = Flask(__name__) - -PROJECT_ID = "" - -# init the DescopeClient -descope_client = DescopeClient(PROJECT_ID) - - -class Error(Exception): - def __init__(self, error, status_code): - self.error = error - self.status_code = status_code - - -@APP.errorhandler(Error) -def handle_auth_error(ex): - response = jsonify(ex.error) - response.status_code = ex.status_code - return response - - -# This needs authentication -@APP.route("/api/private") -@descope_validate_auth(descope_client) -def private(): - response = "This is a private API and you must be authenticated to see this" - return jsonify(message=response) - - -@APP.route("/api/logout") -@descope_logout(descope_client) -def logout(): - response = "Logged out" - return jsonify(message=response) - - -@APP.route("/api/oauth", methods=["GET"]) -@descope_oauth(descope_client) -def oauth(*args, **kwargs): - pass - - -# This doesn't need authentication -@APP.route("/") -def home(): - return "OK" - - -if __name__ == "__main__": - APP.run(host="127.0.0.1", port=9000) diff --git a/samples/otp_sample_app.py b/samples/otp_sample_app.py deleted file mode 100755 index a705c7e66..000000000 --- a/samples/otp_sample_app.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging - -from descope import ( - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, - AuthException, - DeliveryMethod, - DescopeClient, -) - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - try: - descope_client = DescopeClient(project_id=project_id) - - logging.info("Going to signup or in using OTP...") - email = input("Please insert email to signup or in:\n") - masked_email = descope_client.otp.sign_up_or_in(method=DeliveryMethod.EMAIL, login_id=email) - - value = input(f"Please insert the code you received by email to {masked_email}:\n") - try: - jwt_response = descope_client.otp.verify_code(method=DeliveryMethod.EMAIL, login_id=email, code=value) - logging.info("Code is valid") - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - logging.info(f"jwt_response: {jwt_response}") - except AuthException as e: - logging.info(f"Invalid code {e}") - raise - - try: - logging.info("going to validate session..") - descope_client.validate_session(session_token) - logging.info("Session is valid and all is OK") - except AuthException as e: - logging.info(f"Session is not valid {e}") - - try: - logging.info("refreshing the session token..") - claims = descope_client.refresh_session(refresh_token) - logging.info("going to revalidate the session with the newly refreshed token..") - - new_session_token = claims.get(SESSION_TOKEN_NAME).get("jwt") - descope_client.validate_and_refresh_session(new_session_token, refresh_token) - logging.info("Session is valid also for the refreshed token.") - except AuthException as e: - logging.info(f"Session is not valid for the refreshed token: {e}") - - try: - descope_client.logout(refresh_token) - logging.info("User logged out") - except AuthException as e: - logging.info(f"Failed to logged out user, err: {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/otp_web_sample_app.py b/samples/otp_web_sample_app.py deleted file mode 100644 index f813fef2c..000000000 --- a/samples/otp_web_sample_app.py +++ /dev/null @@ -1,156 +0,0 @@ -import json - -from flask import Flask, Response, jsonify, request - -from descope import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_COOKIE_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - AuthException, - DeliveryMethod, - DescopeClient, -) -from descope.flask import set_cookie_on_response - -APP = Flask(__name__) - -PROJECT_ID = "" - -# SECURITY WARNING: skip_verify=True disables TLS certificate verification -# ONLY use this for local development with self-signed certificates -# NEVER use skip_verify=True in production - remove this parameter for production -descope_client = DescopeClient(PROJECT_ID, skip_verify=True) - - -class Error(Exception): - def __init__(self, error, status_code): - self.error = error - self.status_code = status_code - - -@APP.errorhandler(Error) -def handle_auth_error(ex): - response = jsonify(ex.error) - response.status_code = ex.status_code - return response - - -@APP.route("/otp/signup", methods=["POST"]) -def signup(): - data = request.get_json(force=True) - email = data.get("email", None) - name = data.get("name", None) - if not email: - return Response("Unauthorized", 401) - - try: - user = {"name": name, "phone": "", "email": email} - descope_client.otp.sign_up(DeliveryMethod.EMAIL, email, user) - except AuthException: - return Response("Unauthorized", 401) - - return Response("This is SignUp API handling", 200) - - -@APP.route("/otp/signin", methods=["POST"]) -def signin(): - data = request.get_json(force=True) - email = data.get("email", None) - if not email: - return Response("Unauthorized, missing email", 401) - - try: - descope_client.otp.sign_in(DeliveryMethod.EMAIL, email) - except AuthException: - return Response("Unauthorized, something went wrong when sending email", 401) - - return Response("This is SignIn API handling", 200) - - -@APP.route("/api/signuporin", methods=["POST"]) -def signuporin(): - data = request.get_json(force=True) - email = data.get("email", None) - if not email: - return Response("Unauthorized, missing email", 401) - - try: - descope_client.otp.sign_up_or_in(DeliveryMethod.EMAIL, email) - except AuthException: - return Response("Unauthorized, something went wrong when sending email", 401) - - return Response("This is SignUpOrIn API handling", 200) - - -@APP.route("/otp/verify", methods=["POST"]) -def verify(): - data = request.get_json(force=True) - email = data.get("email", None) - code = data.get("code", None) - if not code or not email: - return Response("Unauthorized", 401) - - try: - jwt_response = descope_client.otp.verify_code(DeliveryMethod.EMAIL, email, code) - except AuthException: - return Response("Unauthorized", 401) - - response = Response( - f"This is Verify code API handling, info example: {json.dumps(jwt_response)}", - 200, - ) - - set_cookie_on_response(response, jwt_response[SESSION_TOKEN_NAME], jwt_response[COOKIE_DATA_NAME]) - set_cookie_on_response( - response, - jwt_response[REFRESH_SESSION_TOKEN_NAME], - jwt_response[COOKIE_DATA_NAME], - ) - - return response - - -# This needs authentication -@APP.route("/api/private", methods=["POST"]) -def private(): - cookies = request.cookies.copy() - session_token = cookies.get(SESSION_COOKIE_NAME, None) - refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, None) - try: - jwt_response = descope_client.validate_and_refresh_session(session_token, refresh_token) - except AuthException: - return Response("Access denied", 401) - - response = Response("This is a private API and you must be authenticated to see this", 200) - - if jwt_response.get(COOKIE_DATA_NAME, None): - set_cookie_on_response(response, jwt_response[SESSION_TOKEN_NAME], jwt_response[COOKIE_DATA_NAME]) - - return response - - -@APP.route("/api/logout") -def logout(): - cookies = request.cookies.copy() - refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME) - try: - descope_client.logout(refresh_token) - except AuthException as e: - return Response(f"Logout failed {e}", e.status_code) - - response = Response("This is Logout API handling", 200) - response.delete_cookie(SESSION_COOKIE_NAME) - response.delete_cookie(REFRESH_SESSION_COOKIE_NAME) - return response - - -# This doesn't need authentication -@APP.route("/") -def home(): - return "OK" - - -if __name__ == "__main__": - APP.run(host="0.0.0.0", port=9000) diff --git a/samples/password_sample_app.py b/samples/password_sample_app.py deleted file mode 100755 index 7d3190c5a..000000000 --- a/samples/password_sample_app.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging - -from descope import ( - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, - AuthException, - DescopeClient, -) - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - try: - descope_client = DescopeClient(project_id=project_id) - - logging.info("Going to signup using password...") - email = input("Please insert email to signup with:\n") - password = input("Please insert a password to signup with:\n") - try: - jwt_response = descope_client.password.sign_up(email, password) - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - except AuthException as e: - logging.info(f"Failed to sign up {e}") - raise - - logging.info("Validating email address...") - try: - descope_client.magiclink.update_user_email(email, email, refresh_token) - except AuthException as e: - logging.info(f"Failed to sign up {e}") - raise - - token = input("Validation email send, please paste the token you received by email:\n") - try: - jwt_response = descope_client.magiclink.verify(token) - logging.info("Token is valid") - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - logging.info(f"jwt_response: {jwt_response}") - except AuthException as e: - logging.info(f"Invalid code {e}") - raise - - logging.info("Going to reset password...") - descope_client.password.send_reset(email) - token = input("Reset email send, please paste the token you received by email:\n") - try: - jwt_response = descope_client.magiclink.verify(token) - logging.info("Token is valid") - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - logging.info(f"jwt_response: {jwt_response}") - except AuthException as e: - logging.info(f"Invalid code {e}") - raise - - new_password = input("Please insert a new password:\n") - try: - descope_client.password.update(email, new_password, refresh_token) - except AuthException as e: - logging.info(f"Unable to update password {e}") - raise - - logging.info("Attempting to log in using the new password...") - try: - jwt_response = descope_client.password.sign_in(email, new_password) - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - logging.info(f"jwt_response: {jwt_response}") - except AuthException as e: - logging.info(f"Failed to sign in {e}") - raise - - try: - logging.info("going to validate session..") - descope_client.validate_session(session_token) - logging.info("Session is valid and all is OK") - except AuthException as e: - logging.info(f"Session is not valid {e}") - - try: - logging.info("refreshing the session token..") - claims = descope_client.refresh_session(refresh_token) - logging.info("going to revalidate the session with the newly refreshed token..") - - new_session_token = claims.get(SESSION_TOKEN_NAME).get("jwt") - descope_client.validate_and_refresh_session(new_session_token, refresh_token) - logging.info("Session is valid also for the refreshed token.") - except AuthException as e: - logging.info(f"Session is not valid for the refreshed token: {e}") - - try: - descope_client.logout(refresh_token) - logging.info("User logged out") - except AuthException as e: - logging.info(f"Failed to logged out user, err: {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/password_web_sample_app.py b/samples/password_web_sample_app.py deleted file mode 100755 index aacb7e16f..000000000 --- a/samples/password_web_sample_app.py +++ /dev/null @@ -1,111 +0,0 @@ -from flask import Flask, Response, jsonify, request - -from descope import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_COOKIE_NAME, - SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - AuthException, - DescopeClient, -) -from descope.flask import set_cookie_on_response - -APP = Flask(__name__) - -PROJECT_ID = "" - -# SECURITY WARNING: skip_verify=True disables TLS certificate verification -# ONLY use this for local development with self-signed certificates -# NEVER use skip_verify=True in production - remove this parameter for production -descope_client = DescopeClient(PROJECT_ID, skip_verify=True) - - -class Error(Exception): - def __init__(self, error, status_code): - self.error = error - self.status_code = status_code - - -@APP.errorhandler(Error) -def handle_auth_error(ex): - response = jsonify(ex.error) - response.status_code = ex.status_code - return response - - -@APP.route("/password/signup", methods=["POST"]) -def signup(): - data = request.get_json(force=True) - email = data.get("email", None) - password = data.get("password", None) - name = data.get("name", None) - if not email or not password: - return Response("Unauthorized", 401) - - try: - user = {"name": name, "phone": "", "email": email} - descope_client.password.sign_up(email, password, user) - except AuthException: - return Response("Unauthorized, failed to sign up with password", 401) - - return Response("This is SignUp API handling", 200) - - -@APP.route("/password/signin", methods=["POST"]) -def signin(): - data = request.get_json(force=True) - email = data.get("email", None) - password = data.get("password", None) - if not email or not password: - return Response("Unauthorized, email and password are required", 401) - - try: - descope_client.password.sign_in(email, password) - except AuthException: - return Response("Unauthorized, failed to authenticate with password", 401) - - return Response("This is SignIn API handling", 200) - - -# This needs authentication -@APP.route("/api/private", methods=["POST"]) -def private(): - cookies = request.cookies.copy() - session_token = cookies.get(SESSION_COOKIE_NAME, None) - refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME, None) - try: - jwt_response = descope_client.validate_and_refresh_session(session_token, refresh_token) - except AuthException: - return Response("Access denied", 401) - - response = Response("This is a private API and you must be authenticated to see this", 200) - - if jwt_response.get(COOKIE_DATA_NAME, None): - set_cookie_on_response(response, jwt_response[SESSION_TOKEN_NAME], jwt_response[COOKIE_DATA_NAME]) - - return response - - -@APP.route("/api/logout") -def logout(): - cookies = request.cookies.copy() - refresh_token = cookies.get(REFRESH_SESSION_COOKIE_NAME) - try: - descope_client.logout(refresh_token) - except AuthException as e: - return Response(f"Logout failed {e}", e.status_code) - - response = Response("This is Logout API handling", 200) - response.delete_cookie(SESSION_COOKIE_NAME) - response.delete_cookie(REFRESH_SESSION_COOKIE_NAME) - return response - - -# This doesn't need authentication -@APP.route("/") -def home(): - return "OK" - - -if __name__ == "__main__": - APP.run(host="0.0.0.0", port=9000) diff --git a/samples/saml_sample_app.py b/samples/saml_sample_app.py deleted file mode 100644 index 134b2a535..000000000 --- a/samples/saml_sample_app.py +++ /dev/null @@ -1,24 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - tenant_id = "" - - try: - descope_client = DescopeClient(project_id=project_id) - - logging.info("Going to login with SAML auth method ...") - resp = descope_client.saml.start(tenant_id, "https://www.google.com") - logging.info(f"saml response: {resp}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/sso_sample_app.py b/samples/sso_sample_app.py deleted file mode 100644 index fcda6103c..000000000 --- a/samples/sso_sample_app.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging - -from descope import AuthException, DescopeClient - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - tenant_id = "" - - try: - descope_client = DescopeClient(project_id=project_id) - - logging.info("Going to login with SSO auth method (saml/oidc based on tenant settings)...") - resp = descope_client.sso.start(tenant_id, "https://www.google.com") - logging.info(f"sso response: {resp}") - - # Note: after redirecting to the login page (result of the above call) we will get a code - # that should be exchanged back by using the following call that will return the user JWTs: - - # resp = descope_client.sso.exchange_token(code) - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/stepup_sample_app.py b/samples/stepup_sample_app.py deleted file mode 100644 index cca0f3907..000000000 --- a/samples/stepup_sample_app.py +++ /dev/null @@ -1,99 +0,0 @@ -import logging - -from descope import ( - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, - AuthException, - DeliveryMethod, - DescopeClient, - common, -) - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - try: - descope_client = DescopeClient(project_id=project_id) - logging.info("Going to sign in using OTP...") - email = input("Please insert email to signin:\n") - descope_client.otp.sign_up_or_in(method=DeliveryMethod.EMAIL, login_id=email) - - value = input("Please insert the code you received by email:\n") - try: - jwt_response = descope_client.otp.verify_code(method=DeliveryMethod.EMAIL, login_id=email, code=value) - logging.info("Code is valid") - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - - # Now add phone number - phone = input("Please insert phone to add to your account:\n") - descope_client.otp.update_user_phone( - method=DeliveryMethod.SMS, - login_id=email, - phone=phone, - refresh_token=refresh_token, - ) - value = input("Please insert the code you received by sms:\n") - jwt_response = descope_client.otp.verify_code(method=DeliveryMethod.SMS, login_id=email, code=value) - logging.info("Code is valid") - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - - # Now start all over again, and do a stepup flow with given email and phone - descope_client.otp.sign_in(method=DeliveryMethod.EMAIL, login_id=email) - value = input("Please insert the code you received by email to start stepup flow:\n") - jwt_response = descope_client.otp.verify_code(method=DeliveryMethod.EMAIL, login_id=email, code=value) - logging.info("Initial login is valid, lets continue to the stepup") - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - - descope_client.otp.sign_in( - method=DeliveryMethod.SMS, - login_id=email, - login_options=common.LoginOptions(True, {"k1": "v1"}), - refresh_token=refresh_token, - ) - value = input("Please insert the code you received by sms to complete stepup:\n") - jwt_response = descope_client.otp.verify_code(method=DeliveryMethod.SMS, login_id=email, code=value) - - logging.info("Code is valid, user properly stepped up") - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - - logging.info(f"jwt_response: {jwt_response}") - except AuthException as e: - logging.info(f"Invalid code {e}") - raise - - try: - logging.info("going to validate session..") - descope_client.validate_session(session_token) - logging.info("Session is valid and all is OK") - except AuthException as e: - logging.info(f"Session is not valid {e}") - - try: - logging.info("refreshing the session token..") - claims = descope_client.refresh_session(refresh_token) - logging.info("going to revalidate the session with the newly refreshed token..") - - new_session_token = claims.get(SESSION_TOKEN_NAME).get("jwt") - descope_client.validate_and_refresh_session(new_session_token, refresh_token) - logging.info("Session is valid also for the refreshed token.") - except AuthException as e: - logging.info(f"Session is not valid for the refreshed token: {e}") - - try: - descope_client.logout(refresh_token) - logging.info("User logged out") - except AuthException as e: - logging.info(f"Failed to logged out user, err: {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/templates/webauthn_demo.html b/samples/templates/webauthn_demo.html deleted file mode 100644 index a9dcd37b0..000000000 --- a/samples/templates/webauthn_demo.html +++ /dev/null @@ -1,346 +0,0 @@ - - - - - - Webauthn - - - - - - - -
- -
- - - - - diff --git a/samples/totp_sample_app.py b/samples/totp_sample_app.py deleted file mode 100644 index d64dfe11f..000000000 --- a/samples/totp_sample_app.py +++ /dev/null @@ -1,78 +0,0 @@ -import json -import logging - -from descope import ( - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, - AuthException, - DeliveryMethod, - DescopeClient, -) - -logging.basicConfig(level=logging.INFO) - - -def main(): - project_id = "" - - try: - descope_client = DescopeClient(project_id=project_id) - - logging.info("Going to sign in using TOTP ...") - email = input("Please insert your login ID:\n") - - signup = input("Do you want to sign up via TOTP? (yes/y):\n") - if signup == "yes" or signup == "y": - totp_info = descope_client.totp.sign_up(email) - logging.info("=== use this info in Authenticator app ===") - logging.info(json.dumps(totp_info, indent=2, sort_keys=True)) - logging.info("=========================================") - else: - register = input("Do you need to register an authenticator to an existing account? (yes/y):\n") - if register == "yes" or register == "y": - logging.info("Please sign in via OTP...") - descope_client.otp.sign_up_or_in(DeliveryMethod.EMAIL, email) - - code = input("Please insert the code you received by email:\n") - - jwt_response = descope_client.otp.verify_code(DeliveryMethod.EMAIL, email, code) - refresh_token = jwt_response.get(REFRESH_SESSION_TOKEN_NAME).get("jwt") - totp_info = descope_client.totp.update_user(email, refresh_token) - logging.info("=== use this info in Authenticator app ===") - logging.info(json.dumps(totp_info, indent=2, sort_keys=True)) - logging.info("=========================================") - - code = input("Please insert code from Authenticator:\n") - - jwt_response = descope_client.totp.sign_in_code( - login_id=email, - code=code, - ) - - logging.info("Code is valid") - - session_token = jwt_response.get(SESSION_TOKEN_NAME).get("jwt") - refresh_token = jwt_response.get(REFRESH_SESSION_TOKEN_NAME).get("jwt") - - # validate session - try: - logging.info("going to validate session...") - jwt_response = descope_client.validate_and_refresh_session(session_token, refresh_token) - logging.info(f"Session is valid and all is OK, claims: {jwt_response}") - except AuthException as e: - logging.info(f"Session is not valid {e}") - - # logout - try: - logging.info("Going to logout...") - descope_client.logout(refresh_token) - logging.info("User logged out") - except AuthException as e: - logging.info(f"Failed to logged, err: {e}") - - except AuthException: - raise - - -if __name__ == "__main__": - main() diff --git a/samples/verbose_mode_example.py b/samples/verbose_mode_example.py deleted file mode 100644 index 872597827..000000000 --- a/samples/verbose_mode_example.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -""" -Example demonstrating the verbose mode feature for capturing HTTP metadata. - -This is useful for debugging failed requests by accessing headers like cf-ray, -status codes, and raw response data. -""" - -import logging - -from descope import AuthException, DescopeClient - -# Setup logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def example_with_verbose_mode(): - """Example showing how to use verbose mode to capture cf-ray headers for debugging.""" - - # Create client with verbose=True to enable response metadata capture - client = DescopeClient( - project_id="your-project-id", - management_key="your-management-key", - verbose=True, # Enable verbose mode - ) - - try: - # Make a management API call - client.mgmt.user.create( - login_id="test@example.com", - email="test@example.com", - display_name="Test User", - ) - - # Access the last response metadata - response = client.get_last_response() - if response: - logger.info("Request succeeded!") - logger.info("Status: %s", response.status_code) - logger.info("cf-ray: %s", response.headers.get("cf-ray")) - logger.info("x-request-id: %s", response.headers.get("x-request-id")) - - except AuthException: - # When an error occurs, capture the response metadata for debugging - response = client.get_last_response() - if response: - logger.error("Request failed with status %s", response.status_code) - logger.error("cf-ray: %s", response.headers.get("cf-ray")) - logger.error("x-request-id: %s", response.headers.get("x-request-id")) - logger.error("Response body: %s", response.text) - - # You can now provide cf-ray to Descope support for debugging - cf_ray = response.headers.get("cf-ray") - logger.info("Provide this cf-ray to support: %s", cf_ray) - - raise - - -def example_without_verbose_mode(): - """Example showing default behavior (no metadata captured).""" - - # Default: verbose=False (no metadata captured) - client = DescopeClient( - project_id="your-project-id", - management_key="your-management-key", - # verbose not specified, defaults to False - ) - - try: - client.mgmt.user.create(login_id="test@example.com", email="test@example.com") - - # get_last_response() returns None when verbose mode is disabled - response = client.get_last_response() - assert response is None - - except AuthException as exc: - logger.error("Request failed: %s", exc) - # No metadata available in default mode - response = client.get_last_response() - assert response is None - - -if __name__ == "__main__": - logger.info("Verbose mode examples:") - logger.info("\n1. With verbose mode (captures metadata):") - logger.info(" client = DescopeClient(project_id, management_key, verbose=True)") - logger.info(" # ... make API calls ...") - logger.info(" response = client.get_last_response()") - logger.info(" logger.info(response.headers.get('cf-ray'))") - - logger.info("\n2. Without verbose mode (default, no metadata):") - logger.info(" client = DescopeClient(project_id, management_key)") - logger.info(" # ... make API calls ...") - logger.info(" response = client.get_last_response() # Returns None") diff --git a/samples/webauthn_web_sample_app.py b/samples/webauthn_web_sample_app.py deleted file mode 100644 index 5735591f7..000000000 --- a/samples/webauthn_web_sample_app.py +++ /dev/null @@ -1,83 +0,0 @@ -from flask import Flask, jsonify, render_template, request - -from descope import DescopeClient - -APP = Flask(__name__) - -PROJECT_ID = "" - -# init the DescopeClient -descope_client = DescopeClient(PROJECT_ID) - -# Note: Use "https://localhost:443 in the browser (and not 127.0.0.1) - - -class Error(Exception): - def __init__(self, error, status_code): - self.error = error - self.status_code = status_code - - -@APP.errorhandler(Error) -def handle_auth_error(ex): - response = jsonify(ex.error) - response.status_code = ex.status_code - return response - - -# This doesn't need authentication -@APP.route("/") -def home(): - return render_template("webauthn_demo.html") - - -@APP.route("/webauthn/signup/start", methods=["POST"]) -def webauthn_signup_start(): - data = request.get_json() - user = data["user"] - response = descope_client.webauthn.sign_up_start( - user["loginId"], - data["origin"], - user, - ) - return response - - -@APP.route("/webauthn/signup/finish", methods=["POST"]) -def webauthn_signup_finish(): - data = request.get_json() - response = descope_client.webauthn.sign_up_finish(data["transactionId"], data["response"]) - return response - - -@APP.route("/webauthn/signin/start", methods=["POST"]) -def webauthn_signin_start(): - data = request.get_json() - response = descope_client.webauthn.sign_in_start(data["loginId"], data["origin"]) - return response - - -@APP.route("/webauthn/signin/finish", methods=["POST"]) -def webauthn_signin_finish(): - data = request.get_json() - response = descope_client.webauthn.sign_in_finish(data["transactionId"], data["response"]) - return response - - -@APP.route("/webauthn/device/add/start", methods=["POST"]) -def webauthn_update_start(): - data = request.get_json() - refresh_token = request.cookies.get("DSR") - response = descope_client.webauthn.update_start(data["loginId"], refresh_token, data["origin"]) - return response - - -@APP.route("/webauthn/device/add/finish", methods=["POST"]) -def webauthn_update_finish(): - data = request.get_json() - descope_client.webauthn.update_finish(data["transactionId"], data["response"]) - return jsonify("{}") - - -if __name__ == "__main__": - APP.run(host="0.0.0.0", port=443, ssl_context="adhoc") diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 3c2c998e6..000000000 --- a/tests/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from descope.common import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_COOKIE_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - DeliveryMethod, - LoginOptions, -) -from descope.descope_client import DescopeClient -from descope.exceptions import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - ERROR_TYPE_SERVER_ERROR, - AuthException, - RateLimitException, -) -from descope.management.common import ( - AssociatedTenant, - SAMLIDPAttributeMappingInfo, - SAMLIDPGroupsMappingInfo, - SAMLIDPRoleGroupMappingInfo, -) -from descope.management.sso_settings import ( - AttributeMapping, - RoleMapping, - SSOOIDCSettings, - SSOSAMLSettings, - SSOSAMLSettingsByMetadata, -) diff --git a/tests/_unified.py b/tests/_unified.py deleted file mode 100644 index 2de512b15..000000000 --- a/tests/_unified.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Shared base class for unified sync/async client wrappers. - -The unit-test UnifiedClient (tests/conftest.py) subclasses this to add its -mock_* helpers; the e2e suite (tests/e2e/conftest.py) instantiates -UnifiedClientBase directly. This shares the invoke() pattern without coupling -e2e code to mock-oriented helpers. -""" - -from __future__ import annotations - -import asyncio - - -class UnifiedClientBase: - """ - Wraps a DescopeClient or DescopeClientAsync with a uniform interface. - - - ``mode`` — "sync" or "async". - - attribute access — delegated to the underlying raw client. - - ``invoke(maybe_coro)`` — awaits coroutines (async mode) or passes values - through as-is (sync mode), so test bodies are identical for both clients. - """ - - def __init__(self, mode: str, raw): - self.mode = mode # "sync" | "async" - self._raw = raw - - def __getattr__(self, name): - return getattr(self._raw, name) - - async def invoke(self, maybe_coro): - """Uniformly run a sync return value or an async coroutine.""" - if asyncio.iscoroutine(maybe_coro): - return await maybe_coro - return maybe_coro diff --git a/tests/common.py b/tests/common.py deleted file mode 100644 index 7541a6b5e..000000000 --- a/tests/common.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import os -import platform -import unittest -from importlib.metadata import version - -DEFAULT_BASE_URL = "http://127.0.0.1" - - -def sdk_version(): - return version("descope") - - -default_headers = { - "Content-Type": "application/json", - "x-descope-sdk-name": "python", - "x-descope-sdk-python-version": platform.python_version(), - "x-descope-sdk-version": sdk_version(), -} - - -class DescopeTest(unittest.TestCase): - def setUp(self) -> None: - os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL # Make sure tests always running against localhost - # Some tests instantiate Auth directly; provide defaults they can use - self.dummy_project_id = getattr(self, "dummy_project_id", "dummy") - self.public_key_dict = getattr( - self, - "public_key_dict", - { - "alg": "ES384", - "crv": "P-384", - "kid": "testkid", - "kty": "EC", - "use": "sig", - "x": "x", - "y": "y", - }, - ) - - # Test helper to build a default HTTP client - def make_http_client(self, management_key: str | None = None): - from descope.http_client import HTTPClient - - return HTTPClient( - project_id=self.dummy_project_id, - timeout_seconds=60, - secure=True, - management_key=management_key, - ) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e894f44e1..000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -import os -from contextlib import contextmanager -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from descope.common import DEFAULT_TIMEOUT_SECONDS -from descope.descope_client import DescopeClient -from descope.descope_client_async import DescopeClientAsync -from tests._unified import UnifiedClientBase -from tests.common import DEFAULT_BASE_URL -from tests.testutils import PUBLIC_KEY_DICT, SSLMatcher - -# --------------------------------------------------------------------------- -# Claude Code sandbox workaround — DO NOT COMMIT uncommented -# -# Claude Code's sandbox routes traffic through a local SOCKS5 proxy. httpx -# picks it up automatically (trust_env=True) but socksio isn't installed, so -# async fixture construction fails with: -# ImportError: Using SOCKS proxy, but the 'socksio' package is not installed. -# -# Uncomment the fixture below to suppress proxy pickup during the test session. -# -# @pytest.fixture(autouse=True) -# def _disable_httpx_proxy(): -# import httpx -# _orig = httpx.AsyncClient.__init__ -# def _patched(self, *args, **kwargs): -# kwargs.setdefault("trust_env", False) -# _orig(self, *args, **kwargs) -# with patch.object(httpx.AsyncClient, "__init__", _patched): -# yield -# --------------------------------------------------------------------------- - - -PROJECT_ID = "dummy" - - -def assert_http_called(mock_http, mode, url, **kwargs): - """Assert the patched HTTP mock was called with the given arguments. - - In sync mode, ``verify`` and ``timeout`` are passed per-call; in async mode - they are set on the ``httpx.AsyncClient`` constructor and absent from each call. - This helper injects them automatically for sync so test bodies stay identical. - """ - if mode == "sync": - kwargs.setdefault("verify", SSLMatcher()) - kwargs.setdefault("timeout", DEFAULT_TIMEOUT_SECONDS) - mock_http.assert_called_with(url, **kwargs) - - -def make_response(json_data=None, *, status=200, cookies=None): - """Build a mock httpx.Response usable as the return value of a mocked HTTP call.""" - m = MagicMock() - m.is_success = status < 400 - m.status_code = status - m.json.return_value = json_data or {} - cm = MagicMock() - cm.get = MagicMock(side_effect=lambda k, d=None: (cookies or {}).get(k, d)) - m.cookies = cm - m.headers = {} - m.text = str(json_data or "") - return m - - -class UnifiedClient(UnifiedClientBase): - """ - Extends UnifiedClientBase (mode / attribute-delegation / invoke) with the - mock_* helpers that patch the right HTTP layer per mode, so unit-test bodies - run unchanged against both the sync and async clients. - """ - - @contextmanager - def mock_get(self, response): - with self._patch_ctx("get", response) as m: - yield m - - @contextmanager - def mock_post(self, response): - with self._patch_ctx("post", response) as m: - yield m - - @contextmanager - def mock_mgmt_post(self, response): - with self._patch_ctx("post", response, mgmt=True) as m: - yield m - - @contextmanager - def mock_mgmt_get(self, response): - with self._patch_ctx("get", response, mgmt=True) as m: - yield m - - @contextmanager - def mock_mgmt_put(self, response): - with self._patch_ctx("put", response, mgmt=True) as m: - yield m - - @contextmanager - def mock_mgmt_delete(self, response): - with self._patch_ctx("delete", response, mgmt=True) as m: - yield m - - @contextmanager - def mock_mgmt_patch(self, response): - with self._patch_ctx("patch", response, mgmt=True) as m: - yield m - - @contextmanager - def mock_mgmt_by_token_post(self, response): - with self._patch_ctx("post", response, mgmt_by_token=True) as m: - yield m - - def _patch_ctx(self, method: str, response, *, mgmt: bool = False, mgmt_by_token: bool = False): - """ - Patch the right layer per mode: - - - sync → ``httpx.`` (the module-level function HTTPClient calls). - - async → ``_async_client.`` on the AsyncHTTPClient instance. - """ - if self.mode == "sync": - return patch(f"httpx.{method}", return_value=response) - if mgmt_by_token: - target = self._raw._mgmt._outbound_application_by_token._http - elif mgmt: - target = self._raw._mgmt._http - else: - target = self._raw._auth_http - return patch.object(target._async_client, method, AsyncMock(return_value=response)) - - -class ClientFactory: - """ - Use via the ``client_factory`` fixture when a test must control construction - arguments directly (bad project_id, jwt_validation_leeway, auth_management_key…). - - ``make(*args, **kwargs)`` returns a UnifiedClient on success or propagates - the construction exception — so tests that expect failure just wrap the call - in ``pytest.raises``. - """ - - def __init__(self, mode: str): - self.mode = mode - self._async_clients: list = [] # tracked so teardown can aclose them - - def make(self, *args, **kwargs) -> UnifiedClient: - """Construct a (Async)DescopeClient and wrap it in UnifiedClient.""" - if self.mode == "sync": - return UnifiedClient("sync", DescopeClient(*args, **kwargs)) - client = DescopeClientAsync(*args, **kwargs) - self._async_clients.append(client) - return UnifiedClient("async", client) - - -@pytest.fixture(params=["sync", "async"]) -async def descope_client(request): - """ - Parametrized fixture — yields a UnifiedClient wrapping DescopeClient (sync) - or DescopeClientAsync (async). Each consuming test runs twice. - """ - # Save and restore DESCOPE_BASE_URI so it doesn't leak into other tests. - _prev = os.environ.get("DESCOPE_BASE_URI") - os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL - try: - if request.param == "sync": - yield UnifiedClient("sync", DescopeClient(PROJECT_ID, PUBLIC_KEY_DICT)) - else: - raw = DescopeClientAsync(PROJECT_ID, PUBLIC_KEY_DICT) - yield UnifiedClient("async", raw) - await raw.aclose() # release the underlying httpx.AsyncClient cleanly - finally: - if _prev is None: - os.environ.pop("DESCOPE_BASE_URI", None) - else: - os.environ["DESCOPE_BASE_URI"] = _prev - - -@pytest.fixture(params=["sync", "async"]) -async def client_factory(request): - """ - Parametrized factory fixture — yields a ClientFactory so tests can - construct clients with custom arguments (bad keys, leeway, mgmt key, …). - Each consuming test runs twice (sync + async). - """ - _prev = os.environ.get("DESCOPE_BASE_URI") - os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL - factory = ClientFactory(request.param) - try: - yield factory - finally: - for raw in factory._async_clients: - await raw.aclose() - if _prev is None: - os.environ.pop("DESCOPE_BASE_URI", None) - else: - os.environ["DESCOPE_BASE_URI"] = _prev diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py deleted file mode 100644 index 8a06031ff..000000000 --- a/tests/e2e/conftest.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Fixtures for the e2e test suite. - -Requires a real Descope backend reachable at DESCOPE_BASE_URI (defaults to -https://api.descope.com). When the required env vars are absent the e2e tests -are skipped (not errored), so a local `pytest tests/` still runs the unit suite -and fork PRs without repository secrets collect cleanly. - -Required env vars: - DESCOPE_PROJECT_ID — the project to run tests against - DESCOPE_MANAGEMENT_KEY — a management key for that project - -Optional env vars: - DESCOPE_BASE_URI — override the API base URL (e.g. https://localhost:8000 - when running against a local cluster); auto toggling skip_verify -""" - -from __future__ import annotations - -import os -from urllib.parse import urlparse - -import pytest - -if not os.environ.get("GITHUB_ACTIONS"): - try: - from dotenv import load_dotenv - - load_dotenv() - except ImportError: - pass - -from descope import DescopeClient # noqa: E402 -from descope.descope_client_async import DescopeClientAsync # noqa: E402 -from tests._unified import UnifiedClientBase # noqa: E402 - - -@pytest.fixture(params=["sync", "async"]) -async def descope_client(request): # type: ignore[misc] - """ - Parametrized fixture — yields a UnifiedClientBase wrapping DescopeClient (sync) - or DescopeClientAsync (async) against a real backend. Each consuming test runs twice. - """ - project_id = os.environ.get("DESCOPE_PROJECT_ID") - management_key = os.environ.get("DESCOPE_MANAGEMENT_KEY") - if not project_id or not management_key: - pytest.skip("Missing required e2e environment variables: DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY") - - base_uri = os.environ.get("DESCOPE_BASE_URI", "") - skip_verify = urlparse(base_uri).hostname in {"localhost", "127.0.0.1", "::1"} - if base_uri and not skip_verify and urlparse(base_uri).scheme != "https": - pytest.fail(f"DESCOPE_BASE_URI must use https for a remote target, got: {base_uri}") - - if request.param == "sync": - yield UnifiedClientBase( - "sync", - DescopeClient(project_id=project_id, management_key=management_key, skip_verify=skip_verify), - ) - else: - raw = DescopeClientAsync(project_id=project_id, management_key=management_key, skip_verify=skip_verify) - yield UnifiedClientBase("async", raw) - await raw.aclose() diff --git a/tests/e2e/management/__init__.py b/tests/e2e/management/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/e2e/management/test_flow.py b/tests/e2e/management/test_flow.py deleted file mode 100644 index e1675d9a3..000000000 --- a/tests/e2e/management/test_flow.py +++ /dev/null @@ -1,14 +0,0 @@ -"""E2E test: list flows.""" - -import pytest - -pytestmark = pytest.mark.e2e - - -class TestE2E_ListFlows: - async def test_list_flows(self, descope_client): - flows_resp = await descope_client.invoke(descope_client.mgmt.flow.list_flows()) - flows = flows_resp["flows"] - total = flows_resp["total"] - assert total == len(flows) - assert total > 0 diff --git a/tests/e2e/management/test_jwt.py b/tests/e2e/management/test_jwt.py deleted file mode 100644 index f8e56589e..000000000 --- a/tests/e2e/management/test_jwt.py +++ /dev/null @@ -1,30 +0,0 @@ -"""E2E test: management JWT sign-in.""" - -import uuid - -import pytest - -from descope import AuthException - -pytestmark = pytest.mark.e2e - - -class TestE2E_ManagementJWT: - async def test_management_jwt_sign_in(self, descope_client): - login_id = f"jwt-user-{uuid.uuid4()}@example.com" - - try: - await descope_client.invoke(descope_client.mgmt.user.create(login_id)) - - resp = await descope_client.invoke(descope_client.mgmt.jwt.sign_in(login_id)) - - session_token = resp["sessionToken"] - assert session_token - assert session_token["jwt"] - - finally: - try: - await descope_client.invoke(descope_client.mgmt.user.delete(login_id)) - except AuthException as e: - if e.status_code and e.status_code >= 500: - raise diff --git a/tests/e2e/management/test_sso.py b/tests/e2e/management/test_sso.py deleted file mode 100644 index 9ccbbe2ab..000000000 --- a/tests/e2e/management/test_sso.py +++ /dev/null @@ -1,281 +0,0 @@ -"""E2E test: SSO settings (OIDC and SAML).""" - -import os -import uuid - -import pytest - -from descope import ( - AttributeMapping, - OIDCAttributeMapping, - RoleMapping, - SSOOIDCSettings, - SSOSAMLSettings, - SSOSAMLSettingsByMetadata, -) - -pytestmark = pytest.mark.e2e - - -class TestE2E_ManagementSSO: - async def test_management_sso_capabilities_oidc_settings(self, descope_client): - tenant_name = f"sso-tenant-{uuid.uuid4().hex[:10]}" - tid = (await descope_client.invoke(descope_client.mgmt.tenant.create(tenant_name)))["id"] - - try: - settings = SSOOIDCSettings( - name="myProvider", - client_id="iddd", - client_secret="secret", - auth_url="https://dummy.com/auth", - token_url="https://dummy.com/token", - user_data_url="https://dummy.com/userInfo", - scope=["openid", "profile", "email"], - attribute_mapping=OIDCAttributeMapping( - login_id="subject", - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - email="email", - verified_email="verifiedEmail", - username="username", - phone_number="phoneNumber", - verified_phone="verifiedPhone", - picture="picture", - ), - ) - await descope_client.invoke(descope_client.mgmt.sso.configure_oidc_settings(tid, settings)) - - res = await descope_client.invoke(descope_client.mgmt.sso.load_settings(tid)) - oidc = res["oidc"] - - assert oidc["name"] == "myProvider" - assert oidc["clientId"] == "iddd" - assert oidc["clientSecret"] == "" # redacted by backend - assert oidc["authUrl"] == "https://dummy.com/auth" - assert oidc["tokenUrl"] == "https://dummy.com/token" - assert oidc["userDataUrl"] == "https://dummy.com/userInfo" - assert oidc["scope"] == ["openid", "profile", "email"] - - mapping = oidc["userAttrMapping"] - assert mapping["loginId"] == "subject" - assert mapping["name"] == "name" - assert mapping["givenName"] == "givenName" - assert mapping["middleName"] == "middleName" - assert mapping["familyName"] == "familyName" - assert mapping["email"] == "email" - assert mapping["verifiedEmail"] == "verifiedEmail" - assert mapping["username"] == "username" - assert mapping["phoneNumber"] == "phoneNumber" - assert mapping["verifiedPhone"] == "verifiedPhone" - assert mapping["picture"] == "picture" - finally: - await descope_client.invoke(descope_client.mgmt.tenant.delete(tid)) - - async def test_management_sso_capabilities_saml_settings(self, descope_client): - project_id = os.environ.get("DESCOPE_PROJECT_ID", "") - role_name = f"sso-role-{uuid.uuid4().hex[:10]}" - tenant_name = f"sso-tenant-{uuid.uuid4().hex[:10]}" - - await descope_client.invoke(descope_client.mgmt.role.create(role_name)) - tid = (await descope_client.invoke(descope_client.mgmt.tenant.create(tenant_name)))["id"] - - try: - settings = SSOSAMLSettings( - idp_url="https://dummy.com/saml", - idp_entity_id="entity1234", - idp_cert="my certificate", - attribute_mapping=AttributeMapping( - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - picture="picture", - email="email", - phone_number="phoneNumber", - group="groups", - ), - role_mappings=[RoleMapping(groups=["grp1"], role_name=role_name)], - ) - await descope_client.invoke(descope_client.mgmt.sso.configure_saml_settings(tid, settings)) - - res = await descope_client.invoke(descope_client.mgmt.sso.load_settings(tid)) - saml = res["saml"] - - assert saml["idpSSOUrl"] == "https://dummy.com/saml" - assert saml["idpEntityId"] == "entity1234" - assert saml["idpCertificate"] == "my certificate" - assert saml["spEntityId"] == f"{project_id}-{tid}" - assert f"projectId={project_id}" in saml["spACSUrl"] - assert f"tenantId={tid}" in saml["spACSUrl"] - - attr_map = saml["attributeMapping"] - assert attr_map["group"] == "groups" - - groups_mapping = saml["groupsMapping"] - assert len(groups_mapping) == 1 - assert groups_mapping[0]["role"]["name"] == role_name - assert groups_mapping[0]["groups"] == ["grp1"] - finally: - await descope_client.invoke(descope_client.mgmt.role.delete(role_name)) - await descope_client.invoke(descope_client.mgmt.tenant.delete(tid)) - - async def test_management_sso_capabilities_saml_settings_by_metadata(self, descope_client): - project_id = os.environ.get("DESCOPE_PROJECT_ID", "") - role_name = f"sso-role-{uuid.uuid4().hex[:10]}" - tenant_name = f"sso-tenant-{uuid.uuid4().hex[:10]}" - - await descope_client.invoke(descope_client.mgmt.role.create(role_name)) - tid = (await descope_client.invoke(descope_client.mgmt.tenant.create(tenant_name)))["id"] - - try: - settings = SSOSAMLSettingsByMetadata( - idp_metadata_url="https://dummy.com/saml", - attribute_mapping=AttributeMapping( - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - picture="picture", - email="email", - phone_number="phoneNumber", - group="groups", - ), - role_mappings=[RoleMapping(groups=["grp1"], role_name=role_name)], - ) - await descope_client.invoke(descope_client.mgmt.sso.configure_saml_settings_by_metadata(tid, settings)) - - res = await descope_client.invoke(descope_client.mgmt.sso.load_settings(tid)) - saml = res["saml"] - - assert saml["idpMetadataUrl"] == "https://dummy.com/saml" - assert saml["spEntityId"] == f"{project_id}-{tid}" - assert f"projectId={project_id}" in saml["spACSUrl"] - assert f"tenantId={tid}" in saml["spACSUrl"] - - attr_map = saml["attributeMapping"] - assert attr_map["group"] == "groups" - - groups_mapping = saml["groupsMapping"] - assert len(groups_mapping) == 1 - assert groups_mapping[0]["role"]["name"] == role_name - assert groups_mapping[0]["groups"] == ["grp1"] - finally: - await descope_client.invoke(descope_client.mgmt.role.delete(role_name)) - await descope_client.invoke(descope_client.mgmt.tenant.delete(tid)) - - async def test_management_sso_capabilities(self, descope_client): - # NOTE: These APIs are deprecated; retained for backwards-compatibility parity. - project_id = os.environ.get("DESCOPE_PROJECT_ID", "") - tenant_name = f"sso-tenant-{uuid.uuid4().hex[:10]}" - tid = (await descope_client.invoke(descope_client.mgmt.tenant.create(tenant_name)))["id"] - - try: - loaded = await descope_client.invoke(descope_client.mgmt.tenant.load(tid)) - assert loaded["id"] == tid - assert loaded["name"] == tenant_name - - # --- Initial empty state --- - settings = await descope_client.invoke(descope_client.mgmt.sso.get_settings(tid)) - assert settings["tenantId"] - assert settings["idpEntityId"] == "" - assert settings["idpSSOUrl"] == "" - assert settings["idpCertificate"] == "" - assert settings.get("idpMetadataUrl", "") == "" - assert settings["spEntityId"] == f"{project_id}-{tid}" - assert f"projectId={project_id}" in settings["spACSUrl"] - assert f"tenantId={tid}" in settings["spACSUrl"] - assert settings.get("groupsMapping", []) == [] - assert settings.get("redirectUrl", "") == "" - assert settings.get("domain", "") == "" - assert settings.get("domains", []) == [] - assert settings.get("userMapping", {}) == { - "name": "name", - "email": "email", - "username": "", - "phoneNumber": "phone", - "group": "", - "givenName": "", - "middleName": "", - "familyName": "", - "picture": "", - "verifiedEmail": "", - "verifiedPhone": "", - "customAttributes": {}, - } - - await descope_client.invoke( - descope_client.mgmt.sso.configure( - tid, - "http://idpURL", - "entity", - "mycert", - "https://redirect", - ["domain.com", "app.domain.com"], - ) - ) - - settings = await descope_client.invoke(descope_client.mgmt.sso.get_settings(tid)) - assert settings["idpEntityId"] == "entity" - assert settings["idpSSOUrl"] == "http://idpURL" - assert settings["idpCertificate"] == "mycert" - assert settings["spEntityId"] == f"{project_id}-{tid}" - assert f"projectId={project_id}" in settings["spACSUrl"] - assert f"tenantId={tid}" in settings["spACSUrl"] - assert settings["redirectUrl"] == "https://redirect" - assert settings.get("domain", "") == "domain.com" - assert set(settings["domains"]) == {"domain.com", "app.domain.com"} - - await descope_client.invoke( - descope_client.mgmt.sso.configure_via_metadata( - tid, - "http://idpMetadataURL", - domains=["domain2.com", "app.domain2.com"], - ) - ) - - await descope_client.invoke( - descope_client.mgmt.sso.mapping( - tid, - role_mappings=[RoleMapping(["a"], "Tenant Admin")], - attribute_mapping=AttributeMapping(name="MyName"), - ) - ) - - settings = await descope_client.invoke(descope_client.mgmt.sso.get_settings(tid)) - assert settings.get("idpMetadataUrl") == "http://idpMetadataURL" - assert settings["spEntityId"] == f"{project_id}-{tid}" - assert f"projectId={project_id}" in settings["spACSUrl"] - assert f"tenantId={tid}" in settings["spACSUrl"] - assert settings.get("redirectUrl", "") == "https://redirect" - assert settings.get("domain", "") == "domain2.com" - assert set(settings["domains"]) == {"domain2.com", "app.domain2.com"} - user_mapping = settings.get("userMapping", {}) - assert user_mapping == { - "name": "MyName", - "email": "", - "username": "", - "phoneNumber": "", - "group": "", - "givenName": "", - "middleName": "", - "familyName": "", - "picture": "", - "verifiedEmail": "", - "verifiedPhone": "", - "customAttributes": {}, - } - groups_mapping = settings.get("groupsMapping", []) - assert len(groups_mapping) == 1 - assert groups_mapping[0]["role"]["name"] == "Tenant Admin" - assert groups_mapping[0]["groups"] == ["a"] - - await descope_client.invoke(descope_client.mgmt.sso.delete_settings(tid)) - - settings = await descope_client.invoke(descope_client.mgmt.sso.get_settings(tid)) - assert settings["idpEntityId"] == "" - assert settings["idpSSOUrl"] == "" - assert settings["idpCertificate"] == "" - finally: - await descope_client.invoke(descope_client.mgmt.tenant.delete(tid)) diff --git a/tests/e2e/management/test_user.py b/tests/e2e/management/test_user.py deleted file mode 100644 index 1ca9bc190..000000000 --- a/tests/e2e/management/test_user.py +++ /dev/null @@ -1,199 +0,0 @@ -"""E2E test: user management CRUD + test-user flow + invite.""" - -import uuid - -import pytest - -from descope import AuthException, DeliveryMethod -from descope.common import REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME -from descope.management.user import UserObj - -pytestmark = pytest.mark.e2e - - -class TestE2E_ManagementUser: - async def test_management_user_capabilities(self, descope_client): - user_login_id = f"des-{uuid.uuid4().hex[:8]}@copeland.com" - invited_login_id = f"des-invited-{uuid.uuid4().hex[:8]}@copeland.com" - invited1_login_id = f"des-1-invited-{uuid.uuid4().hex[:8]}@copeland.com" - invited2_login_id = f"des-2-invited-{uuid.uuid4().hex[:8]}@copeland.com" - test_user_login_id = f"test-{uuid.uuid4().hex[:10]}" - role1_name = f"role-{uuid.uuid4().hex[:8]}" - role2_name = f"role-{uuid.uuid4().hex[:8]}" - updated_login_id: str = "" - - try: - # ---------------------------------------------------------------- - # User CRUD - # ---------------------------------------------------------------- - resp = await descope_client.invoke(descope_client.mgmt.user.create(user_login_id)) - user = resp["user"] - assert user - assert user["email"] == user_login_id - assert user["verifiedEmail"] - assert user["status"] == "invited" - - resp = await descope_client.invoke(descope_client.mgmt.user.load(user_login_id)) - assert resp["user"]["email"] == user_login_id - assert resp["user"]["verifiedEmail"] - assert resp["user"]["status"] == "invited" - - new_login_id = f"bane-{uuid.uuid4().hex[:8]}@copeland.com" - resp = await descope_client.invoke(descope_client.mgmt.user.update_login_id(user_login_id, new_login_id)) - updated_login_id = new_login_id - assert updated_login_id in resp["user"]["loginIds"] - - search_resp = await descope_client.invoke(descope_client.mgmt.user.search_all()) - users = search_resp.get("users", []) - assert any(updated_login_id in (u.get("loginIds") or []) for u in users) - - await descope_client.invoke( - descope_client.mgmt.user.update(updated_login_id, display_name="Desmond Copeland") - ) - - await descope_client.invoke(descope_client.mgmt.role.create(role1_name)) - await descope_client.invoke(descope_client.mgmt.role.create(role2_name)) - - await descope_client.invoke( - descope_client.mgmt.user.patch( - updated_login_id, - phone="+1234567890", - middle_name="Middle", - role_names=[role1_name], - ) - ) - resp = await descope_client.invoke(descope_client.mgmt.user.load(updated_login_id)) - u = resp["user"] - assert updated_login_id in u["loginIds"] - assert u["name"] == "Desmond Copeland" - assert u.get("middleName") == "Middle" - assert u.get("phone") == "+1234567890" - assert role1_name in u.get("roleNames", []) - assert u.get("verifiedPhone") - - await descope_client.invoke( - descope_client.mgmt.user.patch(updated_login_id, role_names=[role1_name, role2_name]) - ) - u = (await descope_client.invoke(descope_client.mgmt.user.load(updated_login_id)))["user"] - role_names_list = u.get("roleNames", []) - assert len(role_names_list) == 2 - assert role1_name in role_names_list - assert role2_name in role_names_list - - await descope_client.invoke(descope_client.mgmt.user.patch(updated_login_id, role_names=[])) - u = (await descope_client.invoke(descope_client.mgmt.user.load(updated_login_id)))["user"] - assert len(u.get("roleNames", [])) == 0 - assert u.get("phone") == "+1234567890" - - await descope_client.invoke(descope_client.mgmt.user.delete(updated_login_id)) - search_resp = await descope_client.invoke(descope_client.mgmt.user.search_all()) - assert not any(updated_login_id in (u.get("loginIds") or []) for u in search_resp.get("users", [])) - - # ---------------------------------------------------------------- - # Test-user flow - # ---------------------------------------------------------------- - with pytest.raises(AuthException): - await descope_client.invoke( - descope_client.mgmt.user.generate_otp_for_test_user( - method=DeliveryMethod.EMAIL, - login_id=test_user_login_id, - ) - ) - - create_resp = await descope_client.invoke( - descope_client.mgmt.user.create_test_user( - login_id=test_user_login_id, - email=f"e2e-{uuid.uuid4().hex[:8]}@example.com", - phone="+12025550142", - display_name="foo bar test", - ) - ) - test_user = create_resp["user"] - assert test_user["loginIds"][0] == test_user_login_id - test_user_id = test_user["userId"] - - resp = await descope_client.invoke(descope_client.mgmt.user.load_by_user_id(test_user_id)) - assert resp["user"] - - search_resp = await descope_client.invoke(descope_client.mgmt.user.search_all(test_users_only=True)) - test_users = search_resp.get("users", []) - assert any(u["userId"] == test_user_id for u in test_users) - - gen_resp = await descope_client.invoke( - descope_client.mgmt.user.generate_otp_for_test_user( - method=DeliveryMethod.EMAIL, - login_id=test_user_login_id, - ) - ) - code = gen_resp["code"] - assert code - assert gen_resp["loginId"] == test_user_login_id - - jwt_response = await descope_client.invoke( - descope_client.otp.verify_code( - method=DeliveryMethod.EMAIL, - login_id=test_user_login_id, - code=code, - ) - ) - assert jwt_response.get("firstSeen") - assert jwt_response[SESSION_TOKEN_NAME]["jwt"] - assert jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] - assert jwt_response["user"]["userId"] == test_user_id - - await descope_client.invoke(descope_client.mgmt.user.delete(test_user_login_id)) - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.mgmt.user.load_by_user_id(test_user_id)) - - # ---------------------------------------------------------------- - # Invite flow - # ---------------------------------------------------------------- - resp = await descope_client.invoke(descope_client.mgmt.user.invite(invited_login_id)) - u = resp["user"] - assert u["email"] == invited_login_id - assert u["verifiedEmail"] - assert u["status"] == "invited" - - resp = await descope_client.invoke(descope_client.mgmt.user.load(invited_login_id)) - assert resp["user"]["email"] == invited_login_id - assert resp["user"]["verifiedEmail"] - assert resp["user"]["status"] == "invited" - - batch_resp = await descope_client.invoke( - descope_client.mgmt.user.invite_batch([UserObj(invited1_login_id), UserObj(invited2_login_id)]) - ) - created = batch_resp["createdUsers"] - failed = batch_resp["failedUsers"] - assert len(created) == 2 - assert len(failed) == 0 - for cu in created: - assert cu["email"] - assert cu["verifiedEmail"] - assert cu["status"] == "invited" - - await descope_client.invoke(descope_client.mgmt.user.load(invited1_login_id)) - await descope_client.invoke(descope_client.mgmt.user.load(invited2_login_id)) - - finally: - for rname in [role1_name, role2_name]: - try: - await descope_client.invoke(descope_client.mgmt.role.delete(rname)) - except AuthException as e: - if e.status_code and e.status_code >= 500: - raise - login_ids = [ - user_login_id, - updated_login_id, - test_user_login_id, - invited_login_id, - invited1_login_id, - invited2_login_id, - ] - for uid in login_ids: - if not uid: - continue - try: - await descope_client.invoke(descope_client.mgmt.user.delete(uid)) - except AuthException as e: - if e.status_code and e.status_code >= 500: - raise diff --git a/tests/e2e/test_access_keys.py b/tests/e2e/test_access_keys.py deleted file mode 100644 index f5c2971fd..000000000 --- a/tests/e2e/test_access_keys.py +++ /dev/null @@ -1,97 +0,0 @@ -"""E2E test: access key exchange and CRUD.""" - -import os - -import pytest - -from descope import AccessKeyLoginOptions -from descope.common import SESSION_TOKEN_NAME - -pytestmark = pytest.mark.e2e - - -class TestE2E_AccessKeys: - async def test_exchange_access_key(self, descope_client): - key1_id = None - key2_id = None - try: - # Create AK1 with custom claims and description - resp1 = await descope_client.invoke( - descope_client.mgmt.access_key.create( - name="AK1", - custom_claims={"k1": "v1"}, - description="hey", - ) - ) - access_key_cleartext = resp1["cleartext"] - key1 = resp1["key"] - key1_id = key1["id"] - assert key1["permittedIps"] == [] - assert key1["description"] == "hey" - - # Create AK2 with permitted IPs - resp2 = await descope_client.invoke( - descope_client.mgmt.access_key.create( - name="AK2", - custom_claims={"k1": "v1"}, - permitted_ips=["10.0.0.1", "192.168.1.0/24"], - ) - ) - key2 = resp2["key"] - key2_id = key2["id"] - assert key2["permittedIps"] == ["10.0.0.1", "192.168.1.0/24"] - assert key2["description"] == "" - - # Exchange AK1 with custom claims in login options - loc = AccessKeyLoginOptions(custom_claims={"nsec-k1": "nsec-v1"}) - jwt_response = await descope_client.invoke( - descope_client.exchange_access_key( - access_key=access_key_cleartext, - login_options=loc, - ) - ) - assert jwt_response, "exchange_access_key returned empty response" - - token = jwt_response[SESSION_TOKEN_NAME] - assert token["k1"] == "v1" - assert token["nsec"] is not None - assert token["nsec"]["nsec-k1"] == "nsec-v1" - assert jwt_response["projectId"] == os.environ.get("DESCOPE_PROJECT_ID", "") - finally: - if key1_id: - await descope_client.invoke(descope_client.mgmt.access_key.delete(key1_id)) - if key2_id: - await descope_client.invoke(descope_client.mgmt.access_key.delete(key2_id)) - - async def test_update_access_key(self, descope_client): - key_id = None - try: - # Create - resp = await descope_client.invoke(descope_client.mgmt.access_key.create(name="AAA", description="hey")) - key = resp["key"] - key_id = key["id"] - assert key["name"] == "AAA" - assert key["description"] == "hey" - - # Update name and description - await descope_client.invoke( - descope_client.mgmt.access_key.update(id=key_id, name="ABA", description="hello there") - ) - - # Load and verify update - resp = await descope_client.invoke(descope_client.mgmt.access_key.load(key_id)) - key = resp["key"] - assert key["name"] == "ABA" - assert key["description"] == "hello there" - - # Update with description=None — existing description must be preserved - await descope_client.invoke(descope_client.mgmt.access_key.update(id=key_id, name="ABA1", description=None)) - - # Load and verify name changed but description unchanged - resp = await descope_client.invoke(descope_client.mgmt.access_key.load(key_id)) - key = resp["key"] - assert key["name"] == "ABA1" - assert key["description"] == "hello there" - finally: - if key_id: - await descope_client.invoke(descope_client.mgmt.access_key.delete(key_id)) diff --git a/tests/e2e/test_magiclink.py b/tests/e2e/test_magiclink.py deleted file mode 100644 index f7b536e18..000000000 --- a/tests/e2e/test_magiclink.py +++ /dev/null @@ -1,112 +0,0 @@ -"""E2E test: magic-link sign-up, sign-in, and session flow.""" - -import uuid -from urllib.parse import parse_qs, urlparse - -import pytest - -from descope import AuthException, DeliveryMethod -from descope.common import REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME - -pytestmark = pytest.mark.e2e - - -def _extract_project_id_from_iss(iss: str) -> str: - """Return the last path component of the 'iss' JWT claim.""" - return iss.rsplit("/", 1)[-1] - - -class TestE2E_Magiclink: - @pytest.fixture - async def created_users(self, descope_client): - login_ids: list[str] = [] - yield login_ids - for lid in login_ids: - try: - await descope_client.invoke(descope_client.mgmt.user.delete(lid)) - except AuthException as e: - if e.status_code and e.status_code >= 500: - raise - - async def test_magiclink_methods(self, descope_client, created_users): - login_id = f"user-{uuid.uuid4()}" - created_users.append(login_id) - uri = "http://test.me" - - await descope_client.invoke( - descope_client.mgmt.user.create_test_user( - login_id=login_id, - email=f"e2e-{uuid.uuid4().hex[:8]}@example.com", - phone="+12025550142", - display_name="foo bar test", - ) - ) - - # --- Sign-up via magic link --- - td = await descope_client.invoke( - descope_client.mgmt.user.generate_magic_link_for_test_user( - method=DeliveryMethod.EMAIL, - login_id=login_id, - uri=uri, - ) - ) - raw_link = td["link"] - token = parse_qs(urlparse(raw_link).query)["t"][0] - - await descope_client.invoke( - descope_client.magiclink.sign_up_or_in( - method=DeliveryMethod.EMAIL, - login_id=login_id, - uri=uri, - ) - ) - - jwt_response = await descope_client.invoke(descope_client.magiclink.verify(token=token)) - assert jwt_response, "magiclink.verify after sign-up returned empty response" - - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] - assert refresh_token, "Refresh token is empty after sign-up" - - # --- Sign-in via magic link --- - td2 = await descope_client.invoke( - descope_client.mgmt.user.generate_magic_link_for_test_user( - method=DeliveryMethod.EMAIL, - login_id=login_id, - uri=uri, - ) - ) - raw_link2 = td2["link"] - sign_in_token = parse_qs(urlparse(raw_link2).query)["t"][0] - - await descope_client.invoke( - descope_client.magiclink.sign_in( - method=DeliveryMethod.EMAIL, - login_id=login_id, - uri=uri, - ) - ) - - jwt_response = await descope_client.invoke(descope_client.magiclink.verify(token=sign_in_token)) - session_token = jwt_response[SESSION_TOKEN_NAME]["jwt"] - assert session_token, "Session token is empty after sign-in" - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] - assert refresh_token, "Refresh token is empty after sign-in" - - # --- Validate & refresh session --- - await descope_client.invoke(descope_client.validate_session(session_token)) - - refreshed = await descope_client.invoke(descope_client.refresh_session(refresh_token)) - assert refreshed, "refresh_session returned empty response" - - new_session_token = refreshed[SESSION_TOKEN_NAME]["jwt"] - assert new_session_token, "New session token is empty after refresh" - - resp = await descope_client.invoke(descope_client.validate_session(new_session_token)) - assert resp, "validate_session returned empty response for refreshed token" - - token_data = resp[SESSION_TOKEN_NAME] - assert token_data["sub"] == resp["userId"] - assert _extract_project_id_from_iss(token_data["iss"]) == resp["projectId"] - - # --- Logout --- - await descope_client.invoke(descope_client.logout(refresh_token)) diff --git a/tests/e2e/test_otp.py b/tests/e2e/test_otp.py deleted file mode 100644 index be66690fa..000000000 --- a/tests/e2e/test_otp.py +++ /dev/null @@ -1,109 +0,0 @@ -"""E2E test: OTP sign-up / sign-in flow. - -Runs against a real Descope backend; skipped when env vars are absent. -Exercises both the sync DescopeClient and async DescopeClientAsync via the -UnifiedClientBase.invoke() pattern. -""" - -import uuid - -import pytest - -from descope import AuthException, DeliveryMethod -from descope.common import REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME - -pytestmark = pytest.mark.e2e - -_PHONE = "+12025550142" - - -class TestE2E_OTP: - @pytest.fixture - async def created_users(self, descope_client): - login_ids: list[str] = [] - yield login_ids - for lid in login_ids: - try: - await descope_client.invoke(descope_client.mgmt.user.delete(lid)) - except AuthException as e: - if e.status_code and e.status_code >= 500: - raise - - async def test_otp_sign_up_and_sign_in(self, descope_client, created_users): - login_id = f"user-{uuid.uuid4()}" - created_users.append(login_id) - - await descope_client.invoke( - descope_client.mgmt.user.create_test_user( - login_id=login_id, - phone=_PHONE, - display_name="E2E OTP Test User", - ) - ) - - # --- Sign-up --- - # Pre-generate the OTP code before initiating sign-up so it is ready - generate_res = await descope_client.invoke( - descope_client.mgmt.user.generate_otp_for_test_user( - method=DeliveryMethod.SMS, - login_id=login_id, - ) - ) - await descope_client.invoke( - descope_client.otp.sign_up( - method=DeliveryMethod.SMS, - login_id=login_id, - user={"name": "E2E OTP Test User", "phone": _PHONE}, - ) - ) - code = generate_res.get("code", "") - assert code, "OTP code returned by generate_otp_for_test_user was empty" - - jwt_response = await descope_client.invoke( - descope_client.otp.verify_code( - method=DeliveryMethod.SMS, - login_id=login_id, - code=code, - ) - ) - assert jwt_response, "verify_code after sign-up returned empty response" - - # --- Sign-in --- - generate_res = await descope_client.invoke( - descope_client.mgmt.user.generate_otp_for_test_user( - method=DeliveryMethod.SMS, - login_id=login_id, - ) - ) - await descope_client.invoke( - descope_client.otp.sign_in( - method=DeliveryMethod.SMS, - login_id=login_id, - ) - ) - code = generate_res.get("code", "") - assert code, "OTP code returned by generate_otp_for_test_user was empty" - - jwt_response = await descope_client.invoke( - descope_client.otp.verify_code( - method=DeliveryMethod.SMS, - login_id=login_id, - code=code, - ) - ) - assert jwt_response, "verify_code after sign-in returned empty response" - - session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") - assert session_token, "Session token is empty after sign-in" - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") - assert refresh_token, "Refresh token is empty after sign-in" - - # --- Validate & refresh session --- - # validate_session is sync on both clients (inherited from _client_base) - await descope_client.invoke(descope_client.validate_session(session_token)) - - refreshed = await descope_client.invoke(descope_client.refresh_session(refresh_token)) - assert refreshed, "refresh_session returned empty response" - - # --- Logout --- - await descope_client.invoke(descope_client.logout(refresh_token)) diff --git a/tests/e2e/test_password.py b/tests/e2e/test_password.py deleted file mode 100644 index 65f6a0f78..000000000 --- a/tests/e2e/test_password.py +++ /dev/null @@ -1,115 +0,0 @@ -"""E2E test: password sign-up, sign-in, and management flows. - -Prerequisites: the e2e project must have password authentication enabled with a -policy of minLength >= 9, uppercase required, non-alphanumeric required. - -Deferred: test_password_reset (requires MailSlurp — not ported). -""" - -import uuid - -import pytest - -from descope import AuthException -from descope.common import REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME - -pytestmark = pytest.mark.e2e - -_PASSWORD = "WASD+ijkl" - - -class TestE2E_Password: - async def test_password_sign_up(self, descope_client): - login_id = f"pw-user-{uuid.uuid4()}@example.com" - try: - # Verify the project policy is configured as required by this test - policy = await descope_client.invoke(descope_client.password.get_policy()) - assert policy["minLength"] > 5, f"sandbox password policy: minLength must be > 5, got {policy['minLength']}" - assert policy.get("nonAlphanumeric"), ( - f"sandbox password policy: nonAlphanumeric must be enabled (got {policy})" - ) - - # Negative: too-short password - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.password.sign_up(login_id, "A!a4", {"name": "Test User"})) - - # Negative: no non-alphanumeric character - with pytest.raises(AuthException): - await descope_client.invoke( - descope_client.password.sign_up(login_id, "Aaa456789", {"name": "Test User"}) - ) - - # Success: valid sign-up - jwt_response = await descope_client.invoke( - descope_client.password.sign_up(login_id, _PASSWORD, {"name": "Test User"}) - ) - assert jwt_response, "password.sign_up returned empty response" - assert jwt_response[SESSION_TOKEN_NAME]["jwt"], "Session token missing after sign-up" - assert jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"], "Refresh token missing after sign-up" - assert jwt_response.get("firstSeen") is True - - # Duplicate sign-up must raise - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.password.sign_up(login_id, _PASSWORD)) - - # Negative sign-ins - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.password.sign_in("bar@foo.com", _PASSWORD)) - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.password.sign_in(login_id, "")) - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.password.sign_in(login_id, "asdf")) - - # Success: sign-in - jwt_response = await descope_client.invoke(descope_client.password.sign_in(login_id, _PASSWORD)) - assert jwt_response[SESSION_TOKEN_NAME]["jwt"], "Session token missing after sign-in" - assert jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"], "Refresh token missing after sign-in" - assert jwt_response.get("firstSeen") is False - assert jwt_response.get("user", {}).get("name") == "Test User" - - # Replace password - new_password = _PASSWORD + "a" - await descope_client.invoke(descope_client.password.replace(login_id, _PASSWORD, new_password)) - - # Sign in with new password - jwt_response = await descope_client.invoke(descope_client.password.sign_in(login_id, new_password)) - assert jwt_response[SESSION_TOKEN_NAME]["jwt"], "Session token missing after sign-in with new password" - assert jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"], ( - "Refresh token missing after sign-in with new password" - ) - assert jwt_response.get("firstSeen") is False - refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] - - await descope_client.invoke(descope_client.logout(refresh_token)) - finally: - try: - await descope_client.invoke(descope_client.mgmt.user.delete(login_id)) - except AuthException as e: - if e.status_code and e.status_code >= 500: - raise - - async def test_set_temporary_and_active_password(self, descope_client): - login_id = f"pw-user-{uuid.uuid4()}@example.com" - try: - # Set up: sign up the user first - await descope_client.invoke(descope_client.password.sign_up(login_id, _PASSWORD, {"name": "Temp Pw Test"})) - - # Set a temporary password — must require replacement before use - await descope_client.invoke(descope_client.mgmt.user.set_temporary_password(login_id, "WASD+ijklll")) - - # Temporary password cannot be used directly for sign-in - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.password.sign_in(login_id, "WASD+ijklll")) - - # Set an active password — immediately usable - await descope_client.invoke(descope_client.mgmt.user.set_active_password(login_id, "WASD+ijkqqq")) - - # Active password works for sign-in - jwt_response = await descope_client.invoke(descope_client.password.sign_in(login_id, "WASD+ijkqqq")) - assert jwt_response[SESSION_TOKEN_NAME]["jwt"], "Session token missing after sign-in with active password" - finally: - try: - await descope_client.invoke(descope_client.mgmt.user.delete(login_id)) - except AuthException as e: - if e.status_code and e.status_code >= 500: - raise diff --git a/tests/management/__init__.py b/tests/management/__init__.py deleted file mode 100644 index 64f98ee19..000000000 --- a/tests/management/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from descope.common import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_COOKIE_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - DeliveryMethod, - LoginOptions, -) -from descope.descope_client import DescopeClient -from descope.exceptions import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - AuthException, - RateLimitException, -) -from descope.management.common import ( - AssociatedTenant, - SAMLIDPAttributeMappingInfo, - SAMLIDPGroupsMappingInfo, - SAMLIDPRoleGroupMappingInfo, -) -from descope.management.sso_settings import ( - AttributeMapping, - RoleMapping, - SSOOIDCSettings, - SSOSAMLSettings, - SSOSAMLSettingsByMetadata, -) diff --git a/tests/management/test_access_key.py b/tests/management/test_access_key.py deleted file mode 100644 index 65cf02d1c..000000000 --- a/tests/management/test_access_key.py +++ /dev/null @@ -1,366 +0,0 @@ -import pytest - -from descope import AssociatedTenant, AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestAccessKey: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.create("key-name")) - - # Test success flow - with client.mock_mgmt_post(make_response({"key": {"id": "ak1"}, "cleartext": "abc"})) as mock_post: - resp = await client.invoke( - client.mgmt.access_key.create( - name="key-name", - expire_time=123456789, - key_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - user_id="userid", - custom_claims={"k1": "v1"}, - description="this is my access key", - permitted_ips=["10.0.0.1", "192.168.1.0/24"], - custom_attributes={"attr1": "value1"}, - ) - ) - access_key = resp["key"] - assert access_key["id"] == "ak1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "key-name", - "expireTime": 123456789, - "roleNames": [], - "keyTenants": [ - {"tenantId": "tenant1", "roleNames": []}, - {"tenantId": "tenant2", "roleNames": ["role1", "role2"]}, - ], - "userId": "userid", - "customClaims": {"k1": "v1"}, - "description": "this is my access key", - "permittedIps": ["10.0.0.1", "192.168.1.0/24"], - "customAttributes": {"attr1": "value1"}, - }, - follow_redirects=False, - ) - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.load("key-id")) - - # Test success flow - with client.mock_mgmt_get(make_response({"key": {"id": "ak1"}})) as mock_get: - resp = await client.invoke(client.mgmt.access_key.load("key-id")) - access_key = resp["key"] - assert access_key["id"] == "ak1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_load_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"id": "key-id"}, - follow_redirects=True, - ) - - async def test_search_all_users(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.search_all_access_keys(["t1, t2"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"keys": [{"id": "ak1"}, {"id": "ak2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.access_key.search_all_access_keys( - ["t1, t2"], "bound-user-id", "creator-user", {"attr1": "value1"} - ) - ) - keys = resp["keys"] - assert len(keys) == 2 - assert keys[0]["id"] == "ak1" - assert keys[1]["id"] == "ak2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_keys_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1, t2"], - "boundUserId": "bound-user-id", - "creatingUser": "creator-user", - "customAttributes": {"attr1": "value1"}, - }, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.update("key-id", "new-name")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - result = await client.invoke( - client.mgmt.access_key.update( - "key-id", - name="new-name", - description="desc", - custom_claims={"k1": "v1"}, - permitted_ips=["192.168.1.1"], - custom_attributes={"attr1": "value1"}, - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "key-id", - "name": "new-name", - "description": "desc", - "customClaims": {"k1": "v1"}, - "permittedIps": ["192.168.1.1"], - "customAttributes": {"attr1": "value1"}, - }, - follow_redirects=False, - ) - - async def test_deactivate(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.deactivate("key-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - result = await client.invoke(client.mgmt.access_key.deactivate("ak1")) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_deactivate_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "ak1", - }, - follow_redirects=False, - ) - - async def test_activate(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.activate("key-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - result = await client.invoke(client.mgmt.access_key.activate("ak1")) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_activate_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "ak1", - }, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.delete("key-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - result = await client.invoke(client.mgmt.access_key.delete("ak1")) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "ak1", - }, - follow_redirects=False, - ) - - async def test_rotate(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.rotate("key-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({"key": {"id": "ak1"}, "cleartext": "new-secret"})) as mock_post: - resp = await client.invoke(client.mgmt.access_key.rotate("ak1")) - assert resp["key"]["id"] == "ak1" - assert resp["cleartext"] == "new-secret" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_rotate_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "ak1", - }, - follow_redirects=False, - ) - - async def test_activate_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.activate_batch(["ak1", "ak2"])) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - result = await client.invoke(client.mgmt.access_key.activate_batch(["ak1", "ak2", "ak3"])) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_activate_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "ids": ["ak1", "ak2", "ak3"], - }, - follow_redirects=False, - ) - - async def test_deactivate_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.deactivate_batch(["ak1", "ak2"])) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - result = await client.invoke(client.mgmt.access_key.deactivate_batch(["ak1", "ak2", "ak3"])) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_deactivate_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "ids": ["ak1", "ak2", "ak3"], - }, - follow_redirects=False, - ) - - async def test_delete_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.access_key.delete_batch(["ak1", "ak2"])) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - result = await client.invoke(client.mgmt.access_key.delete_batch(["ak1", "ak2", "ak3"])) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.access_key_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "ids": ["ak1", "ak2", "ak3"], - }, - follow_redirects=False, - ) diff --git a/tests/management/test_analytics.py b/tests/management/test_analytics.py deleted file mode 100644 index 45624bdd9..000000000 --- a/tests/management/test_analytics.py +++ /dev/null @@ -1,127 +0,0 @@ -from datetime import datetime - -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestAnalytics: - async def test_search(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.analytics.search()) - - # Test success flow - from_ts = datetime(2024, 1, 1, 0, 0, 0) - to_ts = datetime(2024, 12, 31, 23, 59, 59) - json_data = { - "analytics": [ - { - "projectId": "P123", - "action": "login", - "created": "1704067200000", - "device": "Desktop", - "method": "otp", - "geo": "US", - "tenant": "tenant1", - "referrer": "https://example.com", - "cnt": "100", - } - ] - } - with client.mock_mgmt_post(make_response(json_data)) as mock_post: - resp = await client.invoke( - client.mgmt.analytics.search( - actions=["login"], - excluded_actions=["logout"], - from_ts=from_ts, - to_ts=to_ts, - devices=["Desktop"], - methods=["otp"], - geos=["US"], - tenants=["tenant1"], - group_by_action=True, - group_by_device=True, - group_by_method=True, - group_by_geo=True, - group_by_tenant=True, - group_by_referrer=True, - group_by_created="d", - ) - ) - assert resp is not None - assert "analytics" in resp - assert len(resp["analytics"]) == 1 - assert resp["analytics"][0]["action"] == "login" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.analytics_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "actions": ["login"], - "excludedActions": ["logout"], - "from": int(from_ts.timestamp() * 1000), - "to": int(to_ts.timestamp() * 1000), - "devices": ["Desktop"], - "methods": ["otp"], - "geos": ["US"], - "tenants": ["tenant1"], - "groupByAction": True, - "groupByDevice": True, - "groupByMethod": True, - "groupByGeo": True, - "groupByTenant": True, - "groupByReferrer": True, - "groupByCreated": "d", - }, - follow_redirects=False, - ) - - async def test_search_minimal(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow with minimal parameters - json_data = {"analytics": []} - with client.mock_mgmt_post(make_response(json_data)) as mock_post: - resp = await client.invoke(client.mgmt.analytics.search()) - assert resp is not None - assert "analytics" in resp - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.analytics_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "actions": [], - "excludedActions": [], - "devices": [], - "methods": [], - "geos": [], - "tenants": [], - "groupByAction": False, - "groupByDevice": False, - "groupByMethod": False, - "groupByGeo": False, - "groupByTenant": False, - "groupByReferrer": False, - }, - follow_redirects=False, - ) diff --git a/tests/management/test_audit.py b/tests/management/test_audit.py deleted file mode 100644 index 3a90076ad..000000000 --- a/tests/management/test_audit.py +++ /dev/null @@ -1,134 +0,0 @@ -from datetime import datetime - -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -@pytest.mark.asyncio -class TestAudit: - async def test_search(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed search - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.audit.search("data")) - - # Test success search - audit_resp = { - "audits": [ - { - "projectId": "p", - "userId": "u1", - "action": "a1", - "externalIds": ["e1"], - "occurred": str(datetime.now().timestamp() * 1000), - }, - { - "projectId": "p", - "userId": "u2", - "action": "a2", - "externalIds": ["e2"], - "occurred": str(datetime.now().timestamp() * 1000), - }, - ] - } - with client.mock_mgmt_post(make_response(audit_resp)) as mock_post: - resp = await client.invoke(client.mgmt.audit.search()) - audits = resp["audits"] - assert len(audits) == 2 - assert audits[0]["loginIds"][0] == "e1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.audit_search}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"noTenants": False}, - follow_redirects=False, - ) - - async def test_create_event(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed create_event - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.audit.create_event("a", "b", "c", "d")) - - # Test success create_event - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.audit.create_event( - action="pencil.created", - user_id="user-id", - actor_id="actor-id", - tenant_id="tenant-id", - type="info", - data={"some": "data"}, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.audit_create_event}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "action": "pencil.created", - "userId": "user-id", - "actorId": "actor-id", - "tenantId": "tenant-id", - "type": "info", - "data": {"some": "data"}, - }, - follow_redirects=False, - ) - - async def test_create_audit_webhook(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed create_audit_webhook - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.audit.create_audit_webhook("webhook-name")) - - # Test success create_audit_webhook - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.audit.create_audit_webhook( - name="webhook-name", - url="https://example.com/webhook", - headers={"Authorization": "Bearer token"}, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.audit_webhook_set_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "webhook-name", - "url": "https://example.com/webhook", - "headers": {"Authorization": "Bearer token"}, - }, - follow_redirects=False, - ) diff --git a/tests/management/test_authz.py b/tests/management/test_authz.py deleted file mode 100644 index 12dfb8f5f..000000000 --- a/tests/management/test_authz.py +++ /dev/null @@ -1,487 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - -AUTH_HEADERS = { - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, -} - - -class TestAuthz: - async def test_save_schema(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed save_schema - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.save_schema({}, True)) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.authz.save_schema({"name": "kuku"}, True)) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_schema_save}", - headers=AUTH_HEADERS, - params=None, - json={"schema": {"name": "kuku"}, "upgrade": True}, - follow_redirects=False, - ) - - async def test_delete_schema(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed delete_schema - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.delete_schema()) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.authz.delete_schema()) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_schema_delete}", - headers=AUTH_HEADERS, - params=None, - json=None, - follow_redirects=False, - ) - - async def test_load_schema(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed load_schema - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.load_schema()) - - # Test success flow - with client.mock_mgmt_post(make_response({"schema": {"name": "kuku"}})) as mock: - result = await client.invoke(client.mgmt.authz.load_schema()) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_schema_load}", - headers=AUTH_HEADERS, - params=None, - json=None, - follow_redirects=False, - ) - - async def test_save_namespace(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed save_namespace - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.save_namespace({})) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.authz.save_namespace({"name": "kuku"}, "old", "v1")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_ns_save}", - headers=AUTH_HEADERS, - params=None, - json={ - "namespace": {"name": "kuku"}, - "oldName": "old", - "schemaName": "v1", - }, - follow_redirects=False, - ) - - async def test_delete_namespace(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed delete_namespace - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.delete_namespace("a")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.authz.delete_namespace("a", "b")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_ns_delete}", - headers=AUTH_HEADERS, - params=None, - json={"name": "a", "schemaName": "b"}, - follow_redirects=False, - ) - - async def test_save_relation_definition(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed save_relation_definition - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.save_relation_definition({}, "a")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.authz.save_relation_definition({"name": "kuku"}, "a", "old", "v1")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_rd_save}", - headers=AUTH_HEADERS, - params=None, - json={ - "relationDefinition": {"name": "kuku"}, - "namespace": "a", - "oldName": "old", - "schemaName": "v1", - }, - follow_redirects=False, - ) - - async def test_delete_relation_definition(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed delete_relation_definition - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.delete_relation_definition("a", "b")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.authz.delete_relation_definition("a", "b", "c")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_rd_delete}", - headers=AUTH_HEADERS, - params=None, - json={"name": "a", "namespace": "b", "schemaName": "c"}, - follow_redirects=False, - ) - - async def test_create_relations(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed create_relations - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.create_relations([])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke( - client.mgmt.authz.create_relations( - [ - { - "resource": "r", - "relationDefinition": "rd", - "namespace": "ns", - "target": "u", - } - ] - ) - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_create}", - headers=AUTH_HEADERS, - params=None, - json={ - "relations": [ - { - "resource": "r", - "relationDefinition": "rd", - "namespace": "ns", - "target": "u", - } - ] - }, - follow_redirects=False, - ) - - async def test_delete_relations(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed delete_relations - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.delete_relations([])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke( - client.mgmt.authz.delete_relations( - [ - { - "resource": "r", - "relationDefinition": "rd", - "namespace": "ns", - "target": "u", - } - ] - ) - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_delete}", - headers=AUTH_HEADERS, - params=None, - json={ - "relations": [ - { - "resource": "r", - "relationDefinition": "rd", - "namespace": "ns", - "target": "u", - } - ] - }, - follow_redirects=False, - ) - - async def test_delete_relations_for_resources(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed delete_relations_for_resources - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.delete_relations_for_resources([])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.authz.delete_relations_for_resources(["r"])) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_delete_resources}", - headers=AUTH_HEADERS, - params=None, - json={"resources": ["r"]}, - follow_redirects=False, - ) - - async def test_has_relations(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed has_relations - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.has_relations([])) - - # Test success flow - with client.mock_mgmt_post(make_response({"relationQueries": []})) as mock: - result = await client.invoke( - client.mgmt.authz.has_relations( - [ - { - "resource": "r", - "relationDefinition": "rd", - "namespace": "ns", - "target": "u", - } - ] - ) - ) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_has_relations}", - headers=AUTH_HEADERS, - params=None, - json={ - "relationQueries": [ - { - "resource": "r", - "relationDefinition": "rd", - "namespace": "ns", - "target": "u", - } - ] - }, - follow_redirects=False, - ) - - async def test_who_can_access(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed who_can_access - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.who_can_access("a", "b", "c")) - - # Test success flow - with client.mock_mgmt_post(make_response({"targets": []})) as mock: - result = await client.invoke(client.mgmt.authz.who_can_access("a", "b", "c")) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_who}", - headers=AUTH_HEADERS, - params=None, - json={"resource": "a", "relationDefinition": "b", "namespace": "c"}, - follow_redirects=False, - ) - - async def test_resource_relations(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed resource_relations - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.resource_relations("a")) - - # Test success flow - with client.mock_mgmt_post(make_response({"relations": []})) as mock: - result = await client.invoke(client.mgmt.authz.resource_relations("a")) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_resource}", - headers=AUTH_HEADERS, - params=None, - json={"resource": "a"}, - follow_redirects=False, - ) - - async def test_targets_relations(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed targets_relations - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.targets_relations(["a"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"relations": []})) as mock: - result = await client.invoke(client.mgmt.authz.targets_relations(["a"])) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_targets}", - headers=AUTH_HEADERS, - params=None, - json={"targets": ["a"]}, - follow_redirects=False, - ) - - async def test_what_can_target_access(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed what_can_target_access - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.what_can_target_access("a")) - - # Test success flow - with client.mock_mgmt_post(make_response({"relations": []})) as mock: - result = await client.invoke(client.mgmt.authz.what_can_target_access("a")) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_target_all}", - headers=AUTH_HEADERS, - params=None, - json={"target": "a"}, - follow_redirects=False, - ) - - async def test_what_can_target_access_with_relation(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed what_can_target_access_with_relation - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.what_can_target_access_with_relation("a", "b", "c")) - - # Test success flow - with client.mock_mgmt_post(make_response({"relations": []})) as mock: - result = await client.invoke(client.mgmt.authz.what_can_target_access_with_relation("a", "b", "c")) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_target_with_relation}", - headers=AUTH_HEADERS, - params=None, - json={"target": "a", "relationDefinition": "b", "namespace": "c"}, - follow_redirects=False, - ) - - async def test_get_modified(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed get_modified - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.authz.get_modified()) - - # Test success flow - with client.mock_mgmt_post(make_response({"relations": []})) as mock: - result = await client.invoke(client.mgmt.authz.get_modified()) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.authz_get_modified}", - headers=AUTH_HEADERS, - params=None, - json={"since": 0}, - follow_redirects=False, - ) - - async def test_authz_cache_url_who_can_access(self, client_factory): - fga_cache_url = "https://my-fga-cache.example.com" - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) - - with client.mock_mgmt_post(make_response({"targets": ["u1"]})) as mock: - result = await client.invoke(client.mgmt.authz.who_can_access("a", "b", "c")) - assert_http_called( - mock, - client.mode, - f"{fga_cache_url}{MgmtV1.authz_re_who}", - headers=AUTH_HEADERS, - params=None, - json={ - "resource": "a", - "relationDefinition": "b", - "namespace": "c", - }, - follow_redirects=False, - ) - assert result == ["u1"] - - async def test_authz_cache_url_what_can_target_access(self, client_factory): - fga_cache_url = "https://my-fga-cache.example.com" - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) - - with client.mock_mgmt_post(make_response({"relations": [{"resource": "r1"}]})) as mock: - result = await client.invoke(client.mgmt.authz.what_can_target_access("a")) - assert_http_called( - mock, - client.mode, - f"{fga_cache_url}{MgmtV1.authz_re_target_all}", - headers=AUTH_HEADERS, - params=None, - json={"target": "a"}, - follow_redirects=False, - ) - assert result == [{"resource": "r1"}] diff --git a/tests/management/test_descoper.py b/tests/management/test_descoper.py deleted file mode 100644 index 9c26a336f..000000000 --- a/tests/management/test_descoper.py +++ /dev/null @@ -1,459 +0,0 @@ -import pytest - -from descope import ( - AuthException, - DescoperAttributes, - DescoperCreate, - DescoperProjectRole, - DescoperRBAC, - DescoperRole, - DescoperTagRole, -) -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -@pytest.mark.asyncio -class TestDescoper: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_put(make_response(status=400)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.descoper.create( - [ - DescoperCreate( - login_id="user1@example.com", - ) - ] - ) - ) - - # Test empty descopers - with pytest.raises(ValueError): - await client.invoke(client.mgmt.descoper.create([])) - - # Test success flow - with client.mock_mgmt_put( - make_response( - { - "descopers": [ - { - "id": "U2111111111111111111111111", - "attributes": { - "displayName": "Test User 2", - "email": "user2@example.com", - "phone": "+123456", - }, - "rbac": { - "isCompanyAdmin": False, - "tags": [], - "projects": [ - { - "projectIds": ["P2111111111111111111111111"], - "role": "admin", - } - ], - }, - "status": "invited", - } - ], - "total": 1, - } - ) - ) as mock_put: - resp = await client.invoke( - client.mgmt.descoper.create( - descopers=[ - DescoperCreate( - login_id="user1@example.com", - attributes=DescoperAttributes( - display_name="Test User 2", - phone="+123456", - email="user2@example.com", - ), - rbac=DescoperRBAC( - projects=[ - DescoperProjectRole( - project_ids=["P2111111111111111111111111"], - role=DescoperRole.ADMIN, - ) - ], - ), - ) - ], - ) - ) - descopers = resp["descopers"] - assert len(descopers) == 1 - assert descopers[0]["id"] == "U2111111111111111111111111" - assert resp["total"] == 1 - assert_http_called( - mock_put, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.descoper_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "descopers": [ - { - "loginId": "user1@example.com", - "attributes": { - "displayName": "Test User 2", - "email": "user2@example.com", - "phone": "+123456", - }, - "sendInvite": False, - "rbac": { - "isCompanyAdmin": False, - "tags": [], - "projects": [ - { - "projectIds": ["P2111111111111111111111111"], - "role": "admin", - } - ], - }, - } - ] - }, - follow_redirects=False, - ) - - async def test_create_with_tag_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow with tag roles - with client.mock_mgmt_put( - make_response( - { - "descopers": [ - { - "id": "U2111111111111111111111111", - "attributes": { - "displayName": "Test User", - "email": "user@example.com", - "phone": "", - }, - "rbac": { - "isCompanyAdmin": False, - "tags": [{"tags": ["tag1", "tag2"], "role": "auditor"}], - "projects": [], - }, - "status": "invited", - } - ], - "total": 1, - } - ) - ) as mock_put: - resp = await client.invoke( - client.mgmt.descoper.create( - descopers=[ - DescoperCreate( - login_id="user@example.com", - rbac=DescoperRBAC( - tags=[ - DescoperTagRole( - tags=["tag1", "tag2"], - role=DescoperRole.AUDITOR, - ) - ], - ), - ) - ], - ) - ) - descopers = resp["descopers"] - assert len(descopers) == 1 - assert len(descopers[0]["rbac"]["tags"]) == 1 - assert descopers[0]["rbac"]["tags"][0]["tags"] == ["tag1", "tag2"] - assert_http_called( - mock_put, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.descoper_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "descopers": [ - { - "loginId": "user@example.com", - "attributes": None, - "sendInvite": False, - "rbac": { - "isCompanyAdmin": False, - "tags": [{"tags": ["tag1", "tag2"], "role": "auditor"}], - "projects": [], - }, - } - ] - }, - follow_redirects=False, - ) - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=400)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.descoper.load("descoper-id")) - - # Test empty id - with pytest.raises(ValueError): - await client.invoke(client.mgmt.descoper.load("")) - - # Test success flow - with client.mock_mgmt_get( - make_response( - { - "descoper": { - "id": "U2222222222222222222222222", - "attributes": { - "displayName": "Test User 2", - "email": "user2@example.com", - "phone": "+123456", - }, - "rbac": { - "isCompanyAdmin": False, - "tags": [], - "projects": [ - { - "projectIds": ["P2111111111111111111111111"], - "role": "admin", - } - ], - }, - "status": "invited", - } - } - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.descoper.load("U2222222222222222222222222")) - descoper = resp["descoper"] - assert descoper["id"] == "U2222222222222222222222222" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.descoper_load_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"id": "U2222222222222222222222222"}, - follow_redirects=True, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_patch(make_response(status=400)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.descoper.update( - "descoper-id", - None, - DescoperRBAC(is_company_admin=True), - ) - ) - - # Test empty id - with pytest.raises(ValueError): - await client.invoke(client.mgmt.descoper.update("")) - - # Test success flow - with client.mock_mgmt_patch( - make_response( - { - "descoper": { - "id": "U2333333333333333333333333", - "attributes": { - "displayName": "Updated User", - "email": "user4@example.com", - "phone": "+1234358730", - }, - "rbac": { - "isCompanyAdmin": True, - "tags": [], - "projects": [], - }, - "status": "invited", - } - } - ) - ) as mock_patch: - resp = await client.invoke( - client.mgmt.descoper.update( - "U2333333333333333333333333", - DescoperAttributes("Updated User", "user4@example.com", "+1234358730"), - DescoperRBAC(is_company_admin=True), - ) - ) - descoper = resp["descoper"] - assert descoper["id"] == "U2333333333333333333333333" - assert descoper["rbac"]["isCompanyAdmin"] is True - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.descoper_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "U2333333333333333333333333", - "rbac": { - "isCompanyAdmin": True, - "tags": [], - "projects": [], - }, - "attributes": { - "displayName": "Updated User", - "email": "user4@example.com", - "phone": "+1234358730", - }, - }, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_delete(make_response(status=400)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.descoper.delete("descoper-id")) - - # Test empty id - with pytest.raises(ValueError): - await client.invoke(client.mgmt.descoper.delete("")) - - # Test success flow - with client.mock_mgmt_delete(make_response()) as mock_delete: - assert await client.invoke(client.mgmt.descoper.delete("U2111111111111111111111111")) is None - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.descoper_delete_path}", - params={"id": "U2111111111111111111111111"}, - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=False, - ) - - async def test_list(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=400)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.descoper.list()) - - # Test success flow - with client.mock_mgmt_post( - make_response( - { - "descopers": [ - { - "id": "U2444444444444444444444444", - "attributes": { - "displayName": "Admin User", - "email": "admin@example.com", - "phone": "", - }, - "rbac": { - "isCompanyAdmin": True, - "tags": [], - "projects": [], - }, - "status": "enabled", - }, - { - "id": "U2555555555555555555555555", - "attributes": { - "displayName": "Another User", - "email": "user3@example.com", - "phone": "+123456", - }, - "rbac": { - "isCompanyAdmin": False, - "tags": [], - "projects": [], - }, - "status": "invited", - }, - { - "id": "U2666666666666666666666666", - "attributes": { - "displayName": "Test User 1", - "email": "user2@example.com", - "phone": "+123456", - }, - "rbac": { - "isCompanyAdmin": False, - "tags": [], - "projects": [ - { - "projectIds": ["P2222222222222222222222222"], - "role": "admin", - } - ], - }, - "status": "invited", - }, - ], - "total": 3, - } - ) - ) as mock_post: - resp = await client.invoke(client.mgmt.descoper.list()) - descopers = resp["descopers"] - assert len(descopers) == 3 - assert resp["total"] == 3 - - # First descoper - company admin - assert descopers[0]["id"] == "U2444444444444444444444444" - assert descopers[0]["attributes"]["displayName"] == "Admin User" - assert descopers[0]["rbac"]["isCompanyAdmin"] is True - assert descopers[0]["status"] == "enabled" - - # Second descoper - assert descopers[1]["id"] == "U2555555555555555555555555" - assert descopers[1]["rbac"]["isCompanyAdmin"] is False - - # Third descoper - with project role - assert descopers[2]["id"] == "U2666666666666666666666666" - assert len(descopers[2]["rbac"]["projects"]) == 1 - - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.descoper_list_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={}, - follow_redirects=False, - ) diff --git a/tests/management/test_engine.py b/tests/management/test_engine.py deleted file mode 100644 index 02e6eea8e..000000000 --- a/tests/management/test_engine.py +++ /dev/null @@ -1,152 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - -MGMT_HEADERS = { - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, -} - -# The management Engine exposes only id/name/secret/createdTime; createdTime is an int32 -# epoch-seconds JSON number. -ENGINE_RESPONSE = { - "engine": { - "id": "eng1", - "name": "my-engine", - "secret": "s3cret", - "createdTime": 1719571200, - } -} - - -class TestEngine: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.engine.create("my-engine")) - - # Test success flow - with client.mock_mgmt_post(make_response(ENGINE_RESPONSE)) as mock_post: - resp = await client.invoke(client.mgmt.engine.create("my-engine")) - engine = resp["engine"] - assert engine["id"] == "eng1" - assert engine["secret"] == "s3cret" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.engine_create_path}", - headers=MGMT_HEADERS, - params=None, - json={"name": "my-engine"}, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.engine.update("eng1", "renamed")) - - with client.mock_mgmt_post(make_response({"engine": {"id": "eng1", "name": "renamed"}})) as mock_post: - resp = await client.invoke(client.mgmt.engine.update("eng1", "renamed")) - assert resp["engine"]["name"] == "renamed" - # Update never returns a secret. - assert "secret" not in resp["engine"] - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.engine_update_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "eng1", "name": "renamed"}, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.engine.delete("eng1")) - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.engine.delete("eng1")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.engine_delete_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "eng1"}, - follow_redirects=False, - ) - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.engine.load("eng1")) - - with client.mock_mgmt_get(make_response({"engine": {"id": "eng1", "name": "my-engine"}})) as mock_get: - resp = await client.invoke(client.mgmt.engine.load("eng1")) - assert resp["engine"]["id"] == "eng1" - # Load never returns a secret. - assert "secret" not in resp["engine"] - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.engine_load_path}", - headers=MGMT_HEADERS, - params={"id": "eng1"}, - follow_redirects=True, - ) - - async def test_load_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.engine.load_all()) - - with client.mock_mgmt_get(make_response({"engines": [{"id": "eng1"}, {"id": "eng2"}]})) as mock_get: - resp = await client.invoke(client.mgmt.engine.load_all()) - assert len(resp["engines"]) == 2 - assert resp["engines"][0]["id"] == "eng1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.engine_load_all_path}", - headers=MGMT_HEADERS, - params=None, - follow_redirects=True, - ) - - async def test_rotate_secret(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.engine.rotate_secret("eng1")) - - with client.mock_mgmt_post(make_response({"secret": "newS3cret"})) as mock_post: - resp = await client.invoke(client.mgmt.engine.rotate_secret("eng1")) - assert resp["secret"] == "newS3cret" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.engine_rotate_secret_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "eng1"}, - follow_redirects=False, - ) diff --git a/tests/management/test_family.py b/tests/management/test_family.py deleted file mode 100644 index 25d7513cc..000000000 --- a/tests/management/test_family.py +++ /dev/null @@ -1,436 +0,0 @@ -import pytest - -from descope import AuthException, CustomAttribute, CustomAttributeOption -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - -MGMT_HEADERS = { - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, -} - -FAMILY = { - "id": "f1", - "name": "Demo Family", - "customAttributes": {"plan": "free"}, - "disabled": False, - "photo": "https://example.com/photo.png", - "createdTime": 1700000000, -} - - -def assert_post(mock_post, mode, path, json): - assert_http_called( - mock_post, - mode, - f"{DEFAULT_BASE_URL}{path}", - headers=MGMT_HEADERS, - params=None, - json=json, - follow_redirects=False, - ) - - -def assert_get(mock_get, mode, path): - assert_http_called( - mock_get, - mode, - f"{DEFAULT_BASE_URL}{path}", - headers=MGMT_HEADERS, - params=None, - follow_redirects=True, - ) - - -class TestFamily: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.create("Demo Family")) - - # Test success flow, only the name - with client.mock_mgmt_post(make_response({"family": FAMILY})) as mock_post: - resp = await client.invoke(client.mgmt.family.create("Demo Family")) - assert resp["family"] == FAMILY - assert_post(mock_post, client.mode, MgmtV1.family_create_path, {"name": "Demo Family"}) - - # Test success flow, all fields - with client.mock_mgmt_post(make_response({"family": FAMILY})) as mock_post: - resp = await client.invoke( - client.mgmt.family.create( - "Demo Family", - id="f1", - custom_attributes={"plan": "free"}, - photo="https://example.com/photo.png", - disabled=False, - ) - ) - assert resp["family"]["id"] == "f1" - assert_post( - mock_post, - client.mode, - MgmtV1.family_create_path, - { - "name": "Demo Family", - "familyId": "f1", - "customAttributes": {"plan": "free"}, - "photo": "https://example.com/photo.png", - "disabled": False, - }, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.update("f1", "New Name")) - - # Test success flow, omitted fields are not sent - with client.mock_mgmt_post(make_response({"family": FAMILY})) as mock_post: - resp = await client.invoke(client.mgmt.family.update("f1", disabled=True)) - assert resp["family"] == FAMILY - assert_post(mock_post, client.mode, MgmtV1.family_update_path, {"id": "f1", "disabled": True}) - - # Test success flow, all fields - with client.mock_mgmt_post(make_response({"family": FAMILY})) as mock_post: - await client.invoke( - client.mgmt.family.update( - "f1", - name="New Name", - custom_attributes={"plan": "premium"}, - photo="https://example.com/new.png", - disabled=False, - ) - ) - assert_post( - mock_post, - client.mode, - MgmtV1.family_update_path, - { - "id": "f1", - "name": "New Name", - "customAttributes": {"plan": "premium"}, - "photo": "https://example.com/new.png", - "disabled": False, - }, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.delete("f1")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - resp = await client.invoke(client.mgmt.family.delete("f1")) - assert resp is None - assert_post(mock_post, client.mode, MgmtV1.family_delete_path, {"id": "f1"}) - - async def test_search_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.search_all()) - - # Test success flow, no filters returns all families - with client.mock_mgmt_post(make_response({"families": [FAMILY]})) as mock_post: - resp = await client.invoke(client.mgmt.family.search_all()) - assert resp["families"] == [FAMILY] - assert_post(mock_post, client.mode, MgmtV1.family_search_path, {}) - - # Test success flow, all filters - with client.mock_mgmt_post(make_response({"families": [FAMILY]})) as mock_post: - await client.invoke( - client.mgmt.family.search_all( - ids=["f1"], - names=["Demo Family"], - text="demo", - custom_attributes={"plan": "free"}, - page=0, - size=10, - ) - ) - assert_post( - mock_post, - client.mode, - MgmtV1.family_search_path, - { - "familyIds": ["f1"], - "freeText": "demo", - "familyNames": ["Demo Family"], - "page": 0, - "size": 10, - "customAttributes": {"plan": "free"}, - }, - ) - - async def test_create_dependent(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.create_dependent("f1", name="Demo Kid")) - - # Test success flow, only the family - user = {"userId": "u1", "loginIds": ["demo-kid"], "dependent": True} - with client.mock_mgmt_post(make_response({"user": user})) as mock_post: - resp = await client.invoke(client.mgmt.family.create_dependent("f1")) - assert resp["user"] == user - assert_post(mock_post, client.mode, MgmtV1.family_dependent_create_path, {"familyId": "f1"}) - - # Test success flow, all fields - with client.mock_mgmt_post(make_response({"user": user})) as mock_post: - await client.invoke( - client.mgmt.family.create_dependent( - "f1", - login_id="demo-kid", - name="Demo Kid", - email="guardian@example.com", - phone="+15555550100", - given_name="Demo", - middle_name="M", - family_name="Kid", - picture="https://example.com/kid.png", - custom_attributes={"ak": "av"}, - family_scoped_attributes={"nickname": "Kiddo"}, - ) - ) - assert_post( - mock_post, - client.mode, - MgmtV1.family_dependent_create_path, - { - "familyId": "f1", - "loginId": "demo-kid", - "name": "Demo Kid", - "email": "guardian@example.com", - "phone": "+15555550100", - "givenName": "Demo", - "middleName": "M", - "familyName": "Kid", - "picture": "https://example.com/kid.png", - "customAttributes": {"ak": "av"}, - "familyScopedAttributes": {"f1": {"nickname": "Kiddo"}}, - }, - ) - - async def test_delete_dependent(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.delete_dependent("u1")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - resp = await client.invoke(client.mgmt.family.delete_dependent("u1")) - assert resp is None - assert_post(mock_post, client.mode, MgmtV1.family_dependent_delete_path, {"userId": "u1"}) - - async def test_impersonate_dependent(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test empty input - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.impersonate_dependent("", "demo-kid")) - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.impersonate_dependent("guardian", "")) - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.impersonate_dependent("guardian", "demo-kid")) - - # Test success flow, no selected family - with client.mock_mgmt_post(make_response({"jwt": "imp-jwt"})) as mock_post: - jwt = await client.invoke(client.mgmt.family.impersonate_dependent("guardian", "demo-kid")) - assert jwt == "imp-jwt" - assert_post( - mock_post, - client.mode, - MgmtV1.family_impersonate_path, - {"impersonatorUserIdOrLoginId": "guardian", "dependentLoginId": "demo-kid"}, - ) - - # Test success flow, with selected family - with client.mock_mgmt_post(make_response({"jwt": "imp-jwt"})) as mock_post: - jwt = await client.invoke(client.mgmt.family.impersonate_dependent("guardian", "demo-kid", "f1")) - assert jwt == "imp-jwt" - assert_post( - mock_post, - client.mode, - MgmtV1.family_impersonate_path, - { - "impersonatorUserIdOrLoginId": "guardian", - "dependentLoginId": "demo-kid", - "selectedFamily": "f1", - }, - ) - - async def test_stop_impersonation(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test empty input - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.stop_impersonation("")) - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.stop_impersonation("imp-jwt")) - - # Test success flow, only the jwt - with client.mock_mgmt_post(make_response({"jwt": "own-jwt"})) as mock_post: - jwt = await client.invoke(client.mgmt.family.stop_impersonation("imp-jwt")) - assert jwt == "own-jwt" - assert_post(mock_post, client.mode, MgmtV1.family_stop_impersonation_path, {"jwt": "imp-jwt"}) - - # Test success flow, all fields - with client.mock_mgmt_post(make_response({"jwt": "own-jwt"})) as mock_post: - jwt = await client.invoke( - client.mgmt.family.stop_impersonation("imp-jwt", custom_claims={"k1": "v1"}, refresh_duration=300) - ) - assert jwt == "own-jwt" - assert_post( - mock_post, - client.mode, - MgmtV1.family_stop_impersonation_path, - {"jwt": "imp-jwt", "customClaims": {"k1": "v1"}, "refreshDuration": 300}, - ) - - async def test_get_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.get_settings()) - - # Test success flow - settings = {"enabled": True, "maxFamilyMembers": 6, "allowMultipleFamiliesUsers": False} - with client.mock_mgmt_get(make_response(settings)) as mock_get: - resp = await client.invoke(client.mgmt.family.get_settings()) - assert resp == settings - assert_get(mock_get, client.mode, MgmtV1.family_settings_path) - - async def test_configure_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.configure_settings(enabled=True)) - - settings = {"enabled": True, "maxFamilyMembers": 6, "allowMultipleFamiliesUsers": True} - - # Test success flow, partial update - with client.mock_mgmt_post(make_response(settings)) as mock_post: - resp = await client.invoke(client.mgmt.family.configure_settings(enabled=True)) - assert resp == settings - assert_post(mock_post, client.mode, MgmtV1.family_settings_path, {"enabled": True}) - - # Test success flow, all fields - with client.mock_mgmt_post(make_response(settings)) as mock_post: - await client.invoke( - client.mgmt.family.configure_settings( - enabled=True, max_family_members=6, allow_multiple_families_users=True - ) - ) - assert_post(mock_post, client.mode, MgmtV1.family_settings_path, settings) - - async def test_load_custom_attributes(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.load_custom_attributes()) - - # Test success flow - data = {"data": [{"name": "plan", "type": 1}]} - with client.mock_mgmt_get(make_response(data)) as mock_get: - resp = await client.invoke(client.mgmt.family.load_custom_attributes()) - assert resp == data - assert_get(mock_get, client.mode, MgmtV1.family_load_custom_attributes_path) - - async def test_create_custom_attributes(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - attributes = [ - CustomAttribute("plan", 1, display_name="Plan"), - CustomAttribute( - "tier", - 4, - display_name="Tier", - options=[CustomAttributeOption("gold", "Gold"), CustomAttributeOption("silver", "Silver")], - default_value="silver", - view_permissions=["view"], - edit_permissions=["edit"], - ), - ] - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.create_custom_attributes(attributes)) - - # Test success flow - data = {"data": [{"name": "plan", "type": 1}, {"name": "tier", "type": 4}]} - with client.mock_mgmt_post(make_response(data)) as mock_post: - resp = await client.invoke(client.mgmt.family.create_custom_attributes(attributes)) - assert resp == data - assert_post( - mock_post, - client.mode, - MgmtV1.family_create_custom_attributes_path, - { - "attributes": [ - {"name": "plan", "type": 1, "displayName": "Plan"}, - { - "name": "tier", - "type": 4, - "displayName": "Tier", - "options": [ - {"value": "gold", "label": "Gold"}, - {"value": "silver", "label": "Silver"}, - ], - "defaultValue": "silver", - "viewPermissions": ["view"], - "editPermissions": ["edit"], - }, - ] - }, - ) - - async def test_delete_custom_attributes(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.family.delete_custom_attributes(["plan"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"data": []})) as mock_post: - resp = await client.invoke(client.mgmt.family.delete_custom_attributes(["plan"])) - assert resp == {"data": []} - assert_post( - mock_post, - client.mode, - MgmtV1.family_delete_custom_attributes_path, - {"names": ["plan"]}, - ) diff --git a/tests/management/test_fga.py b/tests/management/test_fga.py deleted file mode 100644 index 4c3e98da7..000000000 --- a/tests/management/test_fga.py +++ /dev/null @@ -1,323 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - -MGMT_HEADERS = { - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, -} - -TUPLE = { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", -} - - -class TestFGA: - async def test_save_schema(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed save_schema - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.save_schema("")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.fga.save_schema("model AuthZ 1.0")) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_save_schema}", - headers=MGMT_HEADERS, - params=None, - json={"dsl": "model AuthZ 1.0"}, - follow_redirects=False, - ) - - async def test_create_relations(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed create_relations - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.create_relations([])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.fga.create_relations([TUPLE])) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_create_relations}", - headers=MGMT_HEADERS, - params=None, - json={"tuples": [TUPLE]}, - follow_redirects=False, - ) - - async def test_delete_relations(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed delete_relations - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.delete_relations([])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.fga.delete_relations([TUPLE])) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_delete_relations}", - headers=MGMT_HEADERS, - params=None, - json={"tuples": [TUPLE]}, - follow_redirects=False, - ) - - async def test_check(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed check - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.check([])) - - # Test success flow - with client.mock_mgmt_post(make_response({"tuples": []})) as mock: - result = await client.invoke(client.mgmt.fga.check([TUPLE])) - assert result is not None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_check}", - headers=MGMT_HEADERS, - params=None, - json={"tuples": [TUPLE]}, - follow_redirects=False, - ) - - async def test_load_resources_details_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - response_body = { - "resourcesDetails": [ - {"resourceId": "r1", "resourceType": "type1", "displayName": "Name1"}, - {"resourceId": "r2", "resourceType": "type2", "displayName": "Name2"}, - ] - } - ids = [ - {"resourceId": "r1", "resourceType": "type1"}, - {"resourceId": "r2", "resourceType": "type2"}, - ] - with client.mock_mgmt_post(make_response(response_body)) as mock: - details = await client.invoke(client.mgmt.fga.load_resources_details(ids)) - assert details == response_body["resourcesDetails"] - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_resources_load}", - headers=MGMT_HEADERS, - params=None, - json={"resourceIdentifiers": ids}, - follow_redirects=False, - ) - - async def test_load_resources_details_error(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - ids = [{"resourceId": "r1", "resourceType": "type1"}] - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.load_resources_details(ids)) - - async def test_save_resources_details_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - details = [{"resourceId": "r1", "resourceType": "type1", "displayName": "Name1"}] - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.fga.save_resources_details(details)) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_resources_save}", - headers=MGMT_HEADERS, - params=None, - json={"resourcesDetails": details}, - follow_redirects=False, - ) - - async def test_save_resources_details_error(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - details = [{"resourceId": "r1", "resourceType": "type1", "displayName": "Name1"}] - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.save_resources_details(details)) - - async def test_load_mappable_schema(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed load_mappable_schema - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.load_mappable_schema("tenant1")) - - # Test success flow - response_body = {"schema": "test"} - with client.mock_mgmt_get(make_response(response_body)) as mock: - result = await client.invoke(client.mgmt.fga.load_mappable_schema("tenant1", resources_limit=10)) - assert result == response_body - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_mappable_schema_path}", - headers=MGMT_HEADERS, - params={"tenantId": "tenant1", "resourcesLimit": "10"}, - follow_redirects=True, - ) - - async def test_load_mappable_resources(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed load_mappable_resources - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.load_mappable_resources("tenant1", [])) - - # Test success flow - response_body = {"mappableResources": [{"id": "r1"}]} - with client.mock_mgmt_post(make_response(response_body)) as mock: - result = await client.invoke( - client.mgmt.fga.load_mappable_resources("tenant1", [{"query": "test"}], resources_limit=5) - ) - assert result == response_body["mappableResources"] - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_mappable_resources_path}", - headers=MGMT_HEADERS, - params=None, - json={"tenantId": "tenant1", "resourcesQueries": [{"query": "test"}], "resourcesLimit": "5"}, - follow_redirects=False, - ) - - async def test_save_schema_dryrun(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed save_schema_dryrun - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.fga.save_schema_dryrun("")) - - # Test success flow - response_body = {"valid": True} - with client.mock_mgmt_post(make_response(response_body)) as mock: - result = await client.invoke(client.mgmt.fga.save_schema_dryrun("model AuthZ 1.0")) - assert result == response_body - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_schema_dryrun_path}", - headers=MGMT_HEADERS, - params=None, - json={"dsl": "model AuthZ 1.0"}, - follow_redirects=False, - ) - - async def test_fga_cache_url_save_schema(self, client_factory): - fga_cache_url = "https://my-fga-cache.example.com" - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) - - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.fga.save_schema("model AuthZ 1.0")) - assert_http_called( - mock, - client.mode, - f"{fga_cache_url}{MgmtV1.fga_save_schema}", - headers=MGMT_HEADERS, - params=None, - json={"dsl": "model AuthZ 1.0"}, - follow_redirects=False, - ) - - async def test_fga_cache_url_create_relations(self, client_factory): - fga_cache_url = "https://my-fga-cache.example.com" - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) - - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.fga.create_relations([TUPLE])) - assert_http_called( - mock, - client.mode, - f"{fga_cache_url}{MgmtV1.fga_create_relations}", - headers=MGMT_HEADERS, - params=None, - json={"tuples": [TUPLE]}, - follow_redirects=False, - ) - - async def test_fga_cache_url_delete_relations(self, client_factory): - fga_cache_url = "https://my-fga-cache.example.com" - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) - - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.fga.delete_relations([TUPLE])) - assert_http_called( - mock, - client.mode, - f"{fga_cache_url}{MgmtV1.fga_delete_relations}", - headers=MGMT_HEADERS, - params=None, - json={"tuples": [TUPLE]}, - follow_redirects=False, - ) - - async def test_fga_cache_url_check(self, client_factory): - fga_cache_url = "https://my-fga-cache.example.com" - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) - - response_body = { - "tuples": [ - { - "allowed": True, - "tuple": TUPLE, - } - ] - } - - with client.mock_mgmt_post(make_response(response_body)) as mock: - result = await client.invoke(client.mgmt.fga.check([TUPLE])) - assert_http_called( - mock, - client.mode, - f"{fga_cache_url}{MgmtV1.fga_check}", - headers=MGMT_HEADERS, - params=None, - json={"tuples": [TUPLE]}, - follow_redirects=False, - ) - assert len(result) == 1 - assert result[0]["allowed"] is True - assert result[0]["relation"] == TUPLE - - async def test_fga_without_cache_url_uses_default_base_url(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock: - await client.invoke(client.mgmt.fga.save_schema("model AuthZ 1.0")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.fga_save_schema}", - headers=MGMT_HEADERS, - params=None, - json={"dsl": "model AuthZ 1.0"}, - follow_redirects=False, - ) diff --git a/tests/management/test_flow.py b/tests/management/test_flow.py deleted file mode 100644 index 93065148a..000000000 --- a/tests/management/test_flow.py +++ /dev/null @@ -1,370 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import FlowRunOptions, MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestFlow: - async def test_list_flows(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.list_flows()) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.list_flows()) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_list_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json=None, - follow_redirects=False, - ) - - async def test_delete_flows(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed delete flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.delete_flows(["flow-1", "flow-2"])) - - # Test success delete flows - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.delete_flows(["flow-1", "flow-2"])) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"ids": ["flow-1", "flow-2"]}, - follow_redirects=False, - ) - - async def test_export_flow(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.export_flow("name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.export_flow("test")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_export_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"flowId": "test"}, - follow_redirects=False, - ) - - async def test_import_flow(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.import_flow("name", {"name": "test"}, [{"id": "test"}])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.import_flow("name", {"name": "test"}, [{"id": "test"}])) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_import_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "flowId": "name", - "flow": {"name": "test"}, - "screens": [{"id": "test"}], - }, - follow_redirects=False, - ) - - async def test_export_theme(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.export_theme()) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.export_theme()) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.theme_export_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={}, - follow_redirects=False, - ) - - async def test_import_theme(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.import_theme({"id": "test"})) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.import_theme({"id": "test"})) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.theme_import_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"theme": {"id": "test"}}, - follow_redirects=False, - ) - - async def test_run_flow(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed run flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.run_flow("test-flow")) - - # Test success run flow with no options - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.run_flow("test-flow")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"flowId": "test-flow"}, - follow_redirects=False, - ) - - # Test success run flow with dict options - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.flow.run_flow( - "test-flow", - {"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"}, - ) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "flowId": "test-flow", - "input": {"key": "value"}, - "preview": True, - "tenant": "tenant-id", - }, - follow_redirects=False, - ) - - # Test success run flow with FlowRunOptions object - with client.mock_mgmt_post(make_response()) as mock_post: - options = FlowRunOptions( - flow_input={"key": "value"}, - preview=True, - tenant="tenant-id", - ) - result = await client.invoke(client.mgmt.flow.run_flow("test-flow", options)) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "flowId": "test-flow", - "input": {"key": "value"}, - "preview": True, - "tenant": "tenant-id", - }, - follow_redirects=False, - ) - - async def test_flow_run_options_from_dict(self, client_factory): - # Test from_dict with None returns None - assert FlowRunOptions.from_dict(None) is None - - # Test from_dict with valid dict - options = FlowRunOptions.from_dict({"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"}) - assert options is not None - assert options.flow_input == {"key": "value"} - assert options.preview - assert options.tenant == "tenant-id" - - async def test_run_flow_async(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed run flow async - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.run_flow_async("test-flow")) - - # Test success run flow async with no options - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.run_flow_async("test-flow")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"flowId": "test-flow"}, - follow_redirects=False, - ) - - # Test success run flow async with dict options - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.flow.run_flow_async( - "test-flow", - {"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"}, - ) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "flowId": "test-flow", - "input": {"key": "value"}, - "preview": True, - "tenant": "tenant-id", - }, - follow_redirects=False, - ) - - # Test success run flow async with FlowRunOptions object - with client.mock_mgmt_post(make_response()) as mock_post: - options = FlowRunOptions( - flow_input={"key": "value"}, - preview=True, - tenant="tenant-id", - ) - result = await client.invoke(client.mgmt.flow.run_flow_async("test-flow", options)) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "flowId": "test-flow", - "input": {"key": "value"}, - "preview": True, - "tenant": "tenant-id", - }, - follow_redirects=False, - ) - - async def test_get_flow_async_result(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed get flow async result - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.flow.get_flow_async_result("execution-123")) - - # Test success get flow async result - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.flow.get_flow_async_result("execution-123")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.flow_async_result_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"executionId": "execution-123"}, - follow_redirects=False, - ) diff --git a/tests/management/test_group.py b/tests/management/test_group.py deleted file mode 100644 index 451b703c6..000000000 --- a/tests/management/test_group.py +++ /dev/null @@ -1,127 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestGroup: - async def test_load_all_groups(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.group.load_all_groups("tenant_id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert await client.invoke(client.mgmt.group.load_all_groups("someTenantId")) is not None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.group_load_all_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "someTenantId", - }, - follow_redirects=False, - ) - - async def test_load_all_groups_with_sso_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # sso_id scopes the load to one SSO configuration; it is sent only when provided - # (the exact-match json assertion in test_load_all_groups proves it is absent otherwise) - with client.mock_mgmt_post(make_response({})) as mock_post: - assert ( - await client.invoke(client.mgmt.group.load_all_groups("someTenantId", sso_id="sso-config-1")) - is not None - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.group_load_all_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "someTenantId", - "ssoId": "sso-config-1", - }, - follow_redirects=False, - ) - - async def test_load_all_groups_for_members(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.group.load_all_groups_for_members("tenant_id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert ( - await client.invoke( - client.mgmt.group.load_all_groups_for_members("someTenantId", ["one", "two"], ["three", "four"]) - ) - is not None - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.group_load_all_for_member_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "someTenantId", - "loginIds": ["three", "four"], - "userIds": ["one", "two"], - }, - follow_redirects=False, - ) - - async def test_load_all_group_members(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.group.load_all_group_members("tenant_id", "group_id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert ( - await client.invoke(client.mgmt.group.load_all_group_members("someTenantId", "someGroupId")) is not None - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.group_load_all_group_members_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "someTenantId", - "groupId": "someGroupId", - }, - follow_redirects=False, - ) diff --git a/tests/management/test_jwt.py b/tests/management/test_jwt.py deleted file mode 100644 index e9526c250..000000000 --- a/tests/management/test_jwt.py +++ /dev/null @@ -1,344 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtLoginOptions, MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestJWT: - async def test_update_jwt(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response({}, status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.update_jwt("jwt", {"k1": "v1"}, 0)) - - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.update_jwt("", {"k1": "v1"}, 0)) - - # Test success flow - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - resp = await client.invoke(client.mgmt.jwt.update_jwt("test", {"k1": "v1"}, 40)) - assert resp == "response" - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.update_jwt_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "jwt": "test", - "customClaims": {"k1": "v1"}, - "refreshDuration": 40, - }, - follow_redirects=False, - params=None, - ) - - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - resp = await client.invoke(client.mgmt.jwt.update_jwt("test", {"k1": "v1"})) - assert resp == "response" - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.update_jwt_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "jwt": "test", - "customClaims": {"k1": "v1"}, - "refreshDuration": 0, - }, - follow_redirects=False, - params=None, - ) - - async def test_impersonate(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response({}, status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.impersonate("imp1", "imp2", False)) - - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.impersonate("", "imp2", False)) - - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.impersonate("imp1", "", False)) - - # Test success flow - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - resp = await client.invoke(client.mgmt.jwt.impersonate("imp1", "imp2", True)) - assert resp == "response" - expected_uri = f"{DEFAULT_BASE_URL}{MgmtV1.impersonate_path}" - assert_http_called( - mock, - client.mode, - expected_uri, - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "imp2", - "impersonatorId": "imp1", - "validateConsent": True, - "customClaims": None, - "selectedTenant": None, - "refreshDuration": None, - "stepup": None, - }, - follow_redirects=False, - params=None, - ) - - # Test stepup flow - with client.mock_mgmt_post(make_response({"jwt": "stepup_response"})) as mock: - resp = await client.invoke(client.mgmt.jwt.impersonate("imp1", "imp2", True, stepup=True)) - assert resp == "stepup_response" - assert_http_called( - mock, - client.mode, - expected_uri, - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "imp2", - "impersonatorId": "imp1", - "validateConsent": True, - "customClaims": None, - "selectedTenant": None, - "refreshDuration": None, - "stepup": True, - }, - follow_redirects=False, - params=None, - ) - - async def test_stop_impersonation(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response({}, status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.stop_impersonation("")) - - # Test success flow - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - resp = await client.invoke(client.mgmt.jwt.stop_impersonation("jwtstr")) - assert resp == "response" - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.stop_impersonation_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "jwt": "jwtstr", - "customClaims": None, - "selectedTenant": None, - "refreshDuration": None, - }, - follow_redirects=False, - params=None, - ) - - async def test_sign_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.sign_in("")) - - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.sign_in("loginId", MgmtLoginOptions(mfa=True))) - - # Test success flow - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - await client.invoke(client.mgmt.jwt.sign_in("loginId")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_in_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "loginId", - "stepup": False, - "mfa": False, - "revokeOtherSessions": None, - "customClaims": None, - "jwt": None, - "refreshDuration": None, - }, - follow_redirects=False, - params=None, - ) - - async def test_sign_up(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.sign_up("")) - - # Test success flow - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - await client.invoke(client.mgmt.jwt.sign_up("loginId")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_up_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "loginId", - "user": { - "name": None, - "givenName": None, - "middleName": None, - "familyName": None, - "phone": None, - "email": None, - "emailVerified": None, - "phoneVerified": None, - "ssoAppId": None, - }, - "emailVerified": None, - "phoneVerified": None, - "ssoAppId": None, - "customClaims": None, - "refreshDuration": None, - }, - follow_redirects=False, - params=None, - ) - - async def test_sign_up_or_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.sign_up_or_in("")) - - # Test success flow - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - await client.invoke(client.mgmt.jwt.sign_up_or_in("loginId")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_up_or_in_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "loginId", - "user": { - "name": None, - "givenName": None, - "middleName": None, - "familyName": None, - "phone": None, - "email": None, - "emailVerified": None, - "phoneVerified": None, - "ssoAppId": None, - }, - "emailVerified": None, - "phoneVerified": None, - "ssoAppId": None, - "customClaims": None, - "refreshDuration": None, - }, - follow_redirects=False, - params=None, - ) - - async def test_impersonate_stepup(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response({}, status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.impersonate_stepup("imp1", "imp2", False)) - - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.impersonate_stepup("", "imp2", False)) - - with pytest.raises(AuthException): - await client.invoke(client.mgmt.jwt.impersonate_stepup("imp1", "", False)) - - # Test success flow - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - resp = await client.invoke(client.mgmt.jwt.impersonate_stepup("imp1", "imp2", True)) - assert resp == "response" - expected_uri = f"{DEFAULT_BASE_URL}{MgmtV1.impersonate_stepup_path}" - assert_http_called( - mock, - client.mode, - expected_uri, - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "imp2", - "impersonatorId": "imp1", - "validateConsent": True, - "customClaims": None, - "selectedTenant": None, - "refreshDuration": None, - }, - follow_redirects=False, - params=None, - ) - - async def test_anonymous(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow - with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: - await client.invoke(client.mgmt.jwt.anonymous({"k1": "v1"}, "id")) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.anonymous_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "customClaims": {"k1": "v1"}, - "selectedTenant": "id", - "refreshDuration": None, - }, - follow_redirects=False, - params=None, - ) diff --git a/tests/management/test_license.py b/tests/management/test_license.py deleted file mode 100644 index ef8ea2273..000000000 --- a/tests/management/test_license.py +++ /dev/null @@ -1,51 +0,0 @@ -from unittest import mock - -import pytest - -from descope import AuthException, DescopeClient -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -# Infrastructure tests — sync only, test HTTP client internals directly -class TestLicenseInfra: - def test_header_injected_after_handshake(self): - client = DescopeClient(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Simulate a completed handshake by setting the cached tier directly. - client._mgmt_http_client.rate_limit_tier = "tier2" - headers = client._mgmt_http_client._get_default_headers() - assert headers.get("x-descope-license") == "tier2" - - def test_header_absent_when_tier_not_cached(self): - client = DescopeClient(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Default state has no rate limit tier yet. - client._mgmt_http_client.rate_limit_tier = None - headers = client._mgmt_http_client._get_default_headers() - assert "x-descope-license" not in headers - - -# Parametrized async-style tests for management API behavior -@pytest.mark.asyncio -class TestLicense: - async def test_get_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with client.mock_mgmt_get(make_response(status=400)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.license.get()) - - async def test_get_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with client.mock_mgmt_get(make_response({"rateLimitTier": "tier4"})) as mock_get: - resp = await client.invoke(client.mgmt.license.get()) - assert resp == {"rateLimitTier": "tier4"} - - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.license_get_path}", - headers=mock.ANY, - params=None, - follow_redirects=True, - ) diff --git a/tests/management/test_lists.py b/tests/management/test_lists.py deleted file mode 100644 index faeefd9a2..000000000 --- a/tests/management/test_lists.py +++ /dev/null @@ -1,351 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - -MGMT_HEADERS = { - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, -} - -LIST_RESPONSE = { - "list": { - "id": "list1", - "name": "my-list", - "description": "Test list", - "type": "texts", - "data": ["item1", "item2"], - "createdTime": 1719571200, - } -} - - -class TestLists: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.create("my-list", "texts", "Test list", ["item1"])) - - # Test success flow - with client.mock_mgmt_post(make_response(LIST_RESPONSE)) as mock_post: - resp = await client.invoke(client.mgmt.list.create("my-list", "texts", "Test list", ["item1"])) - assert resp["list"]["id"] == "list1" - assert resp["list"]["name"] == "my-list" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "name": "my-list", - "type": "texts", - "description": "Test list", - "data": ["item1"], - }, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.update("list1", "renamed", "ips", None, ["1.2.3.4"])) - - with client.mock_mgmt_post(make_response({"list": {"id": "list1", "name": "renamed"}})) as mock_post: - resp = await client.invoke(client.mgmt.list.update("list1", "renamed", "ips", None, ["1.2.3.4"])) - assert resp["list"]["name"] == "renamed" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_update_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "name": "renamed", "type": "ips", "data": ["1.2.3.4"]}, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.delete("list1")) - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.list.delete("list1")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_delete_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1"}, - follow_redirects=False, - ) - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.load("list1")) - - with client.mock_mgmt_get(make_response({"list": {"id": "list1", "name": "my-list"}})) as mock_get: - resp = await client.invoke(client.mgmt.list.load("list1")) - assert resp["list"]["id"] == "list1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_path}", - headers=MGMT_HEADERS, - params={"id": "list1"}, - follow_redirects=True, - ) - - async def test_load_by_name(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.load_by_name("my-list")) - - with client.mock_mgmt_get(make_response({"list": {"id": "list1", "name": "my-list"}})) as mock_get: - resp = await client.invoke(client.mgmt.list.load_by_name("my-list")) - assert resp["list"]["name"] == "my-list" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_name_path}", - headers=MGMT_HEADERS, - params={"name": "my-list"}, - follow_redirects=True, - ) - - async def test_load_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.load_all()) - - with client.mock_mgmt_get(make_response({"lists": [{"id": "list1"}, {"id": "list2"}]})) as mock_get: - resp = await client.invoke(client.mgmt.list.load_all()) - assert len(resp["lists"]) == 2 - assert resp["lists"][0]["id"] == "list1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_all_path}", - headers=MGMT_HEADERS, - params=None, - follow_redirects=True, - ) - - async def test_import_lists(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - lists_to_import = [ - {"id": "list1", "name": "List 1", "type": "texts", "data": ["a", "b"]}, - {"id": "list2", "name": "List 2", "type": "ips", "data": ["1.2.3.4"]}, - ] - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.import_lists(lists_to_import)) - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.list.import_lists(lists_to_import)) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_import_path}", - headers=MGMT_HEADERS, - params=None, - json={"lists": lists_to_import}, - follow_redirects=False, - ) - - async def test_add_ips(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.add_ips("list1", ["1.2.3.4", "5.6.7.8"])) - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.list.add_ips("list1", ["1.2.3.4", "5.6.7.8"])) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_ip_add_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "ips": ["1.2.3.4", "5.6.7.8"]}, - follow_redirects=False, - ) - - async def test_remove_ips(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.remove_ips("list1", ["1.2.3.4"])) - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.list.remove_ips("list1", ["1.2.3.4"])) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_ip_remove_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "ips": ["1.2.3.4"]}, - follow_redirects=False, - ) - - async def test_check_ip(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.check_ip("list1", "1.2.3.4")) - - with client.mock_mgmt_post(make_response({"exists": True})) as mock_post: - result = await client.invoke(client.mgmt.list.check_ip("list1", "1.2.3.4")) - assert result is True - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_ip_check_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "ip": "1.2.3.4"}, - follow_redirects=False, - ) - - with client.mock_mgmt_post(make_response({"exists": False})) as mock_post: - result = await client.invoke(client.mgmt.list.check_ip("list1", "1.2.3.4")) - assert result is False - - async def test_add_texts(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.add_texts("list1", ["text1", "text2"])) - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.list.add_texts("list1", ["text1", "text2"])) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_text_add_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "texts": ["text1", "text2"]}, - follow_redirects=False, - ) - - async def test_remove_texts(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.remove_texts("list1", ["text1"])) - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.list.remove_texts("list1", ["text1"])) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_text_remove_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "texts": ["text1"]}, - follow_redirects=False, - ) - - async def test_check_text(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.check_text("list1", "text1")) - - with client.mock_mgmt_post(make_response({"exists": True})) as mock_post: - result = await client.invoke(client.mgmt.list.check_text("list1", "text1")) - assert result is True - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_text_check_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "text": "text1"}, - follow_redirects=False, - ) - - with client.mock_mgmt_post(make_response({"exists": False})) as mock_post: - result = await client.invoke(client.mgmt.list.check_text("list1", "text1")) - assert result is False - - async def test_check_texts(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.check_texts("list1", ["text1", "text2"])) - - with client.mock_mgmt_post(make_response({"exists": True})) as mock_post: - result = await client.invoke(client.mgmt.list.check_texts("list1", ["text1", "text2"])) - assert result is True - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_text_check_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "texts": ["text1", "text2"], "matchAll": False}, - follow_redirects=False, - ) - - with client.mock_mgmt_post(make_response({"exists": False})) as mock_post: - result = await client.invoke(client.mgmt.list.check_texts("list1", ["text1", "text2"], match_all=True)) - assert result is False - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_text_check_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1", "texts": ["text1", "text2"], "matchAll": True}, - follow_redirects=False, - ) - - async def test_clear(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.list.clear("list1")) - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.list.clear("list1")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.list_clear_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "list1"}, - follow_redirects=False, - ) diff --git a/tests/management/test_mgmtkey.py b/tests/management/test_mgmtkey.py deleted file mode 100644 index 3256452a0..000000000 --- a/tests/management/test_mgmtkey.py +++ /dev/null @@ -1,490 +0,0 @@ -import pytest - -from descope import ( - MgmtKeyProjectRole, - MgmtKeyReBac, - MgmtKeyStatus, - MgmtKeyTagRole, -) -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestManagementKey: - async def test_create_empty_name(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with pytest.raises(ValueError) as exc_info: - await client.invoke( - client.mgmt.management_key.create( - name="", - rebac=MgmtKeyReBac(company_roles=["role1"]), - ) - ) - assert str(exc_info.value) == "name cannot be empty" - - async def test_create_none_rebac(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with pytest.raises(ValueError) as exc_info: - await client.invoke( - client.mgmt.management_key.create( - name="test-key", - rebac=None, - ) - ) - assert str(exc_info.value) == "rebac cannot be empty" - - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow - with client.mock_mgmt_put( - make_response( - { - "cleartext": "cleartext-secret", - "key": { - "id": "mk1", - "name": "test-key", - "description": "test key", - "permittedIps": ["10.0.0.1"], - "status": "active", - "createdTime": 1764849768, - "expireTime": 3600, - "reBac": { - "companyRoles": ["role1"], - "projectRoles": [], - "tagRoles": [], - }, - "version": 1, - "authzVersion": 1, - }, - } - ) - ) as mock_put: - resp = await client.invoke( - client.mgmt.management_key.create( - name="test-key", - rebac=MgmtKeyReBac(company_roles=["role1"]), - description="test key", - expires_in=3600, - permitted_ips=["10.0.0.1"], - ) - ) - assert resp["cleartext"] == "cleartext-secret" - key = resp["key"] - assert key["name"] == "test-key" - assert key["description"] == "test key" - assert len(key["permittedIps"]) == 1 - assert key["permittedIps"][0] == "10.0.0.1" - assert key["expireTime"] == 3600 - assert key["reBac"] is not None - assert len(key["reBac"]["companyRoles"]) == 1 - assert key["reBac"]["companyRoles"][0] == "role1" - assert_http_called( - mock_put, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "test-key", - "description": "test key", - "expiresIn": 3600, - "permittedIps": ["10.0.0.1"], - "reBac": { - "companyRoles": ["role1"], - }, - }, - follow_redirects=False, - ) - - async def test_create_with_project_and_tag_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow with project_roles and tag_roles - with client.mock_mgmt_put( - make_response( - { - "cleartext": "cleartext-secret", - "key": { - "id": "mk1", - "name": "test-key", - "description": "test key", - "permittedIps": [], - "status": "active", - "createdTime": 1764849768, - "expireTime": 0, - "reBac": { - "companyRoles": [], - "projectRoles": [{"projectIds": ["proj1"], "roles": ["admin"]}], - "tagRoles": [{"tags": ["tag1"], "roles": ["viewer"]}], - }, - "version": 1, - "authzVersion": 1, - }, - } - ) - ) as mock_put: - resp = await client.invoke( - client.mgmt.management_key.create( - name="test-key", - rebac=MgmtKeyReBac( - project_roles=[MgmtKeyProjectRole(project_ids=["proj1"], roles=["admin"])], - tag_roles=[MgmtKeyTagRole(tags=["tag1"], roles=["viewer"])], - ), - ) - ) - assert resp["cleartext"] == "cleartext-secret" - key = resp["key"] - assert key["name"] == "test-key" - assert len(key["reBac"]["projectRoles"]) == 1 - assert key["reBac"]["projectRoles"][0]["projectIds"] == ["proj1"] - assert len(key["reBac"]["tagRoles"]) == 1 - assert key["reBac"]["tagRoles"][0]["tags"] == ["tag1"] - assert_http_called( - mock_put, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "test-key", - "description": None, - "expiresIn": 0, - "permittedIps": [], - "reBac": { - "projectRoles": [{"projectIds": ["proj1"], "roles": ["admin"]}], - "tagRoles": [{"tags": ["tag1"], "roles": ["viewer"]}], - }, - }, - follow_redirects=False, - ) - - async def test_update_empty_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with pytest.raises(ValueError) as exc_info: - await client.invoke( - client.mgmt.management_key.update( - id="", - name="updated-key", - description="updated key", - permitted_ips=["1.2.3.4"], - status=MgmtKeyStatus.INACTIVE, - ) - ) - assert str(exc_info.value) == "id cannot be empty" - - async def test_update_empty_name(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with pytest.raises(ValueError) as exc_info: - await client.invoke( - client.mgmt.management_key.update( - id="mk1", - name="", - description="updated key", - permitted_ips=["1.2.3.4"], - status=MgmtKeyStatus.INACTIVE, - ) - ) - assert str(exc_info.value) == "name cannot be empty" - - async def test_update_none_status(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with pytest.raises(ValueError) as exc_info: - await client.invoke( - client.mgmt.management_key.update( - id="mk1", - name="updated-key", - description="updated key", - permitted_ips=["1.2.3.4"], - status=None, - ) - ) - assert str(exc_info.value) == "status cannot be empty" - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow - with client.mock_mgmt_patch( - make_response( - { - "key": { - "id": "mk1", - "name": "updated-key", - "description": "updated key", - "permittedIps": ["1.2.3.4"], - "status": "inactive", - "createdTime": 1764673442, - "expireTime": 0, - "reBac": { - "companyRoles": [], - "projectRoles": [], - "tagRoles": [], - }, - "version": 22, - "authzVersion": 1, - }, - } - ) - ) as mock_patch: - resp = await client.invoke( - client.mgmt.management_key.update( - id="mk1", - name="updated-key", - description="updated key", - permitted_ips=["1.2.3.4"], - status=MgmtKeyStatus.INACTIVE, - ) - ) - key = resp["key"] - assert key["id"] == "mk1" - assert key["name"] == "updated-key" - assert key["description"] == "updated key" - assert len(key["permittedIps"]) == 1 - assert key["permittedIps"][0] == "1.2.3.4" - assert key["status"] == "inactive" - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - # reBac is omitted when rebac is not provided, so the key keeps its roles - json={ - "id": "mk1", - "name": "updated-key", - "description": "updated key", - "permittedIps": ["1.2.3.4"], - "status": "inactive", - }, - follow_redirects=False, - ) - - async def test_update_with_rebac(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow with rebac - with client.mock_mgmt_patch( - make_response( - { - "key": { - "id": "mk1", - "name": "updated-key", - "description": "updated key", - "permittedIps": ["1.2.3.4"], - "status": "active", - "createdTime": 1764673442, - "expireTime": 0, - "reBac": { - "companyRoles": ["role1"], - "projectRoles": [{"projectIds": ["proj1"], "roles": ["admin"]}], - "tagRoles": [{"tags": ["tag1"], "roles": ["viewer"]}], - }, - "version": 23, - "authzVersion": 2, - }, - } - ) - ) as mock_patch: - resp = await client.invoke( - client.mgmt.management_key.update( - id="mk1", - name="updated-key", - description="updated key", - permitted_ips=["1.2.3.4"], - status=MgmtKeyStatus.ACTIVE, - rebac=MgmtKeyReBac( - company_roles=["role1"], - project_roles=[MgmtKeyProjectRole(project_ids=["proj1"], roles=["admin"])], - tag_roles=[MgmtKeyTagRole(tags=["tag1"], roles=["viewer"])], - ), - ) - ) - key = resp["key"] - assert key["id"] == "mk1" - assert key["reBac"]["companyRoles"] == ["role1"] - assert key["reBac"]["projectRoles"][0]["projectIds"] == ["proj1"] - assert key["reBac"]["tagRoles"][0]["tags"] == ["tag1"] - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "mk1", - "name": "updated-key", - "description": "updated key", - "permittedIps": ["1.2.3.4"], - "status": "active", - "reBac": { - "companyRoles": ["role1"], - "projectRoles": [{"projectIds": ["proj1"], "roles": ["admin"]}], - "tagRoles": [{"tags": ["tag1"], "roles": ["viewer"]}], - }, - }, - follow_redirects=False, - ) - - async def test_load_empty_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with pytest.raises(ValueError) as exc_info: - await client.invoke(client.mgmt.management_key.load("")) - assert str(exc_info.value) == "id cannot be empty" - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow - with client.mock_mgmt_get( - make_response( - { - "key": { - "id": "mk1", - "name": "test-key", - "description": "a key description", - "status": "active", - "createdTime": 1764677065, - "expireTime": 0, - "permittedIps": [], - "reBac": { - "companyRoles": [], - "projectRoles": [], - "tagRoles": [], - }, - "version": 1, - "authzVersion": 1, - }, - } - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.management_key.load("mk1")) - key = resp["key"] - assert key is not None - assert key["name"] == "test-key" - assert key["description"] == "a key description" - assert key["status"] == "active" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_load_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"id": "mk1"}, - follow_redirects=True, - ) - - async def test_delete_empty_ids(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with pytest.raises(ValueError) as exc_info: - await client.invoke(client.mgmt.management_key.delete([])) - assert str(exc_info.value) == "ids list cannot be empty" - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow - with client.mock_mgmt_post(make_response({"total": 2})) as mock_post: - resp = await client.invoke(client.mgmt.management_key.delete(["mk1", "mk2"])) - assert resp["total"] == 2 - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"ids": ["mk1", "mk2"]}, - follow_redirects=False, - ) - - async def test_search(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow - with client.mock_mgmt_get( - make_response( - { - "keys": [ - { - "id": "mk1", - "name": "key1", - "description": "", - "status": "active", - "createdTime": 1764677065, - "expireTime": 0, - "permittedIps": [], - "reBac": { - "companyRoles": [], - "projectRoles": [], - "tagRoles": [], - }, - "version": 1, - "authzVersion": 1, - }, - { - "id": "mk2", - "name": "key2", - "description": "", - "status": "inactive", - "createdTime": 1764773205, - "expireTime": 1234, - "permittedIps": [], - "reBac": { - "companyRoles": [], - "projectRoles": [], - "tagRoles": [], - }, - "version": 1, - "authzVersion": 1, - }, - ], - } - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.management_key.search()) - keys = resp["keys"] - assert keys is not None - assert len(keys) == 2 - assert keys[0]["id"] == "mk1" - assert keys[0]["name"] == "key1" - assert keys[0]["status"] == "active" - assert keys[1]["id"] == "mk2" - assert keys[1]["name"] == "key2" - assert keys[1]["status"] == "inactive" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - follow_redirects=True, - ) diff --git a/tests/management/test_outbound_application.py b/tests/management/test_outbound_application.py deleted file mode 100644 index 76db78a86..000000000 --- a/tests/management/test_outbound_application.py +++ /dev/null @@ -1,1017 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import AccessType, MgmtV1, PromptType, URLParam -from descope.management.outbound_application import OutboundApplication -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - -DUMMY_TOKEN = "inbound-app-token" - -APP_RESPONSE = { - "app": { - "id": "app123", - "name": "Test App", - "description": "Test Description", - } -} - -TOKEN_RESPONSE = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } -} - -MGMT_HEADERS = { - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, -} - - -class TestOutboundApplication: - async def test_create_application_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(APP_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application.create_application( - "Test App", description="Test Description", client_secret="secret" - ) - ) - assert response == APP_RESPONSE - - async def test_create_application_with_all_parameters_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - auth_params = [ - URLParam("response_type", "code"), - URLParam("client_id", "test-client"), - ] - token_params = [URLParam("grant_type", "authorization_code")] - prompts = [PromptType.LOGIN, PromptType.CONSENT] - - with client.mock_mgmt_post( - make_response( - { - "app": { - "id": "app123", - "name": "Test OAuth App", - "description": "Test Description", - } - } - ) - ) as _: - response = await client.invoke( - client.mgmt.outbound_application.create_application( - name="Test OAuth App", - description="Test Description", - logo="https://example.com/logo.png", - id="app123", - client_secret="secret", - client_id="test-client-id", - discovery_url="https://accounts.google.com/.well-known/openid_configuration", - authorization_url="https://accounts.google.com/o/oauth2/v2/auth", - authorization_url_params=auth_params, - token_url="https://oauth2.googleapis.com/token", - token_url_params=token_params, - revocation_url="https://oauth2.googleapis.com/revoke", - default_scopes=["https://www.googleapis.com/auth/userinfo.profile"], - default_redirect_url="https://myapp.com/callback", - callback_domain="myapp.com", - pkce=True, - access_type=AccessType.OFFLINE, - prompt=prompts, - ) - ) - assert response == { - "app": { - "id": "app123", - "name": "Test OAuth App", - "description": "Test Description", - } - } - - async def test_create_application_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.create_application("Test App")) - - async def test_update_application_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post( - make_response( - { - "app": { - "id": "app123", - "name": "Updated App", - "description": "Updated Description", - } - } - ) - ) as _: - response = await client.invoke( - client.mgmt.outbound_application.update_application( - "app123", - "Updated App", - description="Updated Description", - client_secret="new-secret", - ) - ) - assert response == { - "app": { - "id": "app123", - "name": "Updated App", - "description": "Updated Description", - } - } - - async def test_update_application_with_all_parameters_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - auth_params = [ - URLParam("response_type", "code"), - URLParam("client_id", "test-client"), - ] - token_params = [URLParam("grant_type", "authorization_code")] - prompts = [PromptType.LOGIN, PromptType.CONSENT, PromptType.SELECT_ACCOUNT] - - with client.mock_mgmt_post( - make_response( - { - "app": { - "id": "app123", - "name": "Updated OAuth App", - "description": "Updated Description", - } - } - ) - ) as _: - response = await client.invoke( - client.mgmt.outbound_application.update_application( - id="app123", - name="Updated OAuth App", - description="Updated Description", - logo="https://example.com/new-logo.png", - client_secret="new-secret", - client_id="new-client-id", - discovery_url="https://accounts.google.com/.well-known/openid_configuration", - authorization_url="https://accounts.google.com/o/oauth2/v2/auth", - authorization_url_params=auth_params, - token_url="https://oauth2.googleapis.com/token", - token_url_params=token_params, - revocation_url="https://oauth2.googleapis.com/revoke", - default_scopes=[ - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", - ], - default_redirect_url="https://myapp.com/updated-callback", - callback_domain="myapp.com", - pkce=True, - access_type=AccessType.OFFLINE, - prompt=prompts, - ) - ) - assert response == { - "app": { - "id": "app123", - "name": "Updated OAuth App", - "description": "Updated Description", - } - } - - async def test_update_application_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.update_application("app123", "Updated App")) - - async def test_delete_application_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=200)): - await client.invoke(client.mgmt.outbound_application.delete_application("app123")) - - async def test_delete_application_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.delete_application("app123")) - - async def test_load_application_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(APP_RESPONSE)) as mock_get: - response = await client.invoke(client.mgmt.outbound_application.load_application("app123")) - assert response == APP_RESPONSE - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_load_path}/app123", - headers=MGMT_HEADERS, - params=None, - follow_redirects=True, - ) - - async def test_load_application_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.load_application("app123")) - - async def test_load_all_applications_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - apps_response = { - "apps": [ - {"id": "app1", "name": "App 1", "description": "Description 1"}, - {"id": "app2", "name": "App 2", "description": "Description 2"}, - ] - } - with client.mock_mgmt_get(make_response(apps_response)) as mock_get: - response = await client.invoke(client.mgmt.outbound_application.load_all_applications()) - assert response == apps_response - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_load_all_path}", - headers=MGMT_HEADERS, - params=None, - follow_redirects=True, - ) - - async def test_load_all_applications_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.load_all_applications()) - - async def test_fetch_token_by_scopes_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(TOKEN_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application.fetch_token_by_scopes( - "app123", - "user456", - ["read", "write"], - {"refreshToken": True}, - "tenant789", - ) - ) - assert response == TOKEN_RESPONSE - - async def test_fetch_token_by_scopes_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application.fetch_token_by_scopes("app123", "user456", ["read"]) - ) - - async def test_fetch_token_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(TOKEN_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application.fetch_token("app123", "user456", "tenant789", {"forceRefresh": True}) - ) - assert response == TOKEN_RESPONSE - - async def test_fetch_token_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.fetch_token("app123", "user456")) - - async def test_fetch_tenant_token_by_scopes_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(TOKEN_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application.fetch_tenant_token_by_scopes( - "app123", "tenant789", ["read", "write"], {"refreshToken": True} - ) - ) - assert response == TOKEN_RESPONSE - - async def test_fetch_tenant_token_by_scopes_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application.fetch_tenant_token_by_scopes("app123", "tenant789", ["read"]) - ) - - async def test_fetch_tenant_token_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(TOKEN_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application.fetch_tenant_token("app123", "tenant789", {"forceRefresh": True}) - ) - assert response == TOKEN_RESPONSE - - async def test_fetch_tenant_token_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.fetch_tenant_token("app123", "tenant789")) - - def test_compose_create_update_body(self): - body = OutboundApplication._compose_create_update_body( - "Test App", - "Test Description", - "https://example.com/logo.png", - "app123", - "secret", - ) - - expected_body = { - "name": "Test App", - "id": "app123", - "description": "Test Description", - "logo": "https://example.com/logo.png", - "clientSecret": "secret", - } - - assert body == expected_body - - def test_compose_create_update_body_without_client_secret(self): - body = OutboundApplication._compose_create_update_body( - "Test App", "Test Description", "https://example.com/logo.png", "app123" - ) - - expected_body = { - "name": "Test App", - "id": "app123", - "description": "Test Description", - "logo": "https://example.com/logo.png", - } - - assert body == expected_body - - def test_compose_create_update_body_with_all_new_parameters(self): - auth_params = [ - URLParam("response_type", "code"), - URLParam("client_id", "test-client"), - ] - token_params = [URLParam("grant_type", "authorization_code")] - prompts = [PromptType.LOGIN, PromptType.CONSENT] - - body = OutboundApplication._compose_create_update_body( - name="Test OAuth App", - description="Test Description", - logo="https://example.com/logo.png", - id="app123", - client_secret="secret", - client_id="test-client-id", - discovery_url="https://accounts.google.com/.well-known/openid_configuration", - authorization_url="https://accounts.google.com/o/oauth2/v2/auth", - authorization_url_params=auth_params, - token_url="https://oauth2.googleapis.com/token", - token_url_params=token_params, - revocation_url="https://oauth2.googleapis.com/revoke", - default_scopes=["https://www.googleapis.com/auth/userinfo.profile"], - default_redirect_url="https://myapp.com/callback", - callback_domain="myapp.com", - pkce=True, - access_type=AccessType.OFFLINE, - prompt=prompts, - ) - - expected_body = { - "name": "Test OAuth App", - "id": "app123", - "description": "Test Description", - "logo": "https://example.com/logo.png", - "clientSecret": "secret", - "clientId": "test-client-id", - "discoveryUrl": "https://accounts.google.com/.well-known/openid_configuration", - "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth", - "authorizationUrlParams": [ - {"name": "response_type", "value": "code"}, - {"name": "client_id", "value": "test-client"}, - ], - "tokenUrl": "https://oauth2.googleapis.com/token", - "tokenUrlParams": [{"name": "grant_type", "value": "authorization_code"}], - "revocationUrl": "https://oauth2.googleapis.com/revoke", - "defaultScopes": ["https://www.googleapis.com/auth/userinfo.profile"], - "defaultRedirectUrl": "https://myapp.com/callback", - "callbackDomain": "myapp.com", - "pkce": True, - "accessType": "offline", - "prompt": ["login", "consent"], - } - - assert body == expected_body - - def test_compose_create_update_body_with_partial_new_parameters(self): - body = OutboundApplication._compose_create_update_body( - name="Test App", - description="Test Description", - logo="https://example.com/logo.png", - id="app123", - client_secret="secret", - client_id="test-client-id", - discovery_url="https://accounts.google.com/.well-known/openid_configuration", - pkce=False, - access_type=AccessType.ONLINE, - ) - - expected_body = { - "name": "Test App", - "id": "app123", - "description": "Test Description", - "logo": "https://example.com/logo.png", - "clientSecret": "secret", - "clientId": "test-client-id", - "discoveryUrl": "https://accounts.google.com/.well-known/openid_configuration", - "pkce": False, - "accessType": "online", - } - - assert body == expected_body - - def test_compose_create_update_body_with_url_params_only(self): - auth_params = [URLParam("response_type", "code")] - token_params = [URLParam("grant_type", "authorization_code")] - - body = OutboundApplication._compose_create_update_body( - name="Test App", - description="Test Description", - authorization_url_params=auth_params, - token_url_params=token_params, - ) - - expected_body = { - "name": "Test App", - "id": None, - "description": "Test Description", - "logo": None, - "authorizationUrlParams": [{"name": "response_type", "value": "code"}], - "tokenUrlParams": [{"name": "grant_type", "value": "authorization_code"}], - } - - assert body == expected_body - - def test_compose_create_update_body_with_prompt_types(self): - prompts = [PromptType.LOGIN, PromptType.CONSENT, PromptType.SELECT_ACCOUNT] - - body = OutboundApplication._compose_create_update_body( - name="Test App", description="Test Description", prompt=prompts - ) - - expected_body = { - "name": "Test App", - "id": None, - "description": "Test Description", - "logo": None, - "prompt": ["login", "consent", "select_account"], - } - - assert body == expected_body - - def test_compose_create_update_body_with_none_values(self): - body = OutboundApplication._compose_create_update_body( - name="Test App", - description="Test Description", - pkce=None, - access_type=None, - prompt=None, - ) - - expected_body = { - "name": "Test App", - "id": None, - "description": "Test Description", - "logo": None, - } - - assert body == expected_body - - def test_compose_create_update_body_with_empty_lists(self): - body = OutboundApplication._compose_create_update_body( - name="Test App", - description="Test Description", - authorization_url_params=[], - token_url_params=[], - default_scopes=[], - prompt=[], - ) - - expected_body = { - "name": "Test App", - "id": None, - "description": "Test Description", - "logo": None, - "authorizationUrlParams": [], - "tokenUrlParams": [], - "defaultScopes": [], - "prompt": [], - } - - assert body == expected_body - - async def test_delete_user_tokens_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_delete(make_response(status=200)) as mock_delete: - await client.invoke(client.mgmt.outbound_application.delete_user_tokens(app_id="app123", user_id="user456")) - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_user_tokens_path}", - headers=MGMT_HEADERS, - params={"appId": "app123", "userId": "user456"}, - follow_redirects=False, - ) - - async def test_delete_user_tokens_with_app_id_only(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_delete(make_response(status=200)) as mock_delete: - await client.invoke(client.mgmt.outbound_application.delete_user_tokens(app_id="app123")) - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_user_tokens_path}", - headers=MGMT_HEADERS, - params={"appId": "app123"}, - follow_redirects=False, - ) - - async def test_delete_user_tokens_with_user_id_only(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_delete(make_response(status=200)) as mock_delete: - await client.invoke(client.mgmt.outbound_application.delete_user_tokens(user_id="user456")) - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_user_tokens_path}", - headers=MGMT_HEADERS, - params={"userId": "user456"}, - follow_redirects=False, - ) - - async def test_delete_user_tokens_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_delete(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application.delete_user_tokens(app_id="app123", user_id="user456") - ) - - async def test_delete_token_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_delete(make_response(status=200)) as mock_delete: - await client.invoke(client.mgmt.outbound_application.delete_token("token123")) - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_token_path}", - headers=MGMT_HEADERS, - params={"id": "token123"}, - follow_redirects=False, - ) - - async def test_delete_token_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_delete(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.delete_token("token123")) - - async def test_list_apps_with_user_token_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response({"appIds": ["app1", "app2"]})) as mock_get: - response = await client.invoke( - client.mgmt.outbound_application.list_apps_with_user_token("user456", tenant_id="tenant789") - ) - assert response == {"appIds": ["app1", "app2"]} - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_list_apps_with_user_token_path}", - headers=MGMT_HEADERS, - params={"userId": "user456", "tenantId": "tenant789"}, - follow_redirects=True, - ) - - async def test_list_apps_with_user_token_no_tenant(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response({"appIds": ["app1"]})) as mock_get: - await client.invoke(client.mgmt.outbound_application.list_apps_with_user_token("user456")) - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_list_apps_with_user_token_path}", - headers=MGMT_HEADERS, - params={"userId": "user456"}, - follow_redirects=True, - ) - - async def test_list_apps_with_user_token_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_application.list_apps_with_user_token("user456")) - - async def test_upload_user_api_key_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.outbound_application.upload_user_api_key( - "app123", "user456", "secret-key", tenant_id="tenant789" - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_upload_user_api_key_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "appId": "app123", - "userId": "user456", - "apiKey": "secret-key", - "tenantId": "tenant789", - }, - follow_redirects=False, - ) - - async def test_upload_user_api_key_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application.upload_user_api_key("app123", "user456", "secret-key") - ) - - async def test_upload_tenant_api_key_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.outbound_application.upload_tenant_api_key("app123", "tenant789", "secret-key") - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_upload_tenant_api_key_path}", - headers=MGMT_HEADERS, - params=None, - json={"appId": "app123", "tenantId": "tenant789", "apiKey": "secret-key"}, - follow_redirects=False, - ) - - async def test_upload_user_token_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.outbound_application.upload_user_token( - "app123", - "user456", - refresh_token="refresh", - scopes=["read"], - verify_refresh=True, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_upload_user_token_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "appId": "app123", - "userId": "user456", - "refreshToken": "refresh", - "scopes": ["read"], - "verifyRefresh": True, - }, - follow_redirects=False, - ) - - async def test_upload_tenant_token_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.outbound_application.upload_tenant_token( - "app123", - "tenant789", - access_token="access", - access_token_expiry=3600, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_upload_tenant_token_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "appId": "app123", - "tenantId": "tenant789", - "accessToken": "access", - "accessTokenExpiry": 3600, - }, - follow_redirects=False, - ) - - async def test_batch_upload_user_tokens_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - tokens = [ - {"appId": "app123", "userId": "user1", "accessToken": "a1"}, - {"appId": "app123", "userId": "user2", "accessToken": "a2"}, - ] - failures = {"failures": [{"appId": "app123", "userId": "user2", "errorCode": "E152110", "reason": "bad token"}]} - with client.mock_mgmt_post(make_response(failures)) as mock_post: - response = await client.invoke(client.mgmt.outbound_application.batch_upload_user_tokens(tokens)) - assert response == failures - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_batch_upload_user_tokens_path}", - headers=MGMT_HEADERS, - params=None, - json={"tokens": tokens}, - follow_redirects=False, - ) - - async def test_batch_upload_tenant_tokens_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - tokens = [{"appId": "app123", "tenantId": "tenant1", "accessToken": "a1"}] - with client.mock_mgmt_post(make_response({"failures": []})) as mock_post: - response = await client.invoke(client.mgmt.outbound_application.batch_upload_tenant_tokens(tokens)) - assert response == {"failures": []} - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_batch_upload_tenant_tokens_path}", - headers=MGMT_HEADERS, - params=None, - json={"tokens": tokens}, - follow_redirects=False, - ) - - def test_url_param_to_dict(self): - param = URLParam("test_name", "test_value") - param_dict = param.to_dict() - - expected_dict = {"name": "test_name", "value": "test_value"} - assert param_dict == expected_dict - - def test_access_type_enum_values(self): - assert AccessType.OFFLINE.value == "offline" - assert AccessType.ONLINE.value == "online" - - def test_prompt_type_enum_values(self): - assert PromptType.NONE.value == "none" - assert PromptType.LOGIN.value == "login" - assert PromptType.CONSENT.value == "consent" - assert PromptType.SELECT_ACCOUNT.value == "select_account" - - -class TestOutboundApplicationByToken: - async def test_fetch_token_by_scopes_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) - - with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application_by_token.fetch_token_by_scopes( - DUMMY_TOKEN, - "app123", - "user456", - ["read", "write"], - {"refreshToken": True}, - "tenant789", - ) - ) - assert response == TOKEN_RESPONSE - - async def test_fetch_token_by_scopes_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) - - # Empty token should raise AuthException immediately (no HTTP call needed) - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_token_by_scopes( - "", - "app123", - "user456", - ["read"], - ) - ) - - # Invalid response failure - with client.mock_mgmt_by_token_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_token_by_scopes( - DUMMY_TOKEN, - "app123", - "user456", - ["read"], - ) - ) - - async def test_fetch_token_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) - - with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application_by_token.fetch_token( - DUMMY_TOKEN, - "app123", - "user456", - "tenant789", - {"forceRefresh": True}, - ) - ) - assert response == TOKEN_RESPONSE - - async def test_fetch_token_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) - - # Empty token should raise AuthException immediately - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_token( - "", - "app123", - "user456", - ) - ) - - # Invalid response failure - with client.mock_mgmt_by_token_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_token( - DUMMY_TOKEN, - "app123", - "user456", - ) - ) - - async def test_fetch_tenant_token_by_scopes_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) - - with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( - DUMMY_TOKEN, - "app123", - "tenant789", - ["read", "write"], - {"refreshToken": True}, - ) - ) - assert response == TOKEN_RESPONSE - - async def test_fetch_tenant_token_by_scopes_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) - - # Empty token should raise AuthException immediately - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( - "", - "app123", - "tenant789", - ["read"], - ) - ) - - # Invalid response failure - with client.mock_mgmt_by_token_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( - DUMMY_TOKEN, - "app123", - "tenant789", - ["read"], - ) - ) - - async def test_fetch_tenant_token_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) - - with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)) as _: - response = await client.invoke( - client.mgmt.outbound_application_by_token.fetch_tenant_token( - DUMMY_TOKEN, "app123", "tenant789", {"forceRefresh": True} - ) - ) - assert response == TOKEN_RESPONSE - - async def test_fetch_tenant_token_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) - - # Empty token should raise AuthException immediately - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_tenant_token( - "", - "app123", - "tenant789", - ) - ) - - # Invalid response failure - with client.mock_mgmt_by_token_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_tenant_token( - DUMMY_TOKEN, - "app123", - "tenant789", - ) - ) - - -class TestOutboundApplicationByTokenVerbose: - async def test_by_token_response_reaches_client_get_last_response(self, client_factory): - """The by-token client builds its own key-less HTTPClient. - - It has to be handed the owning client's verbose flag and last-response store, - or its responses are invisible to DescopeClient.get_last_response(). - """ - client = client_factory.make( - PROJECT_ID, - PUBLIC_KEY_DICT, - False, - management_key="test-mgmt-key", - verbose=True, - ) - if client_factory.mode == "async": - client._raw._license_attempted = True - - response = make_response(TOKEN_RESPONSE) - response.headers = {"cf-ray": "by-token-ray"} - - with client.mock_mgmt_by_token_post(response): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_token_by_scopes( - DUMMY_TOKEN, - "app123", - "user456", - ["read"], - ) - ) - - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp.headers.get("cf-ray") == "by-token-ray" - - async def test_by_token_not_captured_when_verbose_disabled(self, client_factory): - client = client_factory.make( - PROJECT_ID, - PUBLIC_KEY_DICT, - False, - management_key="test-mgmt-key", - ) - if client_factory.mode == "async": - client._raw._license_attempted = True - - with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)): - await client.invoke( - client.mgmt.outbound_application_by_token.fetch_token_by_scopes( - DUMMY_TOKEN, - "app123", - "user456", - ["read"], - ) - ) - - assert client.get_last_response() is None diff --git a/tests/management/test_outbound_scim.py b/tests/management/test_outbound_scim.py deleted file mode 100644 index d914d6173..000000000 --- a/tests/management/test_outbound_scim.py +++ /dev/null @@ -1,232 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from descope.management.outbound_scim import OutboundSCIM -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - -CONFIG_RESPONSE = { - "configuration": { - "appId": "app1", - "configuration": {"baseUrl": "https://scim.example.com"}, - "enabled": True, - "lastExportTime": 1719571200, - "lastProcessingTime": 1719571300, - "failures": 0, - "version": 3, - } -} - -MGMT_HEADERS = { - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, -} - - -class TestOutboundSCIM: - async def test_create_configuration_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(CONFIG_RESPONSE)) as mock_post: - response = await client.invoke( - client.mgmt.outbound_scim.create_configuration( - "app1", - {"baseUrl": "https://scim.example.com"}, - ) - ) - assert response == CONFIG_RESPONSE - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_scim_create_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "appId": "app1", - "configuration": {"baseUrl": "https://scim.example.com"}, - }, - follow_redirects=False, - ) - - async def test_create_configuration_minimal_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(CONFIG_RESPONSE)) as mock_post: - response = await client.invoke(client.mgmt.outbound_scim.create_configuration("app1")) - assert response == CONFIG_RESPONSE - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_scim_create_path}", - headers=MGMT_HEADERS, - params=None, - json={"appId": "app1"}, - follow_redirects=False, - ) - - async def test_create_configuration_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_scim.create_configuration("app1")) - - async def test_update_configuration_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(CONFIG_RESPONSE)) as mock_post: - response = await client.invoke( - client.mgmt.outbound_scim.update_configuration( - "app1", - 3, - {"baseUrl": "https://scim.example.com"}, - ) - ) - assert response == CONFIG_RESPONSE - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_scim_update_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "appId": "app1", - "version": 3, - "configuration": {"baseUrl": "https://scim.example.com"}, - }, - follow_redirects=False, - ) - - async def test_update_configuration_no_config_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(CONFIG_RESPONSE)) as mock_post: - await client.invoke(client.mgmt.outbound_scim.update_configuration("app1", 3)) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_scim_update_path}", - headers=MGMT_HEADERS, - params=None, - json={"appId": "app1", "version": 3}, - follow_redirects=False, - ) - - async def test_update_configuration_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_scim.update_configuration("app1", 3, {})) - - async def test_delete_configuration_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.outbound_scim.delete_configuration("app1")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_scim_delete_path}", - headers=MGMT_HEADERS, - params=None, - json={"appId": "app1"}, - follow_redirects=False, - ) - - async def test_delete_configuration_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_scim.delete_configuration("app1")) - - async def test_load_configuration_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(CONFIG_RESPONSE)) as mock_get: - response = await client.invoke(client.mgmt.outbound_scim.load_configuration("app1")) - assert response == CONFIG_RESPONSE - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_scim_load_path}/app1", - headers=MGMT_HEADERS, - params=None, - follow_redirects=True, - ) - - async def test_load_configuration_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_scim.load_configuration("app1")) - - async def test_set_enabled_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(CONFIG_RESPONSE)) as mock_post: - response = await client.invoke(client.mgmt.outbound_scim.set_enabled("app1", True)) - assert response == CONFIG_RESPONSE - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_scim_set_enabled_path}", - headers=MGMT_HEADERS, - params=None, - json={"appId": "app1", "enabled": True}, - follow_redirects=False, - ) - - async def test_set_enabled_false_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(CONFIG_RESPONSE)) as mock_post: - await client.invoke(client.mgmt.outbound_scim.set_enabled("app1", False)) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.outbound_scim_set_enabled_path}", - headers=MGMT_HEADERS, - params=None, - json={"appId": "app1", "enabled": False}, - follow_redirects=False, - ) - - async def test_set_enabled_failure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.outbound_scim.set_enabled("app1", True)) - - def test_compose_create_body(self): - body = OutboundSCIM._compose_create_body("app1", {"baseUrl": "https://scim.example.com"}) - assert body == { - "appId": "app1", - "configuration": {"baseUrl": "https://scim.example.com"}, - } - - def test_compose_create_body_without_configuration(self): - body = OutboundSCIM._compose_create_body("app1") - assert body == {"appId": "app1"} - - def test_compose_update_body(self): - body = OutboundSCIM._compose_update_body( - "app1", - 5, - {"baseUrl": "https://scim.example.com"}, - ) - assert body == { - "appId": "app1", - "version": 5, - "configuration": {"baseUrl": "https://scim.example.com"}, - } - - def test_compose_update_body_without_configuration(self): - body = OutboundSCIM._compose_update_body("app1", 5) - assert body == {"appId": "app1", "version": 5} diff --git a/tests/management/test_password.py b/tests/management/test_password.py deleted file mode 100644 index d22eecf29..000000000 --- a/tests/management/test_password.py +++ /dev/null @@ -1,91 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -@pytest.mark.asyncio -class TestPassword: - async def test_get_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed get_settings - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.password.get_settings()) - - # Test success get_settings - settings_resp = { - "enabled": True, - "minLength": 8, - "lowercase": True, - "uppercase": True, - "number": True, - "nonAlphanumeric": False, - "expiration": False, - "expirationWeeks": 0, - "reuse": False, - "reuseAmount": 0, - "lock": True, - "lockAttempts": 5, - } - with client.mock_mgmt_get(make_response(settings_resp)) as mock_get: - resp = await client.invoke(client.mgmt.password.get_settings("tenant-id")) - assert resp == settings_resp - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.password_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenantId": "tenant-id"}, - follow_redirects=True, - ) - - async def test_configure_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed configure_settings - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.password.configure_settings("", {})) - - # Test success configure_settings - settings = { - "enabled": True, - "minLength": 10, - "lowercase": True, - "uppercase": True, - "number": True, - "nonAlphanumeric": True, - "expiration": True, - "expirationWeeks": 12, - "reuse": True, - "reuseAmount": 3, - "lock": True, - "lockAttempts": 5, - } - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.password.configure_settings("tenant-id", settings)) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.password_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - **settings, - }, - follow_redirects=False, - ) diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py deleted file mode 100644 index 46d594c75..000000000 --- a/tests/management/test_permission.py +++ /dev/null @@ -1,336 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestPermission: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.create("name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.permission.create("P1", "Something")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "P1", - "description": "Something", - }, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.update("name", "new-name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert (await client.invoke(client.mgmt.permission.update("name", "new-name", "new-description"))) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - "newName": "new-name", - "description": "new-description", - }, - follow_redirects=False, - ) - - async def test_update_by_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.update_by_id("PERM123", "new-name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert ( - await client.invoke(client.mgmt.permission.update_by_id("PERM123", "new-name", "new-description")) - ) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "PERM123", - "newName": "new-name", - "description": "new-description", - }, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.delete("name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.permission.delete("name")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - }, - follow_redirects=False, - ) - - async def test_delete_by_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.delete_by_id("PERM123")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.permission.delete_by_id("PERM123")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"id": "PERM123"}, - follow_redirects=False, - ) - - async def test_create_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.create_batch([{"name": "P1"}])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert ( - await client.invoke( - client.mgmt.permission.create_batch( - [ - {"name": "P1", "description": "desc1"}, - {"name": "P2"}, - ] - ) - ) - ) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_create_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "permissions": [ - {"name": "P1", "description": "desc1"}, - {"name": "P2"}, - ] - }, - follow_redirects=False, - ) - - async def test_update_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.update_batch([{"name": "P1", "newName": "P1-new"}])) - - # Test success flow — by name - with client.mock_mgmt_post(make_response()) as mock_post: - assert ( - await client.invoke( - client.mgmt.permission.update_batch( - [ - {"name": "P1", "newName": "P1-new", "description": "d1"}, - {"name": "P2", "newName": "P2-new"}, - ] - ) - ) - ) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_update_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "permissions": [ - {"name": "P1", "newName": "P1-new", "description": "d1"}, - {"name": "P2", "newName": "P2-new"}, - ] - }, - follow_redirects=False, - ) - - # Test success flow — by id - with client.mock_mgmt_post(make_response()) as mock_post: - assert ( - await client.invoke( - client.mgmt.permission.update_batch( - [ - {"id": "PERM1", "newName": "P1-new", "description": "d1"}, - {"id": "PERM2", "newName": "P2-new"}, - ] - ) - ) - ) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_update_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "permissions": [ - {"id": "PERM1", "newName": "P1-new", "description": "d1"}, - {"id": "PERM2", "newName": "P2-new"}, - ] - }, - follow_redirects=False, - ) - - async def test_delete_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.delete_batch(["P1"])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.permission.delete_batch(["P1", "P2"])) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"names": ["P1", "P2"]}, - follow_redirects=False, - ) - - async def test_delete_batch_by_ids(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.delete_batch_by_ids(["PERM1"])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert (await client.invoke(client.mgmt.permission.delete_batch_by_ids(["PERM1", "PERM2"]))) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"ids": ["PERM1", "PERM2"]}, - follow_redirects=False, - ) - - async def test_load_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.permission.load_all()) - - # Test success flow - with client.mock_mgmt_get(make_response({"permissions": [{"name": "p1"}, {"name": "p2"}]})) as mock_get: - resp = await client.invoke(client.mgmt.permission.load_all()) - permissions = resp["permissions"] - assert len(permissions) == 2 - assert permissions[0]["name"] == "p1" - assert permissions[1]["name"] == "p2" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.permission_load_all_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - follow_redirects=True, - ) diff --git a/tests/management/test_project.py b/tests/management/test_project.py deleted file mode 100644 index d75dd336f..000000000 --- a/tests/management/test_project.py +++ /dev/null @@ -1,315 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestProject: - async def test_update_name(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.update_name("name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.project.update_name("new-name")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_update_name}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "new-name", - }, - follow_redirects=False, - ) - - async def test_update_tags(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.update_tags("tags")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.project.update_tags(["tag1", "tag2"])) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_update_tags}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tags": ["tag1", "tag2"], - }, - follow_redirects=False, - ) - - async def test_list_projects(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.list_projects()) - - # Test success flow - json_data = { - "projects": [ - { - "id": "dummy", - "name": "hey", - "environment": "", - "tags": ["tag1", "tag2"], - } - ] - } - with client.mock_mgmt_post(make_response(json_data)) as mock_post: - resp = await client.invoke(client.mgmt.project.list_projects()) - projects = resp["projects"] - assert len(projects) == 1 - assert projects[0]["id"] == "dummy" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_list_projects}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={}, - follow_redirects=False, - ) - - async def test_clone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.project.clone( - "new-name", - "production", - ["apple", "banana", "cherry"], - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response({"id": "dummy"})) as mock_post: - resp = await client.invoke( - client.mgmt.project.clone( - "new-name", - "production", - ["apple", "banana", "cherry"], - ) - ) - assert resp["id"] == "dummy" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_clone}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "new-name", - "environment": "production", - "tags": ["apple", "banana", "cherry"], - }, - follow_redirects=False, - ) - - async def test_export_project(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.export_project()) - - # Test success flow - with client.mock_mgmt_post(make_response({"files": {"foo": "bar"}})) as mock_post: - resp = await client.invoke(client.mgmt.project.export_project()) - assert resp is not None - assert resp["foo"] == "bar" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_export}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={}, - follow_redirects=False, - ) - - async def test_import_project(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.import_project({"foo": "bar"})) - - # Test success flow - files = {"foo": "bar"} - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke(client.mgmt.project.import_project(files)) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_import}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "files": { - "foo": "bar", - }, - }, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.delete()) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.project.delete()) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={}, - follow_redirects=False, - ) - - async def test_export_snapshot(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.export_snapshot()) - - # Test success flow - json_data = {"files": {"flow.json": "{}"}, "format": "v1"} - with client.mock_mgmt_post(make_response(json_data)) as mock_post: - resp = await client.invoke(client.mgmt.project.export_snapshot("v1")) - assert resp is not None - assert "files" in resp - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_snapshot_export_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"format": "v1"}, - follow_redirects=False, - ) - - async def test_import_snapshot(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.import_snapshot({"flow.json": "{}"})) - - # Test success flow - files = {"flow.json": "{}"} - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke(client.mgmt.project.import_snapshot(files, {"secret": "value"}, ["lists"])) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_snapshot_import_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "files": {"flow.json": "{}"}, - "inputSecrets": {"secret": "value"}, - "excludes": ["lists"], - }, - follow_redirects=False, - ) - - async def test_validate_snapshot(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.project.validate_snapshot({"flow.json": "{}"})) - - # Test success flow - files = {"flow.json": "{}"} - json_data = {"ok": True, "failures": [], "missingSecrets": []} - with client.mock_mgmt_post(make_response(json_data)) as mock_post: - resp = await client.invoke(client.mgmt.project.validate_snapshot(files, {"secret": "value"})) - assert resp is not None - assert resp["ok"] is True - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.project_snapshot_validate_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "files": {"flow.json": "{}"}, - "inputSecrets": {"secret": "value"}, - }, - follow_redirects=False, - ) diff --git a/tests/management/test_role.py b/tests/management/test_role.py deleted file mode 100644 index 8431c8b18..000000000 --- a/tests/management/test_role.py +++ /dev/null @@ -1,659 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestRole: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.create("name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.role.create("R1", "Something", ["P1"], "t1", True, False)) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "R1", - "description": "Something", - "permissionNames": ["P1"], - "tenantId": "t1", - "default": True, - "private": False, - }, - follow_redirects=False, - ) - - async def test_create_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.create_batch([{"name": "R1"}])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert ( - await client.invoke( - client.mgmt.role.create_batch( - [ - { - "name": "R1", - "description": "desc1", - "permissionNames": ["P1"], - "tenantId": "t1", - "default": True, - "private": False, - }, - {"name": "R2"}, - ] - ) - ) - is None - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_create_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "roles": [ - { - "name": "R1", - "description": "desc1", - "permissionNames": ["P1"], - "tenantId": "t1", - "default": True, - "private": False, - }, - {"name": "R2"}, - ] - }, - follow_redirects=False, - ) - - async def test_update_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.update_batch([{"name": "R1", "newName": "R1-new"}])) - - # Test success flow — by name - with client.mock_mgmt_post(make_response()) as mock: - assert ( - await client.invoke( - client.mgmt.role.update_batch( - [ - { - "name": "R1", - "newName": "R1-new", - "description": "d1", - "permissionNames": ["P1", "P2"], - "tenantId": "t1", - "default": False, - "private": True, - }, - {"name": "R2", "newName": "R2-new"}, - ] - ) - ) - is None - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_update_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "roles": [ - { - "name": "R1", - "newName": "R1-new", - "description": "d1", - "permissionNames": ["P1", "P2"], - "tenantId": "t1", - "default": False, - "private": True, - }, - {"name": "R2", "newName": "R2-new"}, - ] - }, - follow_redirects=False, - ) - - # Test success flow — by id - with client.mock_mgmt_post(make_response()) as mock: - assert ( - await client.invoke( - client.mgmt.role.update_batch( - [ - {"id": "ROL1", "newName": "R1-new", "description": "d1"}, - {"id": "ROL2", "newName": "R2-new"}, - ] - ) - ) - is None - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_update_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "roles": [ - {"id": "ROL1", "newName": "R1-new", "description": "d1"}, - {"id": "ROL2", "newName": "R2-new"}, - ] - }, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.update("name", "new-name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert ( - await client.invoke( - client.mgmt.role.update( - "name", - "new-name", - "new-description", - ["P1", "P2"], - "t1", - True, - False, - ) - ) - is None - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - "newName": "new-name", - "description": "new-description", - "permissionNames": ["P1", "P2"], - "tenantId": "t1", - "default": True, - "private": False, - }, - follow_redirects=False, - ) - - async def test_update_by_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.update_by_id("ROL123", "new-name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert ( - await client.invoke( - client.mgmt.role.update_by_id( - "ROL123", - "new-name", - "new-description", - ["P1", "P2"], - "t1", - True, - False, - ) - ) - is None - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "ROL123", - "newName": "new-name", - "description": "new-description", - "permissionNames": ["P1", "P2"], - "tenantId": "t1", - "default": True, - "private": False, - }, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.delete("name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.role.delete("name")) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"name": "name", "tenantId": None}, - follow_redirects=False, - ) - - async def test_delete_by_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.delete_by_id("ROL123")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.role.delete_by_id("ROL123", "t1")) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"id": "ROL123", "tenantId": "t1"}, - follow_redirects=False, - ) - - async def test_delete_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.delete_batch(["R1"])) - - # An empty list would delete every role in the project, so it is rejected - with client.mock_mgmt_post(make_response()) as mock: - with pytest.raises(ValueError): - await client.invoke(client.mgmt.role.delete_batch([])) - mock.assert_not_called() - - # Entries without a name would silently drop out of the filter - with client.mock_mgmt_post(make_response()) as mock: - with pytest.raises(ValueError): - await client.invoke(client.mgmt.role.delete_batch([{"id": "ROL1"}])) - mock.assert_not_called() - - # The endpoint takes a single tenant, so a batch spanning tenants is rejected - with client.mock_mgmt_post(make_response()) as mock: - with pytest.raises(ValueError): - await client.invoke( - client.mgmt.role.delete_batch( - [ - {"name": "R1", "tenantId": "t1"}, - {"name": "R2", "tenantId": "t2"}, - ] - ) - ) - mock.assert_not_called() - - # Test success flow with role names - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.role.delete_batch(["R1", "R2"], "t1")) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"roleNames": ["R1", "R2"], "tenantId": "t1"}, - follow_redirects=False, - ) - - # Test success flow without a tenant - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.role.delete_batch(["R1"])) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"roleNames": ["R1"], "tenantId": None}, - follow_redirects=False, - ) - - # Test success flow with role objects, the shape accepted before the fix - with client.mock_mgmt_post(make_response()) as mock: - assert ( - await client.invoke( - client.mgmt.role.delete_batch( - [ - {"name": "R1", "tenantId": "t1"}, - {"name": "R2"}, - ] - ) - ) - is None - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"roleNames": ["R1", "R2"], "tenantId": "t1"}, - follow_redirects=False, - ) - - async def test_delete_batch_by_ids(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.delete_batch_by_ids(["ROL1"])) - - # An empty list would delete every role in the project, so it is rejected - with client.mock_mgmt_post(make_response()) as mock: - with pytest.raises(ValueError): - await client.invoke(client.mgmt.role.delete_batch_by_ids([])) - mock.assert_not_called() - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.role.delete_batch_by_ids(["ROL1", "ROL2"], "t1")) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"roleIds": ["ROL1", "ROL2"], "tenantId": "t1"}, - follow_redirects=False, - ) - - async def test_load_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.load_all()) - - # Test success flow - with client.mock_mgmt_get( - make_response( - { - "roles": [ - {"name": "R1", "permissionNames": ["P1", "P2"]}, - {"name": "R2"}, - ] - } - ) - ) as mock: - resp = await client.invoke(client.mgmt.role.load_all()) - roles = resp["roles"] - assert len(roles) == 2 - assert roles[0]["name"] == "R1" - assert roles[1]["name"] == "R2" - permissions = roles[0]["permissionNames"] - assert len(permissions) == 2 - assert permissions[0] == "P1" - assert permissions[1] == "P2" - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_load_all_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - follow_redirects=True, - ) - - async def test_search(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.role.search(["t"], ["r"])) - - # Test success flow - with client.mock_mgmt_post( - make_response( - { - "roles": [ - {"name": "R1", "permissionNames": ["P1", "P2"]}, - {"name": "R2"}, - ] - } - ) - ) as mock: - resp = await client.invoke(client.mgmt.role.search(["t"], ["r"], "x", ["p1", "p2"])) - roles = resp["roles"] - assert len(roles) == 2 - assert roles[0]["name"] == "R1" - assert roles[1]["name"] == "R2" - permissions = roles[0]["permissionNames"] - assert len(permissions) == 2 - assert permissions[0] == "P1" - assert permissions[1] == "P2" - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t"], - "roleNames": ["r"], - "roleNameLike": "x", - "permissionNames": ["p1", "p2"], - }, - follow_redirects=False, - ) - - async def test_search_by_role_ids(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response({"roles": [{"id": "ROL123", "name": "R1"}]})) as mock: - resp = await client.invoke(client.mgmt.role.search(role_ids=["ROL123"])) - roles = resp["roles"] - assert len(roles) == 1 - assert roles[0]["id"] == "ROL123" - assert roles[0]["name"] == "R1" - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"roleIds": ["ROL123"]}, - follow_redirects=False, - ) - - async def test_create_with_private_true(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test private=True - with client.mock_mgmt_post(make_response()) as mock: - assert ( - await client.invoke(client.mgmt.role.create("PrivateRole", "Private role", ["P1"], "t1", False, True)) - is None - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "PrivateRole", - "description": "Private role", - "permissionNames": ["P1"], - "tenantId": "t1", - "default": False, - "private": True, - }, - follow_redirects=False, - ) - - async def test_update_with_private_true(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test private=True - with client.mock_mgmt_post(make_response()) as mock: - assert ( - await client.invoke( - client.mgmt.role.update( - "role", - "updated-role", - "Updated private role", - ["P1", "P2"], - "t1", - False, - True, - ) - ) - is None - ) - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "role", - "newName": "updated-role", - "description": "Updated private role", - "permissionNames": ["P1", "P2"], - "tenantId": "t1", - "default": False, - "private": True, - }, - follow_redirects=False, - ) - - async def test_create_without_private_parameter(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test without private parameter (should be None) - with client.mock_mgmt_post(make_response()) as mock: - assert await client.invoke(client.mgmt.role.create("SimpleRole", "Simple role")) is None - assert_http_called( - mock, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.role_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "SimpleRole", - "description": "Simple role", - "permissionNames": [], - "tenantId": None, - "default": None, - "private": None, - }, - follow_redirects=False, - ) diff --git a/tests/management/test_scope_claim_mapping.py b/tests/management/test_scope_claim_mapping.py deleted file mode 100644 index 2670b98d0..000000000 --- a/tests/management/test_scope_claim_mapping.py +++ /dev/null @@ -1,112 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestScopeClaimMapping: - async def test_get(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.scope_claim_mapping.get()) - - # Test success flow - json_data = { - "mappings": [ - { - "scope": "openid", - "claims": {"sub": "userId", "email": "email"}, - "description": "OpenID scope mapping", - } - ] - } - with client.mock_mgmt_post(make_response(json_data)) as mock_post: - resp = await client.invoke(client.mgmt.scope_claim_mapping.get()) - assert resp is not None - assert "mappings" in resp - assert len(resp["mappings"]) == 1 - assert resp["mappings"][0]["scope"] == "openid" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.scope_claim_mapping_get_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={}, - follow_redirects=False, - ) - - async def test_set(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.scope_claim_mapping.set([])) - - # Test success flow - mappings = [ - { - "scope": "profile", - "claims": {"name": "name", "picture": "picture"}, - "description": "Profile scope mapping", - } - ] - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.scope_claim_mapping.set(mappings)) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.scope_claim_mapping_set_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "mappings": [ - { - "scope": "profile", - "claims": {"name": "name", "picture": "picture"}, - "description": "Profile scope mapping", - } - ] - }, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.scope_claim_mapping.delete()) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - assert await client.invoke(client.mgmt.scope_claim_mapping.delete()) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.scope_claim_mapping_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={}, - follow_redirects=False, - ) diff --git a/tests/management/test_sso_application.py b/tests/management/test_sso_application.py deleted file mode 100644 index 40fe7ddaf..000000000 --- a/tests/management/test_sso_application.py +++ /dev/null @@ -1,780 +0,0 @@ -import pytest - -from descope import ( - AuthException, - SAMLIDPAttributeMappingInfo, - SAMLIDPGroupsMappingInfo, - SAMLIDPRoleGroupMappingInfo, -) -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestSSOApplication: - async def test_create_oidc_application(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=400)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso_application.create_oidc_application( - "valid-name", - "http://dummy.com", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response({"id": "app1"})) as mock_post: - resp = await client.invoke( - client.mgmt.sso_application.create_oidc_application( - name="name", - login_page_url="http://dummy.com", - force_authentication=True, - ) - ) - assert resp["id"] == "app1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_oidc_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - "loginPageUrl": "http://dummy.com", - "enabled": True, - "id": None, - "description": None, - "logo": None, - "forceAuthentication": True, - }, - follow_redirects=False, - ) - - @pytest.mark.parametrize( - "client_type_kwargs, client_type_body", - [ - ({}, {}), - ({"client_type": "confidential"}, {"clientType": "confidential"}), - ({"client_type": "public"}, {"clientType": "public"}), - ], - ) - async def test_create_oidc_application_client_type(self, client_factory, client_type_kwargs, client_type_body): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response({"id": "app1"})) as mock_post: - await client.invoke( - client.mgmt.sso_application.create_oidc_application( - name="name", - login_page_url="http://dummy.com", - **client_type_kwargs, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_oidc_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - "loginPageUrl": "http://dummy.com", - "enabled": True, - "id": None, - "description": None, - "logo": None, - "forceAuthentication": False, - **client_type_body, - }, - follow_redirects=False, - ) - - async def test_create_saml_application(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows — validation errors (no HTTP call needed) - with pytest.raises(Exception): - await client.invoke( - client.mgmt.sso_application.create_saml_application( - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="", - ) - ) - - with pytest.raises(Exception): - await client.invoke( - client.mgmt.sso_application.create_saml_application( - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=False, - entity_id="", - ) - ) - - with client.mock_mgmt_post(make_response(status=400)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso_application.create_saml_application( - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="http://dummy.com/md", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response({"id": "app1"})) as mock_post: - resp = await client.invoke( - client.mgmt.sso_application.create_saml_application( - name="name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="http://dummy.com/md", - attribute_mapping=[SAMLIDPAttributeMappingInfo("name1", "type1", "val1")], - groups_mapping=[ - SAMLIDPGroupsMappingInfo( - "name1", - "type1", - "roles", - "val1", - [SAMLIDPRoleGroupMappingInfo("id1", "name1")], - ) - ], - subject_name_id_type="email", - default_relay_state="relayState", - force_authentication=True, - logout_redirect_url="http://dummy.com/logout", - default_signature_algorithm="sha256", - ) - ) - assert resp["id"] == "app1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_saml_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - "loginPageUrl": "http://dummy.com", - "enabled": True, - "id": None, - "description": None, - "logo": None, - "useMetadataInfo": True, - "metadataUrl": "http://dummy.com/md", - "entityId": None, - "acsUrl": None, - "certificate": None, - "attributeMapping": [{"name": "name1", "type": "type1", "value": "val1"}], - "groupsMapping": [ - { - "name": "name1", - "type": "type1", - "filterType": "roles", - "value": "val1", - "roles": [{"id": "id1", "name": "name1"}], - } - ], - "acsAllowedCallbacks": [], - "subjectNameIdType": "email", - "subjectNameIdFormat": None, - "defaultRelayState": "relayState", - "forceAuthentication": True, - "logoutRedirectUrl": "http://dummy.com/logout", - "defaultSignatureAlgorithm": "sha256", - }, - follow_redirects=False, - ) - - async def test_update_oidc_application(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=400)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso_application.update_oidc_application( - "id1", - "valid-name", - "http://dummy.com", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso_application.update_oidc_application( - "app1", - "name", - "http://dummy.com", - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_oidc_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "app1", - "name": "name", - "loginPageUrl": "http://dummy.com", - "enabled": True, - "description": None, - "logo": None, - "forceAuthentication": False, - }, - follow_redirects=False, - ) - - @pytest.mark.parametrize( - "client_type_kwargs, client_type_body", - [ - ({}, {}), - ({"client_type": "confidential"}, {"clientType": "confidential"}), - ({"client_type": "public"}, {"clientType": "public"}), - ], - ) - async def test_update_oidc_application_client_type(self, client_factory, client_type_kwargs, client_type_body): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.sso_application.update_oidc_application( - "app1", - "name", - "http://dummy.com", - **client_type_kwargs, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_oidc_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "app1", - "name": "name", - "loginPageUrl": "http://dummy.com", - "enabled": True, - "description": None, - "logo": None, - "forceAuthentication": False, - **client_type_body, - }, - follow_redirects=False, - ) - - async def test_update_saml_application(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows — validation errors (no HTTP call needed) - with pytest.raises(Exception): - await client.invoke( - client.mgmt.sso_application.update_saml_application( - id="id1", - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="", - ) - ) - - with pytest.raises(Exception): - await client.invoke( - client.mgmt.sso_application.update_saml_application( - id="id1", - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=False, - entity_id="", - ) - ) - - with client.mock_mgmt_post(make_response(status=400)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso_application.update_saml_application( - id="id1", - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="http://dummy.com/md", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso_application.update_saml_application( - id="id1", - name="name", - login_page_url="http://dummy.com", - use_metadata_info=False, - metadata_url="", - entity_id="ent1234", - acs_url="http://dummy.com/acs", - certificate="cert", - attribute_mapping=[SAMLIDPAttributeMappingInfo("name1", "type1", "val1")], - groups_mapping=[ - SAMLIDPGroupsMappingInfo( - "name1", - "type1", - "roles", - "val1", - [SAMLIDPRoleGroupMappingInfo("id1", "name1")], - ) - ], - subject_name_id_type="", - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_saml_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - "loginPageUrl": "http://dummy.com", - "enabled": True, - "id": "id1", - "description": None, - "logo": None, - "useMetadataInfo": False, - "metadataUrl": "", - "entityId": "ent1234", - "acsUrl": "http://dummy.com/acs", - "certificate": "cert", - "attributeMapping": [{"name": "name1", "type": "type1", "value": "val1"}], - "groupsMapping": [ - { - "name": "name1", - "type": "type1", - "filterType": "roles", - "value": "val1", - "roles": [{"id": "id1", "name": "name1"}], - } - ], - "acsAllowedCallbacks": [], - "subjectNameIdType": "", - "subjectNameIdFormat": None, - "defaultRelayState": None, - "forceAuthentication": False, - "logoutRedirectUrl": None, - "defaultSignatureAlgorithm": None, - }, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=400)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso_application.delete("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.sso_application.delete("app1")) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "app1", - }, - follow_redirects=False, - ) - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=400)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso_application.load("valid-id")) - - # Test success flow - load_resp = { - "id": "app1", - "name": "App1", - "description": "", - "enabled": True, - "logo": "", - "appType": "saml", - "samlSettings": { - "loginPageUrl": "http://dummy.com/login", - "idpCert": "cert", - "useMetadataInfo": True, - "metadataUrl": "http://dummy.com/md", - "entityId": "", - "acsUrl": "", - "certificate": "", - "attributeMapping": [{"name": "email", "type": "", "value": "attrVal1"}], - "groupsMapping": [ - { - "name": "grp1", - "type": "", - "filterType": "roles", - "value": "", - "roles": [{"id": "myRoleId", "name": "myRole"}], - } - ], - "idpMetadataUrl": "", - "idpEntityId": "", - "idpSsoUrl": "", - "acsAllowedCallbacks": [], - "subjectNameIdType": "", - "subjectNameIdFormat": "", - }, - "oidcSettings": {"loginPageUrl": "", "issuer": "", "discoveryUrl": ""}, - } - with client.mock_mgmt_get(make_response(load_resp)) as mock_get: - resp = await client.invoke(client.mgmt.sso_application.load("app1")) - assert resp["name"] == "App1" - assert resp["appType"] == "saml" - assert resp["samlSettings"]["loginPageUrl"] == "http://dummy.com/login" - assert resp["samlSettings"]["useMetadataInfo"] is True - assert resp["samlSettings"]["metadataUrl"] == "http://dummy.com/md" - assert resp["samlSettings"]["attributeMapping"] == [{"name": "email", "type": "", "value": "attrVal1"}] - assert resp["samlSettings"]["groupsMapping"] == [ - { - "name": "grp1", - "type": "", - "filterType": "roles", - "value": "", - "roles": [{"id": "myRoleId", "name": "myRole"}], - } - ] - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_load_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"id": "app1"}, - follow_redirects=True, - ) - - async def test_load_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=400)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso_application.load_all()) - - # Test success flow - load_all_resp = { - "apps": [ - { - "id": "app1", - "name": "App1", - "description": "", - "enabled": True, - "logo": "", - "appType": "saml", - "samlSettings": { - "loginPageUrl": "http://dummy.com/login", - "idpCert": "cert", - "useMetadataInfo": True, - "metadataUrl": "http://dummy.com/md", - "entityId": "", - "acsUrl": "", - "certificate": "", - "attributeMapping": [{"name": "email", "type": "", "value": "attrVal1"}], - "groupsMapping": [ - { - "name": "grp1", - "type": "", - "filterType": "roles", - "value": "", - "roles": [{"id": "myRoleId", "name": "myRole"}], - } - ], - "idpMetadataUrl": "", - "idpEntityId": "", - "idpSsoUrl": "", - "acsAllowedCallbacks": [], - "subjectNameIdType": "", - "subjectNameIdFormat": "", - }, - "oidcSettings": {"loginPageUrl": "", "issuer": "", "discoveryUrl": ""}, - }, - { - "id": "app2", - "name": "App2", - "description": "", - "enabled": True, - "logo": "", - "appType": "oidc", - "samlSettings": { - "loginPageUrl": "", - "idpCert": "", - "useMetadataInfo": False, - "metadataUrl": "", - "entityId": "", - "acsUrl": "", - "certificate": "", - "attributeMapping": [], - "groupsMapping": [], - "idpMetadataUrl": "", - "idpEntityId": "", - "idpSsoUrl": "", - "acsAllowedCallbacks": [], - "subjectNameIdType": "", - "subjectNameIdFormat": "", - }, - "oidcSettings": { - "loginPageUrl": "http://dummy.com/login", - "issuer": "http://dummy.com/issuer", - "discoveryUrl": "http://dummy.com/wellknown", - }, - }, - ] - } - with client.mock_mgmt_get(make_response(load_all_resp)) as mock_get: - resp = await client.invoke(client.mgmt.sso_application.load_all()) - apps = resp["apps"] - assert len(apps) == 2 - assert apps[0]["name"] == "App1" - assert apps[0]["appType"] == "saml" - assert apps[0]["samlSettings"]["loginPageUrl"] == "http://dummy.com/login" - assert apps[0]["samlSettings"]["useMetadataInfo"] is True - assert apps[0]["samlSettings"]["metadataUrl"] == "http://dummy.com/md" - assert apps[0]["samlSettings"]["attributeMapping"] == [{"name": "email", "type": "", "value": "attrVal1"}] - assert apps[0]["samlSettings"]["groupsMapping"] == [ - { - "name": "grp1", - "type": "", - "filterType": "roles", - "value": "", - "roles": [{"id": "myRoleId", "name": "myRole"}], - } - ] - assert apps[1]["name"] == "App2" - assert apps[1]["appType"] == "oidc" - assert apps[1]["oidcSettings"]["loginPageUrl"] == "http://dummy.com/login" - assert apps[1]["oidcSettings"]["issuer"] == "http://dummy.com/issuer" - assert apps[1]["oidcSettings"]["discoveryUrl"] == "http://dummy.com/wellknown" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_load_all_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - follow_redirects=True, - ) - - async def test_create_wsfed_application(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=400)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso_application.create_wsfed_application( - "valid-name", - "http://dummy.com", - "urn:realm", - "http://reply.com", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response({"id": "app1"})) as mock_post: - resp = await client.invoke( - client.mgmt.sso_application.create_wsfed_application( - name="name", - login_page_url="http://dummy.com", - realm="urn:realm", - reply_url="http://reply.com", - force_authentication=True, - ) - ) - assert resp["id"] == "app1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_wsfed_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - "loginPageUrl": "http://dummy.com", - "realm": "urn:realm", - "replyUrl": "http://reply.com", - "enabled": True, - "id": None, - "description": None, - "logo": None, - "replyAllowedCallbacks": [], - "attributeMapping": [], - "groupsMapping": [], - "forceAuthentication": True, - "logoutRedirectUrl": None, - "errorRedirectUrl": None, - }, - follow_redirects=False, - ) - - async def test_update_wsfed_application(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=400)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso_application.update_wsfed_application( - "app1", - "valid-name", - "http://dummy.com", - "urn:realm", - "http://reply.com", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.sso_application.update_wsfed_application( - id="app1", - name="name", - login_page_url="http://dummy.com", - realm="urn:realm", - reply_url="http://reply.com", - force_authentication=True, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_wsfed_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "name", - "loginPageUrl": "http://dummy.com", - "realm": "urn:realm", - "replyUrl": "http://reply.com", - "enabled": True, - "id": "app1", - "description": None, - "logo": None, - "replyAllowedCallbacks": [], - "attributeMapping": [], - "groupsMapping": [], - "forceAuthentication": True, - "logoutRedirectUrl": None, - "errorRedirectUrl": None, - }, - follow_redirects=False, - ) - - async def test_get_application_secret(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=400)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso_application.get_application_secret("app1")) - - # Test success flow - with client.mock_mgmt_get(make_response({"cleartext": "secret123"})) as mock_get: - secret = await client.invoke(client.mgmt.sso_application.get_application_secret("app1")) - assert secret == "secret123" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_secret_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"id": "app1"}, - follow_redirects=True, - ) - - async def test_rotate_application_secret(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=400)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso_application.rotate_application_secret("app1")) - - # Test success flow - with client.mock_mgmt_post(make_response({"cleartext": "newsecret456"})) as mock_post: - secret = await client.invoke(client.mgmt.sso_application.rotate_application_secret("app1")) - assert secret == "newsecret456" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_rotate_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"id": "app1"}, - follow_redirects=False, - ) diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py deleted file mode 100644 index 5765b6872..000000000 --- a/tests/management/test_sso_settings.py +++ /dev/null @@ -1,1561 +0,0 @@ -import json - -import pytest - -from descope import AttributeMapping, AuthException, RoleMapping -from descope.management.common import MgmtV1 -from descope.management.sso_settings import ( - FGAGroupMapping, - FGAGroupMappingRelation, - OIDCAttributeMapping, - SSOOIDCSettings, - SSOSAMLSettings, - SSOSAMLSettingsByMetadata, - SSOSettings, - XAAIssuerSettings, - XAAJWTBearerSettings, - XAASettings, -) -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestSSOSettings: - async def test_delete_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_delete(make_response(status=500)) as mock_delete: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.delete_settings("tenant-id")) - - # Test success flow - with client.mock_mgmt_delete(make_response()) as mock_delete: - await client.invoke(client.mgmt.sso.delete_settings("tenant-id")) - - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", - params={"tenantId": "tenant-id"}, - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=False, - ) - - # Test success flow (a specific SSO configuration of a multi-SSO tenant) - with client.mock_mgmt_delete(make_response()) as mock_delete: - await client.invoke(client.mgmt.sso.delete_settings("tenant-id", sso_id="conf1")) - - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", - params={"tenantId": "tenant-id", "ssoId": "conf1"}, - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=False, - ) - - async def test_load_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.load_settings("tenant-id")) - - # Test success flow - resp_data = json.loads( - """{"tenant": {"id": "T2AAAA", "name": "myTenantName", "selfProvisioningDomains": [], "customAttributes": {}, "authType": "saml", "domains": ["lulu", "kuku"]}, "saml": {"idpEntityId": "", "idpSSOUrl": "", "idpCertificate": "", "idpAdditionalCertificates": ["cert1", "cert2"], "defaultSSORoles": ["aa", "bb"], "idpMetadataUrl": "https://dummy.com/metadata", "spEntityId": "", "spACSUrl": "", "spCertificate": "", "attributeMapping": {"name": "name", "email": "email", "username": "", "phoneNumber": "phone", "group": "", "givenName": "", "middleName": "", "familyName": "", "picture": "", "customAttributes": {}}, "groupsMapping": [], "redirectUrl": "", "lastSuccessTestTime": 777}, "oidc": {"name": "", "clientId": "", "clientSecret": "", "redirectUrl": "", "authUrl": "", "tokenUrl": "", "userDataUrl": "", "scope": [], "JWKsUrl": "", "userAttrMapping": {"loginId": "sub", "username": "", "name": "name", "email": "email", "phoneNumber": "phone_number", "verifiedEmail": "email_verified", "verifiedPhone": "phone_number_verified", "picture": "picture", "givenName": "given_name", "middleName": "middle_name", "familyName": "family_name"}, "manageProviderTokens": false, "callbackDomain": "", "prompt": [], "grantType": "authorization_code", "issuer": "", "lastSuccessTestTime": 888}}""" - ) - with client.mock_mgmt_get(make_response(resp_data)) as mock_get: - resp = await client.invoke(client.mgmt.sso.load_settings("T2AAAA")) - tenant = resp.get("tenant", {}) - assert tenant.get("id", "") == "T2AAAA" - assert tenant.get("domains", []) == ["lulu", "kuku"] - saml_settings = resp.get("saml", {}) - assert saml_settings.get("idpMetadataUrl", "") == "https://dummy.com/metadata" - assert saml_settings.get("defaultSSORoles", "") == ["aa", "bb"] - assert saml_settings.get("idpAdditionalCertificates", []) == ["cert1", "cert2"] - assert saml_settings.get("lastSuccessTestTime", 0) == 777 - assert resp.get("oidc", {}).get("lastSuccessTestTime", 0) == 888 - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_load_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenantId": "T2AAAA"}, - follow_redirects=True, - ) - - async def test_configure_oidc_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso.configure_oidc_settings( - "tenant-id", - SSOOIDCSettings( - name="myName", - client_id="cid", - ), - ["domain.com"], - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_oidc_settings( - "tenant-id", - SSOOIDCSettings( - name="myName", - client_id="cid", - client_secret="secret", - redirect_url="http://dummy.com/", - auth_url="http://dummy.com/auth", - token_url="http://dummy.com/token", - user_data_url="http://dummy.com/userInfo", - scope=["openid", "profile", "email"], - attribute_mapping=OIDCAttributeMapping( - login_id="my-id", - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - email="email", - verified_email="verifiedEmail", - username="username", - phone_number="phoneNumber", - verified_phone="verifiedPhone", - picture="picture", - ), - groups_priority=["group1"], - ), - ["domain.com"], - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_oidc_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "settings": { - "name": "myName", - "clientId": "cid", - "clientSecret": "secret", - "redirectUrl": "http://dummy.com/", - "authUrl": "http://dummy.com/auth", - "tokenUrl": "http://dummy.com/token", - "userDataUrl": "http://dummy.com/userInfo", - "scope": ["openid", "profile", "email"], - "JWKsUrl": None, - "manageProviderTokens": False, - "callbackDomain": None, - "prompt": None, - "grantType": None, - "issuer": None, - "userAttrMapping": { - "loginId": "my-id", - "name": "name", - "givenName": "givenName", - "middleName": "middleName", - "familyName": "familyName", - "email": "email", - "verifiedEmail": "verifiedEmail", - "username": "username", - "phoneNumber": "phoneNumber", - "verifiedPhone": "verifiedPhone", - "picture": "picture", - }, - "groupsPriority": ["group1"], - "fgaMappings": None, - }, - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - # usePkce is sent only when set, and False has to be sent as False so PKCE can be turned off. - @pytest.mark.parametrize("use_pkce", [True, False]) - async def test_configure_oidc_settings_use_pkce(self, client_factory, use_pkce): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.sso.configure_oidc_settings( - "tenant-id", - SSOOIDCSettings(name="myName", client_id="cid", use_pkce=use_pkce), - ["domain.com"], - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_oidc_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "settings": { - "name": "myName", - "clientId": "cid", - "clientSecret": None, - "redirectUrl": None, - "authUrl": None, - "tokenUrl": None, - "userDataUrl": None, - "scope": None, - "JWKsUrl": None, - "manageProviderTokens": False, - "callbackDomain": None, - "prompt": None, - "grantType": None, - "issuer": None, - "userAttrMapping": None, - "groupsPriority": None, - "fgaMappings": None, - "usePkce": use_pkce, - }, - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - async def test_configure_saml_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso.configure_saml_settings( - "tenant-id", - SSOSAMLSettings( - idp_url="http://dummy.com", - idp_entity_id="ent1234", - idp_cert="cert", - sp_acs_url="http://spacsurl.com", - sp_entity_id="spentityid", - default_sso_roles=["aa", "bb"], - ), - "https://redirect.com", - ["domain.com"], - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_saml_settings( - "tenant-id", - SSOSAMLSettings( - idp_url="http://dummy.com", - idp_entity_id="ent1234", - idp_cert="cert", - attribute_mapping=AttributeMapping( - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - picture="picture", - email="email", - phone_number="phoneNumber", - group="groups", - ), - role_mappings=[RoleMapping(groups=["grp1"], role_name="rl1")], - sp_acs_url="http://spacsurl.com", - sp_entity_id="spentityid", - default_sso_roles=["aa", "bb"], - groups_priority=["group1"], - ), - "https://redirect.com", - ["domain.com"], - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "settings": { - "idpUrl": "http://dummy.com", - "entityId": "ent1234", - "idpCert": "cert", - "idpAdditionalCerts": None, - "attributeMapping": { - "name": "name", - "givenName": "givenName", - "middleName": "middleName", - "familyName": "familyName", - "picture": "picture", - "email": "email", - "phoneNumber": "phoneNumber", - "group": "groups", - "customAttributes": None, - }, - "roleMappings": [{"groups": ["grp1"], "roleName": "rl1"}], - "spACSUrl": "http://spacsurl.com", - "spEntityId": "spentityid", - "defaultSSORoles": ["aa", "bb"], - "groupsPriority": ["group1"], - "fgaMappings": None, - "configFGATenantIDResourcePrefix": None, - "configFGATenantIDResourceSuffix": None, - "disableSignRequest": False, - }, - "redirectUrl": "https://redirect.com", - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - async def test_configure_xaa_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso.configure_xaa_settings( - "tenant-id", - XAASettings( - enabled=True, - settings=XAAJWTBearerSettings( - issuers={ - "https://issuer.example.com": XAAIssuerSettings( - jwks_uri="https://issuer.example.com/jwks", - ) - } - ), - ), - ) - ) - - # Test success flow (with ssoId) - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_xaa_settings( - "tenant-id", - XAASettings( - enabled=True, - settings=XAAJWTBearerSettings( - issuers={ - "https://issuer.example.com": XAAIssuerSettings( - jwks_uri="https://issuer.example.com/jwks", - sign_algorithm="RS256", - user_info_uri="https://issuer.example.com/userinfo", - external_id_field_name="sub", - jit_disabled=True, - attribute_mapping=AttributeMapping( - email="email", - group="groups", - ), - ) - }, - jwt_bearer_grant_type_audience_to_use="clientId", - ), - role_mappings=[RoleMapping(groups=["grp1"], role_name="rl1")], - default_sso_roles=["aa", "bb"], - groups_priority=["group1"], - group_priority_enabled=True, - allow_override_roles=True, - provider_id="prov1", - ), - sso_id="sso-1", - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_xaa_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "enabled": True, - "settings": { - "issuers": { - "https://issuer.example.com": { - "jwksUri": "https://issuer.example.com/jwks", - "signAlgorithm": "RS256", - "userInfoUri": "https://issuer.example.com/userinfo", - "externalIdFieldName": "sub", - "jitDisabled": True, - "attributeMapping": { - "name": None, - "email": "email", - "phoneNumber": None, - "group": "groups", - "givenName": None, - "middleName": None, - "familyName": None, - "picture": None, - "customAttributes": None, - }, - } - }, - "jwtBearerGrantTypeAudienceToUse": "clientId", - "jwtBearerGrantTypeScopeToUse": None, - "jwtBearerGrantTypeCustomClaimsToUse": None, - }, - "roleMappings": [{"groups": ["grp1"], "roleName": "rl1"}], - "defaultSSORoles": ["aa", "bb"], - "fgaMappings": None, - "groupsPriority": ["group1"], - "groupPriorityEnabled": True, - "allowOverrideRoles": True, - "ssoId": "sso-1", - "providerID": "prov1", - }, - follow_redirects=False, - ) - - async def test_configure_xaa_settings_without_jwt_bearer_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # No per-issuer settings at all (config-level mapping only) - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke(client.mgmt.sso.configure_xaa_settings("tenant-id", XAASettings(enabled=False))) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_xaa_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "enabled": False, - "settings": None, - "roleMappings": [], - "defaultSSORoles": None, - "fgaMappings": None, - "groupsPriority": None, - "groupPriorityEnabled": None, - "allowOverrideRoles": None, - }, - follow_redirects=False, - ) - - async def test_configure_xaa_settings_with_none_issuer_entry(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # An issuer entry with no per-issuer settings, using defaults - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.sso.configure_xaa_settings( - "tenant-id", - XAASettings( - enabled=True, - settings=XAAJWTBearerSettings(issuers={"https://issuer.example.com": None}), - ), - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_xaa_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "enabled": True, - "settings": { - "issuers": {"https://issuer.example.com": None}, - "jwtBearerGrantTypeAudienceToUse": None, - "jwtBearerGrantTypeScopeToUse": None, - "jwtBearerGrantTypeCustomClaimsToUse": None, - }, - "roleMappings": [], - "defaultSSORoles": None, - "fgaMappings": None, - "groupsPriority": None, - "groupPriorityEnabled": None, - "allowOverrideRoles": None, - }, - follow_redirects=False, - ) - - async def test_load_xaa_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.load_xaa_settings("tenant-id")) - - # Test success flow (with ssoId) - resp_data = json.loads( - """{"ssoId": "sso-1", "enabled": true, "settings": {"issuers": {"https://issuer.example.com": {"jwksUri": "https://issuer.example.com/jwks", "signAlgorithm": "RS256"}}}, "groupsMapping": [{"role": {"id": "r1", "name": "role1"}, "groups": ["g1"]}], "defaultSSORoles": ["aa"], "groupPriorityEnabled": true, "allowOverrideRoles": true, "audience": "https://api.descope.com/v1/apps/P1"}""" - ) - with client.mock_mgmt_get(make_response(resp_data)) as mock_get: - resp = await client.invoke(client.mgmt.sso.load_xaa_settings("tenant-id", "sso-1")) - assert resp.get("ssoId") == "sso-1" - assert resp.get("enabled") is True - assert ( - resp.get("settings", {}).get("issuers", {}).get("https://issuer.example.com", {}).get("jwksUri") - == "https://issuer.example.com/jwks" - ) - assert resp.get("groupsMapping")[0]["role"]["name"] == "role1" - # Read-only, project-level: no tenant segment - the tenant travels in the aud_tenant claim. - assert resp.get("audience") == "https://api.descope.com/v1/apps/P1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_xaa_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenantId": "tenant-id", "ssoId": "sso-1"}, - follow_redirects=True, - ) - - async def test_load_all_xaa_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.load_all_xaa_settings("tenant-id")) - - # Test success flow - resp_data = json.loads( - """{"XAASettings": [{"ssoId": "sso-1", "enabled": true}, {"ssoId": "sso-2", "enabled": false}]}""" - ) - with client.mock_mgmt_get(make_response(resp_data)) as mock_get: - resp = await client.invoke(client.mgmt.sso.load_all_xaa_settings("tenant-id")) - settings = resp.get("XAASettings", []) - assert len(settings) == 2 - assert settings[0]["ssoId"] == "sso-1" - assert settings[1]["ssoId"] == "sso-2" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_load_all_xaa_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenantId": "tenant-id"}, - follow_redirects=True, - ) - - async def test_delete_xaa_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_delete(make_response(status=500)) as mock_delete: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.delete_xaa_settings("tenant-id")) - - # Test success flow (with ssoId) - with client.mock_mgmt_delete(make_response()) as mock_delete: - await client.invoke(client.mgmt.sso.delete_xaa_settings("tenant-id", "sso-1")) - - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_xaa_settings}", - params={"tenantId": "tenant-id", "ssoId": "sso-1"}, - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=False, - ) - - async def test_configure_saml_settings_by_metadata(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso.configure_saml_settings_by_metadata( - "tenant-id", - SSOSAMLSettingsByMetadata(idp_metadata_url="http://dummy.com/metadata"), - "https://redirect.com", - ["domain.com"], - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_saml_settings_by_metadata( - "tenant-id", - SSOSAMLSettingsByMetadata( - idp_metadata_url="http://dummy.com/metadata", - idp_entity_id="https://idp.example.com/entity", - attribute_mapping=AttributeMapping( - name="name", - given_name="givenName", - middle_name="middleName", - family_name="familyName", - picture="picture", - email="email", - phone_number="phoneNumber", - group="groups", - ), - role_mappings=[RoleMapping(groups=["grp1"], role_name="rl1")], - sp_acs_url="http://spacsurl.com", - sp_entity_id="spentityid", - default_sso_roles=["aa", "bb"], - groups_priority=["group1"], - ), - "https://redirect.com", - ["domain.com"], - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_by_metadata_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "settings": { - "idpMetadataUrl": "http://dummy.com/metadata", - "entityId": "https://idp.example.com/entity", - "attributeMapping": { - "name": "name", - "givenName": "givenName", - "middleName": "middleName", - "familyName": "familyName", - "picture": "picture", - "email": "email", - "phoneNumber": "phoneNumber", - "group": "groups", - "customAttributes": None, - }, - "roleMappings": [{"groups": ["grp1"], "roleName": "rl1"}], - "spACSUrl": "http://spacsurl.com", - "spEntityId": "spentityid", - "defaultSSORoles": ["aa", "bb"], - "groupsPriority": ["group1"], - "fgaMappings": None, - "configFGATenantIDResourcePrefix": None, - "configFGATenantIDResourceSuffix": None, - "disableSignRequest": False, - }, - "redirectUrl": "https://redirect.com", - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - async def test_configure_saml_settings_with_additional_certs(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow with additional certs - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_saml_settings( - "tenant-id", - SSOSAMLSettings( - idp_url="http://dummy.com", - idp_entity_id="ent1234", - idp_cert="cert", - idp_additional_certs=["cert1", "cert2", "cert3"], - attribute_mapping=AttributeMapping( - name="name", - email="email", - ), - role_mappings=[RoleMapping(groups=["grp1"], role_name="rl1")], - default_sso_roles=["aa", "bb"], - groups_priority=["group1"], - ), - "https://redirect.com", - ["domain.com"], - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "settings": { - "idpUrl": "http://dummy.com", - "entityId": "ent1234", - "idpCert": "cert", - "idpAdditionalCerts": ["cert1", "cert2", "cert3"], - "attributeMapping": { - "name": "name", - "email": "email", - "phoneNumber": None, - "group": None, - "givenName": None, - "middleName": None, - "familyName": None, - "picture": None, - "customAttributes": None, - }, - "roleMappings": [{"groups": ["grp1"], "roleName": "rl1"}], - "spACSUrl": None, - "spEntityId": None, - "defaultSSORoles": ["aa", "bb"], - "groupsPriority": ["group1"], - "fgaMappings": None, - "configFGATenantIDResourcePrefix": None, - "configFGATenantIDResourceSuffix": None, - "disableSignRequest": False, - }, - "redirectUrl": "https://redirect.com", - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - def test_attribute_mapping_to_dict(self): - with pytest.raises(ValueError): - SSOSettings._attribute_mapping_to_dict(None) - - def test_fga_mappings_to_dict(self): - # None input returns None - assert SSOSettings._fga_mappings_to_dict(None) is None - - # Empty dict returns empty dict - assert SSOSettings._fga_mappings_to_dict({}) == {} - - # Group with relations is serialized into camelCase keys - mappings = { - "admins": FGAGroupMapping( - relations=[ - FGAGroupMappingRelation( - resource="tenant:t1", - relation_definition="member", - namespace="tenant", - ), - FGAGroupMappingRelation( - resource="tenant:t1", - relation_definition="owner", - namespace="tenant", - ), - ], - ), - "viewers": FGAGroupMapping(), - } - assert SSOSettings._fga_mappings_to_dict(mappings) == { - "admins": { - "relations": [ - { - "resource": "tenant:t1", - "relationDefinition": "member", - "namespace": "tenant", - }, - { - "resource": "tenant:t1", - "relationDefinition": "owner", - "namespace": "tenant", - }, - ], - }, - "viewers": {"relations": []}, - } - - async def test_configure_saml_settings_with_fga_mappings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_saml_settings( - "tenant-id", - SSOSAMLSettings( - idp_url="http://dummy.com", - idp_entity_id="ent1234", - idp_cert="cert", - fga_mappings={ - "admins": FGAGroupMapping( - relations=[ - FGAGroupMappingRelation( - resource="tenant:t1", - relation_definition="member", - namespace="tenant", - ), - ], - ), - }, - config_fga_tenant_id_resource_prefix="tenant:", - config_fga_tenant_id_resource_suffix="", - ), - "https://redirect.com", - ["domain.com"], - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "settings": { - "idpUrl": "http://dummy.com", - "entityId": "ent1234", - "idpCert": "cert", - "idpAdditionalCerts": None, - "attributeMapping": None, - "roleMappings": [], - "spACSUrl": None, - "spEntityId": None, - "defaultSSORoles": None, - "groupsPriority": None, - "fgaMappings": { - "admins": { - "relations": [ - { - "resource": "tenant:t1", - "relationDefinition": "member", - "namespace": "tenant", - }, - ], - }, - }, - "configFGATenantIDResourcePrefix": "tenant:", - "configFGATenantIDResourceSuffix": "", - "disableSignRequest": False, - }, - "redirectUrl": "https://redirect.com", - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - async def test_configure_saml_settings_by_metadata_with_fga_mappings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_saml_settings_by_metadata( - "tenant-id", - SSOSAMLSettingsByMetadata( - idp_metadata_url="http://dummy.com/metadata", - fga_mappings={ - "admins": FGAGroupMapping( - relations=[ - FGAGroupMappingRelation( - resource="tenant:t1", - relation_definition="member", - namespace="tenant", - ), - ], - ), - }, - config_fga_tenant_id_resource_prefix="tenant:", - config_fga_tenant_id_resource_suffix="-suffix", - ), - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_by_metadata_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "settings": { - "idpMetadataUrl": "http://dummy.com/metadata", - "entityId": None, - "attributeMapping": None, - "roleMappings": [], - "spACSUrl": None, - "spEntityId": None, - "defaultSSORoles": None, - "groupsPriority": None, - "fgaMappings": { - "admins": { - "relations": [ - { - "resource": "tenant:t1", - "relationDefinition": "member", - "namespace": "tenant", - }, - ], - }, - }, - "configFGATenantIDResourcePrefix": "tenant:", - "configFGATenantIDResourceSuffix": "-suffix", - "disableSignRequest": False, - }, - "redirectUrl": None, - "domains": None, - }, - follow_redirects=False, - ) - - async def test_configure_oidc_settings_with_fga_mappings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_oidc_settings( - "tenant-id", - SSOOIDCSettings( - name="myName", - client_id="cid", - fga_mappings={ - "admins": FGAGroupMapping( - relations=[ - FGAGroupMappingRelation( - resource="tenant:t1", - relation_definition="member", - namespace="tenant", - ), - ], - ), - }, - ), - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_oidc_settings}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "settings": { - "name": "myName", - "clientId": "cid", - "clientSecret": None, - "redirectUrl": None, - "authUrl": None, - "tokenUrl": None, - "userDataUrl": None, - "scope": None, - "JWKsUrl": None, - "userAttrMapping": None, - "manageProviderTokens": False, - "callbackDomain": None, - "prompt": None, - "grantType": None, - "issuer": None, - "groupsPriority": None, - "fgaMappings": { - "admins": { - "relations": [ - { - "resource": "tenant:t1", - "relationDefinition": "member", - "namespace": "tenant", - }, - ], - }, - }, - }, - "domains": None, - }, - follow_redirects=False, - ) - - @pytest.mark.parametrize( - "default_sso_roles, expect_replace", - [([], True), (None, False), (["aa"], False)], - ) - async def test_configure_saml_settings_replace_default_sso_roles( - self, client_factory, default_sso_roles, expect_replace - ): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.sso.configure_saml_settings( - "tenant-id", - SSOSAMLSettings( - idp_url="http://dummy.com", - idp_entity_id="ent1234", - idp_cert="cert", - default_sso_roles=default_sso_roles, - ), - ) - ) - settings = mock_post.call_args.kwargs["json"]["settings"] - assert settings["defaultSSORoles"] == default_sso_roles - if expect_replace: - assert settings["replaceDefaultSSORoles"] is True - else: - assert "replaceDefaultSSORoles" not in settings - - @pytest.mark.parametrize( - "default_sso_roles, expect_replace", - [([], True), (None, False), (["aa"], False)], - ) - async def test_configure_saml_settings_by_metadata_replace_default_sso_roles( - self, client_factory, default_sso_roles, expect_replace - ): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.sso.configure_saml_settings_by_metadata( - "tenant-id", - SSOSAMLSettingsByMetadata( - idp_metadata_url="http://dummy.com/metadata", - default_sso_roles=default_sso_roles, - ), - ) - ) - settings = mock_post.call_args.kwargs["json"]["settings"] - assert settings["defaultSSORoles"] == default_sso_roles - if expect_replace: - assert settings["replaceDefaultSSORoles"] is True - else: - assert "replaceDefaultSSORoles" not in settings - - async def test_configure_xaa_settings_never_sends_replace_default_sso_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke(client.mgmt.sso.configure_xaa_settings("tenant-id", XAASettings(default_sso_roles=[]))) - assert "replaceDefaultSSORoles" not in mock_post.call_args.kwargs["json"] - - # Testing DEPRECATED functions - async def test_get_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.get_settings("tenant-id")) - - # Test success flow - with client.mock_mgmt_get(make_response({"domains": ["lulu", "kuku"], "tenantId": "tenant-id"})) as mock_get: - resp = await client.invoke(client.mgmt.sso.get_settings("tenant-id")) - assert resp["tenantId"] == "tenant-id" - assert resp["domains"] == ["lulu", "kuku"] - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenantId": "tenant-id"}, - follow_redirects=True, - ) - - async def test_configure(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso.configure( - "tenant-id", - "https://idp.com", - "entity-id", - "cert", - "https://redirect.com", - ["domain.com"], - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure( - "tenant-id", - "https://idp.com", - "entity-id", - "cert", - "https://redirect.com", - ["domain.com"], - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "idpURL": "https://idp.com", - "entityId": "entity-id", - "idpCert": "cert", - "redirectURL": "https://redirect.com", - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - # Domain is optional - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure( - "tenant-id", - "https://idp.com", - "entity-id", - "cert", - "https://redirect.com", - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "idpURL": "https://idp.com", - "entityId": "entity-id", - "idpCert": "cert", - "redirectURL": "https://redirect.com", - "domains": None, - }, - follow_redirects=False, - ) - - # Redirect is optional - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure( - "tenant-id", - "https://idp.com", - "entity-id", - "cert", - "", - ["domain.com"], - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "idpURL": "https://idp.com", - "entityId": "entity-id", - "idpCert": "cert", - "redirectURL": "", - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - async def test_configure_via_metadata(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso.configure_via_metadata( - "tenant-id", - "https://idp-meta.com", - "https://redirect.com", - ["domain.com"], - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_via_metadata( - "tenant-id", - "https://idp-meta.com", - "https://redirect.com", - ["domain.com"], - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_metadata_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "idpMetadataURL": "https://idp-meta.com", - "redirectURL": "https://redirect.com", - "domains": ["domain.com"], - }, - follow_redirects=False, - ) - - # Test partial arguments - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.configure_via_metadata( - "tenant-id", - "https://idp-meta.com", - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_metadata_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "idpMetadataURL": "https://idp-meta.com", - "redirectURL": None, - "domains": None, - }, - follow_redirects=False, - ) - - async def test_mapping(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso.mapping( - "tenant-id", - [RoleMapping(["a", "b"], "role")], - AttributeMapping(name="UName"), - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.sso.mapping( - "tenant-id", - [RoleMapping(["a", "b"], "role")], - AttributeMapping(name="UName"), - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_mapping_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "roleMappings": [{"groups": ["a", "b"], "roleName": "role"}], - "attributeMapping": { - "name": "UName", - "email": None, - "phoneNumber": None, - "group": None, - "givenName": None, - "middleName": None, - "familyName": None, - "picture": None, - "customAttributes": None, - }, - }, - follow_redirects=False, - ) - - async def test_recalculate_sso_mappings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.recalculate_sso_mappings("tenant-id")) - - # Test success flow with sso_id - with client.mock_mgmt_post(make_response({"affectedUserIds": ["user1", "user2", "user3"]})) as mock_post: - await client.invoke(client.mgmt.sso.recalculate_sso_mappings("tenant-id", "sso-456")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_recalculate_mappings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "ssoId": "sso-456", - }, - follow_redirects=False, - ) - - # Test success flow without sso_id - with client.mock_mgmt_post(make_response({"affectedUserIds": ["user1"]})) as mock_post: - await client.invoke(client.mgmt.sso.recalculate_sso_mappings("tenant-id")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_recalculate_mappings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - }, - follow_redirects=False, - ) - - async def test_configure_sso_redirect_url(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.sso.configure_sso_redirect_url( - "tenant-id", saml_redirect_url="https://example.com/saml" - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.sso.configure_sso_redirect_url( - "tenant-id", - saml_redirect_url="https://example.com/saml", - oauth_redirect_url="https://example.com/oauth", - sso_id="sso123", - ) - ) - - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_redirect_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "samlRedirectUrl": "https://example.com/saml", - "oauthRedirectUrl": "https://example.com/oauth", - "ssoId": "sso123", - }, - follow_redirects=False, - ) - - async def test_load_all_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.load_all_settings("tenant-id")) - - # Test success flow - resp_data = json.loads("""[{"tenant": {"id": "T2AAAA"}, "saml": {}}]""") - with client.mock_mgmt_get(make_response(resp_data)) as mock_get: - resp = await client.invoke(client.mgmt.sso.load_all_settings("T2AAAA")) - assert isinstance(resp, list) - - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_load_all_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenantId": "T2AAAA"}, - follow_redirects=True, - ) - - async def test_configure_auth_type(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.configure_auth_type("tenant-id", "none")) - - # Test success flow (disable a specific configuration) - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.sso.configure_auth_type("tenant-id", "none", sso_id="sso-1")) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_auth_type_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "authType": "none", - "ssoId": "sso-1", - }, - follow_redirects=False, - ) - - # Test success flow (the default configuration carries no ssoId) - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke(client.mgmt.sso.configure_auth_type("tenant-id", "saml")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_auth_type_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "tenant-id", - "authType": "saml", - }, - follow_redirects=False, - ) - - async def test_new_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.sso.new_settings("tenant-id", "My SSO")) - - # Test success flow - resp_data = json.loads("""{"tenant": {"id": "T2AAAA"}, "saml": {}}""") - with client.mock_mgmt_post(make_response(resp_data)) as mock_post: - resp = await client.invoke(client.mgmt.sso.new_settings("T2AAAA", "My SSO", sso_id="sso123")) - assert resp.get("tenant", {}).get("id") == "T2AAAA" - - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.sso_new_settings_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantId": "T2AAAA", - "displayName": "My SSO", - "ssoId": "sso123", - }, - follow_redirects=False, - ) diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py deleted file mode 100644 index c2b304ff0..000000000 --- a/tests/management/test_tenant.py +++ /dev/null @@ -1,642 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import ( - MgmtV1, - SSOSetupSuiteSettings, - SSOSetupSuiteSettingsDisabledFeatures, -) -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - -MGMT_HEADERS = { - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, -} - - -class TestTenant: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.create("valid-name")) - - # Test success flow - with client.mock_mgmt_post(make_response({"id": "t1"})) as mock_post: - resp = await client.invoke(client.mgmt.tenant.create("name", "t1", ["domain.com"])) - assert resp["id"] == "t1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_create_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "name": "name", - "id": "t1", - "selfProvisioningDomains": ["domain.com"], - "enforceSSO": False, - "disabled": False, - }, - follow_redirects=False, - ) - - # Test success flow with custom attributes, enforce_sso, disabled - with client.mock_mgmt_post(make_response({"id": "t1"})) as mock_post: - resp = await client.invoke( - client.mgmt.tenant.create( - "name", - "t1", - ["domain.com"], - {"k1": "v1"}, - enforce_sso=True, - enforce_sso_exclusions=["user1", "user2"], - federated_app_ids=["app1", "app2"], - disabled=True, - ) - ) - assert resp["id"] == "t1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_create_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "name": "name", - "id": "t1", - "selfProvisioningDomains": ["domain.com"], - "customAttributes": {"k1": "v1"}, - "enforceSSO": True, - "enforceSSOExclusions": ["user1", "user2"], - "federatedAppIds": ["app1", "app2"], - "disabled": True, - }, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.update("valid-id", "valid-name")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.tenant.update("t1", "new-name", ["domain.com"], enforce_sso=True, disabled=True) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_update_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "name": "new-name", - "id": "t1", - "selfProvisioningDomains": ["domain.com"], - "enforceSSO": True, - "disabled": True, - }, - follow_redirects=False, - ) - - # Test success flow with custom attributes, enforce_sso, disabled - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.tenant.update( - "t1", - "new-name", - ["domain.com"], - {"k1": "v1"}, - enforce_sso=True, - enforce_sso_exclusions=["user1", "user2"], - federated_app_ids=["app1", "app2"], - disabled=True, - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_update_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "name": "new-name", - "id": "t1", - "selfProvisioningDomains": ["domain.com"], - "customAttributes": {"k1": "v1"}, - "enforceSSO": True, - "enforceSSOExclusions": ["user1", "user2"], - "federatedAppIds": ["app1", "app2"], - "disabled": True, - }, - follow_redirects=False, - ) - - async def test_patch_tenant(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_patch(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.patch_tenant("valid-id")) - - # Test success flow with id only - with client.mock_mgmt_patch(make_response()) as mock_patch: - result = await client.invoke(client.mgmt.tenant.patch_tenant("t1")) - assert result is None - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_patch_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "t1"}, - follow_redirects=False, - ) - - # Test success flow with all fields - with client.mock_mgmt_patch(make_response()) as mock_patch: - result = await client.invoke( - client.mgmt.tenant.patch_tenant( - "t1", - name="new-name", - self_provisioning_domains=["domain.com"], - custom_attributes={"k1": "v1"}, - disabled=True, - enforce_sso=True, - enforce_sso_exclusions=["user1", "user2"], - federated_app_ids=["app1", "app2"], - role_inheritance="tenant2", - ) - ) - assert result is None - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_patch_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "id": "t1", - "name": "new-name", - "selfProvisioningDomains": ["domain.com"], - "customAttributes": {"k1": "v1"}, - "disabled": True, - "enforceSSO": True, - "enforceSSOExclusions": ["user1", "user2"], - "federatedAppIds": ["app1", "app2"], - "roleInheritance": "tenant2", - }, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.delete("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.tenant.delete("t1", True)) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_delete_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "t1", "cascade": True}, - follow_redirects=False, - ) - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.load("valid-id")) - - # Test success flow - with client.mock_mgmt_get( - make_response( - {"id": "t1", "name": "tenant1", "selfProvisioningDomains": ["domain1.com"], "createdTime": 172606520} - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.tenant.load("t1")) - assert resp["name"] == "tenant1" - assert resp["createdTime"] == 172606520 - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_load_path}", - headers=MGMT_HEADERS, - params={"id": "t1"}, - follow_redirects=True, - ) - - async def test_load_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.load_all()) - - # Test success flow - with client.mock_mgmt_get( - make_response( - { - "tenants": [ - { - "id": "t1", - "name": "tenant1", - "selfProvisioningDomains": ["domain1.com"], - "createdTime": 172606520, - }, - { - "id": "t2", - "name": "tenant2", - "selfProvisioningDomains": ["domain1.com"], - "createdTime": 172606520, - }, - ] - } - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.tenant.load_all()) - tenants = resp["tenants"] - assert len(tenants) == 2 - assert tenants[0]["name"] == "tenant1" - assert tenants[1]["name"] == "tenant2" - assert tenants[0]["createdTime"] == 172606520 - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_load_all_path}", - headers=MGMT_HEADERS, - params=None, - follow_redirects=True, - ) - - async def test_search_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.search_all()) - - # Test success flow - with client.mock_mgmt_post( - make_response( - { - "tenants": [ - {"id": "t1", "name": "tenant1", "selfProvisioningDomains": ["domain1.com"]}, - {"id": "t2", "name": "tenant2", "selfProvisioningDomains": ["domain1.com"]}, - ] - } - ) - ) as mock_post: - resp = await client.invoke( - client.mgmt.tenant.search_all( - ids=["id1"], - names=["name1"], - custom_attributes={"k1": "v1"}, - self_provisioning_domains=["spd1"], - ) - ) - tenants = resp["tenants"] - assert len(tenants) == 2 - assert tenants[0]["name"] == "tenant1" - assert tenants[1]["name"] == "tenant2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_search_all_path}", - headers=MGMT_HEADERS, - json={ - "tenantIds": ["id1"], - "tenantNames": ["name1"], - "tenantSelfProvisioningDomains": ["spd1"], - "customAttributes": {"k1": "v1"}, - }, - follow_redirects=False, - params=None, - ) - - async def test_update_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.update_settings("valid-id", {})) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke( - client.mgmt.tenant.update_settings( - "t1", - self_provisioning_domains=["domain1.com"], - domains=["domain1.com", "domain2.com"], - auth_type="oidc", - session_settings_enabled=True, - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", - headers=MGMT_HEADERS, - json={ - "tenantId": "t1", - "selfProvisioningDomains": ["domain1.com"], - "domains": ["domain1.com", "domain2.com"], - "authType": "oidc", - "enabled": True, - }, - follow_redirects=False, - params=None, - ) - - # Test success flow with SSO Setup Suite settings - with client.mock_mgmt_post(make_response()) as mock_post: - sso_disabled_features = SSOSetupSuiteSettingsDisabledFeatures( - saml=True, oidc=False, scim=True, sso_domains=False, group_mapping=True - ) - sso_settings = SSOSetupSuiteSettings( - enabled=True, - style_id="style123", - disabled_features=sso_disabled_features, - ) - result = await client.invoke( - client.mgmt.tenant.update_settings( - "t1", - self_provisioning_domains=["domain1.com"], - domains=["domain1.com", "domain2.com"], - auth_type="oidc", - session_settings_enabled=True, - sso_setup_suite_settings=sso_settings, - ) - ) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", - headers=MGMT_HEADERS, - json={ - "tenantId": "t1", - "selfProvisioningDomains": ["domain1.com"], - "domains": ["domain1.com", "domain2.com"], - "authType": "oidc", - "enabled": True, - "ssoSetupSuiteSettings": { - "enabled": True, - "styleId": "style123", - "disabledFeatures": { - "saml": True, - "oidc": False, - "scim": True, - "ssoDomains": False, - "groupMapping": True, - }, - }, - }, - follow_redirects=False, - params=None, - ) - - async def test_load_settings(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.load_settings("valid-id")) - - # Test success flow - with client.mock_mgmt_get( - make_response( - {"domains": ["domain1.com", "domain2.com"], "authType": "oidc", "sessionSettingsEnabled": True} - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.tenant.load_settings("t1")) - assert resp["domains"] == ["domain1.com", "domain2.com"] - assert resp["authType"] == "oidc" - assert resp["sessionSettingsEnabled"] is True - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", - headers=MGMT_HEADERS, - params={"id": "t1"}, - follow_redirects=True, - ) - - async def test_update_default_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.update_default_roles("valid-id", ["role1"])) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - result = await client.invoke(client.mgmt.tenant.update_default_roles("t1", ["role1", "role2"])) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_update_default_roles_path}", - headers=MGMT_HEADERS, - params=None, - json={"id": "t1", "defaultRoles": ["role1", "role2"]}, - follow_redirects=False, - ) - - # Test load_settings with SSO Setup Suite settings response - with client.mock_mgmt_get( - make_response( - { - "domains": ["domain1.com", "domain2.com"], - "authType": "oidc", - "sessionSettingsEnabled": True, - "ssoSetupSuiteSettings": { - "enabled": True, - "styleId": "style123", - "disabledFeatures": { - "saml": True, - "oidc": False, - "scim": True, - "ssoDomains": False, - "groupMapping": True, - }, - }, - } - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.tenant.load_settings("t1")) - assert resp["domains"] == ["domain1.com", "domain2.com"] - assert resp["authType"] == "oidc" - assert resp["sessionSettingsEnabled"] is True - sso = resp["ssoSetupSuiteSettings"] - assert sso["enabled"] is True - assert sso["styleId"] == "style123" - disabled = sso["disabledFeatures"] - assert disabled["saml"] is True - assert disabled["oidc"] is False - assert disabled["scim"] is True - assert disabled["ssoDomains"] is False - assert disabled["groupMapping"] is True - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", - headers=MGMT_HEADERS, - params={"id": "t1"}, - follow_redirects=True, - ) - - async def test_generate_sso_configuration_link_success(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post( - make_response({"adminSSOConfigurationLink": "https://example.com/sso-config-link"}) - ) as mock_post: - link = await client.invoke(client.mgmt.tenant.generate_sso_configuration_link("t1", 21600)) - assert link == "https://example.com/sso-config-link" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "tenantId": "t1", - "expireTime": 21600, - }, - follow_redirects=False, - ) - - async def test_generate_sso_configuration_link_failed(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.generate_sso_configuration_link("t1", 21600)) - - async def test_generate_sso_configuration_link_with_all_params(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post( - make_response({"adminSSOConfigurationLink": "https://example.com/sso-config-link"}) - ) as mock_post: - link = await client.invoke( - client.mgmt.tenant.generate_sso_configuration_link( - tenant_id="t1", - expire_time=21600, - email="admin@example.com", - sso_id="sso123", - ) - ) - assert link == "https://example.com/sso-config-link" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", - headers=MGMT_HEADERS, - params=None, - json={ - "tenantId": "t1", - "expireTime": 21600, - "email": "admin@example.com", - "ssoId": "sso123", - }, - follow_redirects=False, - ) - - async def test_generate_sso_configuration_link_minimal_params(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post( - make_response({"adminSSOConfigurationLink": "https://example.com/sso-config-link"}) - ) as mock_post: - link = await client.invoke(client.mgmt.tenant.generate_sso_configuration_link("t1")) - assert link == "https://example.com/sso-config-link" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", - headers=MGMT_HEADERS, - params=None, - json={"tenantId": "t1"}, - follow_redirects=False, - ) - - async def test_generate_sso_configuration_link_with_actor(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post( - make_response({"adminSSOConfigurationLink": "https://example.com/sso-config-link"}) - ) as mock_post: - link = await client.invoke( - client.mgmt.tenant.generate_sso_configuration_link( - tenant_id="t1", - actor_id="admin-actor-1", - ) - ) - assert link == "https://example.com/sso-config-link" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", - headers=MGMT_HEADERS, - params=None, - json={"tenantId": "t1", "actorId": "admin-actor-1"}, - follow_redirects=False, - ) - - async def test_revoke_sso_configuration_link(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flow - with client.mock_mgmt_post(make_response(status=500)) as _: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.tenant.revoke_sso_configuration_link(tenant_id="t1")) - - # Test success flow - with client.mock_mgmt_post(make_response()) as mock_post: - await client.invoke( - client.mgmt.tenant.revoke_sso_configuration_link( - tenant_id="t1", - sso_id="sso123", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.tenant_revoke_sso_configuration_link_path}", - headers=MGMT_HEADERS, - params=None, - json={"tenantId": "t1", "ssoId": "sso123"}, - follow_redirects=False, - ) diff --git a/tests/management/test_third_party_application.py b/tests/management/test_third_party_application.py deleted file mode 100644 index f108fdd35..000000000 --- a/tests/management/test_third_party_application.py +++ /dev/null @@ -1,576 +0,0 @@ -import pytest - -from descope import AuthException -from descope.management.common import MgmtV1 -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestThirdPartyApplication: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.third_party_application.create( - "valid-name", - "http://dummy.com", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response({"id": "app1", "cleartext": "secret123"})) as mock_post: - resp = await client.invoke( - client.mgmt.third_party_application.create( - name="test-app", - login_page_url="http://dummy.com", - description="Test application", - approved_callback_urls=["http://callback.com"], - force_pkce=True, - ) - ) - assert resp["id"] == "app1" - assert resp["cleartext"] == "secret123" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "name": "test-app", - "loginPageUrl": "http://dummy.com", - "description": "Test application", - "approvedCallbackUrls": ["http://callback.com"], - "forcePkce": True, - }, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.third_party_application.update( - "app1", - "valid-name", - "http://dummy.com", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.third_party_application.update( - id="app1", - name="updated-app", - login_page_url="http://updated.com", - description="Updated application", - logo="http://logo.png", - default_audience="projectId", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "app1", - "name": "updated-app", - "loginPageUrl": "http://updated.com", - "description": "Updated application", - "logo": "http://logo.png", - "defaultAudience": "projectId", - }, - follow_redirects=False, - ) - - async def test_patch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.third_party_application.patch( - "app1", - name="new-name", - ) - ) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.third_party_application.patch( - id="app1", - name="patched-app", - login_page_url="https://login.example.com", - description="patched description", - logo="logo-data", - approved_callback_urls=["https://cb.example.com"], - permissions_scopes=[{"name": "read", "description": "Read", "values": ["roleA"]}], - attributes_scopes=[{"name": "email", "description": "Email", "values": ["email"]}], - jwt_bearer_settings={"audience": "aud"}, - custom_attributes={"team": "blue"}, - force_pkce=False, - default_audience="my-audience", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_patch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "id": "app1", - "name": "patched-app", - "loginPageUrl": "https://login.example.com", - "description": "patched description", - "logo": "logo-data", - "approvedCallbackUrls": ["https://cb.example.com"], - "permissionsScopes": [{"name": "read", "description": "Read", "values": ["roleA"]}], - "attributesScopes": [{"name": "email", "description": "Email", "values": ["email"]}], - "jwtBearerSettings": {"audience": "aud"}, - "customAttributes": {"team": "blue"}, - "forcePkce": False, - "defaultAudience": "my-audience", - }, - follow_redirects=False, - ) - - @pytest.mark.parametrize( - "client_type_kwargs, client_type_body", - [ - ({}, {}), - ({"client_type": "confidential"}, {"clientType": "confidential"}), - ({"client_type": "public"}, {"clientType": "public"}), - ], - ) - @pytest.mark.parametrize( - "method, path, args, base_body", - [ - ( - "create", - MgmtV1.thirdparty_application_create_path, - {"name": "test-app", "login_page_url": "http://dummy.com"}, - {"name": "test-app", "loginPageUrl": "http://dummy.com"}, - ), - ( - "update", - MgmtV1.thirdparty_application_update_path, - {"id": "app1", "name": "test-app", "login_page_url": "http://dummy.com"}, - {"id": "app1", "name": "test-app", "loginPageUrl": "http://dummy.com"}, - ), - ( - "patch", - MgmtV1.thirdparty_application_patch_path, - {"id": "app1"}, - {"id": "app1"}, - ), - ], - ) - async def test_client_type( - self, client_factory, method, path, args, base_body, client_type_kwargs, client_type_body - ): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(getattr(client.mgmt.third_party_application, method)(**args, **client_type_kwargs)) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={**base_body, **client_type_body}, - follow_redirects=False, - ) - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.third_party_application.delete("app1")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.third_party_application.delete("app1")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"id": "app1"}, - follow_redirects=False, - ) - - async def test_delete_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.third_party_application.delete_batch(["app1", "app2"])) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.third_party_application.delete_batch(["app1", "app2"])) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"ids": ["app1", "app2"]}, - follow_redirects=False, - ) - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.third_party_application.load("app1")) - - # Test success flow - with client.mock_mgmt_get( - make_response( - { - "id": "app1", - "name": "test-app", - "loginPageUrl": "http://dummy.com", - "clientId": "client123", - } - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.third_party_application.load("app1")) - assert resp["id"] == "app1" - assert resp["name"] == "test-app" - assert resp["clientId"] == "client123" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_load_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"id": "app1"}, - follow_redirects=True, - ) - - async def test_load_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.third_party_application.load_all()) - - # Test success flow - with client.mock_mgmt_get( - make_response( - { - "apps": [ - {"id": "app1", "name": "test-app1"}, - {"id": "app2", "name": "test-app2"}, - ] - } - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.third_party_application.load_all()) - assert len(resp["apps"]) == 2 - assert resp["apps"][0]["id"] == "app1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_load_all_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - follow_redirects=True, - ) - - async def test_rotate_secret(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.third_party_application.rotate_secret("app1")) - - # Test success flow - with client.mock_mgmt_post(make_response({"cleartext": "new-secret123"})) as mock_post: - resp = await client.invoke(client.mgmt.third_party_application.rotate_secret("app1")) - assert resp["cleartext"] == "new-secret123" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_rotate_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"id": "app1"}, - follow_redirects=False, - ) - - async def test_get_secret(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.third_party_application.get_secret("app1")) - - # Test success flow - with client.mock_mgmt_get(make_response({"cleartext": "secret123"})) as mock_get: - resp = await client.invoke(client.mgmt.third_party_application.get_secret("app1")) - assert resp["cleartext"] == "secret123" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_secret_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"id": "app1"}, - follow_redirects=True, - ) - - async def test_create_consent(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.third_party_application.create_consent("app1", "user1", ["openid", "read"]) - ) - - # Test success flow with only the required fields - with client.mock_mgmt_post( - make_response({"consent": {"id": "c1", "appId": "app1", "userId": "user1", "scopes": ["openid", "read"]}}) - ) as mock_post: - resp = await client.invoke( - client.mgmt.third_party_application.create_consent("app1", "user1", ["openid", "read"]) - ) - assert resp["consent"]["id"] == "c1" - assert resp["consent"]["scopes"] == ["openid", "read"] - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_consents_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "appId": "app1", - "userId": "user1", - "scopes": ["openid", "read"], - }, - follow_redirects=False, - ) - - # Test success flow with all fields - with client.mock_mgmt_post( - make_response( - { - "consent": { - "id": "c1", - "appId": "app1", - "userId": "user1", - "scopes": ["openid"], - "tenantId": "tenant1", - "ExpireTime": 1700000000, - } - } - ) - ) as mock_post: - resp = await client.invoke( - client.mgmt.third_party_application.create_consent( - app_id="app1", - user_id="user1", - scopes=["openid"], - tenant_id="tenant1", - expire_time=1700000000, - ) - ) - assert resp["consent"]["tenantId"] == "tenant1" - assert resp["consent"]["ExpireTime"] == 1700000000 - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_consents_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "appId": "app1", - "userId": "user1", - "scopes": ["openid"], - "tenantId": "tenant1", - "expireTime": 1700000000, - }, - follow_redirects=False, - ) - - async def test_delete_consents(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke( - client.mgmt.third_party_application.delete_consents(app_id="app1", user_ids=["user1"]) - ) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.third_party_application.delete_consents( - consent_ids=["c1", "c2"], - app_id="app1", - user_ids=["user1"], - tenant_id="tenant1", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_consents_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "consentIds": ["c1", "c2"], - "appId": "app1", - "userIds": ["user1"], - "tenantId": "tenant1", - }, - follow_redirects=False, - ) - - async def test_delete_tenant_consents(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.third_party_application.delete_tenant_consents("tenant1")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke(client.mgmt.third_party_application.delete_tenant_consents("tenant1")) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_consents_delete_tenant_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"tenantId": "tenant1"}, - follow_redirects=False, - ) - - async def test_search_consents(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.third_party_application.search_consents(app_id="app1")) - - # Test success flow - with client.mock_mgmt_post( - make_response({"consents": [{"consentId": "c1", "appId": "app1", "userId": "user1"}]}) - ) as mock_post: - resp = await client.invoke( - client.mgmt.third_party_application.search_consents( - app_id="app1", - user_id="user1", - consent_id="c1", - page=1, - limit=10, - tenant_id="tenant1", - ) - ) - assert len(resp["consents"]) == 1 - assert resp["consents"][0]["consentId"] == "c1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_consents_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "appId": "app1", - "userId": "user1", - "consentId": "c1", - "page": 1, - "limit": 10, - "tenantId": "tenant1", - }, - follow_redirects=False, - ) diff --git a/tests/management/test_user.py b/tests/management/test_user.py deleted file mode 100644 index e05d65072..000000000 --- a/tests/management/test_user.py +++ /dev/null @@ -1,3210 +0,0 @@ -import pytest - -from descope import AssociatedFamily, AssociatedTenant, AuthException, CustomAttribute -from descope.common import DeliveryMethod, LoginOptions -from descope.management.common import MgmtV1, Sort -from descope.management.user import UserObj -from descope.management.user_pwd import ( - UserPassword, - UserPasswordBcrypt, - UserPasswordDjango, - UserPasswordFirebase, - UserPasswordPbkdf2, -) -from tests.common import DEFAULT_BASE_URL, default_headers -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT - - -class TestUser: - async def test_create(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.create("valid-id")) - - with pytest.raises(AuthException) as exc_info: - await client.invoke(client.mgmt.user.create("valid-id", status="invalid_status")) - assert "Invalid status value: invalid_status" in str(exc_info.value) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke( - client.mgmt.user.create( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - picture="https://test.com", - custom_attributes={"ak": "av"}, - additional_login_ids=["id-1", "id-2"], - sso_app_ids=["app1", "app2"], - status="disabled", - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "name@mail.com", - "email": "name@mail.com", - "phone": None, - "displayName": "Name", - "roleNames": [], - "userTenants": [ - {"tenantId": "tenant1", "roleNames": []}, - {"tenantId": "tenant2", "roleNames": ["role1", "role2"]}, - ], - "test": False, - "picture": "https://test.com", - "customAttributes": {"ak": "av"}, - "invite": False, - "additionalLoginIds": ["id-1", "id-2"], - "ssoAppIDs": ["app1", "app2"], - "status": "disabled", - }, - follow_redirects=False, - ) - - async def test_create_with_verified_parameters(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test success flow with verified email and phone - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke( - client.mgmt.user.create( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - picture="https://test.com", - custom_attributes={"ak": "av"}, - verified_email=True, - verified_phone=False, - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "name@mail.com", - "email": "name@mail.com", - "phone": None, - "displayName": "Name", - "roleNames": [], - "userTenants": [ - {"tenantId": "tenant1", "roleNames": []}, - {"tenantId": "tenant2", "roleNames": ["role1", "role2"]}, - ], - "test": False, - "picture": "https://test.com", - "customAttributes": {"ak": "av"}, - "invite": False, - "verifiedEmail": True, - "verifiedPhone": False, - "additionalLoginIds": None, - "ssoAppIDs": None, - }, - follow_redirects=False, - ) - - async def test_create_test_user(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.create("valid-id")) - - with pytest.raises(AuthException) as exc_info: - await client.invoke(client.mgmt.user.create_test_user("valid-id", status="invalid_status")) - assert "Invalid status value: invalid_status" in str(exc_info.value) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke( - client.mgmt.user.create_test_user( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - custom_attributes={"ak": "av"}, - status="disabled", - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.test_user_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "name@mail.com", - "email": "name@mail.com", - "phone": None, - "displayName": "Name", - "roleNames": [], - "userTenants": [ - {"tenantId": "tenant1", "roleNames": []}, - {"tenantId": "tenant2", "roleNames": ["role1", "role2"]}, - ], - "test": True, - "picture": None, - "customAttributes": {"ak": "av"}, - "invite": False, - "additionalLoginIds": None, - "ssoAppIDs": None, - "status": "disabled", - }, - follow_redirects=False, - ) - - async def test_invite(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.invite("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke( - client.mgmt.user.invite( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - custom_attributes={"ak": "av"}, - invite_url="invite.me", - send_sms=True, - sso_app_ids=["app1", "app2"], - template_id="tid", - locale="en", - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_create_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "name@mail.com", - "email": "name@mail.com", - "phone": None, - "displayName": "Name", - "roleNames": [], - "userTenants": [ - {"tenantId": "tenant1", "roleNames": []}, - {"tenantId": "tenant2", "roleNames": ["role1", "role2"]}, - ], - "test": False, - "picture": None, - "customAttributes": {"ak": "av"}, - "invite": True, - "inviteUrl": "invite.me", - "sendSMS": True, - "additionalLoginIds": None, - "ssoAppIDs": ["app1", "app2"], - "templateId": "tid", - "locale": "en", - }, - follow_redirects=False, - ) - - async def test_invite_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.invite_batch([])) - - # Test success flow - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}]})) as mock_post: - user = UserObj( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - custom_attributes={"ak": "av"}, - sso_app_ids=["app1", "app2"], - password=UserPassword( - hashed=UserPasswordFirebase( - hash="h", - salt="s", - salt_separator="sp", - signer_key="sk", - memory=14, - rounds=8, - ), - ), - seed="aaa", - status="invited", - ) - resp = await client.invoke( - client.mgmt.user.invite_batch( - users=[user], - invite_url="invite.me", - send_sms=True, - locale="en", - template_options={"k1": "v1"}, - template_id="tmpl-1", - ) - ) - users = resp["users"] - assert users[0]["id"] == "u1" - - expected_users = { - "users": [ - { - "loginId": "name@mail.com", - "email": "name@mail.com", - "phone": None, - "displayName": "Name", - "roleNames": [], - "userTenants": [ - {"tenantId": "tenant1", "roleNames": []}, - { - "tenantId": "tenant2", - "roleNames": ["role1", "role2"], - }, - ], - "test": False, - "picture": None, - "customAttributes": {"ak": "av"}, - "additionalLoginIds": None, - "ssoAppIDs": ["app1", "app2"], - "hashedPassword": { - "firebase": { - "hash": "h", - "salt": "s", - "saltSeparator": "sp", - "signerKey": "sk", - "memory": 14, - "rounds": 8, - } - }, - "seed": "aaa", - "status": "invited", - }, - ], - "invite": True, - "inviteUrl": "invite.me", - "sendSMS": True, - "templateOptions": {"k1": "v1"}, - "templateId": "tmpl-1", - "locale": "en", - } - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json=expected_users, - follow_redirects=False, - ) - - bcrypt = UserPasswordBcrypt(hash="h") - assert bcrypt.to_dict() == {"bcrypt": {"hash": "h"}} - - pbkdf2 = UserPasswordPbkdf2(hash="h", salt="s", iterations=14, variant="sha256") - assert pbkdf2.to_dict() == { - "pbkdf2": { - "hash": "h", - "salt": "s", - "iterations": 14, - "type": "sha256", - } - } - - django = UserPasswordDjango(hash="h") - assert django.to_dict() == {"django": {"hash": "h"}} - - user.password = UserPassword(cleartext="clear") - resp = await client.invoke( - client.mgmt.user.invite_batch( - users=[user], - invite_url="invite.me", - send_sms=True, - locale="en", - ) - ) - - del expected_users["templateOptions"] - del expected_users["templateId"] - del expected_users["users"][0]["hashedPassword"] - expected_users["users"][0]["password"] = "clear" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json=expected_users, - follow_redirects=False, - ) - - user.password = None - resp = await client.invoke( - client.mgmt.user.invite_batch( - users=[user], - invite_url="invite.me", - send_sms=True, - locale="en", - ) - ) - - del expected_users["users"][0]["password"] - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json=expected_users, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update("valid-id", "email@something.com")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke( - client.mgmt.user.update( - "id", - display_name="new-name", - role_names=["domain.com"], - picture="https://test.com", - custom_attributes={"ak": "av"}, - sso_app_ids=["app1", "app2"], - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id", - "email": None, - "phone": None, - "displayName": "new-name", - "roleNames": ["domain.com"], - "userTenants": [], - "familyAssociations": [], - "test": False, - "picture": "https://test.com", - "customAttributes": {"ak": "av"}, - "additionalLoginIds": None, - "ssoAppIDs": ["app1", "app2"], - }, - follow_redirects=False, - ) - - # Test success flow with verified flags - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update("id", verified_email=True, verified_phone=False)) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id", - "email": None, - "phone": None, - "displayName": None, - "roleNames": [], - "userTenants": [], - "familyAssociations": [], - "test": False, - "picture": None, - "customAttributes": None, - "verifiedEmail": True, - "verifiedPhone": False, - "additionalLoginIds": None, - "ssoAppIDs": None, - }, - follow_redirects=False, - ) - - async def test_patch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_patch(make_response(status=500)) as mock_patch: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.patch("valid-id", "email@something.com")) - - # Test success flow with some params set - with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: - resp = await client.invoke( - client.mgmt.user.patch( - "id", - display_name="new-name", - email=None, - phone=None, - given_name=None, - role_names=["domain.com"], - user_tenants=None, - picture="https://test.com", - custom_attributes={"ak": "av"}, - sso_app_ids=["app1", "app2"], - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id", - "displayName": "new-name", - "roleNames": ["domain.com"], - "picture": "https://test.com", - "customAttributes": {"ak": "av"}, - "ssoAppIds": ["app1", "app2"], - }, - follow_redirects=False, - ) - - # Test success flow with other params - with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: - resp = await client.invoke( - client.mgmt.user.patch( - "id", - email="a@test.com", - phone="+123456789", - given_name="given", - middle_name="middle", - family_name="family", - role_names=None, - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - custom_attributes=None, - verified_email=True, - verified_phone=False, - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id", - "email": "a@test.com", - "phone": "+123456789", - "givenName": "given", - "middleName": "middle", - "familyName": "family", - "verifiedEmail": True, - "verifiedPhone": False, - "userTenants": [ - {"tenantId": "tenant1", "roleNames": []}, - {"tenantId": "tenant2", "roleNames": ["role1", "role2"]}, - ], - }, - follow_redirects=False, - ) - - async def test_patch_with_status(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test invalid status value - with pytest.raises(AuthException) as exc_info: - await client.invoke(client.mgmt.user.patch("valid-id", status="invalid_status")) - - assert exc_info.value.status_code == 400 - assert "Invalid status value: invalid_status" in str(exc_info.value) - - # Test valid status values - valid_statuses = ["enabled", "disabled", "invited"] - - for status in valid_statuses: - with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: - resp = await client.invoke(client.mgmt.user.patch("id", status=status)) - user = resp["user"] - assert user["id"] == "u1" - - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id", - "status": status, - }, - follow_redirects=False, - ) - - # Test that status is not included when None - with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: - resp = await client.invoke(client.mgmt.user.patch("id", display_name="test", status=None)) - user = resp["user"] - assert user["id"] == "u1" - - # Verify that status is not in the JSON payload - call_args = mock_patch.call_args - json_payload = call_args[1]["json"] - assert "status" not in json_payload - assert json_payload["displayName"] == "test" - - async def test_patch_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test invalid status value in batch - users_with_invalid_status = [ - UserObj(login_id="user1", status="invalid_status"), - UserObj(login_id="user2", status="enabled"), - ] - - with pytest.raises(AuthException) as exc_info: - await client.invoke(client.mgmt.user.patch_batch(users_with_invalid_status)) - - assert exc_info.value.status_code == 400 - assert "Invalid status value: invalid_status for user user1" in str(exc_info.value) - - # Test successful batch patch - users = [ - UserObj(login_id="user1", email="user1@test.com", status="enabled"), - UserObj(login_id="user2", display_name="User Two", status="disabled"), - UserObj(login_id="user3", phone="+123456789", status="invited"), - ] - - with client.mock_mgmt_patch( - make_response({"patchedUsers": [{"id": "u1"}, {"id": "u2"}, {"id": "u3"}], "failedUsers": []}) - ) as mock_patch: - resp = await client.invoke(client.mgmt.user.patch_batch(users)) - - assert len(resp["patchedUsers"]) == 3 - assert len(resp["failedUsers"]) == 0 - - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "users": [ - { - "loginId": "user1", - "email": "user1@test.com", - "status": "enabled", - }, - { - "loginId": "user2", - "displayName": "User Two", - "status": "disabled", - }, - { - "loginId": "user3", - "phone": "+123456789", - "status": "invited", - }, - ] - }, - follow_redirects=False, - ) - - # Test batch with mixed success/failure response - with client.mock_mgmt_patch( - make_response( - { - "patchedUsers": [{"id": "u1"}], - "failedUsers": [{"failure": "User not found", "user": {"loginId": "user2"}}], - } - ) - ) as mock_patch: - resp = await client.invoke( - client.mgmt.user.patch_batch([UserObj(login_id="user1"), UserObj(login_id="user2")]) - ) - - assert len(resp["patchedUsers"]) == 1 - assert len(resp["failedUsers"]) == 1 - assert resp["failedUsers"][0]["failure"] == "User not found" - - # Test failed batch operation - with client.mock_mgmt_patch(make_response(status=500)) as mock_patch: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.patch_batch([UserObj(login_id="user1")])) - - # Test with test users flag - with client.mock_mgmt_patch(make_response({"patchedUsers": [{"id": "u1"}], "failedUsers": []})) as mock_patch: - await client.invoke(client.mgmt.user.patch_batch([UserObj(login_id="test_user1")], test=True)) - - call_args = mock_patch.call_args - json_payload = call_args[1]["json"] - assert json_payload["users"][0]["test"] - - async def test_delete(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.delete("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert await client.invoke(client.mgmt.user.delete("u1")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "u1", - }, - follow_redirects=False, - ) - - async def test_delete_by_user_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.delete_by_user_id("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert await client.invoke(client.mgmt.user.delete_by_user_id("u1")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "userId": "u1", - }, - follow_redirects=False, - ) - - async def test_logout(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.logout_user("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert await client.invoke(client.mgmt.user.logout_user("u1")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_logout_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "u1", - }, - follow_redirects=False, - ) - - async def test_logout_by_user_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.logout_user_by_user_id("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert await client.invoke(client.mgmt.user.logout_user_by_user_id("u1")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_logout_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "userId": "u1", - }, - follow_redirects=False, - ) - - async def test_delete_all_test_users(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_delete(make_response(status=500)) as mock_delete: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.delete_all_test_users()) - - # Test success flow - with client.mock_mgmt_delete(make_response({})) as mock_delete: - assert await client.invoke(client.mgmt.user.delete_all_test_users()) is None - assert_http_called( - mock_delete, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_all_test_users_path}", - params=None, - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=False, - ) - - async def test_load(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.load("valid-id")) - - # Test success flow - with client.mock_mgmt_get(make_response({"user": {"id": "u1"}})) as mock_get: - resp = await client.invoke(client.mgmt.user.load("valid-id")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_load_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"loginId": "valid-id"}, - follow_redirects=True, - ) - - async def test_load_by_user_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.load_by_user_id("user-id")) - - # Test success flow - with client.mock_mgmt_get(make_response({"user": {"id": "u1"}})) as mock_get: - resp = await client.invoke(client.mgmt.user.load_by_user_id("user-id")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_load_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={"userId": "user-id"}, - follow_redirects=True, - ) - - async def test_load_users(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.load_users([""])) - - with client.mock_mgmt_post(make_response({})) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.load_users(None, False)) - - # Test success flow - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.load_users( - ["uid"], - include_invalid_users=True, - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.users_load_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "userIds": ["uid"], - "includeInvalidUsers": True, - }, - follow_redirects=False, - ) - - async def test_search_all(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.search_all(["t1, t2"], ["r1", "r2"])) - - with client.mock_mgmt_post(make_response({})) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.search_all([], [], -1, 0)) - - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.search_all([], [], 0, -1)) - - # Test success flow - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all( - ["t1, t2"], - ["r1", "r2"], - with_test_user=True, - sso_app_ids=["app1"], - login_ids=["l1"], - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1, t2"], - "roleNames": ["r1", "r2"], - "limit": 0, - "page": 0, - "testUsersOnly": False, - "withTestUser": True, - "ssoAppIds": ["app1"], - "loginIds": ["l1"], - }, - follow_redirects=False, - ) - - # Test success flow with lock reason filters - with client.mock_mgmt_post( - make_response({"users": [{"id": "u1", "lockReason": "password", "tempLockExpiration": 1791105360}]}) - ) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all( - ["t1"], - ["r1"], - lock_reasons=["password", "totp"], - temp_lock_reasons=["recovery_codes"], - ) - ) - users = resp["users"] - assert users[0]["lockReason"] == "password" - assert users[0]["tempLockExpiration"] == 1791105360 - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1"], - "roleNames": ["r1"], - "limit": 0, - "page": 0, - "testUsersOnly": False, - "withTestUser": False, - "lockReasons": ["password", "totp"], - "tempLockReasons": ["recovery_codes"], - }, - follow_redirects=False, - ) - - # Test success flow with text and sort - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - sort = [Sort(field="kuku", desc=True), Sort(field="bubu")] - resp = await client.invoke( - client.mgmt.user.search_all( - ["t1, t2"], - ["r1", "r2"], - with_test_user=True, - sso_app_ids=["app1"], - text="blue", - sort=sort, - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1, t2"], - "roleNames": ["r1", "r2"], - "limit": 0, - "page": 0, - "testUsersOnly": False, - "withTestUser": True, - "ssoAppIds": ["app1"], - "text": "blue", - "sort": [ - {"desc": True, "field": "kuku"}, - {"desc": False, "field": "bubu"}, - ], - }, - follow_redirects=False, - ) - - # Test success flow with custom attributes - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all( - ["t1, t2"], - ["r1", "r2"], - with_test_user=True, - custom_attributes={"ak": "av"}, - statuses=["invited"], - phones=["+111111"], - emails=["a@b.com"], - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1, t2"], - "roleNames": ["r1", "r2"], - "limit": 0, - "page": 0, - "testUsersOnly": False, - "withTestUser": True, - "customAttributes": {"ak": "av"}, - "statuses": ["invited"], - "emails": ["a@b.com"], - "phones": ["+111111"], - }, - follow_redirects=False, - ) - - # Test success flow with time parameters - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all( - from_created_time=100, - to_created_time=200, - from_modified_time=300, - to_modified_time=400, - limit=10, - page=0, - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": [], - "roleNames": [], - "limit": 10, - "page": 0, - "testUsersOnly": False, - "withTestUser": False, - "fromCreatedTime": 100, - "toCreatedTime": 200, - "fromModifiedTime": 300, - "toModifiedTime": 400, - }, - follow_redirects=False, - ) - - # Test success flow with tenant_role_ids and tenant_role_names - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all( - tenant_role_ids={"tenant1": {"values": ["roleA", "roleB"], "and": True}}, - tenant_role_names={"tenant2": {"values": ["admin", "user"], "and": False}}, - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": [], - "roleNames": [], - "limit": 0, - "page": 0, - "testUsersOnly": False, - "withTestUser": False, - "tenantRoleIds": {"tenant1": {"values": ["roleA", "roleB"], "and": True}}, - "tenantRoleNames": {"tenant2": {"values": ["admin", "user"], "and": False}}, - }, - follow_redirects=False, - ) - - async def test_search_all_test_users(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.search_all_test_users(["t1, t2"], ["r1", "r2"])) - - with client.mock_mgmt_post(make_response({})) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.search_all_test_users([], [], -1, 0)) - - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.search_all_test_users([], [], 0, -1)) - - # Test success flow - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all_test_users( - ["t1, t2"], - ["r1", "r2"], - sso_app_ids=["app1"], - login_ids=["l1"], - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1, t2"], - "roleNames": ["r1", "r2"], - "limit": 0, - "page": 0, - "testUsersOnly": True, - "withTestUser": True, - "ssoAppIds": ["app1"], - "loginIds": ["l1"], - }, - follow_redirects=False, - ) - - # Test success flow with lock reason filters - with client.mock_mgmt_post( - make_response({"users": [{"id": "u1", "lockReason": "password", "tempLockExpiration": 1791105360}]}) - ) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all_test_users( - ["t1"], - ["r1"], - lock_reasons=["password", "totp"], - temp_lock_reasons=["recovery_codes"], - ) - ) - users = resp["users"] - assert users[0]["lockReason"] == "password" - assert users[0]["tempLockExpiration"] == 1791105360 - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1"], - "roleNames": ["r1"], - "limit": 0, - "page": 0, - "testUsersOnly": True, - "withTestUser": True, - "lockReasons": ["password", "totp"], - "tempLockReasons": ["recovery_codes"], - }, - follow_redirects=False, - ) - - # Test success flow with text and sort - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - sort = [Sort(field="kuku", desc=True), Sort(field="bubu")] - resp = await client.invoke( - client.mgmt.user.search_all_test_users( - ["t1, t2"], - ["r1", "r2"], - sso_app_ids=["app1"], - text="blue", - sort=sort, - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1, t2"], - "roleNames": ["r1", "r2"], - "limit": 0, - "page": 0, - "testUsersOnly": True, - "withTestUser": True, - "ssoAppIds": ["app1"], - "text": "blue", - "sort": [ - {"desc": True, "field": "kuku"}, - {"desc": False, "field": "bubu"}, - ], - }, - follow_redirects=False, - ) - - # Test success flow with custom attributes - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all_test_users( - ["t1, t2"], - ["r1", "r2"], - custom_attributes={"ak": "av"}, - statuses=["invited"], - phones=["+111111"], - emails=["a@b.com"], - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": ["t1, t2"], - "roleNames": ["r1", "r2"], - "limit": 0, - "page": 0, - "testUsersOnly": True, - "withTestUser": True, - "customAttributes": {"ak": "av"}, - "statuses": ["invited"], - "emails": ["a@b.com"], - "phones": ["+111111"], - }, - follow_redirects=False, - ) - - # Test success flow with time parameters - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all_test_users( - from_created_time=100, - to_created_time=200, - from_modified_time=300, - to_modified_time=400, - limit=10, - page=0, - ) - ) - users = resp["users"] - assert len(users) == 1 - assert users[0]["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": [], - "roleNames": [], - "limit": 10, - "page": 0, - "testUsersOnly": True, - "withTestUser": True, - "fromCreatedTime": 100, - "toCreatedTime": 200, - "fromModifiedTime": 300, - "toModifiedTime": 400, - }, - follow_redirects=False, - ) - - # Test success flow with tenant_role_ids and tenant_role_names - with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.search_all_test_users( - tenant_role_ids={"tenant1": {"values": ["roleA", "roleB"], "and": True}}, - tenant_role_names={"tenant2": {"values": ["admin", "user"], "and": False}}, - ) - ) - users = resp["users"] - assert len(users) == 2 - assert users[0]["id"] == "u1" - assert users[1]["id"] == "u2" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": [], - "roleNames": [], - "limit": 0, - "page": 0, - "testUsersOnly": True, - "withTestUser": True, - "tenantRoleIds": {"tenant1": {"values": ["roleA", "roleB"], "and": True}}, - "tenantRoleNames": {"tenant2": {"values": ["admin", "user"], "and": False}}, - }, - follow_redirects=False, - ) - - async def test_get_provider_token(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.get_provider_token("valid-id", "p1")) - - # Test success flow - with client.mock_mgmt_get( - make_response( - { - "provider": "p1", - "providerUserId": "puid", - "accessToken": "access123", - "refreshToken": "refresh456", - "expiration": "123123123", - "scopes": ["s1", "s2"], - } - ) - ) as mock_get: - resp = await client.invoke(client.mgmt.user.get_provider_token("valid-id", "p1", True, True)) - assert resp["provider"] == "p1" - assert resp["providerUserId"] == "puid" - assert resp["accessToken"] == "access123" - assert resp["refreshToken"] == "refresh456" - assert resp["expiration"] == "123123123" - assert resp["scopes"] == ["s1", "s2"] - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_get_provider_token}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={ - "loginId": "valid-id", - "provider": "p1", - "withRefreshToken": True, - "forceRefresh": True, - }, - follow_redirects=True, - ) - - async def test_activate(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.activate("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.activate("valid-id")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_status_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "status": "enabled", - }, - follow_redirects=False, - ) - - async def test_deactivate(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.deactivate("valid-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.deactivate("valid-id")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_status_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "status": "disabled", - }, - follow_redirects=False, - ) - - async def test_update_login_id(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update_login_id("valid-id", "a@b.c")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "a@b.c"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update_login_id("valid-id", "a@b.c")) - user = resp["user"] - assert user["id"] == "a@b.c" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_login_id_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "newLoginId": "a@b.c", - }, - follow_redirects=False, - ) - - async def test_update_email(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update_email("valid-id", "a@b.c")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update_email("valid-id", "a@b.c")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_email_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "email": "a@b.c", - "verified": None, - "failOnConflict": None, - }, - follow_redirects=False, - ) - - async def test_update_phone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update_phone("valid-id", "+18005551234")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update_phone("valid-id", "+18005551234", True)) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_phone_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "phone": "+18005551234", - "verified": True, - "failOnConflict": None, - }, - follow_redirects=False, - ) - - async def test_update_display_name(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update_display_name("valid-id", "foo")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update_display_name("valid-id", "foo")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_name_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "displayName": "foo", - }, - follow_redirects=False, - ) - - async def test_update_picture(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update_picture("valid-id", "foo")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update_picture("valid-id", "foo")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_picture_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "picture": "foo", - }, - follow_redirects=False, - ) - - async def test_update_custom_attribute(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update_custom_attribute("valid-id", "foo", "bar")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update_custom_attribute("valid-id", "foo", "bar")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_custom_attribute_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "valid-id", - "attributeKey": "foo", - "attributeValue": "bar", - }, - follow_redirects=False, - params=None, - ) - - async def test_set_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.set_roles("valid-id", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.set_roles("valid-id", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_set_role_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "roleNames": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_add_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.add_roles("valid-id", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.add_roles("valid-id", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_add_role_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "roleNames": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_remove_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_roles("valid-id", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.remove_roles("valid-id", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_role_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "roleNames": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_add_sso_apps(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.add_sso_apps("valid-id", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.add_sso_apps("valid-id", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_add_sso_apps}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "ssoAppIds": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_set_sso_apps(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.set_sso_apps("valid-id", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.set_sso_apps("valid-id", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_set_sso_apps}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "ssoAppIds": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_remove_sso_apps(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_sso_apps("valid-id", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.remove_sso_apps("valid-id", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_sso_apps}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "ssoAppIds": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_add_tenant(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.add_tenant("valid-id", "tid")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.add_tenant("valid-id", "tid")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_add_tenant_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "tenantId": "tid", - }, - follow_redirects=False, - ) - - async def test_remove_tenant(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_tenant("valid-id", "tid")) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.remove_tenant("valid-id", "tid")) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_tenant_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "tenantId": "tid", - }, - follow_redirects=False, - ) - - async def test_set_tenant_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.set_tenant_roles("valid-id", "tid", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.set_tenant_roles("valid-id", "tid", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_set_role_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "tenantId": "tid", - "roleNames": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_add_tenant_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.add_tenant_roles("valid-id", "tid", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.add_tenant_roles("valid-id", "tid", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_add_role_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "tenantId": "tid", - "roleNames": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_remove_tenant_roles(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_tenant_roles("valid-id", "tid", ["foo", "bar"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.remove_tenant_roles("valid-id", "tid", ["foo", "bar"])) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_role_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "valid-id", - "tenantId": "tid", - "roleNames": ["foo", "bar"], - }, - follow_redirects=False, - ) - - async def test_generate_otp_for_test_user(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.generate_otp_for_test_user("login-id", "email")) - - # Test success flow - with client.mock_mgmt_post(make_response({"code": "123456", "loginId": "login-id"})) as mock_post: - login_options = LoginOptions(stepup=True) - resp = await client.invoke( - client.mgmt.user.generate_otp_for_test_user(DeliveryMethod.EMAIL, "login-id", login_options) - ) - assert resp["code"] == "123456" - assert resp["loginId"] == "login-id" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_otp_for_test_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - "deliveryMethod": "email", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, - }, - follow_redirects=False, - ) - - async def test_user_set_temporary_password(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.set_temporary_password("login-id", "some-password")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.user.set_temporary_password( - "login-id", - "some-password", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_set_temporary_password_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - "password": "some-password", - "setActive": False, - }, - follow_redirects=False, - ) - - async def test_user_set_active_password(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.set_active_password("login-id", "some-password")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.user.set_active_password( - "login-id", - "some-password", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_set_active_password_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - "password": "some-password", - "setActive": True, - }, - follow_redirects=False, - ) - - async def test_user_set_password(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.set_password("login-id", "some-password")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.user.set_password( - "login-id", - "some-password", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_set_password_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - "password": "some-password", - }, - follow_redirects=False, - ) - - async def test_user_expire_password(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.expire_password("login-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.user.expire_password( - "login-id", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_expire_password_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - }, - follow_redirects=False, - ) - - async def test_user_remove_all_passkeys(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_all_passkeys("login-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.user.remove_all_passkeys( - "login-id", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_all_passkeys_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - }, - follow_redirects=False, - ) - - async def test_user_remove_totp_seed(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_totp_seed("login-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.user.remove_totp_seed( - "login-id", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_totp_seed_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - }, - follow_redirects=False, - ) - - async def test_user_remove_recovery_codes(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_recovery_codes("login-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - await client.invoke( - client.mgmt.user.remove_recovery_codes( - "login-id", - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_recovery_codes_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - }, - follow_redirects=False, - ) - - async def test_generate_magic_link_for_test_user(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.generate_magic_link_for_test_user("login-id", "email", "bla")) - - # Test success flow - with client.mock_mgmt_post(make_response({"link": "some-link", "loginId": "login-id"})) as mock_post: - login_options = LoginOptions(stepup=True) - resp = await client.invoke( - client.mgmt.user.generate_magic_link_for_test_user( - DeliveryMethod.EMAIL, "login-id", "bla", login_options - ) - ) - assert resp["link"] == "some-link" - assert resp["loginId"] == "login-id" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_magic_link_for_test_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - "deliveryMethod": "email", - "URI": "bla", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, - }, - follow_redirects=False, - ) - - async def test_generate_enchanted_link_for_test_user(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.generate_enchanted_link_for_test_user("login-id", "bla")) - - # Test success flow - with client.mock_mgmt_post( - make_response({"link": "some-link", "loginId": "login-id", "pendingRef": "some-ref"}) - ) as mock_post: - login_options = LoginOptions(stepup=True) - resp = await client.invoke( - client.mgmt.user.generate_enchanted_link_for_test_user("login-id", "bla", login_options) - ) - assert resp["link"] == "some-link" - assert resp["loginId"] == "login-id" - assert resp["pendingRef"] == "some-ref" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_enchanted_link_for_test_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "login-id", - "URI": "bla", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, - }, - follow_redirects=False, - ) - - async def test_generate_embedded_link(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.generate_embedded_link("login-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({"token": "some-token"})) as mock_post: - resp = await client.invoke(client.mgmt.user.generate_embedded_link("login-id", {"k1": "v1"})) - assert resp == "some-token" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_embedded_link_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "login-id", - "customClaims": {"k1": "v1"}, - "timeout": 0, - }, - follow_redirects=False, - params=None, - ) - - async def test_generate_sign_up_embedded_link(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.generate_sign_up_embedded_link("login-id")) - - # Test success flow - with client.mock_mgmt_post(make_response({"token": "some-token"})) as mock_post: - resp = await client.invoke( - client.mgmt.user.generate_sign_up_embedded_link("login-id", email_verified=True, phone_verified=True) - ) - assert resp == "some-token" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_sign_up_embedded_link_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "login-id", - "phoneVerified": True, - "emailVerified": True, - "user": {}, - "loginOptions": {}, - "timeout": 0, - }, - follow_redirects=False, - params=None, - ) - - async def test_history(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.history(["user-id-1", "user-id-2"])) - - # Test success flow - with client.mock_mgmt_post( - make_response( - [ - { - "userId": "kuku", - "city": "kefar saba", - "country": "Israel", - "ip": "1.1.1.1", - "loginTime": 32, - }, - { - "userId": "nunu", - "city": "eilat", - "country": "Israele", - "ip": "1.1.1.2", - "loginTime": 23, - }, - ] - ) - ) as mock_post: - resp = await client.invoke(client.mgmt.user.history(["user-id-1", "user-id-2"])) - assert resp == [ - { - "userId": "kuku", - "city": "kefar saba", - "country": "Israel", - "ip": "1.1.1.1", - "loginTime": 32, - }, - { - "userId": "nunu", - "city": "eilat", - "country": "Israele", - "ip": "1.1.1.2", - "loginTime": 23, - }, - ] - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_history_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - json=["user-id-1", "user-id-2"], - follow_redirects=False, - params=None, - ) - - async def test_update_test_user(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke( - client.mgmt.user.update( - "id", - display_name="test-user", - test=True, - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id", - "email": None, - "phone": None, - "displayName": "test-user", - "roleNames": [], - "userTenants": [], - "familyAssociations": [], - "test": True, - "picture": None, - "customAttributes": None, - "additionalLoginIds": None, - "ssoAppIDs": None, - }, - follow_redirects=False, - ) - - async def test_patch_test_user(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: - resp = await client.invoke( - client.mgmt.user.patch( - "id", - display_name="test-user", - test=True, - ) - ) - user = resp["user"] - assert user["id"] == "u1" - assert_http_called( - mock_patch, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id", - "displayName": "test-user", - "test": True, - }, - follow_redirects=False, - ) - - async def test_create_custom_attribute(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.create_custom_attribute("attr1", "Attribute 1", "string")) - - # Test success flow - with client.mock_mgmt_post(make_response({"attributes": [{"name": "attr1"}]})) as mock_post: - resp = await client.invoke( - client.mgmt.user.create_custom_attribute( - "attr1", "Attribute 1", "string", required=True, options=["a", "b"] - ) - ) - assert resp["attributes"][0]["name"] == "attr1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_create_custom_attribute_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "attributes": [ - { - "name": "attr1", - "displayName": "Attribute 1", - "type": "string", - "required": True, - "options": ["a", "b"], - } - ] - }, - follow_redirects=False, - ) - - async def test_delete_custom_attribute(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.delete_custom_attribute("attr1")) - - # Test success flow - with client.mock_mgmt_post(make_response({"attributes": []})) as mock_post: - resp = await client.invoke(client.mgmt.user.delete_custom_attribute("attr1")) - assert resp["attributes"] == [] - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_custom_attribute_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"names": ["attr1"]}, - follow_redirects=False, - ) - - async def test_load_custom_attributes(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)) as mock_get: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.load_custom_attributes()) - - # Test success flow - with client.mock_mgmt_get(make_response({"attributes": [{"name": "attr1"}]})) as mock_get: - resp = await client.invoke(client.mgmt.user.load_custom_attributes()) - assert resp["attributes"][0]["name"] == "attr1" - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_load_custom_attributes_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params={}, - follow_redirects=True, - ) - - async def test_delete_batch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.delete_batch(["u1", "u2"])) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert await client.invoke(client.mgmt.user.delete_batch(["u1", "u2"])) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_batch_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"userIds": ["u1", "u2"]}, - follow_redirects=False, - ) - - async def test_import_users(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.import_users("auth0", b'{"users":[]}')) - - # Test success flow - with client.mock_mgmt_post(make_response({"imported": 2, "failed": 0})) as mock_post: - resp = await client.invoke( - client.mgmt.user.import_users("auth0", b'{"users":[]}', b'{"hashes":[]}', dryrun=True) - ) - assert resp["imported"] == 2 - assert resp["failed"] == 0 - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_import_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "source": "auth0", - "users": "eyJ1c2VycyI6W119", - "hashes": "eyJoYXNoZXMiOltdfQ==", - "dryrun": True, - }, - follow_redirects=False, - ) - - async def test_delete_passkey(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.delete_passkey("user1", "cred123")) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert await client.invoke(client.mgmt.user.delete_passkey("user1", "cred123")) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_passkey_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "user1", "credentialId": "cred123"}, - follow_redirects=False, - ) - - async def test_list_passkeys(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.list_passkeys("user1")) - - # Test success flow - with client.mock_mgmt_post(make_response({"passkeys": [{"id": "pk1"}]})) as mock_post: - resp = await client.invoke(client.mgmt.user.list_passkeys("user1")) - assert resp["passkeys"][0]["id"] == "pk1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_list_passkeys_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "user1"}, - follow_redirects=False, - ) - - async def test_list_trusted_devices(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.list_trusted_devices(["user1", "user2"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"devices": [{"id": "dev1"}]})) as mock_post: - resp = await client.invoke(client.mgmt.user.list_trusted_devices(["user1", "user2"])) - assert resp["devices"][0]["id"] == "dev1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_list_trusted_devices_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"identifiers": ["user1", "user2"]}, - follow_redirects=False, - ) - - async def test_remove_trusted_device(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_trusted_device("user1", ["dev1", "dev2"])) - - # Test success flow - with client.mock_mgmt_post(make_response({})) as mock_post: - assert await client.invoke(client.mgmt.user.remove_trusted_device("user1", ["dev1", "dev2"])) is None - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_trusted_device_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"identifier": "user1", "deviceIds": ["dev1", "dev2"]}, - follow_redirects=False, - ) - - async def test_update_recovery_email(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update_recovery_email("user1", "recovery@example.com", True)) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update_recovery_email("user1", "recovery@example.com", True)) - assert resp["user"]["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_recovery_email_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "user1", - "recoveryEmail": "recovery@example.com", - "verified": True, - }, - follow_redirects=False, - ) - - async def test_update_recovery_phone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)) as mock_post: - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.update_recovery_phone("user1", "+1234567890", True)) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.update_recovery_phone("user1", "+1234567890", True)) - assert resp["user"]["id"] == "u1" - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_update_recovery_phone_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "user1", - "recoveryPhone": "+1234567890", - "verified": True, - }, - follow_redirects=False, - ) - - async def test_family_associations_on_create_update_patch(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - families = [ - AssociatedFamily("f1", ["Family Admin"], {"nickname": "Mom"}), - AssociatedFamily("f2"), - ] - expected = [ - {"familyId": "f1", "roleNames": ["Family Admin"], "familyScopedAttributes": {"nickname": "Mom"}}, - {"familyId": "f2"}, - ] - - def sent(mock_http, path): - assert mock_http.call_args.args[0] == f"{DEFAULT_BASE_URL}{path}" - return mock_http.call_args.kwargs["json"] - - # Not sent when omitted - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - await client.invoke(client.mgmt.user.create("name@example.com")) - assert "familyAssociations" not in sent(mock_post, MgmtV1.user_create_path) - - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - resp = await client.invoke(client.mgmt.user.create("name@example.com", family_associations=families)) - assert resp["user"]["id"] == "u1" - assert sent(mock_post, MgmtV1.user_create_path)["familyAssociations"] == expected - - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - await client.invoke(client.mgmt.user.create_test_user("name@example.com", family_associations=families)) - assert sent(mock_post, MgmtV1.test_user_create_path)["familyAssociations"] == expected - - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - await client.invoke(client.mgmt.user.invite("name@example.com", family_associations=families)) - body = sent(mock_post, MgmtV1.user_create_path) - assert body["invite"] is True - assert body["familyAssociations"] == expected - - with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: - await client.invoke(client.mgmt.user.update("name@example.com", family_associations=families)) - assert sent(mock_post, MgmtV1.user_update_path)["familyAssociations"] == expected - - with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: - await client.invoke(client.mgmt.user.patch("name@example.com", family_associations=families)) - assert sent(mock_patch, MgmtV1.user_patch_path) == { - "loginId": "name@example.com", - "familyAssociations": expected, - } - - # Batch create and batch patch carry the associations per user - users = [ - UserObj("a@example.com", family_associations=families), - UserObj("b@example.com"), - ] - with client.mock_mgmt_post(make_response({"createdUsers": []})) as mock_post: - await client.invoke(client.mgmt.user.invite_batch(users)) - body_users = sent(mock_post, MgmtV1.user_create_batch_path)["users"] - assert body_users[0]["familyAssociations"] == expected - assert "familyAssociations" not in body_users[1] - - with client.mock_mgmt_patch(make_response({"patchedUsers": []})) as mock_patch: - await client.invoke(client.mgmt.user.patch_batch(users)) - assert sent(mock_patch, MgmtV1.user_patch_batch_path) == { - "users": [ - {"loginId": "a@example.com", "familyAssociations": expected}, - {"loginId": "b@example.com"}, - ] - } - - async def test_search_all_by_family(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response({"users": [{"id": "u1", "dependent": True}]})) as mock_post: - resp = await client.invoke(client.mgmt.user.search_all(family_ids=["f1"], dependent=True)) - assert resp["users"][0]["dependent"] is True - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "tenantIds": [], - "roleNames": [], - "limit": 0, - "page": 0, - "testUsersOnly": False, - "withTestUser": False, - "familyIds": ["f1"], - "dependent": True, - }, - follow_redirects=False, - ) - - # dependent=False is sent explicitly, not dropped - with client.mock_mgmt_post(make_response({"users": []})) as mock_post: - await client.invoke(client.mgmt.user.search_all(dependent=False)) - body = mock_post.call_args.kwargs["json"] - assert body["dependent"] is False - assert "familyIds" not in body - - async def test_search_all_test_users_by_family(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - with client.mock_mgmt_post(make_response({"users": []})) as mock_post: - await client.invoke(client.mgmt.user.search_all_test_users(family_ids=["f1"], dependent=False)) - assert mock_post.call_args.args[0] == f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}" - body = mock_post.call_args.kwargs["json"] - assert body["familyIds"] == ["f1"] - assert body["dependent"] is False - - async def test_add_families(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.add_families("name@example.com", [AssociatedFamily("f1")])) - - # Test success flow - user = {"id": "u1", "userFamilies": [{"familyId": "f1", "roleNames": ["Family Admin"]}]} - with client.mock_mgmt_post(make_response({"user": user})) as mock_post: - resp = await client.invoke( - client.mgmt.user.add_families( - "name@example.com", - [ - AssociatedFamily("f1", family_scoped_attributes={"nickname": "Mommy"}), - AssociatedFamily("f2", role_names=["Family Admin"]), - ], - ) - ) - assert resp["user"] == user - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_add_families_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "name@example.com", - "familyAssociations": [ - {"familyId": "f1", "familyScopedAttributes": {"nickname": "Mommy"}}, - {"familyId": "f2", "roleNames": ["Family Admin"]}, - ], - }, - follow_redirects=False, - ) - - async def test_remove_families(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.remove_families("name@example.com", ["f1"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"user": {"id": "u1", "userFamilies": []}})) as mock_post: - resp = await client.invoke(client.mgmt.user.remove_families("name@example.com", ["f1", "f2"])) - assert resp["user"]["userFamilies"] == [] - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_families_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "name@example.com", "familyIds": ["f1", "f2"]}, - follow_redirects=False, - ) - - async def test_load_family_scoped_custom_attributes(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.load_family_scoped_custom_attributes()) - - # Test success flow - data = {"data": [{"name": "nickname", "type": 1}]} - with client.mock_mgmt_get(make_response(data)) as mock_get: - resp = await client.invoke(client.mgmt.user.load_family_scoped_custom_attributes()) - assert resp == data - assert_http_called( - mock_get, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_load_family_scoped_custom_attributes_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - follow_redirects=True, - ) - - async def test_create_family_scoped_custom_attributes(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - attributes = [CustomAttribute("nickname", 1, display_name="Nickname")] - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.create_family_scoped_custom_attributes(attributes)) - - # Test success flow - data = {"data": [{"name": "nickname", "type": 1, "displayName": "Nickname"}]} - with client.mock_mgmt_post(make_response(data)) as mock_post: - resp = await client.invoke(client.mgmt.user.create_family_scoped_custom_attributes(attributes)) - assert resp == data - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_create_family_scoped_custom_attributes_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"attributes": [{"name": "nickname", "type": 1, "displayName": "Nickname"}]}, - follow_redirects=False, - ) - - async def test_delete_family_scoped_custom_attributes(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - - # Test failed flows - with client.mock_mgmt_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.delete_family_scoped_custom_attributes(["nickname"])) - - # Test success flow - with client.mock_mgmt_post(make_response({"data": []})) as mock_post: - resp = await client.invoke(client.mgmt.user.delete_family_scoped_custom_attributes(["nickname"])) - assert resp == {"data": []} - assert_http_called( - mock_post, - client.mode, - f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_family_scoped_custom_attributes_path}", - headers={ - **default_headers, - "Authorization": f"Bearer {PROJECT_ID}:key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"names": ["nickname"]}, - follow_redirects=False, - ) diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index ac881ef74..000000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,1298 +0,0 @@ -import json -import os -import unittest -from enum import Enum -from http import HTTPStatus -from types import SimpleNamespace -from unittest import mock -from unittest.mock import patch - -import jwt - -from descope import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - ERROR_TYPE_SERVER_ERROR, - AccessKeyLoginOptions, - AuthException, - DeliveryMethod, - RateLimitException, -) -from descope.auth import Auth -from descope.common import ( - DEFAULT_TIMEOUT_SECONDS, - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, - EndpointsV1, -) -from tests.testutils import SSLMatcher - -from . import common - - -class TestAuth(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - self.public_key_str = json.dumps(self.public_key_dict) - - def test_validate_phone(self): - self.assertRaises(AuthException, Auth.validate_phone, method=DeliveryMethod.SMS, phone="") - - self.assertRaises( - AuthException, - Auth.validate_phone, - method=DeliveryMethod.SMS, - phone="asd234234234", - ) - - self.assertRaises( - AuthException, - Auth.validate_phone, - method=DeliveryMethod.EMAIL, - phone="+1111111", - ) - - Auth.validate_phone(method=DeliveryMethod.WHATSAPP, phone="+1111111") - - def test_validate_email(self): - self.assertRaises(AuthException, Auth.validate_email, email="") - - self.assertRaises(AuthException, Auth.validate_email, email="@dummy.com") - - Auth.validate_email(email="dummy@dummy.com") - - def test_validate_and_load_public_key(self): - # test invalid json - self.assertRaises( - AuthException, - Auth._validate_and_load_public_key, - public_key="invalid json", - ) - # test public key without kid property - self.assertRaises( - AuthException, - Auth._validate_and_load_public_key, - public_key={"test": "dummy"}, - ) - - # test not dict object - self.assertRaises(AuthException, Auth._validate_and_load_public_key, public_key=555) - # test invalid dict - self.assertRaises( - AuthException, - Auth._validate_and_load_public_key, - public_key={"kid": "dummy"}, - ) - - def test_fetch_public_key(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - valid_keys_response = """{"keys":[ - { - "alg": "ES384", - "crv": "P-384", - "kid": "299psneX92K3vpbqPMRCnbZKb27", - "kty": "EC", - "use": "sig", - "x": "435yhcD0tqH6z5M8kNFYEcEYXjzBQWiOvIOZO17rOatpXj-MbA6CKrktiblT4xMb", - "y": "YMf1EIz68z2_RKBys5byWRUXlqNF_BhO5F0SddkaRtiqZ8M6n7ZnKl65JGN0EEGr" - } -]} - """ - - # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, auth._fetch_public_keys) - - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = True - mock_get.return_value.text = "invalid json" - self.assertRaises(AuthException, auth._fetch_public_keys) - - # test success flow - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = True - mock_get.return_value.text = valid_keys_response - self.assertIsNone(auth._fetch_public_keys()) - - def test_empty_project_id(self): - self.assertRaises(AuthException, Auth, http_client=self.make_http_client()) - - def test_base_url_for_project_id(self): - self.assertEqual("https://api.descope.com", Auth.base_url_for_project_id("")) - self.assertEqual("https://api.descope.com", Auth.base_url_for_project_id("Puse")) - self.assertEqual("https://api.descope.com", Auth.base_url_for_project_id("Puse1ar")) - self.assertEqual( - "https://api.descope.com", - Auth.base_url_for_project_id("P2aAc4T2V93bddihGEx2Ryhc8e5Z"), - ) - self.assertEqual( - "https://api.use1.descope.com", - Auth.base_url_for_project_id("Puse12aAc4T2V93bddihGEx2Ryhc8e5Z"), - ) - self.assertEqual( - "https://api.use1.descope.com", - Auth.base_url_for_project_id("Puse12aAc4T2V93bddihGEx2Ryhc8e5Zfoobar"), - ) - - @staticmethod - def _regions_under_test(): - if not os.environ.get("CI"): - # Local runs. CI fetches the regions devops generates into REGIONS: - # https://imgs.descope.com/regions/regions.json (descope/etc#18332). - return ["use1", "euc1", "euw2", "aps1", "aps2", "cac1", "sae1"] - return [region["symbol"] for region in json.loads(os.environ["REGIONS"])] - - def test_base_url_resolves_every_region(self): - regions = self._regions_under_test() - ksuid = "2aAc4T2V93bddihGEx2Ryhc8e5Z" - - for region in regions: - with self.subTest(region=region): - self.assertEqual( - f"https://api.{region}.descope.com", - Auth.base_url_for_project_id(f"P{region}{ksuid}"), - ) - - def test_base_url_resolves_a_region_that_does_not_exist(self): - ksuid = "2aAc4T2V93bddihGEx2Ryhc8e5Z" - self.assertEqual( - "https://api.zz99.descope.com", - Auth.base_url_for_project_id(f"Pzz99{ksuid}"), - ) - - def test_verify_delivery_method(self): - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com", None), - False, - ) - - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com", {"phone": ""}), - True, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com", {"phone": ""}), - True, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy.com", {"phone": ""}), - True, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, "", {"phone": ""}), - False, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, "dummy@dummy", {"phone": ""}), - False, - ) - - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, "111111111111", {"email": ""}), - True, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, "+111111111111", {"email": ""}), - True, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, "++111111111111", {"email": ""}), - False, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, "asdsad", {"email": ""}), - False, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, "", {"email": ""}), - False, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.SMS, "unvalid@phone.number", {"email": ""}), - False, - ) - - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.VOICE, "111111111111", {"email": ""}), - True, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.VOICE, "+111111111111", {"email": ""}), - True, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.VOICE, "++111111111111", {"email": ""}), - False, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.VOICE, "asdsad", {"email": ""}), - False, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.VOICE, "", {"email": ""}), - False, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.VOICE, "unvalid@phone.number", {"email": ""}), - False, - ) - - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.WHATSAPP, "111111111111", {"email": ""}), - True, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.WHATSAPP, "", {"email": ""}), - False, - ) - self.assertEqual( - Auth.adjust_and_verify_delivery_method(DeliveryMethod.WHATSAPP, "unvalid@phone.number", {"email": ""}), - False, - ) - - class AAA(Enum): - DUMMY = 4 - - self.assertEqual( - Auth.adjust_and_verify_delivery_method(AAA.DUMMY, "unvalid@phone.number", {"phone": ""}), - False, - ) - - def test_get_login_id_name_by_method(self): - user = {"email": "dummy@dummy.com", "phone": "11111111"} - self.assertEqual( - Auth.get_login_id_by_method(DeliveryMethod.EMAIL, user), - ("email", "dummy@dummy.com"), - ) - self.assertEqual( - Auth.get_login_id_by_method(DeliveryMethod.SMS, user), - ("phone", "11111111"), - ) - self.assertEqual( - Auth.get_login_id_by_method(DeliveryMethod.VOICE, user), - ("voice", "11111111"), - ) - self.assertEqual( - Auth.get_login_id_by_method(DeliveryMethod.WHATSAPP, user), - ("whatsapp", "11111111"), - ) - - class AAA(Enum): - DUMMY = 4 - - self.assertRaises(AuthException, Auth.get_login_id_by_method, AAA.DUMMY, user) - - def test_refresh_session(self): - dummy_refresh_token = "dummy refresh token" - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Test fail flow - with patch("httpx.post") as mock_request: - mock_request.return_value.is_success = False - self.assertRaises( - AuthException, - auth.refresh_session, - dummy_refresh_token, - ) - - def test_validate_session_and_refresh_input(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Bad input for session - with self.assertRaises(AuthException): - auth.validate_and_refresh_session(None, None) - - # Test validate_session with Ratelimit exception - with patch("httpx.get") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = { - "errorCode": "E130429", - "errorDescription": "https://docs.descope.com/rate-limit", - "errorMessage": "API rate limit exceeded.", - } - mock_request.return_value.headers = {API_RATE_LIMIT_RETRY_AFTER_HEADER: "10"} - ds = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" - with self.assertRaises(RateLimitException) as cm: - auth.validate_session(ds) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, "E130429") - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, "https://docs.descope.com/rate-limit") - self.assertEqual(the_exception.error_message, "API rate limit exceeded.") - self.assertEqual( - the_exception.rate_limit_parameters, - {API_RATE_LIMIT_RETRY_AFTER_HEADER: 10}, - ) - - # Test refresh_session with Ratelimit exception - with patch("httpx.get") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = { - "errorCode": "E130429", - "errorDescription": "https://docs.descope.com/rate-limit", - "errorMessage": "API rate limit exceeded.", - } - mock_request.return_value.headers = {API_RATE_LIMIT_RETRY_AFTER_HEADER: "10"} - dsr = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" - with self.assertRaises(RateLimitException) as cm: - auth.refresh_session(dsr) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, "E130429") - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, "https://docs.descope.com/rate-limit") - self.assertEqual(the_exception.error_message, "API rate limit exceeded.") - self.assertEqual( - the_exception.rate_limit_parameters, - {API_RATE_LIMIT_RETRY_AFTER_HEADER: 10}, - ) - - def test_exchange_access_key(self): - dummy_access_key = "dummy access key" - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Test fail flow - with patch("httpx.post") as mock_request: - mock_request.return_value.is_success = False - self.assertRaises( - AuthException, - auth.exchange_access_key, - dummy_access_key, - ) - - # Test success flow - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL" - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = {"sessionJwt": valid_jwt_token} - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - jwt_response = auth.exchange_access_key( - access_key=dummy_access_key, - login_options=AccessKeyLoginOptions(custom_claims={"k1": "v1"}), - ) - self.assertEqual(jwt_response["keyId"], "U2Cu0j0WPw3YOiPISJb52L0wUVMg") - self.assertEqual(jwt_response["projectId"], "P2CtzUhdqpIF2ys9gg7ms06UvtC4") - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.exchange_auth_access_key_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:dummy access key", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"loginOptions": {"customClaims": {"k1": "v1"}}}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test success flow with selected tenant - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = {"sessionJwt": valid_jwt_token} - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - jwt_response = auth.exchange_access_key( - access_key=dummy_access_key, - login_options=AccessKeyLoginOptions(custom_claims={"k1": "v1"}, selected_tenant="t1"), - ) - self.assertEqual(jwt_response["keyId"], "U2Cu0j0WPw3YOiPISJb52L0wUVMg") - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.exchange_auth_access_key_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:dummy access key", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginOptions": { - "customClaims": {"k1": "v1"}, - "selectedTenant": "t1", - } - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_exchange_token_success_and_empty_code(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Empty code -> error - with self.assertRaises(AuthException): - auth.exchange_token("/oauth/exchange", "") - - # Success path - with patch("httpx.post") as mock_post: - net_resp = mock.Mock() - net_resp.is_success = True - net_resp.cookies = {"DSR": "cookie_token"} - # Make validator return claims - auth._validate_token = lambda token, audience=None: { - "iss": "https://issuer/PX", - "sub": "user-x", - } - net_resp.json.return_value = { - "sessionJwt": "s1", - "refreshJwt": "r1", - "user": {"id": "user-x"}, - "firstSeen": True, - } - mock_post.return_value = net_resp - out = auth.exchange_token("/oauth/exchange", code="abc") - self.assertEqual(out["projectId"], "PX") - self.assertEqual(out["userId"], "user-x") - - def test_validate_session_success(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - # Stub validator to bypass network - auth._validate_token = lambda token, audience=None: { - "iss": "P123", - "sub": "u123", - "permissions": ["p1"], - "roles": ["r1"], - "tenants": {"t1": {}}, - } - res = auth.validate_session("token-session") - self.assertEqual(res["projectId"], "P123") - self.assertEqual(res["userId"], "u123") - self.assertEqual(res["permissions"], ["p1"]) - self.assertIn(SESSION_TOKEN_NAME, res) - - def test_select_tenant_success(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - # Missing refresh token - with self.assertRaises(AuthException): - auth.select_tenant("tenant1", "") - - # Success network path - with patch("httpx.post") as mock_post: - net_resp = mock.Mock() - net_resp.is_success = True - net_resp.cookies = {"DSR": "cookie_r"} - # validator stub - auth._validate_token = lambda token, audience=None: { - "iss": "P77", - "sub": "u77", - } - net_resp.json.return_value = { - "sessionJwt": "s77", - "refreshJwt": "r77", - } - mock_post.return_value = net_resp - out = auth.select_tenant("tenant1", refresh_token="r0") - self.assertEqual(out["projectId"], "P77") - self.assertIn(SESSION_TOKEN_NAME, out) - - def test_compose_url_invalid_method(self): - class Dummy(Enum): - X = 1 - - with self.assertRaises(AuthException): - Auth.compose_url("/base", Dummy.X) - - def test_validate_token_header_errors(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - # Empty token - with self.assertRaises(AuthException): - auth._validate_token("") - - # Garbage token -> header parse error - with self.assertRaises(AuthException): - auth._validate_token("not-a-jwt") - - # Missing alg -> mock header dict without alg - with patch("descope._auth_base.jwt.get_unverified_header") as mock_hdr: - mock_hdr.return_value = {"kid": "kid1"} - with self.assertRaises(AuthException) as cm: - auth._validate_token("any.token.value") - self.assertIn("missing property: alg", str(cm.exception).lower()) - - # Missing kid -> mock header dict without kid - with patch("descope._auth_base.jwt.get_unverified_header") as mock_hdr: - mock_hdr.return_value = {"alg": "ES384"} - with self.assertRaises(AuthException) as cm2: - auth._validate_token("any.token.value") - self.assertIn("missing property: kid", str(cm2.exception).lower()) - - # Algorithm mismatch after fetching keys (kid found but alg different) - with patch("descope._auth_base.jwt.get_unverified_header") as mock_hdr: - mock_hdr.return_value = { - "alg": "RS256", - "kid": self.public_key_dict["kid"], - } - with self.assertRaises(AuthException) as cm3: - auth._validate_token("any.token.value") - self.assertIn("does not match", str(cm3.exception)) - - def test_extract_masked_address_default(self): - # Unknown method should return empty string - class DummyMethod(Enum): - OTHER = 999 - - self.assertEqual(Auth.extract_masked_address({}, DummyMethod.OTHER), "") - - def test_extract_masked_address_known_methods(self): - resp = {"maskedPhone": "+1-***-***-1234", "maskedEmail": "a***@b.com"} - self.assertEqual(Auth.extract_masked_address(resp, DeliveryMethod.SMS), "+1-***-***-1234") - self.assertEqual(Auth.extract_masked_address(resp, DeliveryMethod.EMAIL), "a***@b.com") - - def test_adjust_properties(self): - self.assertEqual( - Auth.adjust_properties(self, jwt_response={}, user_jwt={}), - { - "keyId": "", - "permissions": [], - "projectId": "", - "roles": [], - "tenants": {}, - }, - ) - - jwt_response = { - SESSION_TOKEN_NAME: { - "permissions": ["perm1"], - "roles": ["role1"], - "tenants": {"bla1": "bla1"}, - "iss": "123456", - "sub": "user-id", - }, - REFRESH_SESSION_TOKEN_NAME: { - "permissions": ["perm2"], - "roles": ["role2"], - "tenants": {"bla2": "bla2"}, - }, - } - - self.assertEqual( - Auth.adjust_properties(self, jwt_response=jwt_response, user_jwt=True), - { - "permissions": ["perm1"], - "projectId": "123456", - "refreshSessionToken": { - "permissions": ["perm2"], - "roles": ["role2"], - "tenants": {"bla2": "bla2"}, - }, - "roles": ["role1"], - "sessionToken": { - "iss": "123456", - "permissions": ["perm1"], - "roles": ["role1"], - "sub": "user-id", - "tenants": {"bla1": "bla1"}, - }, - "tenants": {"bla1": "bla1"}, - "userId": "user-id", - }, - ) - - jwt_response = { - SESSION_TOKEN_NAME: { - "permissions": ["perm1"], - "roles": ["role1"], - "tenants": {"bla1": "bla1"}, - "sub": "user-id", - }, - REFRESH_SESSION_TOKEN_NAME: { - "permissions": ["perm2"], - "roles": ["role2"], - "tenants": {"bla2": "bla2"}, - "iss": "https://descope.com/bla/123456", - }, - } - - self.assertEqual( - Auth.adjust_properties(self, jwt_response=jwt_response, user_jwt=False), - { - "permissions": ["perm1"], - "projectId": "123456", - "refreshSessionToken": { - "iss": "https://descope.com/bla/123456", - "permissions": ["perm2"], - "roles": ["role2"], - "tenants": {"bla2": "bla2"}, - }, - "roles": ["role1"], - "sessionToken": { - "permissions": ["perm1"], - "roles": ["role1"], - "sub": "user-id", - "tenants": {"bla1": "bla1"}, - }, - "tenants": {"bla1": "bla1"}, - "keyId": "user-id", - }, - ) - - def test_api_rate_limit_exception(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Test do_post - with patch("httpx.post") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = { - "errorCode": "E130429", - "errorDescription": "https://docs.descope.com/rate-limit", - "errorMessage": "API rate limit exceeded.", - } - mock_request.return_value.headers = {API_RATE_LIMIT_RETRY_AFTER_HEADER: "10"} - with self.assertRaises(RateLimitException) as cm: - auth.http_client.post("http://test.com", body={}, params=None, pswd=None) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, "E130429") - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, "https://docs.descope.com/rate-limit") - self.assertEqual(the_exception.error_message, "API rate limit exceeded.") - self.assertEqual( - the_exception.rate_limit_parameters, - {API_RATE_LIMIT_RETRY_AFTER_HEADER: 10}, - ) - - # Test do_get - with patch("httpx.get") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = { - "errorCode": "E130429", - "errorDescription": "https://docs.descope.com/rate-limit", - "errorMessage": "API rate limit exceeded.", - } - mock_request.return_value.headers = {API_RATE_LIMIT_RETRY_AFTER_HEADER: "10"} - with self.assertRaises(RateLimitException) as cm: - auth.http_client.get(uri="http://test.com", params=False, allow_redirects=True) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, "E130429") - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, "https://docs.descope.com/rate-limit") - self.assertEqual(the_exception.error_message, "API rate limit exceeded.") - self.assertEqual( - the_exception.rate_limit_parameters, - {API_RATE_LIMIT_RETRY_AFTER_HEADER: 10}, - ) - - # Test do_delete - with patch("httpx.delete") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = { - "errorCode": "E130429", - "errorDescription": "https://docs.descope.com/rate-limit", - "errorMessage": "API rate limit exceeded.", - } - mock_request.return_value.headers = {API_RATE_LIMIT_RETRY_AFTER_HEADER: "10"} - with self.assertRaises(RateLimitException) as cm: - auth.http_client.delete("http://test.com") - the_exception = cm.exception - self.assertEqual(the_exception.status_code, "E130429") - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, "https://docs.descope.com/rate-limit") - self.assertEqual(the_exception.error_message, "API rate limit exceeded.") - self.assertEqual( - the_exception.rate_limit_parameters, - {API_RATE_LIMIT_RETRY_AFTER_HEADER: 10}, - ) - - # Test do_delete with params and pswd - with patch("httpx.delete") as mock_delete: - network_resp = mock.Mock() - network_resp.is_success = True - - mock_delete.return_value = network_resp - auth.http_client.delete("/a/b", params={"key": "value"}, pswd="pswd") - - mock_delete.assert_called_with( - "http://127.0.0.1/a/b", - params={"key": "value"}, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{'pswd'}", - "x-descope-project-id": self.dummy_project_id, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test _fetch_public_keys rate limit - with patch("httpx.get") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = { - "errorCode": "E130429", - "errorDescription": "https://docs.descope.com/rate-limit", - "errorMessage": "API rate limit exceeded.", - } - mock_request.return_value.headers = {API_RATE_LIMIT_RETRY_AFTER_HEADER: "10"} - with self.assertRaises(RateLimitException) as cm: - auth._fetch_public_keys() - the_exception = cm.exception - self.assertEqual(the_exception.status_code, "E130429") - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, "https://docs.descope.com/rate-limit") - self.assertEqual(the_exception.error_message, "API rate limit exceeded.") - self.assertEqual( - the_exception.rate_limit_parameters, - {API_RATE_LIMIT_RETRY_AFTER_HEADER: 10}, - ) - - def test_api_rate_limit_invalid_header(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Test do_post empty body - with patch("httpx.post") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = { - "errorCode": "E130429", - "errorDescription": "https://docs.descope.com/rate-limit", - "errorMessage": "API rate limit exceeded.", - } - mock_request.return_value.headers = {API_RATE_LIMIT_RETRY_AFTER_HEADER: "hello"} - with self.assertRaises(RateLimitException) as cm: - auth.http_client.post("http://test.com", body={}, params=None, pswd=None) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, "E130429") - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, "https://docs.descope.com/rate-limit") - self.assertEqual(the_exception.error_message, "API rate limit exceeded.") - self.assertEqual( - the_exception.rate_limit_parameters, - {API_RATE_LIMIT_RETRY_AFTER_HEADER: 0}, - ) - - def test_api_rate_limit_invalid_response_body(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Test do_post empty body - with patch("httpx.post") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = "aaa" - with self.assertRaises(RateLimitException) as cm: - auth.http_client.post("http://test.com", body={}, params=None, pswd=None) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, HTTPStatus.TOO_MANY_REQUESTS) - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_message, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.rate_limit_parameters, {}) - - def test_api_rate_limit_empty_response_body(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Test do_post empty body - with patch("httpx.post") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = "" - with self.assertRaises(RateLimitException) as cm: - auth.http_client.post("http://test.com", body={}, params=None, pswd=None) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, HTTPStatus.TOO_MANY_REQUESTS) - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_message, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.rate_limit_parameters, {}) - - def test_api_rate_limit_none_response_body(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - # Test do_post empty body - with patch("httpx.post") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 429 - mock_request.return_value.json.return_value = None - with self.assertRaises(RateLimitException) as cm: - auth.http_client.post("http://test.com", body={}, params=None, pswd=None) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, HTTPStatus.TOO_MANY_REQUESTS) - self.assertEqual(the_exception.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_description, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.error_message, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(the_exception.rate_limit_parameters, {}) - - def test_raise_from_response(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - with patch("httpx.get") as mock_request: - mock_request.return_value.is_success = False - mock_request.return_value.status_code = 400 - mock_request.return_value.error_type = ERROR_TYPE_SERVER_ERROR - mock_request.return_value.text = ( - """{"errorCode":"E062108","errorDescription":"User not found","errorMessage":"Cannot find user"}""" - ) - with self.assertRaises(AuthException) as cm: - auth.http_client.get(uri="http://test.com", params=False, allow_redirects=True) - the_exception = cm.exception - self.assertEqual(the_exception.status_code, 400) - self.assertEqual(the_exception.error_type, ERROR_TYPE_SERVER_ERROR) - self.assertEqual( - the_exception.error_message, - """{"errorCode":"E062108","errorDescription":"User not found","errorMessage":"Cannot find user"}""", - ) - - def test_validate_session_audience_auto_detection(self): - """Test that validate_session automatically detects audience when token audience matches project ID""" - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: - mock_get_header.return_value = { - "alg": "ES384", - "kid": self.public_key_dict["kid"], - } - mock_decode.side_effect = [ - {"aud": self.dummy_project_id, "sub": "user123", "exp": 9999999999}, - {"aud": self.dummy_project_id, "sub": "user123", "exp": 9999999999}, - ] - - with patch.object( - auth, - "public_keys", - {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}, - ): - with patch.object(auth, "_fetch_public_keys"): - auth.validate_session("dummy_token") - - self.assertEqual(mock_decode.call_count, 2) - first_call = mock_decode.call_args_list[0] - self.assertIn("options", first_call.kwargs) - self.assertIn("verify_aud", first_call.kwargs["options"]) - self.assertFalse(first_call.kwargs["options"]["verify_aud"]) - second_call = mock_decode.call_args_list[1] - self.assertEqual(second_call.kwargs["audience"], self.dummy_project_id) - - def test_validate_session_audience_auto_detection_list(self): - """Test that validate_session automatically detects audience when token audience is a list containing project ID""" - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: - mock_get_header.return_value = { - "alg": "ES384", - "kid": self.public_key_dict["kid"], - } - mock_decode.side_effect = [ - { - "aud": [self.dummy_project_id, "other-audience"], - "sub": "user123", - "exp": 9999999999, - }, - { - "aud": [self.dummy_project_id, "other-audience"], - "sub": "user123", - "exp": 9999999999, - }, - ] - - with patch.object( - auth, - "public_keys", - {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}, - ): - with patch.object(auth, "_fetch_public_keys"): - auth.validate_session("dummy_token") - - self.assertEqual(mock_decode.call_count, 2) - second_call = mock_decode.call_args_list[1] - self.assertEqual(second_call.kwargs["audience"], self.dummy_project_id) - - def test_validate_session_audience_auto_detection_no_match(self): - """Test that validate_session does not auto-detect audience when token audience doesn't match project ID""" - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: - mock_get_header.return_value = { - "alg": "ES384", - "kid": self.public_key_dict["kid"], - } - mock_decode.side_effect = [ - {"aud": "different-project-id", "sub": "user123", "exp": 9999999999}, - {"aud": "different-project-id", "sub": "user123", "exp": 9999999999}, - ] - - with patch.object( - auth, - "public_keys", - {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}, - ): - with patch.object(auth, "_fetch_public_keys"): - auth.validate_session("dummy_token") - - self.assertEqual(mock_decode.call_count, 2) - second_call = mock_decode.call_args_list[1] - self.assertIsNone(second_call.kwargs["audience"]) - - def test_validate_session_explicit_audience(self): - """Test that validate_session uses explicit audience parameter instead of auto-detection""" - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - explicit_audience = "explicit-audience" - - with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: - mock_get_header.return_value = { - "alg": "ES384", - "kid": self.public_key_dict["kid"], - } - mock_decode.return_value = { - "aud": explicit_audience, - "sub": "user123", - "exp": 9999999999, - } - - with patch.object( - auth, - "public_keys", - {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}, - ): - with patch.object(auth, "_fetch_public_keys"): - auth.validate_session("dummy_token", audience=explicit_audience) - - self.assertEqual(mock_decode.call_count, 1) - call_args = mock_decode.call_args - self.assertEqual(call_args.kwargs["audience"], explicit_audience) - - def test_validate_and_refresh_session_audience_auto_detection(self): - """Test that validate_and_refresh_session automatically detects audience when token audience matches project ID""" - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: - mock_get_header.return_value = { - "alg": "ES384", - "kid": self.public_key_dict["kid"], - } - mock_decode.side_effect = [ - {"aud": self.dummy_project_id, "sub": "user123", "exp": 9999999999}, - {"aud": self.dummy_project_id, "sub": "user123", "exp": 9999999999}, - ] - - with patch.object( - auth, - "public_keys", - {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}, - ): - with patch.object(auth, "_fetch_public_keys"): - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - mock_post.return_value.json.return_value = {"sessionJwt": "new_token"} - mock_post.return_value.cookies = {} - - auth.validate_and_refresh_session("dummy_session_token", "dummy_refresh_token") - - self.assertEqual(mock_decode.call_count, 2) - first_call = mock_decode.call_args_list[0] - self.assertIn("options", first_call.kwargs) - self.assertIn("verify_aud", first_call.kwargs["options"]) - self.assertFalse(first_call.kwargs["options"]["verify_aud"]) - second_call = mock_decode.call_args_list[1] - self.assertEqual(second_call.kwargs["audience"], self.dummy_project_id) - - def test_validate_session_audience_mismatch_fails(self): - """Test that validate_session fails when token audience doesn't match project ID and no explicit audience is provided""" - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: - mock_get_header.return_value = { - "alg": "ES384", - "kid": self.public_key_dict["kid"], - } - # First call succeeds (for audience detection), second call fails (for validation with None audience) - mock_decode.side_effect = [ - { - "aud": "different-project-id", - "sub": "user123", - "exp": 9999999999, - }, # First call for audience detection - jwt.InvalidAudienceError("Invalid audience"), # Second call fails because audience doesn't match - ] - - with patch.object( - auth, - "public_keys", - {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}, - ): - with patch.object(auth, "_fetch_public_keys"): - with self.assertRaises(jwt.InvalidAudienceError) as cm: - auth.validate_session("dummy_token") - - # Verify the error is about invalid audience - self.assertIn("Invalid audience", str(cm.exception)) - self.assertEqual(mock_decode.call_count, 2) - - def test_http_client_authorization_header_variants(self): - # Base client without management key - client = self.make_http_client() - headers = client.get_default_headers() - self.assertEqual(headers["Authorization"], f"Bearer {self.dummy_project_id}") - - # With password/pswd only - headers = client.get_default_headers(pswd="sekret") - self.assertEqual(headers["Authorization"], f"Bearer {self.dummy_project_id}:sekret") - - # With management key only - client2 = self.make_http_client(management_key="mkey") - headers2 = client2.get_default_headers() - self.assertEqual(headers2["Authorization"], f"Bearer {self.dummy_project_id}:mkey") - - # With both pswd and management key - headers3 = client2.get_default_headers(pswd="sekret") - self.assertEqual( - headers3["Authorization"], - f"Bearer {self.dummy_project_id}:sekret:mkey", - ) - - def test_compose_url_success(self): - base = "/otp/send" - self.assertEqual(Auth.compose_url(base, DeliveryMethod.EMAIL), f"{base}/email") - self.assertEqual(Auth.compose_url(base, DeliveryMethod.SMS), f"{base}/sms") - self.assertEqual(Auth.compose_url(base, DeliveryMethod.VOICE), f"{base}/voice") - self.assertEqual(Auth.compose_url(base, DeliveryMethod.WHATSAPP), f"{base}/whatsapp") - - def test_internal_rate_limit_helpers(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - - class Resp: - def __init__(self, ok, status_code, body, headers): - self.is_success = ok - self.ok = ok - self.status_code = status_code - self._body = body - self.headers = headers - self.text = "txt" - - def json(self): - return self._body - - # _parse_retry_after - self.assertEqual(auth._parse_retry_after({API_RATE_LIMIT_RETRY_AFTER_HEADER: "7"}), 7) - self.assertEqual(auth._parse_retry_after({API_RATE_LIMIT_RETRY_AFTER_HEADER: "x"}), 0) - - # _raise_rate_limit_exception with valid JSON - r1 = Resp( - ok=False, - status_code=429, - body={ - "errorCode": "E130429", - "errorDescription": "https://docs", - "errorMessage": "rate", - }, - headers={API_RATE_LIMIT_RETRY_AFTER_HEADER: "3"}, - ) - with self.assertRaises(RateLimitException) as cm: - auth._raise_rate_limit_exception(r1) - ex = cm.exception - self.assertEqual(ex.status_code, "E130429") - self.assertEqual(ex.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(ex.error_description, "https://docs") - self.assertEqual(ex.error_message, "rate") - self.assertEqual(ex.rate_limit_parameters, {API_RATE_LIMIT_RETRY_AFTER_HEADER: 3}) - - # _raise_rate_limit_exception with invalid JSON - r2 = Resp(False, 429, "not-a-dict", {API_RATE_LIMIT_RETRY_AFTER_HEADER: "x"}) - with self.assertRaises(RateLimitException) as cm2: - auth._raise_rate_limit_exception(r2) - ex2 = cm2.exception - self.assertEqual(ex2.status_code, HTTPStatus.TOO_MANY_REQUESTS) - self.assertEqual(ex2.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(ex2.error_description, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(ex2.error_message, ERROR_TYPE_API_RATE_LIMIT) - - # _raise_from_response with non-429 - r3 = Resp(False, 400, {}, {}) - with self.assertRaises(AuthException): - auth._raise_from_response(r3) - - # _raise_from_response with 429 invokes rate-limit handler - r4 = Resp( - False, - 429, - {"errorCode": "E130", "errorDescription": "d", "errorMessage": "m"}, - {API_RATE_LIMIT_RETRY_AFTER_HEADER: "2"}, - ) - with self.assertRaises(RateLimitException): - auth._raise_from_response(r4) - - def test_validate_and_refresh_session_refresh_path(self): - auth = Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - # Force validate_session to fail - with patch.object( - Auth, - "validate_session", - side_effect=AuthException(400, ERROR_TYPE_SERVER_ERROR, "e"), - ): - # Stub refresh network - with patch("httpx.post") as mock_post: - net_resp = mock.Mock() - net_resp.is_success = True - net_resp.cookies = {"DSR": "cookie"} - auth._validate_token = lambda token, audience=None: { - "iss": "P1", - "sub": "u1", - } - net_resp.json.return_value = {"sessionJwt": "s", "refreshJwt": "r"} - mock_post.return_value = net_resp - out = auth.validate_and_refresh_session("bad", refresh_token="r0") - self.assertEqual(out["projectId"], "P1") - - def test_validate_token_public_key_not_found(self): - auth = Auth( - self.dummy_project_id, - None, - http_client=self.make_http_client(), - ) - # ensure public keys empty and fetching sets nothing - auth.public_keys = {} - with patch.object( - Auth, - "_fetch_public_keys", - side_effect=lambda self=auth: setattr(auth, "public_keys", {}), - ): - with patch("descope._auth_base.jwt.get_unverified_header") as mock_hdr: - mock_hdr.return_value = {"alg": "ES384", "kid": "unknown"} - with self.assertRaises(AuthException) as cm: - auth._validate_token("any") - self.assertIn("public key not found", str(cm.exception).lower()) - - def test_validate_token_decode_time_errors(self): - auth = Auth( - self.dummy_project_id, - None, - http_client=self.make_http_client(), - ) - # Prepare a fake key entry and matching header - auth.public_keys = {"kid": (SimpleNamespace(key="k"), "ES384")} - with ( - patch("descope._auth_base.jwt.get_unverified_header") as mock_hdr, - patch("descope._auth_base.jwt.decode") as mock_dec, - ): - mock_hdr.return_value = {"alg": "ES384", "kid": "kid"} - from jwt import ImmatureSignatureError - - mock_dec.side_effect = ImmatureSignatureError("early") - with self.assertRaises(AuthException) as cm: - auth._validate_token("tok") - self.assertEqual(cm.exception.status_code, 400) - - def test_validate_token_success(self): - auth = Auth( - self.dummy_project_id, - None, - http_client=self.make_http_client(), - ) - auth.public_keys = {"kid": (SimpleNamespace(key="k"), "ES384")} - with ( - patch("descope._auth_base.jwt.get_unverified_header") as mock_hdr, - patch("descope._auth_base.jwt.decode") as mock_dec, - ): - mock_hdr.return_value = {"alg": "ES384", "kid": "kid"} - mock_dec.return_value = {"sub": "u"} - out = auth._validate_token("tok") - self.assertEqual(out["jwt"], "tok") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_auth_async.py b/tests/test_auth_async.py deleted file mode 100644 index 88ea3cd39..000000000 --- a/tests/test_auth_async.py +++ /dev/null @@ -1,645 +0,0 @@ -"""Tests for AuthAsync — the async sibling of Auth. - -Covers the parts that differ from sync ``Auth``: async fetch, ``ensure_keys`` -idempotency + lock serialization, and that the async I/O paths never call -module-level sync ``httpx`` functions. - -Behavior parity with sync ``Auth`` (validation logic, audience handling, etc.) -is covered indirectly by the sync+async parametrized suite under -``tests/test_auth.py`` and ``tests/test_descope_client.py`` once -``DescopeClientAsync`` is wired to ``AuthAsync`` in step 5. -""" - -from __future__ import annotations - -import asyncio -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from descope.auth_async import AuthAsync -from descope.common import EndpointsV1, EndpointsV2 -from descope.exceptions import AuthException -from descope.http_client_async import HTTPClientAsync -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -PROJECT_ID = "P2CuC9yv2UGtGI1o84gCZEb9qEQW" -JWKS_BODY = json.dumps({"keys": [PUBLIC_KEY_DICT]}) - - -def _make_http_client() -> HTTPClientAsync: - """Construct an HTTPClientAsync without a running loop (mirrors production usage).""" - return HTTPClientAsync(PROJECT_ID, base_url="https://example.com", secure=False) - - -def _jwks_response() -> MagicMock: - resp = MagicMock() - resp.text = JWKS_BODY - resp.status_code = 200 - resp.is_success = True - resp.headers = {} - return resp - - -class TestAuthAsyncEnsureKeys: - @pytest.mark.asyncio - async def test_ensure_keys_fetches_when_cache_empty(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - with patch.object(http._async_client, "get", AsyncMock(return_value=_jwks_response())) as mock_get: - await auth.ensure_keys() - assert mock_get.call_count == 1 - assert PUBLIC_KEY_DICT["kid"] in auth.public_keys - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_ensure_keys_no_op_when_kid_present(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - with patch.object(http._async_client, "get", AsyncMock(return_value=_jwks_response())) as mock_get: - await auth.ensure_keys(PUBLIC_KEY_DICT["kid"]) - assert mock_get.call_count == 0 - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_ensure_keys_refetches_on_unknown_kid(self): - """Mirrors sync Auth behavior: cache miss for a kid triggers a refetch.""" - http = _make_http_client() - # Seed cache with a key whose kid won't match what we ask for. - stale_key = dict(PUBLIC_KEY_DICT, kid="STALE") - auth = AuthAsync(PROJECT_ID, public_key=stale_key, http_client=http) - try: - with patch.object(http._async_client, "get", AsyncMock(return_value=_jwks_response())) as mock_get: - await auth.ensure_keys(PUBLIC_KEY_DICT["kid"]) - assert mock_get.call_count == 1 - assert PUBLIC_KEY_DICT["kid"] in auth.public_keys - # Stale kid is replaced by the fresh JWKS payload. - assert "STALE" not in auth.public_keys - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_ensure_keys_serializes_concurrent_callers(self): - """N concurrent ensure_keys() calls on an empty cache must fetch exactly once.""" - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - # Slow the fetch so concurrent tasks pile up at the lock. - async def _slow_get(*args, **kwargs): - await asyncio.sleep(0.05) - return _jwks_response() - - with patch.object(http._async_client, "get", AsyncMock(side_effect=_slow_get)) as mock_get: - await asyncio.gather(*(auth.ensure_keys() for _ in range(10))) - assert mock_get.call_count == 1 - finally: - await http.aclose() - - -class TestAuthAsyncFetch: - @pytest.mark.asyncio - async def test_fetch_public_keys_uses_async_http_not_module_httpx(self): - """Regression guard: AuthAsync must not call module-level httpx.get.""" - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - with patch.object(http._async_client, "get", AsyncMock(return_value=_jwks_response())): - with patch("httpx.get", side_effect=AssertionError("sync httpx.get must not be called")): - await auth._fetch_public_keys() - assert PUBLIC_KEY_DICT["kid"] in auth.public_keys - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_fetch_public_keys_invalid_jwks_raises(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - bad = MagicMock() - bad.text = "not json" - bad.status_code = 200 - bad.is_success = True - bad.headers = {} - with patch.object(http._async_client, "get", AsyncMock(return_value=bad)): - with pytest.raises(AuthException): - await auth._fetch_public_keys() - finally: - await http.aclose() - - -class TestAuthAsyncValidate: - @pytest.mark.asyncio - async def test_validate_token_sync_raises_when_keys_not_warm(self): - """The sync _validate_token must NOT fetch — that's ensure_keys's job.""" - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - with pytest.raises(AuthException): - auth._validate_token(VALID_SESSION_TOKEN) - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_validate_token_async_warms_then_validates(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - with patch.object(http._async_client, "get", AsyncMock(return_value=_jwks_response())) as mock_get: - claims = await auth.validate_token(VALID_SESSION_TOKEN) - assert mock_get.call_count == 1 - assert claims["jwt"] == VALID_SESSION_TOKEN - assert claims["iss"] == PROJECT_ID - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_validate_token_empty_raises(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - with pytest.raises(AuthException): - await auth.validate_token("") - finally: - await http.aclose() - - -class TestAuthAsyncSessionFlows: - @pytest.mark.asyncio - async def test_validate_session_returns_jwt_response(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - result = await auth.validate_session(VALID_SESSION_TOKEN) - assert result["projectId"] == PROJECT_ID - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_refresh_session_no_blocking_httpx_call(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - refresh_response = MagicMock() - refresh_response.json.return_value = { - "sessionJwt": VALID_SESSION_TOKEN, - "refreshJwt": VALID_REFRESH_TOKEN, - } - refresh_response.cookies = MagicMock() - refresh_response.cookies.get = MagicMock(return_value=None) - refresh_response.status_code = 200 - refresh_response.is_success = True - refresh_response.headers = {} - - with ( - patch.object(http._async_client, "post", AsyncMock(return_value=refresh_response)) as mock_post, - patch("httpx.get", side_effect=AssertionError("sync httpx.get must not be called")), - patch("httpx.post", side_effect=AssertionError("sync httpx.post must not be called")), - ): - result = await auth.refresh_session(VALID_REFRESH_TOKEN) - assert result["projectId"] == PROJECT_ID - # Confirm we hit the refresh endpoint via the async client. - args, kwargs = mock_post.call_args - assert args[0].endswith(EndpointsV1.refresh_token_path) - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_select_tenant_requires_refresh_token(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - with pytest.raises(AuthException): - await auth.select_tenant("t1", "") - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_refresh_session_requires_token(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - with pytest.raises(AuthException): - await auth.refresh_session("") - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_validate_and_refresh_session_returns_when_session_valid(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - result = await auth.validate_and_refresh_session(VALID_SESSION_TOKEN, VALID_REFRESH_TOKEN) - assert result["projectId"] == PROJECT_ID - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_validate_and_refresh_session_falls_back_to_refresh(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - refresh_response = MagicMock() - refresh_response.json.return_value = { - "sessionJwt": VALID_SESSION_TOKEN, - "refreshJwt": VALID_REFRESH_TOKEN, - } - refresh_response.cookies = MagicMock() - refresh_response.cookies.get = MagicMock(return_value=None) - refresh_response.status_code = 200 - refresh_response.is_success = True - refresh_response.headers = {} - - with patch.object(http._async_client, "post", AsyncMock(return_value=refresh_response)): - # Use an obviously-invalid session token to force the refresh path. - result = await auth.validate_and_refresh_session("not.a.jwt", VALID_REFRESH_TOKEN) - assert result["projectId"] == PROJECT_ID - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_validate_and_refresh_session_requires_session(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - with pytest.raises(AuthException): - await auth.validate_and_refresh_session("", VALID_REFRESH_TOKEN) - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_validate_and_refresh_session_requires_refresh_when_session_invalid(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - with pytest.raises(AuthException): - await auth.validate_and_refresh_session("not.a.jwt", "") - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_exchange_token_requires_code(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - with pytest.raises(AuthException): - await auth.exchange_token(EndpointsV1.refresh_token_path, "") - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_exchange_token_posts_and_returns_jwt_response(self): - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - exchange_response = MagicMock() - exchange_response.json.return_value = { - "sessionJwt": VALID_SESSION_TOKEN, - "refreshJwt": VALID_REFRESH_TOKEN, - } - exchange_response.cookies = MagicMock() - exchange_response.cookies.get = MagicMock(return_value=None) - exchange_response.status_code = 200 - exchange_response.is_success = True - exchange_response.headers = {} - - with patch.object(http._async_client, "post", AsyncMock(return_value=exchange_response)) as mock_post: - result = await auth.exchange_token("/v1/oauth/exchange", "code-xyz") - assert result["projectId"] == PROJECT_ID - args, _ = mock_post.call_args - assert args[0].endswith("/v1/oauth/exchange") - finally: - await http.aclose() - - @pytest.mark.asyncio - async def test_ensure_keys_for_jwt_response_skips_unparseable_tokens(self): - """Unparseable sessionJwt / refreshJwt / refresh_token must not crash key-prewarming.""" - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, public_key=PUBLIC_KEY_DICT, http_client=http) - try: - # All tokens malformed → both try blocks must hit their except paths. - body = {"sessionJwt": "not-a-jwt", "refreshJwt": "still-not-a-jwt"} - await auth._ensure_keys_for_jwt_response(body, refresh_token="also-bad") - - # refreshJwt absent + refresh_token malformed → return-on-except path. - await auth._ensure_keys_for_jwt_response({"sessionJwt": "x.y.z"}, refresh_token="bad") - finally: - await http.aclose() - - -class TestAuthAsyncJWKSLoading: - @pytest.mark.asyncio - async def test_fetch_public_keys_skips_invalid_entries(self): - """JWKS entries that fail to load must be skipped; valid ones still cached.""" - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - jwks_with_invalid = json.dumps({"keys": [{"kid": "broken"}, PUBLIC_KEY_DICT]}) - resp = MagicMock() - resp.text = jwks_with_invalid - resp.status_code = 200 - resp.is_success = True - resp.headers = {} - with patch.object(http._async_client, "get", AsyncMock(return_value=resp)): - await auth._fetch_public_keys() - assert PUBLIC_KEY_DICT["kid"] in auth.public_keys - assert "broken" not in auth.public_keys - finally: - await http.aclose() - - -class TestAuthAsyncConstruction: - def test_constructor_requires_project_id(self): - http = _make_http_client() - try: - with pytest.raises(AuthException): - AuthAsync("", http_client=http) - finally: - # Can't await aclose() outside an event loop; httpx.AsyncClient - # is fine being garbage-collected here in a sync test. - pass - - @pytest.mark.asyncio - async def test_jwks_url_matches_endpoint(self): - """Verify the fetch path hits the v2 public-key URL.""" - http = _make_http_client() - auth = AuthAsync(PROJECT_ID, http_client=http) - try: - with patch.object(http._async_client, "get", AsyncMock(return_value=_jwks_response())) as mock_get: - await auth._fetch_public_keys() - args, _ = mock_get.call_args - assert args[0].endswith(f"{EndpointsV2.public_key_path}/{PROJECT_ID}") - finally: - await http.aclose() - - -@pytest.mark.asyncio -async def test_async_client_constructed_outside_loop_does_not_break(): - """AsyncClient + AuthAsync construction must not require a running loop.""" - http = HTTPClientAsync(PROJECT_ID, base_url="https://example.com", secure=False) - auth_built = AuthAsync(PROJECT_ID, http_client=http) - # Ensure no fetch on construction. - assert auth_built.public_keys == {} - assert isinstance(http, HTTPClientAsync) - assert isinstance(http._async_client, httpx.AsyncClient) - await http.aclose() - - -class TestDescopeClientAsyncLicenseHandshake: - """Step 6 guard: the license handshake must happen via ``aopen`` / ``__aenter__``, - not in ``DescopeClientAsync.__init__``. - """ - - @pytest.mark.asyncio - async def test_no_sync_httpx_during_construction(self): - """The async client constructor must not call sync ``httpx.get`` even when a - ``management_key`` is configured. This guards against re-introducing the - blocking license handshake on the event loop.""" - from descope.descope_client_async import DescopeClientAsync - - with patch("httpx.get", side_effect=AssertionError("sync httpx.get must not be called from __init__")): - client = DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) - # ``rate_limit_tier`` is not populated yet — handshake is deferred. - assert client._mgmt_http.rate_limit_tier is None - await client.aclose() - - @pytest.mark.asyncio - async def test_aopen_calls_license_endpoint_once(self): - """``async with`` must run the license handshake exactly once via ``httpx.AsyncClient``.""" - from descope.descope_client_async import DescopeClientAsync - from descope.management.common import MgmtV1 - - license_resp = MagicMock() - license_resp.is_success = True - license_resp.status_code = 200 - license_resp.json.return_value = {"rateLimitTier": "free"} - - async_get = AsyncMock(return_value=license_resp) - - # Patch the AsyncClient.get *method* on the class. The handshake builds a - # one-shot AsyncClient inside ``_fetch_rate_limit_tier_async``. - with patch.object(httpx.AsyncClient, "get", async_get): - async with DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) as client: - assert client._mgmt_http.rate_limit_tier == "free" - - assert async_get.call_count == 1 - called_url = async_get.call_args.args[0] - assert called_url.endswith(MgmtV1.license_get_path) - - @pytest.mark.asyncio - async def test_aopen_skipped_without_management_key(self): - """No license handshake when management_key is not set.""" - from descope.descope_client_async import DescopeClientAsync - - async_get = AsyncMock() - with patch.object(httpx.AsyncClient, "get", async_get): - async with DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - base_url="https://example.com", - ) as client: - assert client._mgmt_http.rate_limit_tier is None - - assert async_get.call_count == 0 - - @pytest.mark.asyncio - async def test_aopen_is_idempotent(self): - """Calling ``aopen`` twice should only hit the license endpoint once.""" - from descope.descope_client_async import DescopeClientAsync - - license_resp = MagicMock() - license_resp.is_success = True - license_resp.status_code = 200 - license_resp.json.return_value = {"rateLimitTier": "free"} - async_get = AsyncMock(return_value=license_resp) - - client = DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) - try: - with patch.object(httpx.AsyncClient, "get", async_get): - await client.aopen() - await client.aopen() - assert async_get.call_count == 1 - finally: - await client.aclose() - - -class TestDescopeClientAsyncLazyLicenseHandshake: - """The license handshake must also fire lazily on the first mgmt-side - request when the caller skips ``aopen`` / ``async with``. Guards against - silent rate-limit-tier degradation for users that hold a long-lived - client without explicit lifecycle management. - """ - - def _license_get_mock(self, tier: str = "free") -> AsyncMock: - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - resp.json.return_value = {"rateLimitTier": tier} - return AsyncMock(return_value=resp) - - @pytest.mark.asyncio - async def test_pre_request_hook_wired_on_mgmt_http_only(self): - """Auth requests must not pay any handshake cost.""" - from descope.descope_client_async import DescopeClientAsync - - client = DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) - try: - assert client._mgmt_http._pre_request_hook is not None - assert client._auth_http._pre_request_hook is None - finally: - await client.aclose() - - @pytest.mark.asyncio - async def test_mgmt_request_triggers_handshake_lazily(self): - """A request through ``_mgmt_http`` without ``aopen`` must run the handshake first.""" - from descope.descope_client_async import DescopeClientAsync - - license_get = self._license_get_mock() - mgmt_resp = MagicMock() - mgmt_resp.status_code = 200 - mgmt_resp.is_success = True - mgmt_resp.headers = {} - - client = DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) - try: - with patch.object(httpx.AsyncClient, "get", license_get): - with patch.object(client._mgmt_http._async_client, "post", AsyncMock(return_value=mgmt_resp)): - await client._mgmt_http.post("/some/mgmt/path", body={}) - assert license_get.call_count == 1 - assert client._mgmt_http.rate_limit_tier == "free" - finally: - await client.aclose() - - @pytest.mark.asyncio - async def test_subsequent_mgmt_requests_do_not_retrigger_handshake(self): - """Once the handshake has run, further mgmt requests must not retrigger it.""" - from descope.descope_client_async import DescopeClientAsync - - license_get = self._license_get_mock() - mgmt_resp = MagicMock() - mgmt_resp.status_code = 200 - mgmt_resp.is_success = True - mgmt_resp.headers = {} - - client = DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) - try: - with patch.object(httpx.AsyncClient, "get", license_get): - with patch.object(client._mgmt_http._async_client, "post", AsyncMock(return_value=mgmt_resp)): - await client._mgmt_http.post("/p1", body={}) - await client._mgmt_http.post("/p2", body={}) - await client._mgmt_http.post("/p3", body={}) - assert license_get.call_count == 1 - finally: - await client.aclose() - - @pytest.mark.asyncio - async def test_concurrent_first_requests_handshake_exactly_once(self): - """N concurrent first-mgmt requests must serialize behind the lock and run handshake exactly once.""" - from descope.descope_client_async import DescopeClientAsync - - license_get = self._license_get_mock() - mgmt_resp = MagicMock() - mgmt_resp.status_code = 200 - mgmt_resp.is_success = True - mgmt_resp.headers = {} - - client = DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) - try: - with patch.object(httpx.AsyncClient, "get", license_get): - with patch.object(client._mgmt_http._async_client, "post", AsyncMock(return_value=mgmt_resp)): - await asyncio.gather(*(client._mgmt_http.post(f"/p{i}", body={}) for i in range(10))) - assert license_get.call_count == 1 - finally: - await client.aclose() - - @pytest.mark.asyncio - async def test_auth_request_does_not_trigger_handshake(self): - """Auth-side requests must not pay any handshake cost — the hook is only on _mgmt_http.""" - from descope.descope_client_async import DescopeClientAsync - - license_get = self._license_get_mock() - auth_resp = MagicMock() - auth_resp.status_code = 200 - auth_resp.is_success = True - auth_resp.headers = {} - - client = DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) - try: - with patch.object(httpx.AsyncClient, "get", license_get): - with patch.object(client._auth_http._async_client, "post", AsyncMock(return_value=auth_resp)): - await client._auth_http.post("/some/auth/path", body={}) - assert license_get.call_count == 0 - assert client._mgmt_http.rate_limit_tier is None - finally: - await client.aclose() - - @pytest.mark.asyncio - async def test_aopen_then_mgmt_request_runs_handshake_once(self): - """Eager + lazy paths must not double-fire: aopen followed by mgmt calls = 1 handshake.""" - from descope.descope_client_async import DescopeClientAsync - - license_get = self._license_get_mock() - mgmt_resp = MagicMock() - mgmt_resp.status_code = 200 - mgmt_resp.is_success = True - mgmt_resp.headers = {} - - client = DescopeClientAsync( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="dummy-mgmt-key", - base_url="https://example.com", - ) - try: - with patch.object(httpx.AsyncClient, "get", license_get): - await client.aopen() - with patch.object(client._mgmt_http._async_client, "post", AsyncMock(return_value=mgmt_resp)): - await client._mgmt_http.post("/some/mgmt/path", body={}) - assert license_get.call_count == 1 - finally: - await client.aclose() diff --git a/tests/test_common.py b/tests/test_common.py deleted file mode 100644 index ed20aca50..000000000 --- a/tests/test_common.py +++ /dev/null @@ -1,70 +0,0 @@ -from enum import Enum - -from descope.common import ( - DeliveryMethod, - LoginOptions, - SignUpOptions, - get_method_string, - signup_options_to_dict, -) -from descope.exceptions import AuthException -from tests import common - - -class TestCommon(common.DescopeTest): - def test_get_method_string(self): - self.assertEqual( - get_method_string(DeliveryMethod.EMAIL), - "email", - ) - self.assertEqual( - get_method_string(DeliveryMethod.SMS), - "sms", - ) - self.assertEqual( - get_method_string(DeliveryMethod.VOICE), - "voice", - ) - self.assertEqual( - get_method_string(DeliveryMethod.WHATSAPP), - "whatsapp", - ) - self.assertEqual( - get_method_string(DeliveryMethod.EMBEDDED), - "Embedded", - ) - - class AAA(Enum): - DUMMY = 4 - - self.assertRaises(AuthException, get_method_string, AAA.DUMMY) - - def test_login_options_optional_fields(self): - opts = LoginOptions( - revoke_other_sessions=True, - template_options={"key": "v"}, - template_id="tpl-1", - locale="en", - ) - self.assertTrue(opts.revokeOtherSessions) - self.assertEqual(opts.templateOptions, {"key": "v"}) - self.assertEqual(opts.templateId, "tpl-1") - self.assertEqual(opts.locale, "en") - - def test_signup_options_to_dict_empty(self): - # None and a fully-empty SignUpOptions both yield {} - self.assertEqual(signup_options_to_dict(None), {}) - self.assertEqual(signup_options_to_dict(SignUpOptions()), {}) - - def test_signup_options_to_dict_full(self): - opts = SignUpOptions( - revoke_other_sessions=True, - custom_claims={"role": "admin"}, - template_options={"key": "v"}, - template_id="tpl-2", - ) - result = signup_options_to_dict(opts) - self.assertEqual(result["revokeOtherSessions"], True) - self.assertEqual(result["customClaims"], {"role": "admin"}) - self.assertEqual(result["templateOptions"], {"key": "v"}) - self.assertEqual(result["templateId"], "tpl-2") diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py deleted file mode 100644 index d2b4f46be..000000000 --- a/tests/test_descope_client.py +++ /dev/null @@ -1,963 +0,0 @@ -from __future__ import annotations - -import json -import sys -from copy import deepcopy -from unittest import mock -from unittest.mock import patch - -import pytest - -from descope import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - SESSION_COOKIE_NAME, - AccessKeyLoginOptions, - AuthException, - RateLimitException, -) -from descope.common import ( - DEFAULT_TIMEOUT_SECONDS, - SESSION_TOKEN_NAME, - DeliveryMethod, - EndpointsV1, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import ( - EXPIRED_SESSION_TOKEN, - PUBLIC_KEY_DICT, - VALID_REFRESH_TOKEN, - VALID_SESSION_TOKEN, - SSLMatcher, -) - -from . import common - -PUBLIC_KEY_STR = json.dumps(PUBLIC_KEY_DICT) - -# The original setUp public_key_dict (kid=2Bt5…) used by a handful of tests -DUMMY_PUBLIC_KEY_DICT = { - "alg": "ES384", - "crv": "P-384", - "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", - "kty": "EC", - "use": "sig", - "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", - "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", -} - - -EXPECTED_USER_ID = "U2CuCPuJgPWHGB5P4GmfbuPGhGVm" -EXPECTED_PROJECT_ID = "P2CuC9yv2UGtGI1o84gCZEb9qEQW" - -# Tokens ported from test_descope_client.py that must fail validate_session -_INVALID_HEADER_TOKEN = ( - "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9" - ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0" - ".Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGP" - "HPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" -) -_MISSING_KID_TOKEN = ( - "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0" - ".eyJleHAiOjE5ODEzOTgxMTF9" - ".GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP" - "3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" -) -_INVALID_PAYLOAD_TOKEN = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" - - -class TestDescopeClient: - async def test_descope_client(self, client_factory): - with pytest.raises(AuthException): - client_factory.make(None, "dummy") - with pytest.raises(AuthException): - client_factory.make("", "dummy") - - with patch("os.getenv") as mock_getenv: - mock_getenv.return_value = "" - with pytest.raises(AuthException): - client_factory.make(None, "dummy") - - assert client_factory.make(PROJECT_ID, None) is not None - assert client_factory.make(PROJECT_ID, "") is not None - with pytest.raises(AuthException): - client_factory.make(PROJECT_ID, "not dict object") - assert client_factory.make(PROJECT_ID, PUBLIC_KEY_STR) is not None - - async def test_project_id_from_env_without_env(self, client_factory): - with patch.dict("os.environ", {"DESCOPE_PROJECT_ID": ""}): - with pytest.raises(AuthException): - client_factory.make("") - - async def test_mgmt(self, descope_client): - # Validate that any invocation of specific mgmt object raises AuthException as mgmt key was not set - # (applies equally to sync DescopeClient and async DescopeClientAsync) - with pytest.raises(AuthException): - _ = descope_client.mgmt.tenant - with pytest.raises(AuthException): - _ = descope_client.mgmt.family - with pytest.raises(AuthException): - _ = descope_client.mgmt.sso_application - with pytest.raises(AuthException): - _ = descope_client.mgmt.user - with pytest.raises(AuthException): - _ = descope_client.mgmt.access_key - with pytest.raises(AuthException): - _ = descope_client.mgmt.sso - with pytest.raises(AuthException): - _ = descope_client.mgmt.jwt - with pytest.raises(AuthException): - _ = descope_client.mgmt.permission - with pytest.raises(AuthException): - _ = descope_client.mgmt.role - with pytest.raises(AuthException): - _ = descope_client.mgmt.group - with pytest.raises(AuthException): - _ = descope_client.mgmt.flow - with pytest.raises(AuthException): - _ = descope_client.mgmt.audit - with pytest.raises(AuthException): - _ = descope_client.mgmt.authz - with pytest.raises(AuthException): - _ = descope_client.mgmt.fga - with pytest.raises(AuthException): - _ = descope_client.mgmt.project - with pytest.raises(AuthException): - _ = descope_client.mgmt.outbound_application - - # Validate that outbound_application_by_token doesn't require mgmt key - try: - _ = descope_client.mgmt.outbound_application_by_token - except AuthException: - pytest.fail("failed to initiate outbound_application_by_token without management key") - - async def test_logout(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.logout(None)) - - with descope_client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.logout("")) - - with descope_client.mock_post(make_response(status=200)): - assert await descope_client.invoke(descope_client.logout("")) is not None - - async def test_logout_all(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.logout_all(None)) - - with descope_client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.logout_all("")) - - with descope_client.mock_post(make_response(status=200)): - assert await descope_client.invoke(descope_client.logout_all("")) is not None - - async def test_me(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.me(None)) - - with descope_client.mock_get(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.me("")) - - data = json.loads("""{"name": "Testy McTester", "email": "testy@tester.com"}""") - with descope_client.mock_get(make_response(data)) as mock_get: - user_response = await descope_client.invoke(descope_client.me("")) - assert user_response is not None - assert data["name"] == user_response["name"] - assert_http_called( - mock_get, - descope_client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.me_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=None, - params=None, - ) - - async def test_my_tenants(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.my_tenants(None)) - - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.my_tenants("")) - - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.my_tenants("", True, ["a"])) - - with descope_client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.my_tenants("", True)) - - data = json.loads("""{"tenants": [{"id": "tenant_id", "name": "tenant_name"}]}""") - with descope_client.mock_post(make_response(data)) as mock_post: - tenant_response = await descope_client.invoke(descope_client.my_tenants("", False, ["a"])) - assert tenant_response is not None - assert data["tenants"][0]["name"] == tenant_response["tenants"][0]["name"] - assert_http_called( - mock_post, - descope_client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.my_tenants_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - json={"dct": False, "ids": ["a"]}, - follow_redirects=False, - params=None, - ) - - async def test_history(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.history(None)) - - with descope_client.mock_get(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.history("")) - - data = json.loads( - """ - [ - { - "userId": "kuku", - "city": "kefar saba", - "country": "Israel", - "ip": "1.1.1.1", - "loginTime": 32 - }, - { - "userId": "nunu", - "city": "eilat", - "country": "Israele", - "ip": "1.1.1.2", - "loginTime": 23 - } - ] - """ - ) - with descope_client.mock_get(make_response(data)) as mock_get: - user_response = await descope_client.invoke(descope_client.history("")) - assert user_response is not None - assert data == user_response - assert_http_called( - mock_get, - descope_client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.history_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=None, - params=None, - ) - - async def test_validate_session(self, client_factory): - # Client with the 2Bt5 key (matching the kid in _INVALID_PAYLOAD_TOKEN) - client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) - - with pytest.raises(AuthException): - await client.invoke(client.validate_session(_MISSING_KID_TOKEN)) - with pytest.raises(AuthException): - await client.invoke(client.validate_session(_INVALID_HEADER_TOKEN)) - with pytest.raises(AuthException): - await client.invoke(client.validate_session(_INVALID_PAYLOAD_TOKEN)) - - # None key client + None token - client4 = client_factory.make(PROJECT_ID, None) - with pytest.raises(AuthException): - await client4.invoke(client4.validate_session(None)) - - async def test_validate_session_response_structure(self, descope_client): - result = await descope_client.invoke(descope_client.validate_session(VALID_SESSION_TOKEN)) - assert result == { - "drn": "DS", - "exp": 2493061415, - "iat": 1659643061, - "iss": EXPECTED_PROJECT_ID, - "sub": EXPECTED_USER_ID, - "jwt": VALID_SESSION_TOKEN, - "permissions": [], - "roles": [], - "tenants": {}, - "projectId": EXPECTED_PROJECT_ID, - "userId": EXPECTED_USER_ID, - "sessionToken": { - "drn": "DS", - "exp": 2493061415, - "iat": 1659643061, - "iss": EXPECTED_PROJECT_ID, - "sub": EXPECTED_USER_ID, - "jwt": VALID_SESSION_TOKEN, - }, - } - - async def test_validate_session_valid_tokens(self, client_factory): - # Client with P2Cu key preloaded - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - dummy_refresh_token = "refresh" - valid_jwt_token = VALID_REFRESH_TOKEN # far-future DSR token, P2Cu kid - - # Valid token validates locally — no network needed - await client.invoke(client.validate_session(valid_jwt_token)) - - assert ( - await client.invoke(client.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) is not None - ) - - # Key id cannot be found — key fetch returns wrong kid - client2 = client_factory.make(PROJECT_ID, None) - fake_key = deepcopy(DUMMY_PUBLIC_KEY_DICT) - fake_key["kid"] = "dummy_kid" - bad_jwks = make_response() - bad_jwks.text = json.dumps([fake_key]) # malformed: missing "keys" wrapper - with client2.mock_get(bad_jwks): - with pytest.raises(AuthException): - await client2.invoke(client2.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - - # Key fetch returns unparsable key - client3 = client_factory.make(PROJECT_ID, None) - bad_jwks2 = make_response() - bad_jwks2.text = """[{"kid": "dummy_kid"}]""" - with client3.mock_get(bad_jwks2): - with pytest.raises(AuthException): - await client3.invoke(client3.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - - # header_alg != key[alg] - bad_alg_key = deepcopy(DUMMY_PUBLIC_KEY_DICT) - bad_alg_key["alg"] = "ES521" - client4 = client_factory.make(PROJECT_ID, bad_alg_key) - bad_jwks3 = make_response() - bad_jwks3.text = """[{"kid": "dummy_kid"}]""" - with client4.mock_get(bad_jwks3): - with pytest.raises(AuthException): - await client4.invoke(client4.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - - # Both session_token and refresh_token are None - client4b = client_factory.make(PROJECT_ID, None) - with pytest.raises(AuthException): - await client4b.invoke(client4b.validate_and_refresh_session(None, None)) - - # Expired session triggers refresh; refreshed token is also expired → fails - expired_jwt_token = EXPIRED_SESSION_TOKEN - valid_refresh_for_expire_test = valid_jwt_token - expired_jwks = make_response(cookies={SESSION_COOKIE_NAME: expired_jwt_token}) - with client3.mock_get(expired_jwks): - with pytest.raises(AuthException): - await client3.invoke( - client3.validate_and_refresh_session(expired_jwt_token, valid_refresh_for_expire_test) - ) - - def test_exception_object(self): - ex = AuthException(401, "dummy-type", "dummy error message") - assert str(ex) is not None - assert repr(ex) is not None - assert ex.status_code == 401 - assert ex.error_type == "dummy-type" - assert ex.error_message == "dummy error message" - - def test_api_rate_limit_exception_object(self): - ex = RateLimitException( - 429, - ERROR_TYPE_API_RATE_LIMIT, - "API rate limit exceeded description", - "API rate limit exceeded", - {API_RATE_LIMIT_RETRY_AFTER_HEADER: "9"}, - ) - assert str(ex) is not None - assert repr(ex) is not None - assert ex.status_code == 429 - assert ex.error_type == ERROR_TYPE_API_RATE_LIMIT - assert ex.error_description == "API rate limit exceeded description" - assert ex.error_message == "API rate limit exceeded" - assert ex.rate_limit_parameters.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, "") == "9" - - async def test_expired_token(self, client_factory): - # expired DS token (kid=P2Cu, exp=1657798328 — past) - expired_jwt_token = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9" - ".eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9" - ".i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" - ) - dummy_refresh_token = "dummy refresh token" - - # Client with P2Cu key (same kid the validate tokens use in refresh path) - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Fail flow: JWKS fetch returns non-success → AuthException. Status 500 raises - # in ``_raise_from_response`` BEFORE the parse step, so the in-memory cache - # is preserved for the next assertions. - failed_jwks = make_response(status=500) - with client.mock_get(failed_jwks): - with pytest.raises(AuthException): - await client.invoke(client.validate_session(expired_jwt_token)) - - # Fail flow: JWKS fetch succeeds but the response body has no ``keys`` field → - # ``_fetch_public_keys`` raises before touching ``self.public_keys``. - bad_jwks_body = make_response() - bad_jwks_body.text = "not a jwks" - with client.mock_get(bad_jwks_body): - with pytest.raises(AuthException): - await client.invoke(client.validate_session(expired_jwt_token)) - - # Fail flow: jwt.get_unverified_header returns {} (no kid) - dummy_session_token = "dummy session token" - # dummy_client has the 2Bt5 key; EXPIRED_SESSION_TOKEN uses P2Cu — key not loaded - dummy_client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) - with patch("jwt.get_unverified_header") as mock_jwt_get_unverified_header: - mock_jwt_get_unverified_header.return_value = {} - with pytest.raises(AuthException): - await dummy_client.invoke( - dummy_client.validate_and_refresh_session(dummy_session_token, dummy_refresh_token) - ) - - # Success flow: expired token → POST refresh → returns valid new session token - new_session_token = VALID_SESSION_TOKEN - valid_refresh_token = VALID_REFRESH_TOKEN - expired_token = EXPIRED_SESSION_TOKEN - resp = make_response({"sessionJwt": new_session_token}, cookies={}) - with client.mock_post(resp): - # Refresh because of expiration - result = await client.invoke(client.validate_and_refresh_session(expired_token, valid_refresh_token)) - new_session_token_from_request = result[SESSION_TOKEN_NAME]["jwt"] - assert new_session_token_from_request == new_session_token, "Failed to refresh token" - - # Refresh explicitly - result = await client.invoke(client.refresh_session(valid_refresh_token)) - new_session_token_from_request = result[SESSION_TOKEN_NAME]["jwt"] - assert new_session_token_from_request == new_session_token, "Failed to refresh token" - - # Fail flow: refreshed token is also expired → AuthException - # dummy_client has 2Bt5 key; EXPIRED_SESSION_TOKEN (kid=P2Cu) is NOT preloaded → - # triggers JWKS fetch; mock returns garbage body → AuthException from JWKS parse. - expired_jwt_token2 = EXPIRED_SESSION_TOKEN - valid_refresh_token2 = VALID_REFRESH_TOKEN - garbage_jwks = make_response() - garbage_jwks.text = "not a jwks" - with dummy_client.mock_get(garbage_jwks): - with pytest.raises(AuthException): - await dummy_client.invoke( - dummy_client.validate_and_refresh_session(expired_jwt_token2, valid_refresh_token2) - ) - - async def test_public_key_load(self, client_factory): - # Test key without kty property - invalid_public_key = deepcopy(PUBLIC_KEY_DICT) - invalid_public_key.pop("kty") - with pytest.raises(AuthException) as exc_info: - client_factory.make(PROJECT_ID, invalid_public_key) - assert exc_info.value.status_code == 500 - - # Test key without kid property - invalid_public_key = deepcopy(PUBLIC_KEY_DICT) - invalid_public_key.pop("kid") - with pytest.raises(AuthException) as exc_info: - client_factory.make(PROJECT_ID, invalid_public_key) - assert exc_info.value.status_code == 500 - - # Test key with unknown algorithm - invalid_public_key = deepcopy(PUBLIC_KEY_DICT) - invalid_public_key["alg"] = "unknown algorithm" - with pytest.raises(AuthException) as exc_info: - client_factory.make(PROJECT_ID, invalid_public_key) - assert exc_info.value.status_code == 500 - - async def test_client_properties(self, descope_client): - # totp is available on both sync and async clients - assert descope_client.totp is not None, "Empty totp object" - - # All other auth-method properties are sync-only - if descope_client.mode != "sync": - return - assert descope_client.magiclink is not None, "Empty Magiclink object" - assert descope_client.otp is not None, "Empty otp object" - assert descope_client.oauth is not None, "Empty oauth object" - assert descope_client.saml is not None, "Empty saml object" - assert descope_client.sso is not None, "Empty saml object" - assert descope_client.webauthn is not None, "Empty webauthN object" - - async def test_validate_permissions(self, descope_client): - jwt_response = {} - assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False - - jwt_response = {"permissions": []} - assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False - assert descope_client.validate_permissions(jwt_response, []) is True - - jwt_response = {"permissions": ["Perm 1"]} - assert descope_client.validate_permissions(jwt_response, "Perm 1") is True - assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is True - assert descope_client.validate_permissions(jwt_response, ["Perm 2"]) is False - - # Tenant level - jwt_response = {"tenants": {}} - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False - - jwt_response = {"tenants": {"t1": {}}} - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False - - jwt_response = {"tenants": {"t1": {"permissions": "Perm 1"}}} - assert descope_client.validate_tenant_permissions(jwt_response, "t1", []) is True - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1"]) is True - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]) is False - assert descope_client.validate_tenant_permissions(jwt_response, "t2", []) is False - - async def test_get_matched_permissions(self, descope_client): - jwt_response = {} - assert descope_client.get_matched_permissions(jwt_response, []) == [] - - jwt_response = {"permissions": []} - assert descope_client.get_matched_permissions(jwt_response, ["Perm 1"]) == [] - - jwt_response = {"permissions": ["Perm 1", "Perm 2"]} - assert descope_client.get_matched_permissions(jwt_response, ["Perm 1"]) == ["Perm 1"] - assert descope_client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2"]) == ["Perm 1", "Perm 2"] - assert descope_client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2", "Perm 3"]) == [ - "Perm 1", - "Perm 2", - ] - - # Tenant level - jwt_response = {"tenants": {}} - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == [] - - jwt_response = {"tenants": {"t1": {}}} - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == [] - - jwt_response = {"tenants": {"t1": {"permissions": ["Perm 1", "Perm 2"]}}} - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == ["Perm 1"] - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]) == [ - "Perm 1", - "Perm 2", - ] - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2", "Perm 3"]) == [ - "Perm 1", - "Perm 2", - ] - - async def test_validate_roles(self, descope_client): - jwt_response = {} - assert descope_client.validate_roles(jwt_response, ["Role 1"]) is False - - jwt_response = {"roles": []} - assert descope_client.validate_roles(jwt_response, ["Role 1"]) is False - assert descope_client.validate_roles(jwt_response, []) is True - - jwt_response = {"roles": ["Role 1"]} - assert descope_client.validate_roles(jwt_response, "Role 1") is True - assert descope_client.validate_roles(jwt_response, ["Role 1"]) is True - assert descope_client.validate_roles(jwt_response, ["Role 2"]) is False - - # Tenant level - jwt_response = {"tenants": {}} - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"]) is False - - jwt_response = {"tenants": {"t1": {}}} - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"]) is False - - jwt_response = {"tenants": {"t1": {"roles": "Role 1"}}} - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 1"]) is True - assert descope_client.validate_tenant_roles(jwt_response, "t1", []) is True - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 2"]) is False - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]) is False - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 1", "Perm 2"]) is False - - async def test_get_matched_roles(self, descope_client): - jwt_response = {} - assert descope_client.get_matched_roles(jwt_response, []) == [] - - jwt_response = {"roles": []} - assert descope_client.get_matched_roles(jwt_response, ["Role 1"]) == [] - - jwt_response = {"roles": ["Role 1", "Role 2"]} - assert descope_client.get_matched_roles(jwt_response, ["Role 1"]) == ["Role 1"] - assert descope_client.get_matched_roles(jwt_response, ["Role 1", "Role 2"]) == ["Role 1", "Role 2"] - assert descope_client.get_matched_roles(jwt_response, ["Role 1", "Role 2", "Role 3"]) == ["Role 1", "Role 2"] - - # Tenant level - jwt_response = {"tenants": {}} - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == [] - - jwt_response = {"tenants": {"t1": {}}} - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == [] - - jwt_response = {"tenants": {"t1": {"roles": ["Role 1", "Role 2"]}}} - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == ["Role 1"] - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]) == ["Role 1", "Role 2"] - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2", "Role 3"]) == [ - "Role 1", - "Role 2", - ] - - async def test_exchange_access_key_empty_param(self, descope_client): - with pytest.raises(AuthException) as exc_info: - await descope_client.invoke(descope_client.exchange_access_key("")) - assert exc_info.value.status_code == 400 - - async def test_exchange_access_key(self, descope_client): - dummy_access_key = "dummy access key" - resp = make_response({"sessionJwt": VALID_REFRESH_TOKEN}) - with descope_client.mock_post(resp) as mock_post: - jwt_response = await descope_client.invoke( - descope_client.exchange_access_key( - access_key=dummy_access_key, - login_options=AccessKeyLoginOptions(custom_claims={"k1": "v1"}), - ) - ) - assert jwt_response["keyId"] == EXPECTED_USER_ID - assert jwt_response["projectId"] == EXPECTED_PROJECT_ID - assert_http_called( - mock_post, - descope_client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.exchange_auth_access_key_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:dummy access key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginOptions": {"customClaims": {"k1": "v1"}}}, - follow_redirects=False, - ) - - async def test_jwt_validation_leeway(self, client_factory): - # Negative leeway forces even far-future tokens to appear expired - min_int = -sys.maxsize - 1 - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, jwt_validation_leeway=min_int) - - with pytest.raises(AuthException) as exc_info: - await client.invoke(client.validate_session(VALID_REFRESH_TOKEN)) - assert exc_info.value.status_code == 400 - assert exc_info.value.error_message is not None - assert "nbf in future" in exc_info.value.error_message - - async def test_select_tenant(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - resp = make_response(data) - with client.mock_post(resp) as mock_post: - await client.invoke(client.select_tenant("t1", VALID_REFRESH_TOKEN)) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.select_tenant_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{VALID_REFRESH_TOKEN}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"tenant": "t1"}, - follow_redirects=False, - ) - - async def test_auth_management_key_with_functions(self, client_factory): - expected_url = f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email" - expected_json = { - "loginId": "test@example.com", - "user": {"email": "test@example.com"}, - "email": "test@example.com", - } - - def expected_headers(key: str) -> dict: - return { - **common.default_headers, - "x-descope-project-id": PROJECT_ID, - "Authorization": f"Bearer {PROJECT_ID}:{key}", - } - - def assert_called(mock_post, key: str) -> None: - if client_factory.mode == "sync": - mock_post.assert_called_with( - expected_url, - headers=expected_headers(key), - json=expected_json, - params=None, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - else: - # HTTPClientAsync sets verify/timeout on the AsyncClient instance, - # not per-call, so they're absent from individual .post() args. - mock_post.assert_called_with( - expected_url, - headers=expected_headers(key), - json=expected_json, - follow_redirects=False, - params=None, - ) - - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.status_code = 200 - my_mock_response.headers = {} - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - - # Test 1: Direct auth_management_key setting (without refresh token) - auth_mgmt_key = "test-auth-mgmt-key" - client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) - with client.mock_post(my_mock_response) as mock_post: - await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com")) - assert_called(mock_post, auth_mgmt_key) - - # Test 2: Environment variable auth_management_key setting - env_auth_mgmt_key = "env-auth-mgmt-key" - with patch.dict("os.environ", {"DESCOPE_AUTH_MANAGEMENT_KEY": env_auth_mgmt_key}): - client_env = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) - with client_env.mock_post(my_mock_response) as mock_post: - await client_env.invoke(client_env.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com")) - assert_called(mock_post, env_auth_mgmt_key) - - # Test 3: Direct parameter takes priority over environment variable - direct_auth_mgmt_key = "direct-auth-mgmt-key" - with patch.dict("os.environ", {"DESCOPE_AUTH_MANAGEMENT_KEY": env_auth_mgmt_key}): - client_priority = client_factory.make( - PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=direct_auth_mgmt_key - ) - with client_priority.mock_post(my_mock_response) as mock_post: - await client_priority.invoke(client_priority.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com")) - assert_called(mock_post, direct_auth_mgmt_key) - - async def test_auth_management_key_with_refresh_token(self, client_factory): - refresh_token = "test_refresh_token" - expected_url = f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}" - expected_json = { - "loginId": "old@example.com", - "email": "new@example.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - } - - def expected_headers(authorization: str) -> dict: - return { - **common.default_headers, - "Authorization": authorization, - "x-descope-project-id": PROJECT_ID, - } - - def assert_called(mock_post, authorization: str) -> None: - if client_factory.mode == "sync": - mock_post.assert_called_with( - expected_url, - headers=expected_headers(authorization), - json=expected_json, - follow_redirects=False, - params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - else: - mock_post.assert_called_with( - expected_url, - headers=expected_headers(authorization), - json=expected_json, - follow_redirects=False, - params=None, - ) - - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.status_code = 200 - my_mock_response.headers = {} - my_mock_response.json.return_value = {"maskedEmail": "n***@example.com"} - - # With auth_management_key — Authorization combines refresh_token + auth_mgmt_key - auth_mgmt_key = "test-auth-mgmt-key" - client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) - with client.mock_post(my_mock_response) as mock_post: - await client.invoke(client.otp.update_user_email("old@example.com", "new@example.com", refresh_token)) - assert_called(mock_post, f"Bearer {PROJECT_ID}:{refresh_token}:{auth_mgmt_key}") - - # Without auth_management_key — refresh token only in Authorization - client_no_auth = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) - with client_no_auth.mock_post(my_mock_response) as mock_post: - await client_no_auth.invoke( - client_no_auth.otp.update_user_email("old@example.com", "new@example.com", refresh_token) - ) - assert_called(mock_post, f"Bearer {PROJECT_ID}:{refresh_token}") - - async def test_base_url_setting(self, client_factory): - custom_base_url = "https://api.use1.descope.com" - client = client_factory.make(PROJECT_ID, base_url=custom_base_url, public_key=PUBLIC_KEY_DICT) - - # Auth HTTP client base_url is available on both sync and async - assert client._auth.http_client.base_url == custom_base_url - - # Management HTTP client is sync-only - if client_factory.mode == "sync": - assert client._mgmt._http.base_url == custom_base_url - - async def test_base_url_none(self, client_factory): - client = client_factory.make(PROJECT_ID, base_url=None, public_key=PUBLIC_KEY_DICT) - - expected_base_url = common.DEFAULT_BASE_URL - assert client._auth.http_client.base_url == expected_base_url - - if client_factory.mode == "sync": - assert client._mgmt._http.base_url == expected_base_url - - async def test_verbose_mode_disabled_by_default(self, client_factory): - client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT) - assert client.get_last_response() is None - - async def test_verbose_mode_enabled(self, client_factory): - client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT, verbose=True) - # Just verify it doesn't error when enabled - assert client.get_last_response() is None # No requests made yet - - async def test_verbose_mode_captures_mgmt_response(self, client_factory): - mock_response = mock.Mock() - mock_response.is_success = True - mock_response.json.return_value = {"user": {"id": "u1", "loginIds": ["test@example.com"]}} - mock_response.headers = {"cf-ray": "mgmt-ray-123", "x-request-id": "req-456"} - mock_response.status_code = 200 - - client = client_factory.make( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="test-mgmt-key", - verbose=True, - ) - if client_factory.mode == "async": - # Bypass the lazy license handshake so the test doesn't make a real - # network call before the (mocked) user.create request runs. - client._raw._license_attempted = True - - with client.mock_mgmt_post(mock_response): - await client.invoke(client.mgmt.user.create(login_id="test@example.com")) - - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp["user"]["id"] == "u1" - assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" - assert last_resp.status_code == 200 - - async def test_auth_and_mgmt_share_one_last_response_store(self, client_factory): - """The auth and mgmt clients must hold the same store object, not two equal ones. - - This is the invariant that makes `get_last_response()` a single read. The - ordering tests cannot catch a re-split on their own: once both clients write - to one store, reading either of them returns the same response, so a stale - `mgmt_resp or auth_resp` still looks correct. Pin the identity instead. - """ - client = client_factory.make( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="test-mgmt-key", - verbose=True, - ) - raw = client._raw - if client_factory.mode == "sync": - auth_store = raw._auth_http_client.last_response_store - mgmt_store = raw._mgmt_http_client.last_response_store - else: - auth_store = raw._auth_http.last_response_store - mgmt_store = raw._mgmt_http.last_response_store - - assert auth_store is mgmt_store - assert raw._last_response_store is auth_store - assert raw._mgmt._outbound_application_by_token._http.last_response_store is auth_store - - async def test_verbose_mode_returns_most_recent_across_mgmt_then_auth(self, client_factory): - """A mgmt call followed by an auth call must return the auth response, not the mgmt one.""" - mgmt_response = mock.Mock() - mgmt_response.is_success = True - mgmt_response.json.return_value = {"user": {"id": "u1"}} - mgmt_response.headers = {"cf-ray": "mgmt-ray"} - mgmt_response.status_code = 200 - - auth_response = mock.Mock() - auth_response.is_success = True - auth_response.json.return_value = {"userId": "u1"} - auth_response.headers = {"cf-ray": "auth-ray"} - auth_response.status_code = 200 - - client = client_factory.make( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="test-mgmt-key", - verbose=True, - ) - if client_factory.mode == "async": - client._raw._license_attempted = True - - with client.mock_mgmt_post(mgmt_response): - await client.invoke(client.mgmt.user.create(login_id="test@example.com")) - assert client.get_last_response().headers.get("cf-ray") == "mgmt-ray" - - with client.mock_get(auth_response): - await client.invoke(client.me("dummy-refresh-token")) - - assert client.get_last_response().headers.get("cf-ray") == "auth-ray" - - async def test_verbose_mode_returns_most_recent_across_auth_then_mgmt(self, client_factory): - """And the other way round — the store has no built-in preference for either side.""" - auth_response = mock.Mock() - auth_response.is_success = True - auth_response.json.return_value = {"userId": "u1"} - auth_response.headers = {"cf-ray": "auth-ray"} - auth_response.status_code = 200 - - mgmt_response = mock.Mock() - mgmt_response.is_success = True - mgmt_response.json.return_value = {"user": {"id": "u1"}} - mgmt_response.headers = {"cf-ray": "mgmt-ray"} - mgmt_response.status_code = 200 - - client = client_factory.make( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="test-mgmt-key", - verbose=True, - ) - if client_factory.mode == "async": - client._raw._license_attempted = True - - with client.mock_get(auth_response): - await client.invoke(client.me("dummy-refresh-token")) - assert client.get_last_response().headers.get("cf-ray") == "auth-ray" - - with client.mock_mgmt_post(mgmt_response): - await client.invoke(client.mgmt.user.create(login_id="test@example.com")) - - assert client.get_last_response().headers.get("cf-ray") == "mgmt-ray" - - async def test_verbose_mode_returns_response_on_non_json_body(self, client_factory): - """get_last_response() must not parse the body: a 502 HTML page is still returned.""" - html = "502 Bad Gateway" - mock_response = mock.Mock() - mock_response.is_success = False - mock_response.status_code = 502 - mock_response.text = html - mock_response.headers = {"cf-ray": "mgmt-ray-502"} - mock_response.json.side_effect = json.JSONDecodeError("Expecting value", html, 0) - - client = client_factory.make( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="test-mgmt-key", - verbose=True, - ) - if client_factory.mode == "async": - client._raw._license_attempted = True - - with client.mock_mgmt_post(mock_response): - with pytest.raises(AuthException): - await client.invoke(client.mgmt.user.create(login_id="test@example.com")) - - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp.status_code == 502 - assert last_resp.text == html - assert last_resp.headers.get("cf-ray") == "mgmt-ray-502" - assert last_resp.is_json is False diff --git a/tests/test_enchantedlink.py b/tests/test_enchantedlink.py deleted file mode 100644 index c60ace51d..000000000 --- a/tests/test_enchantedlink.py +++ /dev/null @@ -1,439 +0,0 @@ -import pytest - -from descope import AuthException, DeliveryMethod -from descope.authmethod.enchantedlink import EnchantedLink -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, - SignUpOptions, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestEnchantedLink: - def test_compose_urls(self): - assert EnchantedLink._compose_signin_url(DeliveryMethod.EMAIL) == "/v1/auth/enchantedlink/signin/email" - assert EnchantedLink._compose_signup_url(DeliveryMethod.EMAIL) == "/v1/auth/enchantedlink/signup/email" - assert ( - EnchantedLink._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) == "/v1/auth/enchantedlink/signup-in/email" - ) - assert EnchantedLink._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signin/sms" - assert EnchantedLink._compose_signup_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signup/sms" - assert EnchantedLink._compose_sign_up_or_in_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/signup-in/sms" - assert EnchantedLink._compose_update_phone_url(DeliveryMethod.SMS) == "/v1/auth/enchantedlink/update/phone/sms" - - def test_compose_body(self): - assert EnchantedLink._compose_signin_body("id1", "uri1") == { - "loginId": "id1", - "URI": "uri1", - "loginOptions": {}, - } - assert EnchantedLink._compose_verify_body("t1") == {"token": "t1"} - assert EnchantedLink._compose_get_session_body("ref1") == {"pendingRef": "ref1"} - - async def test_sign_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_in("", "http://r.me")) - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_in(None, "http://r.me")) - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_in("id", "http://r.me", LoginOptions(mfa=True))) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_in("dummy@dummy.com", "http://r.me")) - - # Success + payload - with client.mock_post(make_response({"pendingRef": "ref123", "linkId": "lnk1"})) as mock_post: - result = await client.invoke(client.enchantedlink.sign_in("dummy@dummy.com", "http://r.me")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "URI": "http://r.me", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_in_with_phone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - with client.mock_post(make_response({"pendingRef": "ref123", "linkId": "lnk1"})) as mock_post: - result = await client.invoke(client.enchantedlink.sign_in_with_phone("+11234567890", "http://r.me")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "+11234567890", "URI": "http://r.me", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_in_with_login_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - - with client.mock_post(make_response({"pendingRef": "ref1", "linkId": "24"})) as mock_post: - await client.invoke(client.enchantedlink.sign_in("dummy@dummy.com", "http://r.me", lo, "refresh")) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:refresh", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://r.me", - "loginOptions": {"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, - }, - follow_redirects=False, - ) - - async def test_sign_up(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - user = {"name": "John", "email": "dummy@dummy.com"} - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_up(None, "http://r.me", user)) - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_up("", "http://r.me", user)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_up("dummy@dummy.com", "http://r.me", user)) - - # Success - with client.mock_post(make_response({"pendingRef": "ref123", "linkId": "lnk1"})): - result = await client.invoke(client.enchantedlink.sign_up("dummy@dummy.com", "http://r.me", user)) - assert result is not None - - async def test_sign_up_with_phone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - user = {"name": "John"} - - # Validation error - invalid phone - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_up_with_phone("id", "http://r.me", {"phone": "not-valid"})) - - # Success + payload - with client.mock_post(make_response({"pendingRef": "ref123", "linkId": "lnk1"})) as mock_post: - result = await client.invoke(client.enchantedlink.sign_up_with_phone("+11234567890", "http://r.me", user)) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_enchantedlink_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "+11234567890", - "URI": "http://r.me", - "user": {"name": "John", "phone": "+11234567890"}, - "phone": "+11234567890", - }, - follow_redirects=False, - ) - - async def test_sign_up_or_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.sign_up_or_in("dummy@dummy.com", "http://r.me")) - - # Success + payload - with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: - result = await client.invoke(client.enchantedlink.sign_up_or_in("dummy@dummy.com", "http://r.me")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "URI": "http://r.me", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_up_or_in_forwards_signup_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: - result = await client.invoke( - client.enchantedlink.sign_up_or_in( - "dummy@dummy.com", - "http://r.me", - SignUpOptions( - revoke_other_sessions=True, - custom_claims={"k1": "v1"}, - template_options={"blah": "blah"}, - template_id="tmpl1", - ), - ) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://r.me", - "loginOptions": { - "stepup": False, - "mfa": False, - "customClaims": {"k1": "v1"}, - "revokeOtherSessions": True, - "templateOptions": {"blah": "blah"}, - "templateId": "tmpl1", - }, - }, - follow_redirects=False, - ) - - async def test_sign_up_or_in_with_phone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: - result = await client.invoke(client.enchantedlink.sign_up_or_in_with_phone("+11234567890", "http://r.me")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "+11234567890", "URI": "http://r.me", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_up_or_in_with_phone_forwards_signup_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: - result = await client.invoke( - client.enchantedlink.sign_up_or_in_with_phone( - "+11234567890", - "http://r.me", - SignUpOptions( - revoke_other_sessions=True, - custom_claims={"k1": "v1"}, - template_options={"blah": "blah"}, - template_id="tmpl1", - ), - ) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "+11234567890", - "URI": "http://r.me", - "loginOptions": { - "stepup": False, - "mfa": False, - "customClaims": {"k1": "v1"}, - "revokeOtherSessions": True, - "templateOptions": {"blah": "blah"}, - "templateId": "tmpl1", - }, - }, - follow_redirects=False, - ) - - async def test_get_session(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.get_session("pending-ref")) - - # Success - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.enchantedlink.get_session("pending-ref")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.get_session_enchantedlink_auth_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"pendingRef": "pending-ref"}, - follow_redirects=False, - ) - - async def test_verify(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.verify("some-token")) - - # Success (returns None) - with client.mock_post(make_response({})) as mock_post: - result = await client.invoke(client.enchantedlink.verify("some-token")) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_enchantedlink_auth_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"token": "some-token"}, - follow_redirects=False, - ) - - async def test_update_user_email(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_email("", "new@example.com", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_email(None, "new@example.com", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_email("id", "bad-email", refresh_token)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_email("id", "new@example.com", refresh_token)) - - # Success + payload - with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: - result = await client.invoke( - client.enchantedlink.update_user_email("dummy@dummy.com", "new@example.com", refresh_token) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_enchantedlink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "email": "new@example.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - ) - - async def test_update_user_phone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_phone("", "+11234567890", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_phone(None, "+11234567890", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_phone("id", "", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_phone("id", "not-a-phone", refresh_token)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.enchantedlink.update_user_phone("id", "+11234567890", refresh_token)) - - # Success + payload - with client.mock_post( - make_response({"pendingRef": "ref123", "linkId": "lnk1", "maskedPhone": "+1123*****90"}) - ) as mock_post: - result = await client.invoke( - client.enchantedlink.update_user_phone("dummy@dummy.com", "+11234567890", refresh_token) - ) - assert result["maskedPhone"] == "+1123*****90" - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_enchantedlink_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "phone": "+11234567890", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - ) diff --git a/tests/test_flask.py b/tests/test_flask.py deleted file mode 100644 index 20e00f1d0..000000000 --- a/tests/test_flask.py +++ /dev/null @@ -1,508 +0,0 @@ -import unittest -from unittest.mock import patch - -from flask import Flask, Response, g - -from descope import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_COOKIE_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_COOKIE_NAME, - SESSION_TOKEN_NAME, - AuthException, - DeliveryMethod, - DescopeClient, -) -from descope.flask import ( - descope_full_login, - descope_logout, - descope_oauth, - descope_signin_magiclink_by_email, - descope_signin_otp_by_email, - descope_signup_magiclink_by_email, - descope_signup_otp_by_email, - descope_validate_auth, - descope_verify_code_by_email, - descope_verify_code_by_phone_sms, - descope_verify_magiclink_token, - set_cookie_on_response, -) -from tests.common import DescopeTest - - -class TestFlaskIntegration(DescopeTest): - def setUp(self): - super().setUp() - self.app = Flask(__name__) - self.app.config["TESTING"] = True - self.client = self.app.test_client() - self.descope_client = DescopeClient("test-project-id") - - # Mock JWT response structure - self.mock_jwt_response = { - SESSION_TOKEN_NAME: {"drn": "DST", "jwt": "mock-session-token"}, - REFRESH_SESSION_TOKEN_NAME: {"drn": "DSR", "jwt": "mock-refresh-token"}, - COOKIE_DATA_NAME: { - "domain": "localhost", - "maxAge": 3600, - "path": "/", - "exp": None, - }, - "permissions": ["read", "write"], - "roles": ["admin"], - "tenants": {"tenant1": {"permissions": ["manage"], "roles": ["owner"]}}, - } - - def test_set_cookie_on_response(self): - """Test cookie setting utility function""" - response = Response("test") - token = {"drn": "DST", "jwt": "test-token"} - cookie_data = {"domain": "localhost", "maxAge": 3600, "path": "/"} - set_cookie_on_response(response, token, cookie_data) - - # Verify cookie was set (Flask sets cookies in headers) - self.assertIsInstance(response, Response) - - def test_otp_signup_decorator_success(self): - """Test OTP signup decorator with valid data""" - - @self.app.route("/signup", methods=["POST"]) - @descope_signup_otp_by_email(self.descope_client) - def signup(): - return Response("Success", 200) - - with patch.object(self.descope_client.otp, "sign_up") as mock_signup: - mock_signup.return_value = "masked-email@example.com" - - response = self.client.post( - "/signup", - json={"email": "test@example.com", "user": {"name": "Test User"}}, - ) - - self.assertEqual(response.status_code, 200) - mock_signup.assert_called_once_with(DeliveryMethod.EMAIL, "test@example.com", {"name": "Test User"}) - - def test_otp_signup_decorator_missing_email(self): - """Test OTP signup decorator with missing email""" - - @self.app.route("/signup", methods=["POST"]) - @descope_signup_otp_by_email(self.descope_client) - def signup(): - return Response("Success", 200) - - response = self.client.post("/signup", json={}) - - self.assertEqual(response.status_code, 400) - self.assertIn(b"Invalid Request, missing email", response.data) - - def test_otp_signup_decorator_auth_exception(self): - """Test OTP signup decorator with AuthException""" - - @self.app.route("/signup", methods=["POST"]) - @descope_signup_otp_by_email(self.descope_client) - def signup(): - return Response("Success", 200) - - with patch.object(self.descope_client.otp, "sign_up") as mock_signup: - mock_signup.side_effect = AuthException(400, "invalid_request", "Invalid email") - - response = self.client.post("/signup", json={"email": "invalid@example.com"}) - - self.assertEqual(response.status_code, 500) - self.assertIn(b"Unable to sign-up user", response.data) - - def test_otp_signin_decorator_success(self): - """Test OTP signin decorator with valid data""" - - @self.app.route("/signin", methods=["POST"]) - @descope_signin_otp_by_email(self.descope_client) - def signin(): - return Response("Success", 200) - - with patch.object(self.descope_client.otp, "sign_in") as mock_signin: - mock_signin.return_value = "masked-email@example.com" - - response = self.client.post("/signin", json={"email": "test@example.com"}) - - self.assertEqual(response.status_code, 200) - mock_signin.assert_called_once_with(DeliveryMethod.EMAIL, "test@example.com") - - def test_otp_verify_decorator_success(self): - """Test OTP verify decorator with valid code""" - - @self.app.route("/verify", methods=["POST"]) - @descope_verify_code_by_email(self.descope_client) - def verify(): - return Response("Verified", 200) - - with patch.object(self.descope_client.otp, "verify_code") as mock_verify: - mock_verify.return_value = self.mock_jwt_response - - response = self.client.post("/verify", json={"email": "test@example.com", "code": "123456"}) - - self.assertEqual(response.status_code, 200) - mock_verify.assert_called_once_with(DeliveryMethod.EMAIL, "test@example.com", "123456") - - def test_otp_verify_decorator_missing_data(self): - """Test OTP verify decorator with missing email or code""" - - @self.app.route("/verify", methods=["POST"]) - @descope_verify_code_by_email(self.descope_client) - def verify(): - return Response("Verified", 200) - - # Missing email - response = self.client.post("/verify", json={"code": "123456"}) - self.assertEqual(response.status_code, 401) - - # Missing code - response = self.client.post("/verify", json={"email": "test@example.com"}) - self.assertEqual(response.status_code, 401) - - def test_sms_verify_decorator_success(self): - """Test SMS verify decorator with valid code""" - - @self.app.route("/verify-sms", methods=["POST"]) - @descope_verify_code_by_phone_sms(self.descope_client) - def verify_sms(): - return Response("Verified", 200) - - with patch.object(self.descope_client.otp, "verify_code") as mock_verify: - mock_verify.return_value = self.mock_jwt_response - - response = self.client.post("/verify-sms", json={"phone": "+1234567890", "code": "123456"}) - - self.assertEqual(response.status_code, 200) - mock_verify.assert_called_once_with(DeliveryMethod.SMS, "+1234567890", "123456") - - def test_validate_auth_decorator_success(self): - """Test auth validation decorator with valid session""" - - @self.app.route("/protected") - @descope_validate_auth(self.descope_client) - def protected(): - return Response("Protected content", 200) - - with patch.object(self.descope_client, "validate_and_refresh_session") as mock_validate: - mock_validate.return_value = self.mock_jwt_response - - response = self.client.get( - "/protected", - headers={"Cookie": f"{SESSION_COOKIE_NAME}=mock-session; {REFRESH_SESSION_COOKIE_NAME}=mock-refresh"}, - ) - - self.assertEqual(response.status_code, 200) - mock_validate.assert_called_once() - - def test_validate_auth_decorator_no_session(self): - """Test auth validation decorator without session""" - - @self.app.route("/protected") - @descope_validate_auth(self.descope_client) - def protected(): - return Response("Protected content", 200) - - with patch.object(self.descope_client, "validate_and_refresh_session") as mock_validate: - mock_validate.side_effect = AuthException(401, "unauthorized", "Invalid session") - - response = self.client.get("/protected") - - self.assertEqual(response.status_code, 401) - self.assertIn(b"Access denied", response.data) - - def test_validate_auth_decorator_with_permissions(self): - """Test auth validation decorator with permission requirements""" - - @self.app.route("/admin") - @descope_validate_auth(self.descope_client, permissions=["admin"]) - def admin(): - return Response("Admin content", 200) - - with patch.object(self.descope_client, "validate_and_refresh_session") as mock_validate: - with patch.object(self.descope_client, "validate_permissions") as mock_perms: - mock_validate.return_value = self.mock_jwt_response - mock_perms.return_value = True - - response = self.client.get( - "/admin", - headers={ - "Cookie": f"{SESSION_COOKIE_NAME}=mock-session; {REFRESH_SESSION_COOKIE_NAME}=mock-refresh" - }, - ) - - self.assertEqual(response.status_code, 200) - mock_perms.assert_called_once_with(self.mock_jwt_response, ["admin"]) - - def test_validate_auth_decorator_insufficient_permissions(self): - """Test auth validation decorator with insufficient permissions""" - - @self.app.route("/admin") - @descope_validate_auth(self.descope_client, permissions=["admin"]) - def admin(): - return Response("Admin content", 200) - - with patch.object(self.descope_client, "validate_and_refresh_session") as mock_validate: - with patch.object(self.descope_client, "validate_permissions") as mock_perms: - mock_validate.return_value = self.mock_jwt_response - mock_perms.return_value = False - - response = self.client.get( - "/admin", - headers={ - "Cookie": f"{SESSION_COOKIE_NAME}=mock-session; {REFRESH_SESSION_COOKIE_NAME}=mock-refresh" - }, - ) - - self.assertEqual(response.status_code, 401) - - def test_validate_auth_decorator_with_roles(self): - """Test auth validation decorator with role requirements""" - - @self.app.route("/manager") - @descope_validate_auth(self.descope_client, roles=["manager"]) - def manager(): - return Response("Manager content", 200) - - with patch.object(self.descope_client, "validate_and_refresh_session") as mock_validate: - with patch.object(self.descope_client, "validate_roles") as mock_roles: - mock_validate.return_value = self.mock_jwt_response - mock_roles.return_value = True - - response = self.client.get( - "/manager", - headers={ - "Cookie": f"{SESSION_COOKIE_NAME}=mock-session; {REFRESH_SESSION_COOKIE_NAME}=mock-refresh" - }, - ) - - self.assertEqual(response.status_code, 200) - mock_roles.assert_called_once_with(self.mock_jwt_response, ["manager"]) - - def test_validate_auth_decorator_with_tenant(self): - """Test auth validation decorator with tenant-specific permissions""" - - @self.app.route("/tenant-admin") - @descope_validate_auth(self.descope_client, permissions=["manage"], tenant="tenant1") - def tenant_admin(): - return Response("Tenant admin content", 200) - - with patch.object(self.descope_client, "validate_and_refresh_session") as mock_validate: - with patch.object(self.descope_client, "validate_tenant_permissions") as mock_tenant_perms: - mock_validate.return_value = self.mock_jwt_response - mock_tenant_perms.return_value = True - - response = self.client.get( - "/tenant-admin", - headers={ - "Cookie": f"{SESSION_COOKIE_NAME}=mock-session; {REFRESH_SESSION_COOKIE_NAME}=mock-refresh" - }, - ) - - self.assertEqual(response.status_code, 200) - mock_tenant_perms.assert_called_once_with(self.mock_jwt_response, ["manage"]) - - def test_magiclink_signup_decorator_success(self): - """Test MagicLink signup decorator with valid data""" - - @self.app.route("/magiclink-signup", methods=["POST"]) - @descope_signup_magiclink_by_email(self.descope_client, "http://example.com/verify") - def magiclink_signup(): - return Response("Success", 200) - - with patch.object(self.descope_client.magiclink, "sign_up") as mock_signup: - mock_signup.return_value = "masked-email@example.com" - - response = self.client.post( - "/magiclink-signup", - json={"email": "test@example.com", "user": {"name": "Test User"}}, - ) - - self.assertEqual(response.status_code, 200) - mock_signup.assert_called_once_with( - DeliveryMethod.EMAIL, - "test@example.com", - "http://example.com/verify", - {"name": "Test User"}, - ) - - def test_magiclink_signin_decorator_success(self): - """Test MagicLink signin decorator with valid data""" - - @self.app.route("/magiclink-signin", methods=["POST"]) - @descope_signin_magiclink_by_email(self.descope_client, "http://example.com/verify") - def magiclink_signin(): - return Response("Success", 200) - - with patch.object(self.descope_client.magiclink, "sign_in") as mock_signin: - mock_signin.return_value = "masked-email@example.com" - - response = self.client.post("/magiclink-signin", json={"email": "test@example.com"}) - - self.assertEqual(response.status_code, 200) - mock_signin.assert_called_once_with(DeliveryMethod.EMAIL, "test@example.com", "http://example.com/verify") - - def test_magiclink_verify_decorator_success(self): - """Test MagicLink verify decorator with valid token""" - - @self.app.route("/magiclink-verify") - @descope_verify_magiclink_token(self.descope_client) - def magiclink_verify(): - return Response("Verified", 200) - - with patch.object(self.descope_client.magiclink, "verify") as mock_verify: - mock_verify.return_value = self.mock_jwt_response - - response = self.client.get("/magiclink-verify?t=mock-token") - - self.assertEqual(response.status_code, 200) - mock_verify.assert_called_once_with("mock-token") - - def test_magiclink_verify_decorator_missing_token(self): - """Test MagicLink verify decorator without token""" - - @self.app.route("/magiclink-verify") - @descope_verify_magiclink_token(self.descope_client) - def magiclink_verify(): - return Response("Verified", 200) - - response = self.client.get("/magiclink-verify") - - self.assertEqual(response.status_code, 401) - self.assertIn(b"Unauthorized", response.data) - - def test_oauth_decorator_success(self): - """Test OAuth decorator with valid provider""" - - @self.app.route("/oauth") - @descope_oauth(self.descope_client) - def oauth(*args, **kwargs): - return Response("OAuth initiated", 200) - - with patch.object(self.descope_client.oauth, "start") as mock_start: - mock_start.return_value = {"url": "https://oauth.provider.com/auth"} - - response = self.client.get("/oauth?provider=google") - - self.assertEqual(response.status_code, 302) - mock_start.assert_called_once_with("google") - - def test_oauth_decorator_auth_exception(self): - """Test OAuth decorator with AuthException""" - - @self.app.route("/oauth") - @descope_oauth(self.descope_client) - def oauth(): - return Response("OAuth initiated", 200) - - with patch.object(self.descope_client.oauth, "start") as mock_start: - mock_start.side_effect = AuthException(400, "invalid_provider", "Invalid provider") - - response = self.client.get("/oauth?provider=invalid") - - self.assertEqual(response.status_code, 400) - self.assertIn(b"OAuth failed", response.data) - - def test_logout_decorator_success(self): - """Test logout decorator with valid refresh token""" - - @self.app.route("/logout", methods=["POST"]) - @descope_logout(self.descope_client) - def logout(): - return Response("Logged out", 200) - - with patch.object(self.descope_client, "logout") as mock_logout: - mock_logout.return_value = None - - # Set the refresh and session cookies using the test client - self.client.set_cookie(REFRESH_SESSION_COOKIE_NAME, "mock-refresh-token") - self.client.set_cookie(SESSION_COOKIE_NAME, "mock-session-token") - response = self.client.post( - "/logout", - ) - - self.assertEqual(response.status_code, 200) - # The decorator should extract the refresh token from cookies and call logout - mock_logout.assert_called_once_with("mock-refresh-token") - - def test_logout_decorator_auth_exception(self): - """Test logout decorator with AuthException""" - - @self.app.route("/logout", methods=["POST"]) - @descope_logout(self.descope_client) - def logout(): - return Response("Logged out", 200) - - with patch.object(self.descope_client, "logout") as mock_logout: - mock_logout.side_effect = AuthException(400, "invalid_token", "Invalid token") - - response = self.client.post( - "/logout", - headers={"Cookie": f"{REFRESH_SESSION_COOKIE_NAME}=invalid-token"}, - ) - - self.assertEqual(response.status_code, 400) - self.assertIn(b"Logout failed", response.data) - - def test_full_login_decorator_success(self): - """Test full login decorator generates correct HTML""" - - @self.app.route("/login") - @descope_full_login( - project_id="test-project", - flow_id="sign-up-or-in", - success_redirect_url="http://localhost/success", - ) - def login(): - return Response("Login page", 200) - - response = self.client.get("/login") - - self.assertEqual(response.status_code, 200) - self.assertIn(b"descope-wc", response.data) - self.assertIn(b"test-project", response.data) - self.assertIn(b"sign-up-or-in", response.data) - self.assertIn(b"http://localhost/success", response.data) - - def test_full_login_decorator_missing_redirect_url(self): - """Test full login decorator with missing redirect URL""" - - @descope_full_login( - project_id="test-project", - flow_id="sign-up-or-in", - success_redirect_url="", - ) - def login(): - return Response("Login page", 200) - - with self.assertRaises(AuthException) as context: - login() - - self.assertEqual(context.exception.status_code, 500) - self.assertIn("Missing success_redirect_url", str(context.exception)) - - def test_request_context_claims_storage(self): - """Test that JWT claims are stored in Flask request context""" - - @self.app.route("/context-test") - @descope_validate_auth(self.descope_client) - def context_test(): - # Access the claims stored in context - claims = getattr(g, "claims", None) - if claims: - return Response(f"Claims found: {claims.get('permissions', [])}", 200) - return Response("No claims", 400) - - with patch.object(self.descope_client, "validate_and_refresh_session") as mock_validate: - mock_validate.return_value = self.mock_jwt_response - - response = self.client.get( - "/context-test", - headers={"Cookie": f"{SESSION_COOKIE_NAME}=mock-session; {REFRESH_SESSION_COOKIE_NAME}=mock-refresh"}, - ) - - self.assertEqual(response.status_code, 200) - self.assertIn(b"Claims found", response.data) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_http_client.py b/tests/test_http_client.py deleted file mode 100644 index 22a87df77..000000000 --- a/tests/test_http_client.py +++ /dev/null @@ -1,1162 +0,0 @@ -import json -import os -import threading -import unittest -from unittest.mock import Mock, patch - -from descope._http_client_base import ThreadLocalLastResponseStore -from descope.http_client import DescopeResponse, HTTPClient - - -class TestDescopeResponse(unittest.TestCase): - def test_dict_like_access(self): - """Test that DescopeResponse acts like a dict for backward compatibility.""" - mock_response = Mock() - mock_response.json.return_value = {"user": {"id": "u1"}, "status": "ok"} - mock_response.headers = {"cf-ray": "abc123"} - mock_response.status_code = 200 - - resp = DescopeResponse(mock_response) - - # Dict-like access - assert resp["user"]["id"] == "u1" - assert resp["status"] == "ok" - assert "user" in resp - assert "missing" not in resp - assert resp.get("status") == "ok" - assert resp.get("missing", "default") == "default" - assert list(resp.keys()) == ["user", "status"] - assert len(resp) == 2 - - def test_http_metadata_access(self): - """Test that HTTP metadata is accessible.""" - mock_response = Mock() - mock_response.json.return_value = {"result": "success"} - mock_response.headers = {"cf-ray": "abc123", "x-request-id": "req456"} - mock_response.status_code = 201 - mock_response.text = '{"result":"success"}' - mock_response.url = "https://api.descope.com/test" - mock_response.is_success = True - - resp = DescopeResponse(mock_response) - - # HTTP metadata - assert resp.headers.get("cf-ray") == "abc123" - assert resp.headers.get("x-request-id") == "req456" - assert resp.status_code == 201 - assert resp.text == '{"result":"success"}' - assert resp.url == "https://api.descope.com/test" - assert resp.ok is True - - def test_json_caching(self): - """Test that JSON parsing is cached.""" - mock_response = Mock() - mock_response.json.return_value = {"data": "value"} - - resp = DescopeResponse(mock_response) - - # First call - result1 = resp.json() - # Second call should use cached value - result2 = resp.json() - - assert result1 == result2 - # json() should only be called once on the underlying response - assert mock_response.json.call_count == 1 - - def test_null_json_body_parses_once(self): - """A `null` body is a real cached value, not a miss to retry. - - The old `if self._json_data is None` sentinel re-parsed on every access here, - which test_json_caching cannot catch because its body is a dict. - """ - mock_response = Mock() - mock_response.json.return_value = None - - resp = DescopeResponse(mock_response) - - assert resp.json() is None - assert resp.json() is None - assert mock_response.json.call_count == 1 - - def test_is_json_does_not_reparse_non_json_body(self): - """is_json probes by parsing, so an unparseable body must not re-probe.""" - body = "502" - mock_response = Mock() - mock_response.json.side_effect = json.JSONDecodeError("Expecting value", body, 0) - mock_response.text = body - - resp = DescopeResponse(mock_response) - - assert resp.is_json is False - assert resp.is_json is False - assert resp.is_json is False - assert mock_response.json.call_count == 1 - - # A failed parse is not cached, so json() itself still raises every time. - with self.assertRaises(json.JSONDecodeError): - resp.json() - - def test_dict_like_values_items(self): - """Test that values() and items() work correctly.""" - mock_response = Mock() - mock_response.json.return_value = {"a": 1, "b": 2} - resp = DescopeResponse(mock_response) - - assert list(resp.values()) == [1, 2] - assert list(resp.items()) == [("a", 1), ("b", 2)] - - def test_string_representation(self): - """Test __str__ and __repr__ methods.""" - mock_response = Mock() - mock_response.json.return_value = {"result": "success"} - resp = DescopeResponse(mock_response) - - assert str(resp) == "{'result': 'success'}" - assert "DescopeResponse" in repr(resp) - - def test_bool_and_len(self): - """Test __bool__ and __len__ methods.""" - mock_response = Mock() - mock_response.json.return_value = {"data": "value"} - resp = DescopeResponse(mock_response) - - assert bool(resp) is True - assert len(resp) == 1 - - def test_equality(self): - """Test __eq__ and __ne__ methods.""" - mock1 = Mock() - mock1.json.return_value = {"data": "value"} - mock2 = Mock() - mock2.json.return_value = {"data": "value"} - mock3 = Mock() - mock3.json.return_value = {"different": "data"} - - resp1 = DescopeResponse(mock1) - resp2 = DescopeResponse(mock2) - resp3 = DescopeResponse(mock3) - - assert resp1 == resp2 - assert resp1 != resp3 - assert resp1 == {"data": "value"} - - def test_iter(self): - """Test __iter__ method.""" - mock_response = Mock() - mock_response.json.return_value = {"a": 1, "b": 2} - resp = DescopeResponse(mock_response) - - assert list(resp) == ["a", "b"] - - def test_cookies_and_content(self): - """Test cookies and content properties.""" - mock_response = Mock() - mock_response.json.return_value = {"data": "test"} - mock_response.cookies = {"session": "abc123"} - mock_response.content = b'{"data":"test"}' - resp = DescopeResponse(mock_response) - - assert resp.cookies.get("session") == "abc123" - assert resp.content == b'{"data":"test"}' - - def test_non_json_body_is_inspectable(self): - """A non-JSON body (e.g. an nginx 502 HTML page) must not break inspection.""" - html = "502 Bad Gateway" - mock_response = Mock() - mock_response.json.side_effect = json.JSONDecodeError("Expecting value", html, 0) - mock_response.status_code = 502 - mock_response.text = html - mock_response.headers = {"cf-ray": "abc123"} - mock_response.is_success = False - - resp = DescopeResponse(mock_response) - - assert bool(resp) is True - assert str(resp) == html - assert "502" in repr(resp) - assert resp.is_json is False - assert resp.status_code == 502 - assert resp.text == html - assert resp.headers.get("cf-ray") == "abc123" - assert resp.ok is False - # Equality falls back to identity rather than raising - assert (resp == DescopeResponse(mock_response)) is False - - # Explicit JSON access still raises - with self.assertRaises(json.JSONDecodeError): - resp.json() - with self.assertRaises(json.JSONDecodeError): - resp.__getitem__("errorCode") - - def test_long_non_json_body_is_truncated_in_str_and_repr(self): - """Body size is upstream-controlled, so logging must not echo it unbounded.""" - body = "x" * 5000 - mock_response = Mock() - mock_response.json.side_effect = json.JSONDecodeError("Expecting value", body, 0) - mock_response.status_code = 502 - mock_response.text = body - - resp = DescopeResponse(mock_response) - - assert len(str(resp)) < 300 - assert "5000 chars" in str(resp) - assert len(repr(resp)) < 300 - assert resp.text == body # full body still reachable - - def test_is_json_true_for_json_body(self): - mock_response = Mock() - mock_response.json.return_value = {"data": "test"} - assert DescopeResponse(mock_response).is_json is True - - def test_empty_json_body_is_truthy(self): - """Truthiness means "a response exists", not "the body is non-empty".""" - mock_response = Mock() - mock_response.json.return_value = {} - resp = DescopeResponse(mock_response) - - assert bool(resp) is True - mock_response.json.assert_not_called() # truthiness must not parse the body - assert len(resp) == 0 - - @patch("httpx.get") - def test_verbose_mode_captures_response_before_error(self, mock_get): - """Test that verbose mode captures response even when errors are raised. - - This is critical for debugging - the whole point of verbose mode is to - capture headers (cf-ray) from failed requests to share with support. - """ - from descope.exceptions import AuthException - - mock_response = Mock() - mock_response.is_success = False - mock_response.status_code = 401 - mock_response.text = "Unauthorized" - mock_response.headers = {"cf-ray": "error123"} - mock_response.json.return_value = {"error": "Unauthorized"} - mock_get.return_value = mock_response - - client = HTTPClient(project_id="test123", verbose=True) - try: - client.get("/test") - raise AssertionError("Should have raised AuthException") - except AuthException: - pass - - last_resp = client.get_last_response() - assert last_resp is not None, "Response should be captured even when error occurs" - assert last_resp.status_code == 401 - assert last_resp.headers.get("cf-ray") == "error123" - assert last_resp.text == "Unauthorized" - - -class TestHTTPClient(unittest.TestCase): - def test_base_url_for_project_id(self): - # short project id -> default base - assert HTTPClient.base_url_for_project_id("short") == "https://api.descope.com" - # long project id -> computed region - pid = "Puse12aAc4T2V93bddihGEx2Ryhc8e5Z" - assert HTTPClient.base_url_for_project_id(pid) == "https://api.use1.descope.com" - - def test_verbose_mode_disabled_by_default(self): - """Test that verbose mode is disabled by default.""" - client = HTTPClient(project_id="test123") - assert client.verbose is False - assert client.get_last_response() is None - - def test_verbose_mode_enabled(self): - """Test that verbose mode can be enabled.""" - client = HTTPClient(project_id="test123", verbose=True) - assert client.verbose is True - - @patch("httpx.get") - def test_verbose_mode_captures_response(self, mock_get): - """Test that responses are captured when verbose mode is enabled.""" - # Setup mock response - mock_response = Mock() - mock_response.is_success = True - mock_response.json.return_value = {"data": "test"} - mock_response.headers = {"cf-ray": "xyz789"} - mock_response.status_code = 200 - mock_get.return_value = mock_response - - # Create client with verbose mode - client = HTTPClient(project_id="test123", verbose=True) - - # Make a request - client.get("/test") - - # Verify response was captured - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp["data"] == "test" - assert last_resp.headers.get("cf-ray") == "xyz789" - assert last_resp.status_code == 200 - - @patch("httpx.get") - def test_verbose_mode_not_capture_when_disabled(self, mock_get): - """Test that responses are NOT captured when verbose mode is disabled.""" - mock_response = Mock() - mock_response.is_success = True - mock_response.json.return_value = {"data": "test"} - mock_get.return_value = mock_response - - # Create client WITHOUT verbose mode - client = HTTPClient(project_id="test123", verbose=False) - - # Make a request - client.get("/test") - - # Verify response was NOT captured - assert client.get_last_response() is None - - @patch("httpx.post") - def test_verbose_mode_captures_post_response(self, mock_post): - """Test that POST responses are captured in verbose mode.""" - mock_response = Mock() - mock_response.is_success = True - mock_response.json.return_value = {"created": "user1"} - mock_response.headers = {"cf-ray": "post123"} - mock_response.status_code = 201 - mock_post.return_value = mock_response - - client = HTTPClient(project_id="test123", verbose=True) - client.post("/users", body={"name": "test"}) - - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp["created"] == "user1" - assert last_resp.status_code == 201 - - @patch("httpx.patch") - def test_verbose_mode_captures_patch_response(self, mock_patch): - """Test that PATCH responses are captured in verbose mode.""" - mock_response = Mock() - mock_response.is_success = True - mock_response.json.return_value = {"updated": "user1"} - mock_response.headers = {"cf-ray": "patch123"} - mock_response.status_code = 200 - mock_patch.return_value = mock_response - - client = HTTPClient(project_id="test123", verbose=True) - client.patch("/users/1", body={"name": "updated"}) - - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp["updated"] == "user1" - assert last_resp.status_code == 200 - - @patch("httpx.put") - def test_verbose_mode_captures_put_response(self, mock_put): - """Test that PUT responses are captured in verbose mode.""" - mock_response = Mock() - mock_response.is_success = True - mock_response.json.return_value = {"replaced": "user1"} - mock_response.headers = {"cf-ray": "put123"} - mock_response.status_code = 200 - mock_put.return_value = mock_response - - client = HTTPClient(project_id="test123", verbose=True) - client.put("/users/1", body={"name": "replaced"}) - - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp["replaced"] == "user1" - assert last_resp.status_code == 200 - - @patch("httpx.put") - def test_verbose_mode_not_capture_put_when_disabled(self, mock_put): - """Test that PUT responses are NOT captured when verbose mode is disabled.""" - mock_response = Mock() - mock_response.is_success = True - mock_response.json.return_value = {"replaced": "user1"} - mock_put.return_value = mock_response - - client = HTTPClient(project_id="test123", verbose=False) - client.put("/users/1", body={"name": "replaced"}) - - assert client.get_last_response() is None - - @patch("httpx.delete") - def test_verbose_mode_captures_delete_response(self, mock_delete): - """Test that DELETE responses are captured in verbose mode.""" - mock_response = Mock() - mock_response.is_success = True - mock_response.json.return_value = {"deleted": "user1"} - mock_response.headers = {"cf-ray": "delete123"} - mock_response.status_code = 204 - mock_delete.return_value = mock_response - - client = HTTPClient(project_id="test123", verbose=True) - client.delete("/users/1") - - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp["deleted"] == "user1" - assert last_resp.status_code == 204 - - @patch("httpx.get") - @patch("httpx.post") - def test_clients_sharing_a_store_see_one_ordering(self, mock_post, mock_get): - """A shared store makes "last" mean last, regardless of which client wrote it.""" - mgmt_response = Mock() - mgmt_response.is_success = True - mgmt_response.json.return_value = {"src": "mgmt"} - mock_post.return_value = mgmt_response - - auth_response = Mock() - auth_response.is_success = True - auth_response.json.return_value = {"src": "auth"} - mock_get.return_value = auth_response - - store = ThreadLocalLastResponseStore() - mgmt = HTTPClient(project_id="test123", verbose=True, last_response_store=store) - auth = HTTPClient(project_id="test123", verbose=True, last_response_store=store) - - mgmt.post("/x", body={}) - assert store.get()["src"] == "mgmt" - - auth.get("/x") - assert store.get()["src"] == "auth" - - def test_shared_store_is_still_per_thread(self): - """Sharing one store must not leak a response between threads.""" - store = ThreadLocalLastResponseStore() - seen = {} - both_written = threading.Barrier(2) - - def worker(name): - response = Mock() - response.json.return_value = {"thread": name} - store.set(DescopeResponse(response)) - both_written.wait() # neither reads until both have written - seen[name] = store.get()["thread"] - - threads = [threading.Thread(target=worker, args=(name,)) for name in ("a", "b")] - for t in threads: - t.start() - for t in threads: - t.join() - - assert seen == {"a": "a", "b": "b"} - - def test_raises_auth_exception_with_empty_project_id(self): - """Test that HTTPClient raises AuthException when project_id is empty.""" - from descope.exceptions import AuthException - - with self.assertRaises(AuthException) as cm: - HTTPClient(project_id="") - - assert cm.exception.status_code == 400 - - @patch("httpx.get") - def test_raises_rate_limit_exception(self, mock_get): - """Test that HTTPClient raises RateLimitException on 429.""" - from descope.exceptions import RateLimitException - - mock_response = Mock() - mock_response.is_success = False - mock_response.status_code = 429 - mock_response.json.return_value = { - "errorCode": "E010", - "errorDescription": "Rate limit exceeded", - "errorMessage": "Too many requests", - } - mock_response.headers = {"Retry-After": "60"} - mock_get.return_value = mock_response - - client = HTTPClient(project_id="test123") - - with self.assertRaises(RateLimitException) as cm: - client.get("/test") - - assert cm.exception.error_type == "API rate limit exceeded" - - @patch("httpx.get") - def test_raises_rate_limit_exception_without_json_body(self, mock_get): - """Test that RateLimitException is raised even when JSON parsing fails.""" - from descope.exceptions import RateLimitException - - mock_response = Mock() - mock_response.is_success = False - mock_response.status_code = 429 - mock_response.json.side_effect = ValueError("Invalid JSON") - mock_response.headers = {"Retry-After": "30"} - mock_get.return_value = mock_response - - client = HTTPClient(project_id="test123") - - with self.assertRaises(RateLimitException) as cm: - client.get("/test") - - assert cm.exception.error_type == "API rate limit exceeded" - - @patch("httpx.get") - def test_raises_auth_exception_on_server_error(self, mock_get): - """Test that HTTPClient raises AuthException on 500.""" - from descope.exceptions import AuthException - - mock_response = Mock() - mock_response.is_success = False - mock_response.status_code = 500 - mock_response.text = "Internal Server Error" - mock_get.return_value = mock_response - - client = HTTPClient(project_id="test123") - - with self.assertRaises(AuthException) as cm: - client.get("/test") - - assert cm.exception.status_code == 500 - - def test_get_default_headers_with_password(self): - """Test get_default_headers with password.""" - client = HTTPClient(project_id="test123") - headers = client.get_default_headers("mypassword") - assert "Authorization" in headers - assert "test123:mypassword" in headers["Authorization"] - - def test_get_default_headers_with_management_key(self): - """Test get_default_headers with management key.""" - client = HTTPClient(project_id="test123", management_key="mgmt-key") - headers = client.get_default_headers() - assert "Authorization" in headers - assert "test123:mgmt-key" in headers["Authorization"] - - def test_parse_retry_after_with_valid_header(self): - """Test _parse_retry_after with valid header.""" - client = HTTPClient(project_id="test123") - headers = {"Retry-After": "60"} - result = client._parse_retry_after(headers) - assert result == 60 - - def test_parse_retry_after_with_missing_header(self): - """Test _parse_retry_after with missing header.""" - client = HTTPClient(project_id="test123") - headers = {} - result = client._parse_retry_after(headers) - assert result == 0 - - def test_parse_retry_after_with_invalid_header(self): - """Test _parse_retry_after with invalid header.""" - client = HTTPClient(project_id="test123") - headers = {"Retry-After": "not-a-number"} - result = client._parse_retry_after(headers) - assert result == 0 - - -class TestVerboseModeThreadSafety(unittest.TestCase): - """Tests demonstrating verbose mode thread safety. - - The HTTPClient uses threading.local() to store _last_response, ensuring - each thread gets its own response when sharing a client instance. - """ - - @patch("httpx.get") - def test_verbose_mode_thread_safe_with_shared_client(self, mock_get): - """Verify that shared client is thread-safe for verbose mode. - - Each thread should see its own response even when sharing the same - HTTPClient instance, thanks to threading.local() storage. - """ - import threading - - results: dict[str, str | None] = { - "thread1_ray": None, - "thread2_ray": None, - } - barrier = threading.Barrier(2) - - def mock_get_side_effect(*args, **kwargs): - """Return different cf-ray based on which thread is calling.""" - thread_name = threading.current_thread().name - response = Mock() - response.is_success = True - response.json.return_value = {"thread": thread_name} - # Each thread gets a unique cf-ray - if "thread1" in thread_name: - response.headers = {"cf-ray": "ray-thread1"} - else: - response.headers = {"cf-ray": "ray-thread2"} - response.status_code = 200 - return response - - mock_get.side_effect = mock_get_side_effect - - # Single shared client - now thread-safe! - client = HTTPClient(project_id="test123", verbose=True) - - def thread1_work(): - client.get("/test") - barrier.wait() # Sync with thread2 - resp = client.get_last_response() - assert resp is not None - results["thread1_ray"] = resp.headers.get("cf-ray") - - def thread2_work(): - client.get("/test") - barrier.wait() # Sync with thread1 - resp = client.get_last_response() - assert resp is not None - results["thread2_ray"] = resp.headers.get("cf-ray") - - t1 = threading.Thread(target=thread1_work, name="thread1") - t2 = threading.Thread(target=thread2_work, name="thread2") - - t1.start() - t2.start() - t1.join() - t2.join() - - # With thread-local storage, each thread sees its OWN response - assert results["thread1_ray"] == "ray-thread1", ( - f"Thread1 should see its own cf-ray, got: {results['thread1_ray']}" - ) - assert results["thread2_ray"] == "ray-thread2", ( - f"Thread2 should see its own cf-ray, got: {results['thread2_ray']}" - ) - - @patch("httpx.get") - def test_verbose_mode_separate_clients_per_thread(self, mock_get): - """Verify separate clients per thread also works (alternative pattern). - - This test shows that using separate client instances per thread - also provides thread-safe access to response metadata. - """ - import threading - - results: dict[str, str | None] = {"thread1_ray": None, "thread2_ray": None} - barrier = threading.Barrier(2) - - def mock_get_side_effect(*args, **kwargs): - thread_name = threading.current_thread().name - response = Mock() - response.is_success = True - response.json.return_value = {"thread": thread_name} - if "thread1" in thread_name: - response.headers = {"cf-ray": "ray-thread1"} - else: - response.headers = {"cf-ray": "ray-thread2"} - response.status_code = 200 - return response - - mock_get.side_effect = mock_get_side_effect - - def thread1_work(): - # Each thread creates its own client - client = HTTPClient(project_id="test123", verbose=True) - client.get("/test") - barrier.wait() - resp = client.get_last_response() - assert resp is not None - results["thread1_ray"] = resp.headers.get("cf-ray") - - def thread2_work(): - # Each thread creates its own client - client = HTTPClient(project_id="test123", verbose=True) - client.get("/test") - barrier.wait() - resp = client.get_last_response() - assert resp is not None - results["thread2_ray"] = resp.headers.get("cf-ray") - - t1 = threading.Thread(target=thread1_work, name="thread1") - t2 = threading.Thread(target=thread2_work, name="thread2") - - t1.start() - t2.start() - t1.join() - t2.join() - - # With separate clients, each thread has its own response - assert results["thread1_ray"] == "ray-thread1", ( - f"Thread1 should see its own cf-ray, got: {results['thread1_ray']}" - ) - assert results["thread2_ray"] == "ray-thread2", ( - f"Thread2 should see its own cf-ray, got: {results['thread2_ray']}" - ) - - -class TestRetryMechanism(unittest.TestCase): - """Tests for automatic retry on specific HTTP status codes.""" - - @patch("time.sleep") - @patch("httpx.get") - def test_retries_on_retryable_codes(self, mock_get, mock_sleep): - """Test that all retryable status codes (503, 520, 521, 522, 524, 530) trigger a retry.""" - for status_code in [503, 520, 521, 522, 524, 530]: - mock_get.reset_mock() - mock_sleep.reset_mock() - - error_response = Mock() - error_response.is_success = False - error_response.status_code = status_code - error_response.text = f"Error {status_code}" - - success_response = Mock() - success_response.is_success = True - success_response.status_code = 200 - success_response.json.return_value = {"result": "ok"} - - mock_get.side_effect = [error_response, success_response] - - client = HTTPClient(project_id="test123") - response = client.get("/test") - - assert mock_get.call_count == 2, f"Should retry once on {status_code}" - assert response.status_code == 200 - mock_sleep.assert_called_once_with(0.1) - - @patch("time.sleep") - @patch("httpx.get") - def test_retries_up_to_three_times(self, mock_get, mock_sleep): - """Test that the client retries up to 3 times (original + 3 retries = 4 calls).""" - error_response = Mock() - error_response.is_success = False - error_response.status_code = 503 - error_response.text = "Service Unavailable" - - mock_get.return_value = error_response - - client = HTTPClient(project_id="test123") - from descope.exceptions import AuthException - - with self.assertRaises(AuthException): - client.get("/test") - - # Original call + 3 retries = 4 total calls - assert mock_get.call_count == 4 - assert mock_sleep.call_count == 3 - - @patch("time.sleep") - @patch("httpx.get") - def test_retry_delays_are_correct(self, mock_get, mock_sleep): - """Test that retry delays are: 100ms first, 5s for subsequent retries.""" - error_response = Mock() - error_response.is_success = False - error_response.status_code = 503 - error_response.text = "Service Unavailable" - - mock_get.return_value = error_response - - client = HTTPClient(project_id="test123") - from descope.exceptions import AuthException - - with self.assertRaises(AuthException): - client.get("/test") - - sleep_calls = [call[0][0] for call in mock_sleep.call_args_list] - assert sleep_calls == [0.1, 5.0, 5.0] - - @patch("time.sleep") - @patch("httpx.get") - def test_no_retry_on_non_retryable_codes(self, mock_get, mock_sleep): - """Test that non-retryable status codes do not trigger retries.""" - for status_code in [400, 401, 403, 404, 500, 502]: - mock_get.reset_mock() - mock_sleep.reset_mock() - - error_response = Mock() - error_response.is_success = False - error_response.status_code = status_code - error_response.text = f"Error {status_code}" - mock_get.return_value = error_response - - client = HTTPClient(project_id="test123") - from descope.exceptions import AuthException - - with self.assertRaises(AuthException): - client.get("/test") - - assert mock_get.call_count == 1, f"Should not retry on {status_code}" - mock_sleep.assert_not_called() - - @patch("time.sleep") - @patch("httpx.post") - def test_retry_works_for_post(self, mock_post, mock_sleep): - """Test that retry works for POST requests.""" - error_response = Mock() - error_response.is_success = False - error_response.status_code = 503 - error_response.text = "Service Unavailable" - - success_response = Mock() - success_response.is_success = True - success_response.status_code = 200 - success_response.json.return_value = {"created": "ok"} - - mock_post.side_effect = [error_response, success_response] - - client = HTTPClient(project_id="test123") - response = client.post("/test", body={"key": "value"}) - - assert mock_post.call_count == 2 - assert response.status_code == 200 - mock_sleep.assert_called_once_with(0.1) - - @patch("time.sleep") - @patch("httpx.put") - def test_retry_works_for_put(self, mock_put, mock_sleep): - """Test that retry works for PUT requests.""" - error_response = Mock() - error_response.is_success = False - error_response.status_code = 522 - error_response.text = "Connection Timed Out" - - success_response = Mock() - success_response.is_success = True - success_response.status_code = 200 - success_response.json.return_value = {"updated": "ok"} - - mock_put.side_effect = [error_response, success_response] - - client = HTTPClient(project_id="test123") - response = client.put("/test", body={"key": "value"}) - - assert mock_put.call_count == 2 - assert response.status_code == 200 - mock_sleep.assert_called_once_with(0.1) - - @patch("time.sleep") - @patch("httpx.patch") - def test_retry_works_for_patch(self, mock_patch, mock_sleep): - """Test that retry works for PATCH requests.""" - error_response = Mock() - error_response.is_success = False - error_response.status_code = 530 - error_response.text = "Cloudflare Error" - - success_response = Mock() - success_response.is_success = True - success_response.status_code = 200 - success_response.json.return_value = {"patched": "ok"} - - mock_patch.side_effect = [error_response, success_response] - - client = HTTPClient(project_id="test123") - response = client.patch("/test", body={"key": "value"}) - - assert mock_patch.call_count == 2 - assert response.status_code == 200 - mock_sleep.assert_called_once_with(0.1) - - @patch("time.sleep") - @patch("httpx.delete") - def test_retry_works_for_delete(self, mock_delete, mock_sleep): - """Test that retry works for DELETE requests.""" - error_response = Mock() - error_response.is_success = False - error_response.status_code = 503 - error_response.text = "Service Unavailable" - - success_response = Mock() - success_response.is_success = True - success_response.status_code = 200 - success_response.json.return_value = {"deleted": "ok"} - - mock_delete.side_effect = [error_response, success_response] - - client = HTTPClient(project_id="test123") - response = client.delete("/test") - - assert mock_delete.call_count == 2 - assert response.status_code == 200 - mock_sleep.assert_called_once_with(0.1) - - @patch("time.sleep") - @patch("httpx.get") - def test_retry_succeeds_on_third_attempt(self, mock_get, mock_sleep): - """Test successful response on 3rd retry (4th total call).""" - error_response = Mock() - error_response.is_success = False - error_response.status_code = 503 - error_response.text = "Service Unavailable" - - success_response = Mock() - success_response.is_success = True - success_response.status_code = 200 - success_response.json.return_value = {"result": "ok"} - - mock_get.side_effect = [ - error_response, - error_response, - error_response, - success_response, - ] - - client = HTTPClient(project_id="test123") - response = client.get("/test") - - assert mock_get.call_count == 4 - assert response.status_code == 200 - assert mock_sleep.call_count == 3 - sleep_calls = [call[0][0] for call in mock_sleep.call_args_list] - assert sleep_calls == [0.1, 5.0, 5.0] - - @patch("time.sleep") - @patch("httpx.get") - def test_prior_response_closed_before_retry(self, mock_get, mock_sleep): - """Test that each retried response is closed to release the connection pool slot.""" - error_response1 = Mock() - error_response1.is_success = False - error_response1.status_code = 503 - error_response1.text = "Service Unavailable" - - error_response2 = Mock() - error_response2.is_success = False - error_response2.status_code = 503 - error_response2.text = "Service Unavailable" - - success_response = Mock() - success_response.is_success = True - success_response.status_code = 200 - success_response.json.return_value = {"result": "ok"} - - mock_get.side_effect = [error_response1, error_response2, success_response] - - client = HTTPClient(project_id="test123") - client.get("/test") - - # Each failed response must be closed before the next attempt - error_response1.close.assert_called_once() - error_response2.close.assert_called_once() - # The final successful response is not closed here - success_response.close.assert_not_called() - - @patch("time.sleep") - @patch("httpx.get") - def test_success_on_first_attempt_no_retry(self, mock_get, mock_sleep): - """Test that no retry happens when the first attempt succeeds.""" - success_response = Mock() - success_response.is_success = True - success_response.status_code = 200 - success_response.json.return_value = {"result": "ok"} - - mock_get.return_value = success_response - - client = HTTPClient(project_id="test123") - client.get("/test") - - assert mock_get.call_count == 1 - mock_sleep.assert_not_called() - - -class TestSSLConfiguration(unittest.TestCase): - """Tests for SSL/TLS verification setup in HTTPClient.""" - - # ------------------------------------------------------------------ # - # client_verify attribute # - # ------------------------------------------------------------------ # - - def test_secure_default_uses_ssl_context(self): - """secure=True (default) → client_verify is an SSLContext.""" - import ssl - - client = HTTPClient(project_id="test123") - assert isinstance(client.client_verify, ssl.SSLContext) - - def test_secure_false_uses_plain_false(self): - """secure=False → client_verify is the boolean False.""" - client = HTTPClient(project_id="test123", secure=False) - assert client.client_verify is False - - def test_default_cert_source_is_certifi(self): - """When no SSL env vars are set, certifi.where() is used as cafile.""" - import certifi - - with patch.dict( - "os.environ", - {}, - clear=False, - ): - # Remove all SSL overrides if present so we hit the default path - for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE"): - os.environ.pop(key, None) - - with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: - mock_ssl_ctx = Mock() - mock_ctx_factory.return_value = mock_ssl_ctx - - HTTPClient(project_id="test123", secure=True) - - mock_ctx_factory.assert_called_once_with( - cafile=certifi.where(), - capath=None, - ) - - def test_ssl_cert_file_env_overrides_certifi(self): - """SSL_CERT_FILE replaces certifi.where() as the cafile.""" - with patch.dict("os.environ", {"SSL_CERT_FILE": "/tmp/custom.pem"}, clear=False): - os.environ.pop("SSL_CERT_DIR", None) - os.environ.pop("REQUESTS_CA_BUNDLE", None) - - with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: - mock_ctx_factory.return_value = Mock() - - HTTPClient(project_id="test123", secure=True) - - mock_ctx_factory.assert_called_once_with( - cafile="/tmp/custom.pem", - capath=None, - ) - - def test_ssl_cert_dir_env_passed_as_capath(self): - """SSL_CERT_DIR is forwarded as the capath argument.""" - import certifi - - with patch.dict("os.environ", {"SSL_CERT_DIR": "/tmp/certs"}, clear=False): - os.environ.pop("SSL_CERT_FILE", None) - os.environ.pop("REQUESTS_CA_BUNDLE", None) - - with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: - mock_ctx_factory.return_value = Mock() - - HTTPClient(project_id="test123", secure=True) - - mock_ctx_factory.assert_called_once_with( - cafile=certifi.where(), - capath="/tmp/certs", - ) - - def test_requests_ca_bundle_env_loaded_into_context(self): - """REQUESTS_CA_BUNDLE triggers an extra load_verify_locations call.""" - with patch.dict("os.environ", {"REQUESTS_CA_BUNDLE": "/tmp/extra.pem"}, clear=False): - os.environ.pop("SSL_CERT_FILE", None) - os.environ.pop("SSL_CERT_DIR", None) - - with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: - mock_ssl_ctx = Mock() - mock_ctx_factory.return_value = mock_ssl_ctx - - HTTPClient(project_id="test123", secure=True) - - mock_ssl_ctx.load_verify_locations.assert_called_once_with(cafile="/tmp/extra.pem") - - def test_no_extra_load_when_requests_ca_bundle_unset(self): - """load_verify_locations is NOT called when REQUESTS_CA_BUNDLE is absent.""" - with patch.dict("os.environ", {}, clear=False): - os.environ.pop("REQUESTS_CA_BUNDLE", None) - - with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: - mock_ssl_ctx = Mock() - mock_ctx_factory.return_value = mock_ssl_ctx - - HTTPClient(project_id="test123", secure=True) - - mock_ssl_ctx.load_verify_locations.assert_not_called() - - # ------------------------------------------------------------------ # - # verify= forwarded to every httpx verb # - # ------------------------------------------------------------------ # - - def _make_success_response(self): - resp = Mock() - resp.is_success = True - resp.status_code = 200 - resp.json.return_value = {} - return resp - - @patch("httpx.get") - def test_get_forwards_verify(self, mock_get): - """GET passes SSLContext (secure) or False (insecure) as verify=.""" - from tests.testutils import SSLMatcher - - mock_get.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=True).get("/x") - assert mock_get.call_args.kwargs["verify"] == SSLMatcher() - - mock_get.reset_mock() - mock_get.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=False).get("/x") - assert mock_get.call_args.kwargs["verify"] == SSLMatcher(insecure=True) - - @patch("httpx.post") - def test_post_forwards_verify(self, mock_post): - """POST passes SSLContext (secure) or False (insecure) as verify=.""" - from tests.testutils import SSLMatcher - - mock_post.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=True).post("/x", body={}) - assert mock_post.call_args.kwargs["verify"] == SSLMatcher() - - mock_post.reset_mock() - mock_post.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=False).post("/x", body={}) - assert mock_post.call_args.kwargs["verify"] == SSLMatcher(insecure=True) - - @patch("httpx.put") - def test_put_forwards_verify(self, mock_put): - """PUT passes SSLContext (secure) or False (insecure) as verify=.""" - from tests.testutils import SSLMatcher - - mock_put.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=True).put("/x", body={}) - assert mock_put.call_args.kwargs["verify"] == SSLMatcher() - - mock_put.reset_mock() - mock_put.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=False).put("/x", body={}) - assert mock_put.call_args.kwargs["verify"] == SSLMatcher(insecure=True) - - @patch("httpx.patch") - def test_patch_forwards_verify(self, mock_patch): - """PATCH passes SSLContext (secure) or False (insecure) as verify=.""" - from tests.testutils import SSLMatcher - - mock_patch.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=True).patch("/x", body={}) - assert mock_patch.call_args.kwargs["verify"] == SSLMatcher() - - mock_patch.reset_mock() - mock_patch.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=False).patch("/x", body={}) - assert mock_patch.call_args.kwargs["verify"] == SSLMatcher(insecure=True) - - @patch("httpx.delete") - def test_delete_forwards_verify(self, mock_delete): - """DELETE passes SSLContext (secure) or False (insecure) as verify=.""" - from tests.testutils import SSLMatcher - - mock_delete.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=True).delete("/x") - assert mock_delete.call_args.kwargs["verify"] == SSLMatcher() - - mock_delete.reset_mock() - mock_delete.return_value = self._make_success_response() - - HTTPClient(project_id="test123", secure=False).delete("/x") - assert mock_delete.call_args.kwargs["verify"] == SSLMatcher(insecure=True) - - # ------------------------------------------------------------------ # - # SSLMatcher self-tests # - # ------------------------------------------------------------------ # - - def test_ssl_matcher_repr(self): - from tests.testutils import SSLMatcher - - assert repr(SSLMatcher()) == "SSLMatcher()" - assert repr(SSLMatcher(insecure=True)) == "SSLMatcher(insecure=True)" - - def test_ssl_matcher_equality(self): - import ssl - - from tests.testutils import SSLMatcher - - real_ctx = ssl.create_default_context() - assert SSLMatcher() == real_ctx - assert not (SSLMatcher() == False) # noqa: E712 - assert SSLMatcher(insecure=True) == False # noqa: E712 - assert not (SSLMatcher(insecure=True) == real_ctx) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_http_client_async.py b/tests/test_http_client_async.py deleted file mode 100644 index dbb7796d1..000000000 --- a/tests/test_http_client_async.py +++ /dev/null @@ -1,460 +0,0 @@ -from __future__ import annotations - -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from descope._http_client_base import ContextVarLastResponseStore, DescopeResponse -from descope.exceptions import AuthException, RateLimitException -from descope.http_client import _RETRY_DELAYS_SECONDS, _RETRY_STATUS_CODES -from descope.http_client_async import HTTPClientAsync -from tests.testutils import SSLMatcher - -_DEFAULT_BASE_URL = "https://api.descope.com" - - -def make_async_client( - *, - secure=True, - verbose=False, - project_id="test123", - base_url=_DEFAULT_BASE_URL, - last_response_store=None, -): - """Build an AsyncHTTPClient with a mocked _async_client (no real socket). - - base_url is passed explicitly so tests are never affected by the - DESCOPE_BASE_URI env var that unittest-based tests leave set. - """ - with patch("descope.http_client_async.httpx.AsyncClient"): - return HTTPClientAsync( - project_id=project_id, - base_url=base_url, - timeout_seconds=60, - secure=secure, - verbose=verbose, - last_response_store=last_response_store, - ) - - -def make_resp(*, status=200, json_data=None, headers=None, text=""): - """Build a mock httpx.Response for async tests.""" - r = MagicMock() - r.is_success = status < 400 - r.status_code = status - r.json.return_value = json_data or {} - r.headers = headers or {} - r.text = text - r.aclose = AsyncMock() # awaited by the retry loop on retryable failures - return r - - -class TestAsyncHTTPClientInit: - def test_secure_passes_ssl_context(self): - with patch("descope.http_client_async.httpx.AsyncClient") as mock_cls: - HTTPClientAsync(project_id="test123", timeout_seconds=30, secure=True) - _, kwargs = mock_cls.call_args - assert kwargs["verify"] == SSLMatcher() - assert kwargs["timeout"] == 30 - - def test_insecure_passes_false(self): - with patch("descope.http_client_async.httpx.AsyncClient") as mock_cls: - HTTPClientAsync(project_id="test123", timeout_seconds=10, secure=False) - _, kwargs = mock_cls.call_args - assert kwargs["verify"] == SSLMatcher(insecure=True) - - def test_empty_project_id_raises(self): - with patch("descope.http_client_async.httpx.AsyncClient"): - with pytest.raises(AuthException) as exc_info: - HTTPClientAsync(project_id="", timeout_seconds=30, secure=True) - assert exc_info.value.status_code == 400 - - -class TestAsyncHTTPClientVerbs: - async def test_get(self): - client = make_async_client(project_id="test123") - client._async_client.get = AsyncMock(return_value=make_resp(json_data={"ok": 1})) - - await client.get("/path", params={"q": "1"}, allow_redirects=False, pswd="tok") - - call = client._async_client.get.await_args - assert call.args[0] == "https://api.descope.com/path" - assert call.kwargs["params"] == {"q": "1"} - assert call.kwargs["follow_redirects"] is False - assert "Bearer test123:tok" in call.kwargs["headers"]["Authorization"] - - async def test_get_default_allow_redirects(self): - client = make_async_client() - client._async_client.get = AsyncMock(return_value=make_resp()) - - await client.get("/path") - - call = client._async_client.get.await_args - assert call.kwargs["follow_redirects"] is True # default allow_redirects=True - - async def test_post(self): - client = make_async_client(project_id="test123") - client._async_client.post = AsyncMock(return_value=make_resp()) - - await client.post("/create", body={"name": "x"}, params={"a": "b"}, pswd="tok") - - call = client._async_client.post.await_args - assert call.args[0] == "https://api.descope.com/create" - assert call.kwargs["json"] == {"name": "x"} - assert call.kwargs["params"] == {"a": "b"} - assert call.kwargs["follow_redirects"] is False - assert "Bearer test123:tok" in call.kwargs["headers"]["Authorization"] - - async def test_post_base_url_override(self): - client = make_async_client() - client._async_client.post = AsyncMock(return_value=make_resp()) - - await client.post("/ep", body={}, base_url="https://custom.example.com") - - url = client._async_client.post.await_args.args[0] - assert url == "https://custom.example.com/ep" - - async def test_put(self): - client = make_async_client(project_id="test123") - client._async_client.put = AsyncMock(return_value=make_resp()) - - await client.put("/update", body={"val": 1}, params={"k": "v"}, pswd="tok") - - call = client._async_client.put.await_args - assert call.args[0] == "https://api.descope.com/update" - assert call.kwargs["json"] == {"val": 1} - assert call.kwargs["follow_redirects"] is False - - async def test_patch(self): - client = make_async_client(project_id="test123") - client._async_client.patch = AsyncMock(return_value=make_resp()) - - await client.patch("/edit", body={"x": 2}, pswd="tok") - - call = client._async_client.patch.await_args - assert call.args[0] == "https://api.descope.com/edit" - assert call.kwargs["json"] == {"x": 2} - assert call.kwargs["follow_redirects"] is False - - async def test_delete(self): - client = make_async_client(project_id="test123") - client._async_client.delete = AsyncMock(return_value=make_resp()) - - await client.delete("/remove", params={"id": "1"}, pswd="tok") - - call = client._async_client.delete.await_args - assert call.args[0] == "https://api.descope.com/remove" - assert call.kwargs["params"] == {"id": "1"} - assert call.kwargs["follow_redirects"] is False - - -class TestAsyncRetry: - async def test_retries_on_retryable_codes(self): - for status_code in _RETRY_STATUS_CODES: - client = make_async_client() - err = make_resp(status=status_code) - ok = make_resp(status=200) - - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()) as mock_sleep: - client._async_client.get = AsyncMock(side_effect=[err, ok]) - resp = await client.get("/x") - - assert client._async_client.get.await_count == 2, f"Should retry once on {status_code}" - assert resp.status_code == 200 - mock_sleep.assert_awaited_once_with(0.1) - err.aclose.assert_awaited_once() - - async def test_retries_to_exhaustion_raises(self): - client = make_async_client() - err = make_resp(status=503, text="Unavailable") - - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()) as mock_sleep: - client._async_client.get = AsyncMock(return_value=err) - with pytest.raises(AuthException): - await client.get("/x") - - # original + 3 retries = 4 total calls - assert client._async_client.get.await_count == 4 - assert mock_sleep.await_count == 3 - - async def test_retry_delay_sequence(self): - client = make_async_client() - err = make_resp(status=503, text="Unavailable") - sleep_calls = [] - - async def fake_sleep(delay): - sleep_calls.append(delay) - - with patch("descope.http_client_async.asyncio.sleep", fake_sleep): - client._async_client.get = AsyncMock(return_value=err) - with pytest.raises(AuthException): - await client.get("/x") - - assert sleep_calls == list(_RETRY_DELAYS_SECONDS) - - async def test_no_retry_on_non_retryable_codes(self): - for status_code in [400, 401, 403, 404, 500, 502]: - client = make_async_client() - err = make_resp(status=status_code, text=f"Error {status_code}") - - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()) as mock_sleep: - client._async_client.get = AsyncMock(return_value=err) - with pytest.raises(AuthException): - await client.get("/x") - - assert client._async_client.get.await_count == 1, f"Should not retry on {status_code}" - mock_sleep.assert_not_awaited() - - async def test_prior_response_closed_before_retry(self): - client = make_async_client() - err1 = make_resp(status=503) - err2 = make_resp(status=503) - ok = make_resp(status=200) - - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): - client._async_client.get = AsyncMock(side_effect=[err1, err2, ok]) - await client.get("/x") - - err1.aclose.assert_awaited_once() - err2.aclose.assert_awaited_once() - ok.aclose.assert_not_awaited() - - async def test_success_on_first_attempt_no_retry(self): - client = make_async_client() - ok = make_resp(status=200) - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()) as mock_sleep: - client._async_client.get = AsyncMock(return_value=ok) - await client.get("/x") - assert client._async_client.get.await_count == 1 - mock_sleep.assert_not_awaited() - - async def test_retry_succeeds_on_third_attempt(self): - client = make_async_client() - err1 = make_resp(status=503) - err2 = make_resp(status=503) - ok = make_resp(status=200) - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): - client._async_client.get = AsyncMock(side_effect=[err1, err2, ok]) - resp = await client.get("/x") - assert resp.status_code == 200 - assert client._async_client.get.await_count == 3 - - async def test_retry_works_for_post(self): - client = make_async_client() - err = make_resp(status=503) - ok = make_resp(status=200) - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): - client._async_client.post = AsyncMock(side_effect=[err, ok]) - resp = await client.post("/x", body={}) - assert resp.status_code == 200 - assert client._async_client.post.await_count == 2 - - async def test_retry_works_for_put(self): - client = make_async_client() - err = make_resp(status=503) - ok = make_resp(status=200) - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): - client._async_client.put = AsyncMock(side_effect=[err, ok]) - resp = await client.put("/x", body={}) - assert resp.status_code == 200 - assert client._async_client.put.await_count == 2 - - async def test_retry_works_for_patch(self): - client = make_async_client() - err = make_resp(status=503) - ok = make_resp(status=200) - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): - client._async_client.patch = AsyncMock(side_effect=[err, ok]) - resp = await client.patch("/x", body={}) - assert resp.status_code == 200 - assert client._async_client.patch.await_count == 2 - - async def test_retry_works_for_delete(self): - client = make_async_client() - err = make_resp(status=503) - ok = make_resp(status=200) - with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): - client._async_client.delete = AsyncMock(side_effect=[err, ok]) - resp = await client.delete("/x") - assert resp.status_code == 200 - assert client._async_client.delete.await_count == 2 - - -class TestAsyncVerbose: - async def test_get_captures_response_when_verbose(self): - client = make_async_client(verbose=True) - client._async_client.get = AsyncMock( - return_value=make_resp(status=200, json_data={"d": 1}, headers={"cf-ray": "r1"}) - ) - - await client.get("/x") - - last = client.get_last_response() - assert last is not None - assert last.status_code == 200 - assert last.headers.get("cf-ray") == "r1" - - async def test_get_does_not_capture_when_not_verbose(self): - client = make_async_client(verbose=False) - client._async_client.get = AsyncMock(return_value=make_resp()) - - await client.get("/x") - - assert client.get_last_response() is None - - async def test_post_captures_response_when_verbose(self): - client = make_async_client(verbose=True) - client._async_client.post = AsyncMock( - return_value=make_resp(status=201, json_data={"id": "u1"}, headers={"cf-ray": "r2"}) - ) - - await client.post("/x", body={}) - - last = client.get_last_response() - assert last is not None - assert last.status_code == 201 - - async def test_patch_captures_response_when_verbose(self): - client = make_async_client(verbose=True) - client._async_client.patch = AsyncMock( - return_value=make_resp(status=200, json_data={"ok": 1}, headers={"cf-ray": "r3"}) - ) - - await client.patch("/x", body={}) - - last = client.get_last_response() - assert last is not None - assert last.status_code == 200 - - async def test_put_captures_response_when_verbose(self): - client = make_async_client(verbose=True) - client._async_client.put = AsyncMock( - return_value=make_resp(status=200, json_data={"replaced": 1}, headers={"cf-ray": "r5"}) - ) - - await client.put("/x", body={}) - - last = client.get_last_response() - assert last is not None - assert last.status_code == 200 - assert last.headers.get("cf-ray") == "r5" - - async def test_put_does_not_capture_when_not_verbose(self): - client = make_async_client(verbose=False) - client._async_client.put = AsyncMock(return_value=make_resp()) - - await client.put("/x", body={}) - - assert client.get_last_response() is None - - async def test_delete_captures_response_when_verbose(self): - client = make_async_client(verbose=True) - client._async_client.delete = AsyncMock( - return_value=make_resp(status=200, json_data={"gone": 1}, headers={"cf-ray": "r4"}) - ) - - await client.delete("/x") - - last = client.get_last_response() - assert last is not None - assert last.status_code == 200 - - -class TestAsyncSharedLastResponseStore: - async def test_clients_sharing_a_store_see_one_ordering(self): - """A shared store makes "last" mean last, regardless of which client wrote it.""" - store = ContextVarLastResponseStore() - mgmt = make_async_client(verbose=True, last_response_store=store) - auth = make_async_client(verbose=True, last_response_store=store) - mgmt._async_client.post = AsyncMock(return_value=make_resp(json_data={"src": "mgmt"})) - auth._async_client.get = AsyncMock(return_value=make_resp(json_data={"src": "auth"})) - - await mgmt.post("/x", body={}) - assert store.get()["src"] == "mgmt" - - await auth.get("/x") - assert store.get()["src"] == "auth" - - async def test_shared_store_is_still_per_task(self): - """Sharing one store must not leak a response between concurrent tasks.""" - store = ContextVarLastResponseStore() - seen = {} - - async def worker(name): - store.set(DescopeResponse(make_resp(json_data={"task": name}))) - await asyncio.sleep(0) # let the sibling task write before reading - seen[name] = store.get()["task"] - - await asyncio.gather(worker("a"), worker("b")) - - assert seen == {"a": "a", "b": "b"} - - -class TestAsyncErrors: - async def test_raises_auth_exception_on_500(self): - client = make_async_client() - client._async_client.get = AsyncMock(return_value=make_resp(status=500, text="Error")) - - with pytest.raises(AuthException) as exc_info: - await client.get("/x") - - assert exc_info.value.status_code == 500 - - async def test_raises_rate_limit_exception_on_429(self): - client = make_async_client() - client._async_client.get = AsyncMock( - return_value=make_resp( - status=429, - json_data={"errorCode": "E010", "errorDescription": "Rate limit exceeded"}, - headers={"Retry-After": "60"}, - ) - ) - - with pytest.raises(RateLimitException) as exc_info: - await client.get("/x") - - assert exc_info.value.error_type == "API rate limit exceeded" - - async def test_raises_rate_limit_when_json_fails(self): - client = make_async_client() - bad = make_resp(status=429, headers={"Retry-After": "30"}) - bad.json.side_effect = ValueError("bad json") - client._async_client.get = AsyncMock(return_value=bad) - - with pytest.raises(RateLimitException): - await client.get("/x") - - -class TestAsyncLifecycle: - async def test_aclose_delegates_to_async_client(self): - client = make_async_client() - client._async_client.aclose = AsyncMock() - - await client.aclose() - - client._async_client.aclose.assert_awaited_once() - - async def test_context_manager_yields_client_and_closes(self): - with patch("descope.http_client_async.httpx.AsyncClient"): - async with HTTPClientAsync(project_id="test123", timeout_seconds=60, secure=True) as c: - assert isinstance(c, HTTPClientAsync) - c._async_client.aclose = AsyncMock() - - c._async_client.aclose.assert_awaited_once() - - -class TestAsyncHTTPClientHeaders: - async def test_management_key_in_authorization_header(self): - """auth_management_key is baked into the Authorization header on every verb call.""" - with patch("descope.http_client_async.httpx.AsyncClient"): - client = HTTPClientAsync( - project_id="proj123", - timeout_seconds=60, - secure=True, - management_key="mgmt-key", - ) - client._async_client.get = AsyncMock(return_value=make_resp(json_data={"ok": 1})) - await client.get("/path") - call = client._async_client.get.await_args - assert call.kwargs["headers"]["Authorization"] == "Bearer proj123:mgmt-key" diff --git a/tests/test_jwt_common.py b/tests/test_jwt_common.py deleted file mode 100644 index 9cfb47e6a..000000000 --- a/tests/test_jwt_common.py +++ /dev/null @@ -1,68 +0,0 @@ -import unittest -import warnings - -from descope.jwt_common import ( - COOKIE_DATA_NAME, - REFRESH_SESSION_TOKEN_NAME, - SESSION_TOKEN_NAME, - decode_token_unverified, - generate_jwt_response, -) - - -class TestJwtCommon(unittest.TestCase): - def test_generate_jwt_response_sets_user_and_first_seen_and_cookie(self): - # Arrange: a response body with cookie and user fields - response_body = { - "sessionJwt": "token1", - # do not provide refreshJwt to exercise refresh_cookie fallback - "cookieExpiration": 123, - "cookieMaxAge": 456, - "cookieDomain": "example.com", - "cookiePath": "/test", - "user": {"name": "Ada"}, - "firstSeen": False, - } - - def validator(token: str, audience=None): - # Return different iss/sub based on which token is being validated - if token == "token1": - return {"iss": "https://issuer.example/P123", "sub": "user-1"} - # refresh cookie fallback - return {"iss": "https://issuer.example/P999", "sub": "user-2"} - - # Act - jwt_response = generate_jwt_response( - response_body, - refresh_cookie="token2", - audience=None, - token_validator=validator, - ) - - # Assert top-level fields - assert jwt_response["user"] == {"name": "Ada"} - assert jwt_response["firstSeen"] is False - # Project ID should be parsed from issuer (last path segment) - assert jwt_response["projectId"] == "P123" - # userId copied from session token sub - assert jwt_response["userId"] == "user-1" - # cookie data present - assert jwt_response[COOKIE_DATA_NAME] == { - "exp": 123, - "maxAge": 456, - "domain": "example.com", - "path": "/test", - } - # both tokens should be decoded (session from body, refresh from cookie) - assert SESSION_TOKEN_NAME in jwt_response - assert REFRESH_SESSION_TOKEN_NAME in jwt_response - - def test_decode_token_unverified_handles_garbage(self): - # Invalid token strings should not raise and should return empty dict - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - assert decode_token_unverified("not-a-jwt") == {} - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_magiclink.py b/tests/test_magiclink.py deleted file mode 100644 index cbb275d30..000000000 --- a/tests/test_magiclink.py +++ /dev/null @@ -1,293 +0,0 @@ -import pytest - -from descope import AuthException, DeliveryMethod -from descope.authmethod.magiclink import MagicLink -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, - SignUpOptions, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestMagicLink: - def test_compose_urls(self): - assert MagicLink._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/magiclink/signin/sms" - assert MagicLink._compose_signup_url(DeliveryMethod.WHATSAPP) == "/v1/auth/magiclink/signup/whatsapp" - assert MagicLink._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) == "/v1/auth/magiclink/signup-in/email" - assert MagicLink._compose_update_phone_url(DeliveryMethod.SMS) == "/v1/auth/magiclink/update/phone/sms" - - def test_compose_body(self): - assert MagicLink._compose_signin_body("id1", "uri1") == { - "loginId": "id1", - "URI": "uri1", - "loginOptions": {}, - } - assert MagicLink._compose_verify_body("t1") == {"token": "t1"} - assert MagicLink._compose_update_user_email_body("id1", "email1", True, False) == { - "loginId": "id1", - "email": "email1", - "addToLoginIDs": True, - "onMergeUseExisting": False, - } - - async def test_sign_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.magiclink.sign_in(DeliveryMethod.EMAIL, "", "http://r.me")) - with pytest.raises(AuthException): - await client.invoke(client.magiclink.sign_in(DeliveryMethod.EMAIL, None, "http://r.me")) - with pytest.raises(AuthException): - await client.invoke( - client.magiclink.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me", LoginOptions(mfa=True)) - ) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.magiclink.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me")) - - # Success + payload - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: - result = await client.invoke( - client.magiclink.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me") - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "URI": "http://r.me", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_up(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - user = {"name": "John", "email": "dummy@dummy.com"} - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.magiclink.sign_up(DeliveryMethod.EMAIL, None, "http://r.me", user)) - with pytest.raises(AuthException): - await client.invoke(client.magiclink.sign_up(DeliveryMethod.EMAIL, "", "http://r.me", user)) - with pytest.raises(AuthException): - await client.invoke( - client.magiclink.sign_up(DeliveryMethod.EMAIL, "id", "http://r.me", {"email": "not-valid"}) - ) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.magiclink.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me", user) - ) - - # Success - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})): - result = await client.invoke( - client.magiclink.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me", user) - ) - assert result is not None - - async def test_sign_up_or_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.magiclink.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me") - ) - - # Success + payload - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: - result = await client.invoke( - client.magiclink.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me") - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "URI": "http://r.me", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_up_or_in_forwards_signup_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: - result = await client.invoke( - client.magiclink.sign_up_or_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://r.me", - SignUpOptions( - revoke_other_sessions=True, - custom_claims={"k1": "v1"}, - template_options={"blah": "blah"}, - template_id="tmpl1", - ), - ) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://r.me", - "loginOptions": { - "stepup": False, - "mfa": False, - "customClaims": {"k1": "v1"}, - "revokeOtherSessions": True, - "templateOptions": {"blah": "blah"}, - "templateId": "tmpl1", - }, - }, - follow_redirects=False, - ) - - async def test_verify(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.magiclink.verify("some-token")) - - # Success - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.magiclink.verify("some-token")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_magiclink_auth_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"token": "some-token"}, - follow_redirects=False, - ) - - async def test_update_user_email(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.magiclink.update_user_email("", "new@example.com", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.magiclink.update_user_email(None, "new@example.com", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.magiclink.update_user_email("id", "bad-email", refresh_token)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.magiclink.update_user_email("id", "new@example.com", refresh_token)) - - # Success + payload - with client.mock_post(make_response({"maskedEmail": "ne***@***le.com"})) as mock_post: - result = await client.invoke( - client.magiclink.update_user_email("dummy@dummy.com", "new@example.com", refresh_token) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_magiclink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "email": "new@example.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - ) - - async def test_update_user_phone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - - # Validation errors - with pytest.raises(AuthException): - await client.invoke( - client.magiclink.update_user_phone(DeliveryMethod.SMS, "", "+11234567890", refresh_token) - ) - with pytest.raises(AuthException): - await client.invoke( - client.magiclink.update_user_phone(DeliveryMethod.SMS, "id", "bad-phone", refresh_token) - ) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.magiclink.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token) - ) - - # Success + payload - with client.mock_post(make_response({"maskedPhone": "+1***890"})) as mock_post: - result = await client.invoke( - client.magiclink.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_magiclink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy", - "phone": "+11234567890", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - ) diff --git a/tests/test_oauth.py b/tests/test_oauth.py deleted file mode 100644 index f15d72a80..000000000 --- a/tests/test_oauth.py +++ /dev/null @@ -1,125 +0,0 @@ -import pytest - -from descope import AuthException -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestOAuth: - def test_compose_start_params(self): - from descope.authmethod.oauth import OAuth - - assert OAuth._compose_start_params("google", "http://example.com") == { - "provider": "google", - "redirectURL": "http://example.com", - } - assert OAuth._compose_start_params("google") == {"provider": "google"} - - def test_verify_provider(self): - from descope.authmethod.oauth import OAuth - - assert OAuth._verify_provider("") is False - assert OAuth._verify_provider(None) is False - assert OAuth._verify_provider("google") is True - - async def test_start(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors — no HTTP call made - with pytest.raises(AuthException): - await client.invoke(client.oauth.start("")) - with pytest.raises(AuthException): - await client.invoke(client.oauth.start(None)) - with pytest.raises(AuthException): - await client.invoke(client.oauth.start("facebook", login_options=LoginOptions(mfa=True))) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.oauth.start("google")) - - # Success - with client.mock_post(make_response({"url": "http://auth.example.com"})): - result = await client.invoke(client.oauth.start("google")) - assert result is not None - - # Verify payload with params - with client.mock_post(make_response({"url": "http://auth.example.com"})) as mock_post: - await client.invoke(client.oauth.start("facebook")) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params={"provider": "facebook"}, - json={}, - follow_redirects=False, - ) - - async def test_start_with_login_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = "dummy-refresh" - - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - with client.mock_post(make_response({})) as mock_post: - await client.invoke(client.oauth.start("facebook", login_options=lo, refresh_token=refresh_token)) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params={"provider": "facebook"}, - json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, - follow_redirects=False, - ) - - async def test_exchange_token(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.oauth.exchange_token("")) - with pytest.raises(AuthException): - await client.invoke(client.oauth.exchange_token(None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.oauth.exchange_token("c1")) - - # Success - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.oauth.exchange_token("c1")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_exchange_token_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"code": "c1"}, - follow_redirects=False, - ) diff --git a/tests/test_otp.py b/tests/test_otp.py deleted file mode 100644 index 8bc02bf4f..000000000 --- a/tests/test_otp.py +++ /dev/null @@ -1,331 +0,0 @@ -import pytest - -from descope import AuthException, DeliveryMethod -from descope.authmethod.otp import OTP -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, - SignUpOptions, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestOTP: - def test_compose_signin_url(self): - assert OTP._compose_signin_url(DeliveryMethod.EMAIL) == "/v1/auth/otp/signin/email" - assert OTP._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/otp/signin/sms" - assert OTP._compose_signin_url(DeliveryMethod.VOICE) == "/v1/auth/otp/signin/voice" - assert OTP._compose_signin_url(DeliveryMethod.WHATSAPP) == "/v1/auth/otp/signin/whatsapp" - - def test_compose_verify_code_url(self): - assert OTP._compose_verify_code_url(DeliveryMethod.EMAIL) == "/v1/auth/otp/verify/email" - assert OTP._compose_verify_code_url(DeliveryMethod.SMS) == "/v1/auth/otp/verify/sms" - - def test_compose_sign_up_or_in_url(self): - assert OTP._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) == "/v1/auth/otp/signup-in/email" - assert OTP._compose_sign_up_or_in_url(DeliveryMethod.SMS) == "/v1/auth/otp/signup-in/sms" - - def test_compose_update_phone_url(self): - assert OTP._compose_update_phone_url(DeliveryMethod.SMS) == "/v1/auth/otp/update/phone/sms" - assert OTP._compose_update_phone_url(DeliveryMethod.WHATSAPP) == "/v1/auth/otp/update/phone/whatsapp" - - async def test_sign_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "")) - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, None)) - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "id", LoginOptions(mfa=True))) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) - - # Success - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})): - result = await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) - assert result is not None - - # Verify payload - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: - await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_up(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - user = {"name": "John", "email": "dummy@dummy.com"} - - # Validation errors — empty login_id returns False from adjust_and_verify - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, None, user)) - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "", user)) - # Bad email in user dict - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "id", {"email": "not-valid"})) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", user)) - - # Success - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})): - result = await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", user)) - assert result is not None - - async def test_sign_up_or_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_up_or_in(DeliveryMethod.EMAIL, "")) - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_up_or_in(DeliveryMethod.EMAIL, None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.otp.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) - - # Success - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: - result = await client.invoke(client.otp.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_up_or_in_forwards_signup_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: - result = await client.invoke( - client.otp.sign_up_or_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - SignUpOptions( - revoke_other_sessions=True, - custom_claims={"k1": "v1"}, - template_options={"blah": "blah"}, - template_id="tmpl1", - ), - ) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "loginOptions": { - "stepup": False, - "mfa": False, - "customClaims": {"k1": "v1"}, - "revokeOtherSessions": True, - "templateOptions": {"blah": "blah"}, - "templateId": "tmpl1", - }, - }, - follow_redirects=False, - ) - - async def test_verify_code(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.otp.verify_code(DeliveryMethod.EMAIL, "", "123456")) - with pytest.raises(AuthException): - await client.invoke(client.otp.verify_code(DeliveryMethod.EMAIL, None, "123456")) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.otp.verify_code(DeliveryMethod.EMAIL, "dummy@dummy.com", "123456")) - - # Success - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.otp.verify_code(DeliveryMethod.EMAIL, "dummy@dummy.com", "123456")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_code_auth_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "code": "123456"}, - follow_redirects=False, - ) - - async def test_update_user_email(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.otp.update_user_email("", "new@example.com", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.otp.update_user_email(None, "new@example.com", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.otp.update_user_email("id", "not-valid-email", refresh_token)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.otp.update_user_email("dummy@dummy.com", "new@example.com", refresh_token)) - - # Success - with client.mock_post(make_response({"maskedEmail": "ne***@***le.com"})) as mock_post: - result = await client.invoke( - client.otp.update_user_email("dummy@dummy.com", "new@example.com", refresh_token) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "email": "new@example.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - ) - - async def test_update_user_phone(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.otp.update_user_phone(DeliveryMethod.SMS, "", "+11234567890", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.otp.update_user_phone(DeliveryMethod.SMS, "id", "not-a-phone", refresh_token)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke( - client.otp.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token) - ) - - # Success - with client.mock_post(make_response({"maskedPhone": "+1***890"})) as mock_post: - result = await client.invoke( - client.otp.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token) - ) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_otp_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy", - "phone": "+11234567890", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - ) - - # mfa flag is forwarded to the request body when set - with client.mock_post(make_response({"maskedPhone": "+1***890"})) as mock_post: - await client.invoke( - client.otp.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token, mfa=True) - ) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_otp_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy", - "phone": "+11234567890", - "addToLoginIDs": False, - "onMergeUseExisting": False, - "mfa": True, - }, - follow_redirects=False, - ) - - def test_compose_update_user_phone_body_mfa(self): - assert OTP._compose_update_user_phone_body("dummy", "+11111111", False, False, mfa=True) == { - "loginId": "dummy", - "phone": "+11111111", - "addToLoginIDs": False, - "onMergeUseExisting": False, - "mfa": True, - } - - def test_compose_update_user_email_body_mfa(self): - assert OTP._compose_update_user_email_body("dummy", "dummy@dummy.com", False, False, mfa=True) == { - "loginId": "dummy", - "email": "dummy@dummy.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - "mfa": True, - } diff --git a/tests/test_password.py b/tests/test_password.py deleted file mode 100644 index e0d952a69..000000000 --- a/tests/test_password.py +++ /dev/null @@ -1,253 +0,0 @@ -import pytest - -from descope import AuthException -from descope.authmethod.password import Password -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestPassword: - def test_compose_signup_body(self): - assert Password._compose_signup_body("id1", "pw1", {"name": "John"}) == { - "loginId": "id1", - "password": "pw1", - "user": {"name": "John"}, - } - assert Password._compose_signup_body("id1", "pw1", None) == { - "loginId": "id1", - "password": "pw1", - } - - async def test_sign_up(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.password.sign_up("", "pw1")) - with pytest.raises(AuthException): - await client.invoke(client.password.sign_up(None, "pw1")) - with pytest.raises(AuthException): - await client.invoke(client.password.sign_up("id", "")) - with pytest.raises(AuthException): - await client.invoke(client.password.sign_up("id", None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.password.sign_up("dummy@dummy.com", "pw123")) - - # Success + payload - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.password.sign_up("dummy@dummy.com", "pw123")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "password": "pw123"}, - follow_redirects=False, - ) - - async def test_sign_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.password.sign_in("", "pw1")) - with pytest.raises(AuthException): - await client.invoke(client.password.sign_in(None, "pw1")) - with pytest.raises(AuthException): - await client.invoke(client.password.sign_in("id", "")) - with pytest.raises(AuthException): - await client.invoke(client.password.sign_in("id", None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.password.sign_in("dummy@dummy.com", "pw123")) - - # Success + payload - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.password.sign_in("dummy@dummy.com", "pw123")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "password": "pw123"}, - follow_redirects=False, - ) - - async def test_send_reset(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.password.send_reset("")) - with pytest.raises(AuthException): - await client.invoke(client.password.send_reset(None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.password.send_reset("dummy@dummy.com")) - - # Success + payload - with client.mock_post( - make_response({"resetMethod": "magiclink", "maskedEmail": "du***@***my.com"}) - ) as mock_post: - result = await client.invoke(client.password.send_reset("dummy@dummy.com", "https://redirect.here.com")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.send_reset_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "redirectUrl": "https://redirect.here.com"}, - follow_redirects=False, - ) - - async def test_update(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.password.update("", "newpw", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.password.update(None, "newpw", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.password.update("id", "", refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.password.update("id", None, refresh_token)) - with pytest.raises(AuthException): - await client.invoke(client.password.update("id", "newpw", "")) - with pytest.raises(AuthException): - await client.invoke(client.password.update("id", "newpw", None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.password.update("dummy@dummy.com", "newpw", refresh_token)) - - # Success (returns None) - with client.mock_post(make_response({})) as mock_post: - result = await client.invoke(client.password.update("dummy@dummy.com", "newpw", refresh_token)) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com", "newPassword": "newpw"}, - follow_redirects=False, - ) - - async def test_replace(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.password.replace("", "oldpw", "newpw")) - with pytest.raises(AuthException): - await client.invoke(client.password.replace(None, "oldpw", "newpw")) - with pytest.raises(AuthException): - await client.invoke(client.password.replace("id", "", "newpw")) - with pytest.raises(AuthException): - await client.invoke(client.password.replace("id", None, "newpw")) - with pytest.raises(AuthException): - await client.invoke(client.password.replace("id", "oldpw", "")) - with pytest.raises(AuthException): - await client.invoke(client.password.replace("id", "oldpw", None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.password.replace("dummy@dummy.com", "oldpw", "newpw")) - - # Success + payload - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.password.replace("dummy@dummy.com", "oldpw", "newpw")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.replace_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "oldPassword": "oldpw", - "newPassword": "newpw", - }, - follow_redirects=False, - ) - - async def test_get_policy(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # HTTP error - with client.mock_get(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.password.get_policy()) - - # Success + payload - with client.mock_get(make_response({"minLength": 8, "lowercase": True})) as mock_get: - result = await client.invoke(client.password.get_policy()) - assert result is not None - assert_http_called( - mock_get, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.password_policy_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - follow_redirects=True, - ) diff --git a/tests/test_saml.py b/tests/test_saml.py deleted file mode 100644 index 8284d42cf..000000000 --- a/tests/test_saml.py +++ /dev/null @@ -1,120 +0,0 @@ -import pytest - -from descope import AuthException -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestSAML: - def test_compose_start_params(self): - from descope.authmethod.saml import SAML - - assert SAML._compose_start_params("tenant1", "http://dummy.com") == { - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - } - - async def test_start(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.saml.start("", "http://dummy.com")) - with pytest.raises(AuthException): - await client.invoke(client.saml.start(None, "http://dummy.com")) - with pytest.raises(AuthException): - await client.invoke(client.saml.start("tenant1", "")) - with pytest.raises(AuthException): - await client.invoke(client.saml.start("tenant1", None)) - with pytest.raises(AuthException): - await client.invoke(client.saml.start("tenant", "http://dummy.com", LoginOptions(mfa=True))) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.saml.start("tenant1", "http://dummy.com")) - - # Success - with client.mock_post(make_response({"url": "http://auth.example.com"})): - result = await client.invoke(client.saml.start("tenant1", "http://dummy.com")) - assert result is not None - - # Verify payload - with client.mock_post(make_response({})) as mock_post: - await client.invoke(client.saml.start("tenant1", "http://dummy.com")) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_saml_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, - json={}, - follow_redirects=False, - ) - - async def test_start_with_login_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - - with client.mock_post(make_response({})) as mock_post: - await client.invoke(client.saml.start("tenant1", "http://dummy.com", lo, "refresh")) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_saml_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:refresh", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, - json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, - follow_redirects=False, - ) - - async def test_exchange_token(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.saml.exchange_token("")) - with pytest.raises(AuthException): - await client.invoke(client.saml.exchange_token(None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.saml.exchange_token("c1")) - - # Success - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.saml.exchange_token("c1")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.saml_exchange_token_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"code": "c1"}, - follow_redirects=False, - ) diff --git a/tests/test_sso.py b/tests/test_sso.py deleted file mode 100644 index e5d3c1b0d..000000000 --- a/tests/test_sso.py +++ /dev/null @@ -1,140 +0,0 @@ -import pytest - -from descope import AuthException -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestSSO: - def test_compose_start_params(self): - from descope.authmethod.sso import SSO - - assert SSO._compose_start_params("tenant1", "http://dummy.com", "", "", "", None) == { - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - } - assert SSO._compose_start_params("tenant1", "http://dummy.com", "bla", "blue", "", None) == { - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - "prompt": "bla", - "ssoId": "blue", - } - assert SSO._compose_start_params("t1", "http://x.com", "consent", "sid", "user@d.com", True) == { - "tenant": "t1", - "redirectURL": "http://x.com", - "prompt": "consent", - "ssoId": "sid", - "loginHint": "user@d.com", - "forceAuthn": True, - } - # forceAuthn=False must be included (not skipped as falsy) - assert SSO._compose_start_params("t1", "http://x.com", "", "", "", False) == { - "tenant": "t1", - "redirectURL": "http://x.com", - "forceAuthn": False, - } - - async def test_start(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.sso.start("", "http://dummy.com")) - with pytest.raises(AuthException): - await client.invoke(client.sso.start(None, "http://dummy.com")) - with pytest.raises(AuthException): - await client.invoke(client.sso.start("tenant", "http://dummy.com", LoginOptions(mfa=True))) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.sso.start("tenant1", "http://dummy.com")) - - # Success - with client.mock_post(make_response({"url": "http://auth.example.com"})): - result = await client.invoke(client.sso.start("tenant1", "http://dummy.com")) - assert result is not None - - # Verify payload - with client.mock_post(make_response({})) as mock_post: - await client.invoke(client.sso.start("tenant1", "http://dummy.com", sso_id="some-sso-id")) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params={ - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - "ssoId": "some-sso-id", - }, - json={}, - follow_redirects=False, - ) - - async def test_start_with_login_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - - with client.mock_post(make_response({})) as mock_post: - await client.invoke(client.sso.start("tenant1", "http://dummy.com", lo, "refresh")) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:refresh", - "x-descope-project-id": PROJECT_ID, - }, - params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, - json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, - follow_redirects=False, - ) - - async def test_exchange_token(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.sso.exchange_token("")) - with pytest.raises(AuthException): - await client.invoke(client.sso.exchange_token(None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.sso.exchange_token("c1")) - - # Success - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.sso.exchange_token("c1")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sso_exchange_token_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"code": "c1"}, - follow_redirects=False, - ) diff --git a/tests/test_totp.py b/tests/test_totp.py deleted file mode 100644 index b8a3833a3..000000000 --- a/tests/test_totp.py +++ /dev/null @@ -1,146 +0,0 @@ -import pytest - -from descope import AuthException -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestTOTP: - async def test_sign_up(self, client_factory): - signup_user_details = { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors — no HTTP call made - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_up("", signup_user_details)) - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_up(None, signup_user_details)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) - - # Success - data = {"provisioningURL": "http://dummy.com", "image": "imagedata", "key": "k01"} - with client.mock_post(make_response(data)): - result = await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) - assert result is not None - - async def test_sign_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = "dummy refresh token" - - # Validation errors — no HTTP call made - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code(None, "1234")) - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("", "1234")) - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("dummy@dummy.com", None)) - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "")) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "1234")) - - # Success + MFA-without-refresh check - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp): - result = await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "1234")) - assert result is not None - # MFA stepup requires a refresh token — omitting it must raise - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "code", LoginOptions(mfa=True))) - - # Verify refresh token propagates correctly into the request - with client.mock_post(success_resp) as mock_post: - await client.invoke( - client.totp.sign_in_code( - "dummy@dummy.com", - "1234", - LoginOptions(stepup=True), - refresh_token=refresh_token, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_totp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "code": "1234", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, - }, - follow_redirects=False, - ) - - async def test_update_user(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - valid_refresh_token = VALID_REFRESH_TOKEN - valid_response = { - "provisioningURL": "http://dummy.com", - "image": "imagedata", - "key": "k01", - "error": "", - } - - # Validation errors — no HTTP call made - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user(None, "")) - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user("", "")) - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user("dummy@dummy.com", None)) - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user("dummy@dummy.com", "")) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user("dummy@dummy.com", "dummy refresh token")) - - # Success + payload assertion - with client.mock_post(make_response(valid_response)) as mock_post: - res = await client.invoke(client.totp.update_user("dummy@dummy.com", valid_refresh_token)) - assert res == valid_response - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_totp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{valid_refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com"}, - follow_redirects=False, - ) diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py deleted file mode 100644 index 8c6bb523d..000000000 --- a/tests/test_webauthn.py +++ /dev/null @@ -1,365 +0,0 @@ -import pytest - -from descope import AuthException -from descope.authmethod.webauthn import WebAuthn -from descope.common import ( - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, -) -from tests.conftest import PROJECT_ID, assert_http_called, make_response -from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN - -from . import common - - -class TestWebAuthn: - def test_compose_sign_up_start_body(self): - assert WebAuthn._compose_sign_up_start_body("dummy@dummy.com", {"name": "dummy"}, "https://example.com") == { - "user": {"loginId": "dummy@dummy.com", "name": "dummy"}, - "origin": "https://example.com", - } - - def test_compose_sign_in_start_body(self): - assert WebAuthn._compose_sign_in_start_body("dummy@dummy.com", "https://example.com") == { - "loginId": "dummy@dummy.com", - "origin": "https://example.com", - "loginOptions": {}, - } - - def test_compose_sign_up_or_in_start_body(self): - assert WebAuthn._compose_sign_up_or_in_start_body("dummy@dummy.com", "https://example.com") == { - "loginId": "dummy@dummy.com", - "origin": "https://example.com", - } - - def test_compose_finish_bodies(self): - assert WebAuthn._compose_sign_up_in_finish_body("t01", "resp01") == { - "transactionId": "t01", - "response": "resp01", - } - assert WebAuthn._compose_update_finish_body("t01", "resp01") == { - "transactionId": "t01", - "response": "resp01", - } - - def test_compose_update_start_body(self): - assert WebAuthn._compose_update_start_body("dummy@dummy.com", "https://example.com") == { - "loginId": "dummy@dummy.com", - "origin": "https://example.com", - } - # mfa is included only when set - assert WebAuthn._compose_update_start_body("dummy@dummy.com", "https://example.com", mfa=True) == { - "loginId": "dummy@dummy.com", - "origin": "https://example.com", - "mfa": True, - } - - async def test_sign_up_start(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_start("", "https://example.com")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_start("id1", "")) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_start("id1", "https://example.com")) - - # Success + payload - with client.mock_post(make_response({"transactionId": "txn1", "options": "{}"})) as mock_post: - result = await client.invoke(client.webauthn.sign_up_start("id1", "https://example.com")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_webauthn_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"user": {"loginId": "id1"}, "origin": "https://example.com"}, - follow_redirects=False, - ) - - async def test_sign_up_finish(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_finish("", "resp")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_finish(None, "resp")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_finish("t01", "")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_finish("t01", None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_finish("t01", "resp")) - - # Success - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.webauthn.sign_up_finish("t01", "resp01")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_webauthn_finish_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"transactionId": "t01", "response": "resp01"}, - follow_redirects=False, - ) - - async def test_sign_in_start(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_start("", "https://example.com")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_start("id", "")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_start("id", "https://example.com", LoginOptions(mfa=True))) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_start("id1", "https://example.com")) - - # Success + payload - with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: - result = await client.invoke(client.webauthn.sign_in_start("id1", "https://example.com")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "id1", "origin": "https://example.com", "loginOptions": {}}, - follow_redirects=False, - ) - - async def test_sign_in_start_with_login_options(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - - with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: - await client.invoke(client.webauthn.sign_in_start("id1", "https://example.com", lo, "refresh")) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:refresh", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id1", - "origin": "https://example.com", - "loginOptions": {"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, - }, - follow_redirects=False, - ) - - async def test_sign_in_finish(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_finish("", "resp")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_finish(None, "resp")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_finish("t01", "")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_finish("t01", None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_in_finish("t01", "resp")) - - # Success - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp) as mock_post: - result = await client.invoke(client.webauthn.sign_in_finish("t01", "resp01")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_finish_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"transactionId": "t01", "response": "resp01"}, - follow_redirects=False, - ) - - async def test_sign_up_or_in_start(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_or_in_start("", "https://example.com")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_or_in_start("id", "")) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.webauthn.sign_up_or_in_start("id1", "https://example.com")) - - # Success + payload - with client.mock_post(make_response({"transactionId": "txn1", "create": True})) as mock_post: - result = await client.invoke(client.webauthn.sign_up_or_in_start("id1", "https://example.com")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_webauthn_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "id1", "origin": "https://example.com"}, - follow_redirects=False, - ) - - async def test_update_start(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_start("", refresh_token, "https://example.com")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_start(None, refresh_token, "https://example.com")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_start("id", "", "https://example.com")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_start("id", None, "https://example.com")) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_start("id1", refresh_token, "https://example.com")) - - # Success + payload - with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: - result = await client.invoke(client.webauthn.update_start("id1", refresh_token, "https://example.com")) - assert result is not None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "id1", "origin": "https://example.com"}, - follow_redirects=False, - ) - - async def test_update_finish(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_finish("", "resp")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_finish(None, "resp")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_finish("t01", "")) - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_finish("t01", None)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.webauthn.update_finish("t01", "resp")) - - # Success (returns None) - with client.mock_post(make_response({})) as mock_post: - result = await client.invoke(client.webauthn.update_finish("t01", "resp01")) - assert result is None - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_finish_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"transactionId": "t01", "response": "resp01"}, - follow_redirects=False, - ) - - async def test_update_start_with_mfa(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = VALID_REFRESH_TOKEN - with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: - await client.invoke(client.webauthn.update_start("id1", refresh_token, "https://example.com", mfa=True)) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_start_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "id1", - "origin": "https://example.com", - "mfa": True, - }, - follow_redirects=False, - ) - - async def test_update_finish_with_mfa(self, client_factory): - # mfa enrollment: finish returns the merged-amr session nested under "jwt" - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - success_resp = make_response( - {"jwt": {"sessionJwt": VALID_SESSION_TOKEN}}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp): - result = await client.invoke(client.webauthn.update_finish("t01", "resp01")) - assert result is not None diff --git a/tests/testutils.py b/tests/testutils.py deleted file mode 100644 index d68bd6d85..000000000 --- a/tests/testutils.py +++ /dev/null @@ -1,58 +0,0 @@ -from ssl import SSLContext - -# Test fixtures: ES384 key + JWTs signed with kid=P2CuC9yv2UGtGI1o84gCZEb9qEQW -PUBLIC_KEY_DICT = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "kty": "EC", - "use": "sig", - "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", - "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", -} - -# drn=DSR, exp=2264443061 (far future) -VALID_REFRESH_TOKEN = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" - ".eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYy" - "VUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ" - ".mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOx" - "ICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" -) - -# drn=DS, exp=2493061415 (far future) -VALID_SESSION_TOKEN = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" - ".eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3" - "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" - ".gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1p" - "SY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR" -) - -# drn=DS, exp=1659644298 (past — use for expiry tests) -EXPIRED_SESSION_TOKEN = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" - ".eyJkcm4iOiJEUyIsImV4cCI6MTY1OTY0NDI5OCwiaWF0IjoxNjU5NjQ0Mjk3LCJpc3MiOiJQMkN1Qzl5djJVR3" - "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" - ".wBuOnIQI_z3SXOszqsWCg8ilOPdE5ruWYHA3jkaeQ3uX9hWgCTd69paFajc-xdMYbqlIF7JHji7T9oVmkCUJvD" - "NgRZRZO9boMFANPyXitLOK4aX3VZpMJBpFxdrWV3GE" -) - - -class SSLMatcher: - """Matcher for the `verify=` kwarg passed to httpx.* calls in tests. - - Default: matches any ssl.SSLContext (secure=True clients). - Use SSLMatcher(insecure=True) to match `verify=False` (secure=False clients). - """ - - def __init__(self, insecure: bool = False): - self._insecure = insecure - - def __eq__(self, other): - if self._insecure: - return other is False - return isinstance(other, SSLContext) - - def __repr__(self): - return f"SSLMatcher(insecure={self._insecure})" if self._insecure else "SSLMatcher()" diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 6d6a63415..000000000 --- a/uv.lock +++ /dev/null @@ -1,1704 +0,0 @@ -version = 1 -revision = 5 -requires-python = ">=3.9, <4.0" -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] - -[[package]] -name = "anyio" -version = "4.12.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "exceptiongroup" }, - { name = "idna" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, -] - -[[package]] -name = "anyio" -version = "4.14.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, -] - -[[package]] -name = "ast-serialize" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, - { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, - { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, - { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, - { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, - { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, - { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, - { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, - { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, - { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, - { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, - { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, - { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, - { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, - { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, - { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, - { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, - { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, - { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, - { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, - { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, - { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, - { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, - { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, -] - -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - -[[package]] -name = "certifi" -version = "2026.10.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, - { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, - { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, - { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, - { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, - { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, - { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, - { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, - { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, -] - -[[package]] -name = "cfgv" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, -] - -[[package]] -name = "cfgv" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - -[[package]] -name = "click" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "coverage" -version = "7.10.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, - { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, - { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, - { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, - { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, - { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, - { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, - { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, - { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, - { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, - { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, - { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, - { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, - { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, - { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, - { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, - { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, - { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, - { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, - { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, - { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, - { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, - { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, - { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, - { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, - { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, - { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli" }, -] - -[[package]] -name = "coverage" -version = "7.14.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" }, - { url = "https://files.pythonhosted.org/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" }, - { url = "https://files.pythonhosted.org/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" }, - { url = "https://files.pythonhosted.org/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" }, - { url = "https://files.pythonhosted.org/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" }, - { url = "https://files.pythonhosted.org/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" }, - { url = "https://files.pythonhosted.org/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" }, - { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, - { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, - { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, - { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, - { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, - { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, - { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, - { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, - { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, - { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, - { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, - { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, - { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, - { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, - { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, - { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, - { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, - { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, - { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, - { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, - { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, - { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, - { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, - { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, - { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, - { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, - { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, - { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, - { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, - { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, - { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, - { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, - { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, - { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, - { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, - { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, - { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, - { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, - { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, - { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, - { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - -[[package]] -name = "cryptography" -version = "47.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, - { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, - { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, - { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, -] - -[[package]] -name = "cryptography" -version = "49.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", - "python_full_version > '3.9' and python_full_version < '3.10'", -] -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, -] - -[[package]] -name = "descope" -version = "2.17.0" -source = { editable = "." } -default-groups = "all" -dependencies = [ - { name = "certifi" }, - { name = "email-validator" }, - { name = "httpx" }, - { name = "pyjwt", extra = ["crypto"] }, -] - -[package.optional-dependencies] -flask = [ - { name = "flask" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pre-commit" }, - { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "python-dotenv", version = "1.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "ruff" }, -] -tests = [ - { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, - { name = "coverage", version = "7.14.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest-asyncio", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-cov" }, -] -types = [ - { name = "mypy", version = "1.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "mypy", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] - -[package.metadata] -requires-dist = [ - { name = "certifi", specifier = ">=2026.10.19" }, - { name = "email-validator", specifier = ">=2,<3" }, - { name = "flask", marker = "extra == 'flask'", specifier = ">=2" }, - { name = "httpx", specifier = ">=0.28,<0.29" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.4.0" }, -] -provides-extras = ["flask"] - -[package.metadata.requires-dev] -dev = [ - { name = "pre-commit", specifier = "==3.8.0" }, - { name = "python-dotenv", specifier = ">=1.2.1" }, - { name = "ruff", specifier = "==0.17.0" }, -] -tests = [ - { name = "coverage", extras = ["toml"], specifier = ">=7.3.1,<8" }, - { name = "pytest", marker = "python_full_version < '3.10'", specifier = ">=8.4,<9" }, - { name = "pytest", marker = "python_full_version >= '3.10'", specifier = ">=9.0.3" }, - { name = "pytest-asyncio", marker = "python_full_version < '3.10'", specifier = "==1.2.0" }, - { name = "pytest-asyncio", marker = "python_full_version >= '3.10'", specifier = "==1.4.0" }, - { name = "pytest-cov", specifier = ">=5" }, -] -types = [ - { name = "mypy", marker = "python_full_version < '3.10'", specifier = ">=1.11.2,<1.12" }, - { name = "mypy", marker = "python_full_version >= '3.10'", specifier = ">=1.20.1" }, -] - -[[package]] -name = "distlib" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, -] - -[[package]] -name = "dnspython" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, -] - -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "filelock" -version = "3.19.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, -] - -[[package]] -name = "filelock" -version = "3.29.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, -] - -[[package]] -name = "flask" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "blinker" }, - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "anyio", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "identify" -version = "2.6.15" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, -] - -[[package]] -name = "identify" -version = "2.6.19" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "librt" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, - { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, - { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, - { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, - { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, - { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, - { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, - { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, - { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, - { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, - { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, - { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, - { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, - { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, - { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, - { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, - { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, - { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, - { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, - { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, - { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, - { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, - { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, - { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, - { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, - { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, - { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, - { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, - { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, - { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, - { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, - { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, - { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, - { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, - { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, - { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, - { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, - { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, - { url = "https://files.pythonhosted.org/packages/66/54/5d5f27cc840d2d8a64d60e0650dba14044a95d85a875e42af2eb104ac8b9/librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f", size = 142475, upload-time = "2026-05-10T18:17:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/f9/72/535efe79cf47f70975e0b14ceb3b7984bb7e8b97fb2867d3979771be0b6a/librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677", size = 143365, upload-time = "2026-05-10T18:17:09.565Z" }, - { url = "https://files.pythonhosted.org/packages/83/cc/4130d462aeaf190357517d2a48a0a25030fbfd604230f6c45908452fff9c/librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab", size = 475743, upload-time = "2026-05-10T18:17:10.822Z" }, - { url = "https://files.pythonhosted.org/packages/62/e8/3c8000edefeb443fd2139692fb966f6c5556cb1032c44f734550896df3b9/librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0", size = 467088, upload-time = "2026-05-10T18:17:12.273Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a1/6de754256493924874e5fa6c0f4f990d8b101c38d974589020d9dc3d02af/librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1", size = 496277, upload-time = "2026-05-10T18:17:13.662Z" }, - { url = "https://files.pythonhosted.org/packages/92/fb/c34cb5358d6f993f85014045decd6dccd089a6f11d188660e062ee6262ff/librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e", size = 489320, upload-time = "2026-05-10T18:17:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/48/65/7761d70841bac875be9627496546b2eccbdeb07da3e42431bc4a40cf0819/librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa", size = 510221, upload-time = "2026-05-10T18:17:16.595Z" }, - { url = "https://files.pythonhosted.org/packages/cb/8d/af9d4ac1057cd4e472b89553924b528b3d34afa6b7167645b7e6db39596b/librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1", size = 516650, upload-time = "2026-05-10T18:17:18.245Z" }, - { url = "https://files.pythonhosted.org/packages/86/f4/08faaf48ce0833d3717ebe0a0054c09a05df1bc83ee2715113c9901cc147/librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5", size = 496622, upload-time = "2026-05-10T18:17:19.857Z" }, - { url = "https://files.pythonhosted.org/packages/0d/11/ec3e390627f70477093909875a38843c826ee2ff554d1649645c7cc59248/librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192", size = 538049, upload-time = "2026-05-10T18:17:21.221Z" }, - { url = "https://files.pythonhosted.org/packages/cd/a7/649401dae7ea8645dd218aa2d9c351afa7b9e0645f07dc8776a1972c0cad/librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f", size = 100360, upload-time = "2026-05-10T18:17:22.537Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7e/6a9711d78f338445e36992a90071962294f5bab388b554ef8a313e6412dd/librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3", size = 118407, upload-time = "2026-05-10T18:17:24.01Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, - { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, - { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, - { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, - { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, - { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, -] - -[[package]] -name = "mypy" -version = "1.11.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "mypy-extensions" }, - { name = "tomli" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806, upload-time = "2024-08-24T22:50:11.357Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401, upload-time = "2024-08-24T22:49:18.929Z" }, - { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697, upload-time = "2024-08-24T22:49:32.504Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508, upload-time = "2024-08-24T22:49:12.327Z" }, - { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712, upload-time = "2024-08-24T22:49:49.399Z" }, - { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319, upload-time = "2024-08-24T22:49:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630, upload-time = "2024-08-24T22:49:51.895Z" }, - { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973, upload-time = "2024-08-24T22:49:21.428Z" }, - { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659, upload-time = "2024-08-24T22:49:35.02Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010, upload-time = "2024-08-24T22:49:29.725Z" }, - { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873, upload-time = "2024-08-24T22:49:40.448Z" }, - { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335, upload-time = "2024-08-24T22:49:54.245Z" }, - { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119, upload-time = "2024-08-24T22:49:03.451Z" }, - { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856, upload-time = "2024-08-24T22:50:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066, upload-time = "2024-08-24T22:50:03.89Z" }, - { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000, upload-time = "2024-08-24T22:49:59.703Z" }, - { url = "https://files.pythonhosted.org/packages/16/64/bb5ed751487e2bea0dfaa6f640a7e3bb88083648f522e766d5ef4a76f578/mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", size = 10937294, upload-time = "2024-08-24T22:49:37.878Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a3/67a0069abed93c3bf3b0bebb8857e2979a02828a4a3fd82f107f8f1143e8/mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", size = 10107707, upload-time = "2024-08-24T22:49:00.237Z" }, - { url = "https://files.pythonhosted.org/packages/2f/4d/0379daf4258b454b1f9ed589a9dabd072c17f97496daea7b72fdacf7c248/mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d", size = 12498367, upload-time = "2024-08-24T22:49:06.504Z" }, - { url = "https://files.pythonhosted.org/packages/3b/dc/3976a988c280b3571b8eb6928882dc4b723a403b21735a6d8ae6ed20e82b/mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", size = 13018014, upload-time = "2024-08-24T22:49:09.242Z" }, - { url = "https://files.pythonhosted.org/packages/83/84/adffc7138fb970e7e2a167bd20b33bb78958370179853a4ebe9008139342/mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", size = 9568056, upload-time = "2024-08-24T22:49:45.55Z" }, - { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625, upload-time = "2024-08-24T22:50:01.842Z" }, -] - -[[package]] -name = "mypy" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -dependencies = [ - { name = "ast-serialize" }, - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, - { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, - { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, - { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, - { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, - { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, - { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, - { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, - { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, - { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, - { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, - { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, - { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, - { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, - { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, - { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, - { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, - { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, - { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, - { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, - { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, - { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, - { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.10.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pre-commit" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "identify", version = "2.6.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "identify", version = "2.6.19", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815, upload-time = "2024-07-28T19:59:01.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643, upload-time = "2024-07-28T19:58:59.335Z" }, -] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "cryptography", version = "49.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -dependencies = [ - { name = "backports-asyncio-runner" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, - { name = "coverage", version = "7.14.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, - { name = "pluggy" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, -] - -[[package]] -name = "python-discovery" -version = "1.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "filelock", version = "3.29.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version > '3.9' and python_full_version < '3.10'", - "python_full_version <= '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.10' and python_full_version < '3.15'", -] -sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, - { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, -] - -[[package]] -name = "ruff" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, - { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, - { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, - { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, - { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, - { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, - { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, - { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, - { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, - { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, - { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, - { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, -] - -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "virtualenv" -version = "21.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "filelock", version = "3.29.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "python-discovery" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, -] - -[[package]] -name = "werkzeug" -version = "3.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, -] - -[[package]] -name = "zipp" -version = "3.23.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, -]