From 7c38dc12f6334d7c3957796dbfcd0382a4391a2b Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 20:45:14 +0530 Subject: [PATCH 1/7] docs: Added CLAUDE.md with AGENTS.md and references --- .gitignore | 1 + AGENTS.md | 3 + CLAUDE.md | 129 +++++++++++++++++++++++++++++++++++++ references/code-style.md | 73 +++++++++++++++++++++ references/commands.md | 41 ++++++++++++ references/docs-update.md | 25 +++++++ references/flow-map.md | 20 ++++++ references/git-workflow.md | 37 +++++++++++ references/pitfalls.md | 9 +++ references/testing.md | 33 ++++++++++ 10 files changed, 371 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 references/code-style.md create mode 100644 references/commands.md create mode 100644 references/docs-update.md create mode 100644 references/flow-map.md create mode 100644 references/git-workflow.md create mode 100644 references/pitfalls.md create mode 100644 references/testing.md diff --git a/.gitignore b/.gitignore index 59de4e0..42f6d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ #Environments .env .venv +.venv-* env/ #Session Cache diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e7c9c08 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AI Agent Guidelines for auth0-api-python + +@./CLAUDE.md for all coding guidelines, commands, project structure, code style, testing conventions, and boundaries. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2179d79 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# AI Agent Guidelines for auth0-api-python + +## Your Role + +You are a Python SDK engineer on auth0-api-python, the async library APIs use to verify Auth0 access tokens (Bearer and DPoP) and perform server-side token exchange. + +--- + +## Working Principles + +- **Think before coding.** State assumptions. When a request is ambiguous, surface the interpretations and ask before building. +- **Simplicity first.** Write the minimum code that solves the stated problem. No speculative options, single-use abstractions, or error handling for impossible states. +- **Surgical changes.** Every changed line traces to the request. Clean up imports your change orphaned, leave pre-existing dead code alone. +- **Verify before done.** Turn the request into a checkable criterion (e.g. "reject X" becomes a failing test for X, then a fix) and run it before claiming success. + +--- + +## Project Structure + +``` +src/auth0_api_python/ + __init__.py # Public API: everything in __all__ is the supported surface + api_client.py # ApiClient: verify_request, verify_access_token, verify_dpop_proof, + # get_access_token_for_connection, get_token_by_exchange_profile, + # get_token_on_behalf_of; private helpers (_discover, _fetch_jwks, ...) at the end, + # except _resolve_allowed_domains, which sits above verify_request (leave it there) + config.py # ApiClientOptions (all constructor options and defaults) + errors.py # BaseAuthError hierarchy (status code, OAuth error code, WWW-Authenticate headers) + cache.py # CacheAdapter ABC + InMemoryCache (LRU + TTL) for discovery/JWKS + utils.py # normalize_domain, OIDC/JWKS fetch, unverified header/payload decode, htu normalization + act.py # get_current_actor / get_delegation_chain (RFC 8693 act claim) + types.py # TypedDicts: DomainsResolverContext, OnBehalfOfTokenResult, ActClaim + token_utils.py # Test helpers (token/DPoP proof generators) with a synthetic test key. Not in __all__, but packaged and importable +tests/ + conftest.py # Shared fixtures (api_client_confidential, mock_discovery) and httpx mock helpers + test_api_client.py, test_cache.py, test_utils.py, test_act.py +docs/ # Caching.md, MultipleCustomDomain.md (hand-written guides, linked from README) +README.md, EXAMPLES.md +pyproject.toml # Poetry manifest; source of truth for dependencies +requirements.txt # Hand-maintained runtime + dev dep list for the Snyk SCA workflow (pins can differ from pyproject.toml) +.version, .shiprc # Release version source (.shiprc keeps .version and pyproject.toml in sync) +.ruff.toml # Lint config (CI-enforced) +``` + +Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), read its row in [references/flow-map.md](references/flow-map.md) for entry points, supporting helpers, tests, and docs. + +--- + +## Boundaries + +### Always Do + +- Run `poetry run pytest` and `poetry run ruff check .` before committing (both run in CI on Python 3.9 to 3.12). +- Make surgical changes: touch only what the request requires. Do not refactor or reformat adjacent code. +- Follow existing style and naming. Add new public `ApiClient` methods after the existing public methods, above the `# ===== Private Methods =====` banner in `api_client.py`. Add new private helpers below it. Never reorder existing declarations. +- Add unit tests for new functionality, marked `@pytest.mark.asyncio` when async, mocking HTTP with `pytest-httpx` (no real network calls). +- Raise typed errors from `errors.py` (subclasses of `BaseAuthError` with `get_status_code()` / `get_error_code()`). Wrap third-party failures in a typed error with `raise ... from e`. Inside a catch-all, re-raise this SDK's own errors first (`except VerifyAccessTokenError: raise`, as `verify_access_token` does) so they are not double-wrapped. Never return `None` or a bare `dict` to signal failure, and never leak raw `httpx`/`authlib` errors from public methods. +- Route discovery and JWKS fetches through `fetch_oidc_metadata` / `fetch_jwks` so `custom_fetch` and caching apply. Token endpoint calls use `httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout))` and map `httpx.TimeoutException` to `ApiError` 504 and `httpx.HTTPError` to 502, as `get_access_token_for_connection` does. +- Export any new public symbol through `__init__.py` `__all__`. +- Update `README.md` and `EXAMPLES.md` (and `docs/*.md` when caching or MCD behaviour changes) in the same PR when changing the public API, `ApiClientOptions`, or supported integration patterns. +- When adding or bumping a runtime dependency in `pyproject.toml`, update `requirements.txt` to match. The SCA scan installs from it, not from Poetry. +- Keep code compatible with Python 3.9 (`target-version = "py39"`, CI matrix includes 3.9). + +### Ask First + +- Any breaking change. Never make one on your own initiative, stop and ask the maintainer before writing it. This includes a renamed or removed public method, a changed signature or default, a new required option, or a raised Python floor. +- Changing an existing error's `get_error_code()` string, `ApiError.code`, or `get_status_code()`. Integrators branch on these and they surface in `WWW-Authenticate`, so treat them as public API. Adding a new error code is not breaking. +- Adding or bumping dependencies (`pyproject.toml`, `poetry.lock`, `requirements.txt`). +- Modifying public API signatures or `ApiClientOptions` defaults (e.g. `dpop_enabled`, `dpop_required`, cache TTL/size, `timeout`). +- Changes to token, DPoP, issuer, or domain validation logic, the allowed algorithm lists, `RESERVED_PARAMS`, or `normalize_domain`. +- Changes to CI/CD configuration (`.github/workflows/`, `.github/actions/`), `.ruff.toml` rule selection, or `.snyk` suppressions. +- Changing the cache key format or what is stored in the cache (affects custom `CacheAdapter` implementations). + +### Never Do + +- Commit secrets, client secrets, real tokens, or private keys. Test fixtures use placeholder values (`cid`, `csecret`, `auth0.local`). +- Hand-edit `poetry.lock` or `CHANGELOG.md`, or edit `.version` / the `pyproject.toml` version (the release flow owns them via `.shiprc`). +- Remove or skip a failing test instead of fixing it, or weaken an assertion to make it pass. +- Fail open. A validation, resolver, discovery, JWKS, or token endpoint failure must raise a typed error, never fall through to a permissive default (no default domain, no "assume valid"). +- Compare secrets or token-derived values with `==` in new code. Use `hmac.compare_digest`. +- Run `ruff format .` repo-wide. It is not a CI gate and the tree is not format-clean (see Common Pitfalls). +- Modify `.venv*/`, `dist/`, `__pycache__/`, `.coverage`, `coverage.xml`. +- Import `token_utils` from library code, or put a real key in it. +- Log, print, or include access tokens, DPoP proofs, client secrets, exchanged tokens, or full token endpoint response bodies in error messages. The SDK has no logger. Do not introduce one that emits these. +- Break backward compatibility without asking first and getting explicit approval. + +--- + +## Security Considerations + +- **Algorithm pinning:** access tokens verify with `RS256` only (`self._jwt = JsonWebToken(["RS256"])`), DPoP proofs with `ES256` only. Never add `none`, `HS*`, or derive the algorithm from the token header. +- **Never fetch from a token-derived host:** in MCD mode (`domains`), the unverified `iss` is normalized and must pass `_resolve_allowed_domains` before `_discover(issuer=...)` runs. In single-domain mode, discovery always targets the configured `domain`, and the token `iss` is matched against the discovery issuer afterwards. Never pass a token-derived issuer or URL to a fetch without the allowlist check. That turns token input into SSRF. +- **Domain normalization:** `normalize_domain` rejects `http://`, credentials, paths, queries, and fragments. Route every domain or issuer comparison through it. +- **MCD resolver input is untrusted:** `request_url`, `request_headers`, and `unverified_iss` in `DomainsResolverContext` are attacker-influenced. Resolver failures raise `DomainsResolverError`, never fall back to accepting the token (see `docs/MultipleCustomDomain.md` "Security Requirements"). +- **Claims:** `iss`, `aud`, `exp`, `iat` are always validated. `required_claims` adds to them, never replaces them. +- **DPoP:** proofs must carry `iat`, `ath`, `htm`, `htu`, `jti`. `htu` is compared via `normalize_url_for_htu`, `ath` must match the access token hash, and `cnf.jkt` must match the proof key thumbprint. `dpop_required=True` rejects Bearer. `jti` is checked for presence and type only. There is no replay store, so replay is bounded only by the `dpop_iat_offset` / `dpop_iat_leeway` window. +- **Token exchange:** `RESERVED_PARAMS` blocks overriding OAuth parameters via `extra`, and `MAX_ARRAY_VALUES_PER_KEY` caps array values. Do not weaken either. +- **Errors:** `_prepare_error` builds `WWW-Authenticate` headers. Error messages reach HTTP responses, so keep them generic and free of token contents. +- **Anonymous callers:** tokens with `sub` starting `anon@` verify like any token. Authorization is the integrator's call, the SDK must not silently accept or reject them. +- **Dependency scanning:** Snyk (`sca_scan.yml`, from `requirements.txt`, on push to `main` only), CodeQL (`codeql.yml`), and ruff `S` (bandit) rules in CI. +- **Vulnerability reports:** go through Auth0's Responsible Disclosure Program (README "Vulnerability Reporting"), never a public issue or PR. + +--- + +> The sections below are reference. Each keeps a short anchor inline and offloads detail to `references/`. + +## Commands + +`poetry install` to set up, `poetry run pytest` to test, `poetry run ruff check .` to lint. See [references/commands.md](references/commands.md) for the full CI-matching command list. Read it when you need to build, test, or lint. + +## Testing + +pytest + pytest-asyncio + pytest-httpx, in `tests/`. The default `poetry run pytest` suite needs no credentials (HTTP is mocked). See [references/testing.md](references/testing.md) for fixtures, async test patterns, and coverage. Read it when writing or running tests. + +## Code Style + +CI-enforced via `.ruff.toml`: rule sets `E, W, F, I, B, C4, UP, S`, isort import order, py39 target, `S101`/`S105`/`S106` allowed only under `tests/`. New non-secret string constants in `src/` use `# noqa: S105` with a reason (existing sites predate this and omit it). See [references/code-style.md](references/code-style.md) for naming, error patterns, and examples. Read it before adding a class, method, or error type. + +## Git Workflow + +Branches `feat/`, `fix/`, `docs/`, `chore/`, `release/`. Commits use Conventional Commits prefixes. See [references/git-workflow.md](references/git-workflow.md) for PR template sections. Read it when committing or opening a PR. + +## Common Pitfalls + +The ones that bite most: the Python 3.9 floor (no `X | None`), `ruff format` is not a CI gate, a missing `@pytest.mark.asyncio`, and `custom_fetch` covering only discovery/JWKS. See [references/pitfalls.md](references/pitfalls.md). Read it when a command, test, or CI job behaves unexpectedly. + +## Docs Update Rules + +Public API, options, or integration pattern changes update `README.md`, `EXAMPLES.md`, and the matching `docs/` guide in the same PR. See [references/docs-update.md](references/docs-update.md) for the tracked-docs inventory and code-to-docs mapping. Read it before finishing any PR that touches `src/`. diff --git a/references/code-style.md b/references/code-style.md new file mode 100644 index 0000000..4815564 --- /dev/null +++ b/references/code-style.md @@ -0,0 +1,73 @@ +# Code Style + +## Naming + +- `snake_case` functions, methods, and options. `PascalCase` classes. `UPPER_SNAKE` module constants (`RESERVED_PARAMS`, `TOKEN_EXCHANGE_GRANT_TYPE`). +- Private helpers carry a leading underscore (`_discover`, `_fetch_jwks`, `_prepare_error`) and sit together after the public methods of `ApiClient`. The one pre-existing exception is `_resolve_allowed_domains`, above `verify_request`. Leave it there. +- Error classes end in `Error` and live in `errors.py`. +- Type hints use builtin generics (`dict[str, Any]`, `list[str]`) with `Optional` / `Union` from `typing`. ruff `UP` targets py39, so do not use `X | Y` syntax. + +## Errors + +Every error subclasses `BaseAuthError` and defines its HTTP mapping: + +```python +class DomainsResolverError(BaseAuthError): + """Error raised when domains resolver function fails.""" + + def get_status_code(self) -> int: + return 500 + + def get_error_code(self) -> str: + return "domains_resolver_error" +``` + +Wrap third-party failures in a typed error and chain the cause: + +```python +# Good (api_client.py, _resolve_allowed_domains) +try: + result = self._allowed_domains(context) + if asyncio.iscoroutine(result) or asyncio.isfuture(result): + result = await result +except Exception as e: + raise DomainsResolverError( + f"Domains resolver function failed: {str(e)}" + ) from e +``` + +Interpolating `str(e)` is acceptable here because the resolver is integrator code. Do not interpolate exception text that can carry token contents or a response body. + +```python +# Bad +def get_token(self, options): # sync, untyped + client = httpx.Client() # no options.timeout + resp = client.post(url, data=options) # raw extras, no RESERVED_PARAMS check + if resp.status_code != 200: + print(resp.text) # may print token material + return None # fails open, untyped + return resp.json() # unvalidated dict escapes +``` + +Each line is a separate violation: sync in an async SDK, no timeout, unfiltered OAuth parameters, a print of a possibly token-bearing body, a `None` a caller can mistake for success, and an unvalidated response in the public API. + +Public errors meant for integrators are exported from `__init__.py` (`ApiError`, `ConfigurationError`, `DomainsResolverError`, `GetTokenByExchangeProfileError`). Others are imported from `auth0_api_python.errors`. + +## Patterns + +- **Options object:** all configuration goes through `ApiClientOptions` and is validated in `ApiClient.__init__`, raising `ConfigurationError` or `MissingRequiredArgumentError` at construction rather than at first use. +- **Async I/O:** network calls use `httpx.AsyncClient`, or the integrator's `custom_fetch` when provided. Public verification and token methods are `async def`. +- **Pluggable cache:** `CacheAdapter` ABC with `get` / `set` / `delete` / `clear`. `InMemoryCache` is the default. Entry TTL is `min(Cache-Control max-age, cache_ttl_seconds)` when the discovery/JWKS response sends `max-age`. +- **Docstrings:** Google style with `Args:` / `Returns:` / `Raises:` sections on public methods. +- **Placement:** `api_client.py` has one banner, `# ===== Private Methods =====`. New public methods go above it after the existing ones, new helpers below it. In files without banners, put new code next to related code or at the end, never at an arbitrary position. + +## Comments and docstrings + +Enforced on every change (code, tests, docs, commit messages): + +1. **Comments earn their place.** Only a non-obvious why: a protocol citation (`# RFC 9449`), a security decision, an intentional omission. Never restate the line below. Default to one sharp line or none. +2. **Docstring format.** One-line description, then `Args`, then `Returns` / `Raises`. No design rationale, that belongs in the PR description. +3. **Plain hyphens only.** No em or en dashes in comments, docstrings, commit messages, or Markdown. +4. **No semicolon clause-splicing.** Split independent clauses into separate sentences. +5. **No internal references.** No ticket numbers, planning-doc paths, internal links, or cross-SDK comparisons ("same as the Node SDK"). +6. **Helpers stay grouped.** Never interleave a private helper between public methods. diff --git a/references/commands.md b/references/commands.md new file mode 100644 index 0000000..f7c811a --- /dev/null +++ b/references/commands.md @@ -0,0 +1,41 @@ +# Commands + +These match `.github/workflows/test.yml` (Poetry 2.2.1, Python 3.9 to 3.12) and `CONTRIBUTING.md`. + +```bash +# Install dependencies and the package (CI runs both steps) +poetry install --no-interaction --no-root +poetry install --no-interaction + +# Run all unit tests (CI command) +poetry run pytest -v --cov=src --cov-report=term-missing --cov-report=xml + +# Run all unit tests (short form; pyproject addopts already adds --cov=src) +poetry run pytest + +# Fast loop without coverage +poetry run pytest --no-cov -q + +# Lint (CI-enforced) +poetry run ruff check . + +# Auto-fix lint issues (review the diff, keep it scoped to your change) +poetry run ruff check . --fix + +# Show formatting state only (not a CI gate, never apply repo-wide) +poetry run ruff format --check . + +# Build sdist and wheel into dist/ +poetry build + +# Clean generated artifacts +rm -rf dist/ .coverage coverage.xml .pytest_cache .ruff_cache +find . -path ./.venv\* -prune -o -name __pycache__ -type d -prune -exec rm -rf {} + +``` + +## Not available here + +- **No typecheck command.** mypy/pyright are not in `pyproject.toml` and no CI step runs them. +- **No integration/e2e tier.** The detected suite runs on `pytest-httpx` mocks, and nothing in `src/`, `tests/`, or CI reads Auth0 tenant credentials. + +`publish.yml` (manual `workflow_dispatch`) builds, runs the RL scanner, and publishes to PyPI. Agents do not run releases. diff --git a/references/docs-update.md b/references/docs-update.md new file mode 100644 index 0000000..a01c6e9 --- /dev/null +++ b/references/docs-update.md @@ -0,0 +1,25 @@ +# Docs Update Rules + +A PR that changes public API, configuration, or integration patterns is not complete until the matching docs are updated in the same PR. + +## Tracked docs + +| Doc | Covers | Exists | +|-----|--------|--------| +| `README.md` | Install, `ApiClient` setup, verification, connection tokens, custom token exchange, OBO, DPoP modes and options, MCD summary, anonymous callers | present | +| `EXAMPLES.md` | OBO exchange, delegation inspection, Bearer and DPoP verification, DPoP modes, anonymous caller patterns | present | +| `docs/Caching.md` | Cache defaults, TTL/max entries, custom `CacheAdapter`, tuning | present | +| `docs/MultipleCustomDomain.md` | `domains` list and resolver modes, hybrid mode, resolver security requirements, MCD with DPoP, errors, migration | present | + +## When you change code, update these docs + +| When this changes | Update | +|-------------------|--------| +| A public `ApiClient` method added, removed, renamed, or its arguments change | `README.md` (relevant numbered section), `EXAMPLES.md` (affected samples) | +| `ApiClientOptions` parameter added, removed, or default changed | `README.md` (setup and the DPoP "Configuration Options" example if DPoP-related), plus `docs/Caching.md` for `cache_*` or `docs/MultipleCustomDomain.md` for `domain` / `domains` | +| `CacheAdapter` interface or `InMemoryCache` behaviour | `docs/Caching.md` ("CacheAdapter API", "Default Behavior") | +| Domain resolver context, MCD validation, or resolver errors | `docs/MultipleCustomDomain.md`, `README.md` section 7 | +| Bearer/DPoP verification behaviour or error codes | `README.md` section 6, `EXAMPLES.md` (Bearer, DPoP sections) | +| Token exchange (connection, exchange profile, OBO) behaviour or errors | `README.md` sections 4 and 5, `EXAMPLES.md` "On Behalf Of Token Exchange" | +| New exported symbol in `__init__.py` | `EXAMPLES.md` (add a usage sample) | +| Supported Python version or install method | `README.md` "Install the SDK" | diff --git a/references/flow-map.md b/references/flow-map.md new file mode 100644 index 0000000..f857eed --- /dev/null +++ b/references/flow-map.md @@ -0,0 +1,20 @@ +# Flow Map + +Before changing a flow, read its entry points and supporting helpers. Public methods are in `ApiClient` (`api_client.py`) above the `# ===== Private Methods =====` banner, and helpers are below it, except `_resolve_allowed_domains`, which sits above `verify_request`. + +| Flow | Entry points | Also read | Docs | +|------|--------------|-----------|------| +| Request verification (Bearer + DPoP) | `verify_request` | `verify_access_token`, `verify_dpop_proof`, `_prepare_error`, `_build_www_authenticate`, `errors.py` | `README.md` sections 3 and 6, `EXAMPLES.md` "Bearer Authentication", "DPoP Authentication" | +| Access token verification | `verify_access_token` | `_resolve_allowed_domains`, `_discover`, `_fetch_jwks`, `_validate_claims_presence`, `utils.normalize_domain`, `get_unverified_header` / `get_unverified_payload` | `README.md` section 3 | +| DPoP proof verification | `verify_dpop_proof` | `utils.normalize_url_for_htu`, `sha256_base64url`, `calculate_jwk_thumbprint`, options `dpop_enabled` / `dpop_required` / `dpop_iat_leeway` / `dpop_iat_offset` | `README.md` section 6, `EXAMPLES.md` "Direct DPoP Proof Verification" | +| Connection access token (Token Vault) | `get_access_token_for_connection` | `GetAccessTokenForConnectionError`, `ApiError`, `_discover` (token endpoint) | `README.md` section 4 | +| Custom token exchange | `get_token_by_exchange_profile` | `_apply_extra`, `RESERVED_PARAMS`, `MAX_ARRAY_VALUES_PER_KEY`, `GetTokenByExchangeProfileError` | `README.md` section 5 | +| On Behalf Of exchange | `get_token_on_behalf_of` | delegates to `get_token_by_exchange_profile`, returns `types.OnBehalfOfTokenResult` | `README.md` "On Behalf Of Token Exchange", `EXAMPLES.md` "On Behalf Of Token Exchange" | +| Delegation inspection | `get_current_actor`, `get_delegation_chain` (`act.py`) | `types.ActClaim` | `README.md` / `EXAMPLES.md` "Inspecting Delegation After Token Verification" | +| Multiple Custom Domains | `ApiClientOptions(domains=...)` | `_resolve_allowed_domains`, `utils.normalize_domain`, `types.DomainsResolverContext`, `DomainsResolverError` | `docs/MultipleCustomDomain.md`, `README.md` section 7 | +| Discovery / JWKS caching | `CacheAdapter`, `InMemoryCache` (`cache.py`) | `_discover`, `_fetch_jwks`, `utils.parse_cache_control_max_age` | `docs/Caching.md` | +| Anonymous callers | no dedicated code, verified like any token | `act.py` is unrelated, `sub` starts with `anon@` | `README.md` section 8, `EXAMPLES.md` "Anonymous Callers" | + +Tests: `tests/test_api_client.py` covers every `ApiClient` flow, `tests/test_act.py` the delegation helpers, `tests/test_cache.py` the cache, `tests/test_utils.py` the utils. + +Two rules cut across every flow: never fetch from a token-derived host unless it passed the allowlist (single-domain mode only fetches the configured `domain`), and fail closed with a typed error on every failure path. diff --git a/references/git-workflow.md b/references/git-workflow.md new file mode 100644 index 0000000..31ffa74 --- /dev/null +++ b/references/git-workflow.md @@ -0,0 +1,37 @@ +# Git Workflow + +## Branch naming + +From merged PR history: `feat/`, `fix/`, `docs/`, `chore/`, `release/` (e.g. `release/1.0.0b10`). Dependabot opens `dependabot/...` branches. Base branch is `main`. + +## Commit messages + +Conventional Commits prefixes (`feat:`, `fix:`, `docs:`, `chore:`, `test:`, `refactor:`), lowercase subject, no trailing period. Example: `docs: add 403 guidance for anonymous caller blocking in EXAMPLES.md`. No commit linter is configured. Older commits do not all follow this, so match the prefix style for new work. + +## Pull requests + +`.github/PULL_REQUEST_TEMPLATE.md` sections: + +- **Changes:** what and why, classes/methods added, changed, or deprecated, and a usage summary for public API changes +- **References:** supporting links (tickets, community posts) +- **Testing:** how to test, with checkboxes for unit coverage, integration coverage, and latest-platform testing +- **Checklist:** Auth0 contribution guidelines, Code of Conduct, all tests passing + +The template asks for a method-level inventory, so write the PR body from the diff of the public surface (`__init__.py` `__all__`, `ApiClient` public methods, `ApiClientOptions`, `errors.py`), not from a summary of intent. + +`CODEOWNERS` assigns every path to `@auth0/project-dx-sdks-engineer-codeowner`. + +## CI workflows + +From `.github/workflows/`. Which of these are required status checks is set in branch protection, not in the repo. + +- **Build and Test** (`test.yml`): `pytest` + `ruff check .` across the Python version matrix in that workflow, on PR, merge group, and push to `main` +- **CodeQL** (`codeql.yml`): on PR, merge group, push to `main`, and a weekly schedule +- **SCA** (`sca_scan.yml`): Snyk against `requirements.txt`, with suppressions in `.snyk`. Runs on push to `main` only, so it does not gate a PR +- **Claude Code Review** (`claude-code-review.yml`) + +`ruff format` is not a check. Reformatting untouched files creates noise CI won't flag but reviewers will. + +## Releases + +`.version` is the version source. `.shiprc` keeps `pyproject.toml` in step with it. Releases are cut by `publish.yml` (manual dispatch, gated on the RL scan), not by an agent editing version files. `CHANGELOG.md` is written as part of that flow. diff --git a/references/pitfalls.md b/references/pitfalls.md new file mode 100644 index 0000000..8ac3730 --- /dev/null +++ b/references/pitfalls.md @@ -0,0 +1,9 @@ +# Common Pitfalls + +1. **Do not run `ruff format .`.** CI runs only `ruff check .`. The codebase is not ruff-formatted (`ruff format --check .` reports 13 files that would be reformatted), so a repo-wide format produces a large unrelated diff. Match surrounding formatting by hand. +2. **Python 3.9 compatibility.** CI tests 3.9 and `.ruff.toml` targets `py39`. Avoid `X | Y` type unions at runtime, `match` statements, and other 3.10+ syntax. `ada-url` has separate version constraints above and below 3.10 in `pyproject.toml`. +3. **Async tests need `@pytest.mark.asyncio`.** No `asyncio_mode` is set in `pyproject.toml`, so pytest-asyncio runs in strict mode and ignores an unmarked async test. Depending on the pytest version it then fails with an "async def functions are not natively supported" message or is skipped with a warning. If a new async test fails that way or shows as skipped, check the marker first. +4. **Two dependency lists.** `pyproject.toml` + `poetry.lock` drive installs. `requirements.txt` is a hand-maintained copy that only the Snyk SCA workflow reads. Changing one without the other leaves the scan out of date. +5. **`custom_fetch` covers only discovery and JWKS.** `fetch_oidc_metadata` and `fetch_jwks` (`utils.py`) use the integrator's `custom_fetch` when set. The token endpoint calls in `get_access_token_for_connection` and `get_token_by_exchange_profile` always build their own `httpx.AsyncClient` with `options.timeout`. Do not assume a new HTTP path inherits either behaviour, pick the matching pattern deliberately. +6. **`.ruff.toml` uses deprecated top-level keys.** ruff warns that `select` / `ignore` / `per-file-ignores` belong under `[lint]`. The warning is harmless. Migrating the file is a separate change, do not fold it into unrelated work. +7. **Stale local venvs.** A hand-made venv in the working tree (anything other than Poetry's) may not have the package or dev dependencies installed. Use `poetry install` then `poetry run ...` so you test the same environment CI does. diff --git a/references/testing.md b/references/testing.md new file mode 100644 index 0000000..8d634ca --- /dev/null +++ b/references/testing.md @@ -0,0 +1,33 @@ +# Testing + +- **Framework:** pytest, with pytest-asyncio, pytest-httpx, pytest-mock, freezegun (versions in `pyproject.toml` dev group) +- **Location:** `tests/`, one `test_.py` per source module +- **Coverage:** pytest-cov. `pyproject.toml` `addopts` always applies `--cov=src --cov-report=term-missing:skip-covered --cov-report=xml`. No minimum threshold is configured. + +The default `poetry run pytest` suite needs no credentials. All Auth0 HTTP calls go through `httpx_mock` against `https://auth0.local`. + +## Running tests + +```bash +poetry run pytest # all unit tests +poetry run pytest tests/test_cache.py # one file +poetry run pytest tests/test_api_client.py -k dpop # filter by name +``` + +## Conventions + +- Module-level `test__` functions (e.g. `test_verify_access_token_fail_no_iss`), often with a one-line docstring, grouped under `# ===== Section =====` comment banners. No test classes. +- Async tests are marked explicitly with `@pytest.mark.asyncio`. No `asyncio_mode` is configured, so an unmarked `async def test_...` will not run as intended. +- Assert error types with `pytest.raises(VerifyAccessTokenError)` and similar. Where both exist, assert on `get_error_code()` / `.code` and `get_status_code()` rather than message text. Codes are the contract, messages are not. +- Test placeholders stay obviously synthetic (`cid`, `csecret`, `auth0.local`). Never paste a real tenant value. +- Time-dependent tests use `@freeze_time(...)` from freezegun. Cache TTL tests in `test_cache.py` use short real sleeps. + +## Mocking and test utilities + +- `tests/conftest.py` provides: + - `api_client_confidential`: an `ApiClient` with `domain="auth0.local"` and placeholder `client_id` / `client_secret` + - `mock_discovery`: registers the discovery response + - `mock_token_response(...)`, `token_success(...)`, `last_form(...)`, `last_auth_header(...)`: token endpoint mocking and request inspection + - `assert_api_error(...)`, `assert_http_basic_auth(...)`, `assert_form_post(...)`, `assert_no_requests(...)`: shared assertions. Use `assert_api_error` rather than hand-rolling `.code` / status checks +- `src/auth0_api_python/token_utils.py` provides `generate_token`, `generate_dpop_proof`, `generate_token_with_cnf`, signed with a synthetic test key. Use these instead of hand-building JWTs. +- Use the constants `DISCOVERY_URL`, `JWKS_URL`, `TOKEN_ENDPOINT` from `conftest.py` rather than repeating URLs. From fccc24aa4eef868f26a3196993d2ac0b11e17a2f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 12:34:12 +0530 Subject: [PATCH 2/7] docs: Updated claude.md and references --- CLAUDE.md | 32 ++++++++++++++++++++------------ references/code-style.md | 3 ++- references/docs-update.md | 11 ++++++++--- references/flow-map.md | 13 +++++++++---- references/pitfalls.md | 6 +++--- references/testing.md | 4 +++- 6 files changed, 45 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2179d79..ac0c37b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,24 +22,28 @@ src/auth0_api_python/ __init__.py # Public API: everything in __all__ is the supported surface api_client.py # ApiClient: verify_request, verify_access_token, verify_dpop_proof, # get_access_token_for_connection, get_token_by_exchange_profile, - # get_token_on_behalf_of; private helpers (_discover, _fetch_jwks, ...) at the end, + # get_token_on_behalf_of, get_client_credentials_token; private helpers (_discover, _fetch_jwks, ...) at the end, # except _resolve_allowed_domains, which sits above verify_request (leave it there) config.py # ApiClientOptions (all constructor options and defaults) errors.py # BaseAuthError hierarchy (status code, OAuth error code, WWW-Authenticate headers) cache.py # CacheAdapter ABC + InMemoryCache (LRU + TTL) for discovery/JWKS + token_store.py # AbstractTokenStore / IndexedTokenStore ABCs, TokenSet, VerifiedToken (caches tokens the SDK mints) + encryption.py # JWE encrypt/decrypt used by AbstractTokenStore + _internal/ # Cache key builders and OBO / Token Vault cache classes. Private, not in __all__ utils.py # normalize_domain, OIDC/JWKS fetch, unverified header/payload decode, htu normalization act.py # get_current_actor / get_delegation_chain (RFC 8693 act claim) types.py # TypedDicts: DomainsResolverContext, OnBehalfOfTokenResult, ActClaim token_utils.py # Test helpers (token/DPoP proof generators) with a synthetic test key. Not in __all__, but packaged and importable tests/ conftest.py # Shared fixtures (api_client_confidential, mock_discovery) and httpx mock helpers - test_api_client.py, test_cache.py, test_utils.py, test_act.py -docs/ # Caching.md, MultipleCustomDomain.md (hand-written guides, linked from README) + test_api_client.py, test_cache.py, test_utils.py, test_act.py, test_token_store.py +docs/ # Caching.md, MultipleCustomDomain.md, TokenStorage.md, OrganizationPolicy.md (hand-written guides, linked from README) README.md, EXAMPLES.md pyproject.toml # Poetry manifest; source of truth for dependencies requirements.txt # Hand-maintained runtime + dev dep list for the Snyk SCA workflow (pins can differ from pyproject.toml) .version, .shiprc # Release version source (.shiprc keeps .version and pyproject.toml in sync) .ruff.toml # Lint config (CI-enforced) +.snyk # Snyk ignores, including license waivers (e.g. jwcrypto LGPL-3.0) ``` Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), read its row in [references/flow-map.md](references/flow-map.md) for entry points, supporting helpers, tests, and docs. @@ -55,7 +59,7 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - Follow existing style and naming. Add new public `ApiClient` methods after the existing public methods, above the `# ===== Private Methods =====` banner in `api_client.py`. Add new private helpers below it. Never reorder existing declarations. - Add unit tests for new functionality, marked `@pytest.mark.asyncio` when async, mocking HTTP with `pytest-httpx` (no real network calls). - Raise typed errors from `errors.py` (subclasses of `BaseAuthError` with `get_status_code()` / `get_error_code()`). Wrap third-party failures in a typed error with `raise ... from e`. Inside a catch-all, re-raise this SDK's own errors first (`except VerifyAccessTokenError: raise`, as `verify_access_token` does) so they are not double-wrapped. Never return `None` or a bare `dict` to signal failure, and never leak raw `httpx`/`authlib` errors from public methods. -- Route discovery and JWKS fetches through `fetch_oidc_metadata` / `fetch_jwks` so `custom_fetch` and caching apply. Token endpoint calls use `httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout))` and map `httpx.TimeoutException` to `ApiError` 504 and `httpx.HTTPError` to 502, as `get_access_token_for_connection` does. +- Route discovery and JWKS fetches through `fetch_oidc_metadata` / `fetch_jwks` so `custom_fetch` and caching apply. Token endpoint calls use `httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout))` and map `httpx.TimeoutException` to `ApiError` 504 and `httpx.HTTPError` to 502, as `get_access_token_for_connection` and `get_client_credentials_token` do. A discovery or JWKS outage during verification (connection error, timeout, HTTP 5xx or 429, classified by `is_auth_server_outage`) raises `AuthServerUnavailableError` (503), not a 401. - Export any new public symbol through `__init__.py` `__all__`. - Update `README.md` and `EXAMPLES.md` (and `docs/*.md` when caching or MCD behaviour changes) in the same PR when changing the public API, `ApiClientOptions`, or supported integration patterns. - When adding or bumping a runtime dependency in `pyproject.toml`, update `requirements.txt` to match. The SCA scan installs from it, not from Poetry. @@ -65,11 +69,11 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - Any breaking change. Never make one on your own initiative, stop and ask the maintainer before writing it. This includes a renamed or removed public method, a changed signature or default, a new required option, or a raised Python floor. - Changing an existing error's `get_error_code()` string, `ApiError.code`, or `get_status_code()`. Integrators branch on these and they surface in `WWW-Authenticate`, so treat them as public API. Adding a new error code is not breaking. -- Adding or bumping dependencies (`pyproject.toml`, `poetry.lock`, `requirements.txt`). -- Modifying public API signatures or `ApiClientOptions` defaults (e.g. `dpop_enabled`, `dpop_required`, cache TTL/size, `timeout`). +- Adding or bumping dependencies (`pyproject.toml`, `poetry.lock`, `requirements.txt`). Include a license check, since `.snyk` waives `jwcrypto` (LGPL-3.0) by name. +- Modifying public API signatures or `ApiClientOptions` defaults (e.g. `dpop_enabled`, `dpop_required`, `organization_policy`, `scope_matching`, cache TTL/size, `timeout`). - Changes to token, DPoP, issuer, or domain validation logic, the allowed algorithm lists, `RESERVED_PARAMS`, or `normalize_domain`. - Changes to CI/CD configuration (`.github/workflows/`, `.github/actions/`), `.ruff.toml` rule selection, or `.snyk` suppressions. -- Changing the cache key format or what is stored in the cache (affects custom `CacheAdapter` implementations). +- Changing the cache key format or what is stored in a cache (affects custom `CacheAdapter` and `AbstractTokenStore` implementations), including the builders in `_internal/cache_keys.py` and the `TokenSet` shape. ### Never Do @@ -81,7 +85,8 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - Run `ruff format .` repo-wide. It is not a CI gate and the tree is not format-clean (see Common Pitfalls). - Modify `.venv*/`, `dist/`, `__pycache__/`, `.coverage`, `coverage.xml`. - Import `token_utils` from library code, or put a real key in it. -- Log, print, or include access tokens, DPoP proofs, client secrets, exchanged tokens, or full token endpoint response bodies in error messages. The SDK has no logger. Do not introduce one that emits these. +- Log, print, or include access tokens, DPoP proofs, client secrets, exchanged tokens, token store values, or full token endpoint response bodies in error messages or logs. The SDK logs through module loggers (`logging.getLogger(__name__)`) and none may emit these. The one documented logged value is the rejected `org_id` warning (`docs/OrganizationPolicy.md`). +- Import `_internal` from outside the package, or lower the 32-character `AbstractTokenStore` secret minimum. - Break backward compatibility without asking first and getting explicit approval. --- @@ -92,10 +97,13 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - **Never fetch from a token-derived host:** in MCD mode (`domains`), the unverified `iss` is normalized and must pass `_resolve_allowed_domains` before `_discover(issuer=...)` runs. In single-domain mode, discovery always targets the configured `domain`, and the token `iss` is matched against the discovery issuer afterwards. Never pass a token-derived issuer or URL to a fetch without the allowlist check. That turns token input into SSRF. - **Domain normalization:** `normalize_domain` rejects `http://`, credentials, paths, queries, and fragments. Route every domain or issuer comparison through it. - **MCD resolver input is untrusted:** `request_url`, `request_headers`, and `unverified_iss` in `DomainsResolverContext` are attacker-influenced. Resolver failures raise `DomainsResolverError`, never fall back to accepting the token (see `docs/MultipleCustomDomain.md` "Security Requirements"). -- **Claims:** `iss`, `aud`, `exp`, `iat` are always validated. `required_claims` adds to them, never replaces them. +- **Claims:** `iss`, `aud`, `exp`, `iat` are always validated, and `nbf` is validated when present (non-numeric or future values are rejected). `required_claims` adds to them, never replaces them. +- **Organization policy:** `organization_policy="required"` rejects tokens without an `org_id` claim (`MissingOrganizationError`). `organization_id` is an allowlist valid only with `required` (`OrganizationNotAllowedError`). Both subclass `VerifyAccessTokenError`. - **DPoP:** proofs must carry `iat`, `ath`, `htm`, `htu`, `jti`. `htu` is compared via `normalize_url_for_htu`, `ath` must match the access token hash, and `cnf.jkt` must match the proof key thumbprint. `dpop_required=True` rejects Bearer. `jti` is checked for presence and type only. There is no replay store, so replay is bounded only by the `dpop_iat_offset` / `dpop_iat_leeway` window. - **Token exchange:** `RESERVED_PARAMS` blocks overriding OAuth parameters via `extra`, and `MAX_ARRAY_VALUES_PER_KEY` caps array values. Do not weaken either. -- **Errors:** `_prepare_error` builds `WWW-Authenticate` headers. Error messages reach HTTP responses, so keep them generic and free of token contents. +- **Token store caching:** off unless `token_store` is set. With a store, `get_token_on_behalf_of` and `get_access_token_for_connection` verify the token first (unless `verified=` is passed), then key the cache from the verified claims. `AbstractTokenStore` supplies `encrypt` / `decrypt` JWE helpers (HKDF-SHA256, `dir`, `A256CBC-HS512`) and a 32-character minimum secret, but the SDK never calls them. A store must encrypt in its own `get` / `set`, otherwise tokens are stored in plaintext. A cached token is reused only with more than 30 seconds left (`EXPIRY_BUFFER_SECONDS`), a store read failure is a cache miss, and a write failure is logged while the fresh token is still returned. Store exceptions are logged with their message, so a store must keep token values out of exception text. Keys are per flow: OBO folds in issuer, incoming client, exchange tenant and client, audience, `org_id`, session and scopes. M2M uses tenant, client, audience and scopes. Token Vault uses tenant, client, `sub`, connection and `login_hint`. +- **`verified=` is a trust boundary:** a supplied `VerifiedToken` is not re-verified. Only its `access_token` is compared to the token being exchanged, and its claims then build the cache key. Pass only claims returned by `verify_access_token`. +- **Errors:** `_prepare_error` builds `WWW-Authenticate` headers and leaves them empty for status 500 and above (`AuthServerUnavailableError`, 503, is the one that reaches it via `verify_request`). Error messages reach HTTP responses, so keep them generic and free of token contents. - **Anonymous callers:** tokens with `sub` starting `anon@` verify like any token. Authorization is the integrator's call, the SDK must not silently accept or reject them. - **Dependency scanning:** Snyk (`sca_scan.yml`, from `requirements.txt`, on push to `main` only), CodeQL (`codeql.yml`), and ruff `S` (bandit) rules in CI. - **Vulnerability reports:** go through Auth0's Responsible Disclosure Program (README "Vulnerability Reporting"), never a public issue or PR. @@ -114,7 +122,7 @@ pytest + pytest-asyncio + pytest-httpx, in `tests/`. The default `poetry run pyt ## Code Style -CI-enforced via `.ruff.toml`: rule sets `E, W, F, I, B, C4, UP, S`, isort import order, py39 target, `S101`/`S105`/`S106` allowed only under `tests/`. New non-secret string constants in `src/` use `# noqa: S105` with a reason (existing sites predate this and omit it). See [references/code-style.md](references/code-style.md) for naming, error patterns, and examples. Read it before adding a class, method, or error type. +CI-enforced via `.ruff.toml`: rule sets `E, W, F, I, B, C4, UP, S, PLC0415` (imports at module top level), isort import order, py39 target, `S101`/`S105`/`S106` allowed only under `tests/`. New non-secret string constants in `src/` use `# noqa: S105` with a reason (existing sites predate this and omit it). See [references/code-style.md](references/code-style.md) for naming, error patterns, and examples. Read it before adding a class, method, or error type. ## Git Workflow @@ -122,7 +130,7 @@ Branches `feat/`, `fix/`, `docs/`, `chore/`, `release/`. Commits use Co ## Common Pitfalls -The ones that bite most: the Python 3.9 floor (no `X | None`), `ruff format` is not a CI gate, a missing `@pytest.mark.asyncio`, and `custom_fetch` covering only discovery/JWKS. See [references/pitfalls.md](references/pitfalls.md). Read it when a command, test, or CI job behaves unexpectedly. +The ones that bite most: the Python 3.9 floor (no `X | None`), `ruff format` is not a CI gate, a missing `@pytest.mark.asyncio`, function-level imports (`PLC0415`), and `custom_fetch` covering only discovery/JWKS. See [references/pitfalls.md](references/pitfalls.md). Read it when a command, test, or CI job behaves unexpectedly. ## Docs Update Rules diff --git a/references/code-style.md b/references/code-style.md index 4815564..450ed3c 100644 --- a/references/code-style.md +++ b/references/code-style.md @@ -51,13 +51,14 @@ def get_token(self, options): # sync, untyped Each line is a separate violation: sync in an async SDK, no timeout, unfiltered OAuth parameters, a print of a possibly token-bearing body, a `None` a caller can mistake for success, and an unvalidated response in the public API. -Public errors meant for integrators are exported from `__init__.py` (`ApiError`, `ConfigurationError`, `DomainsResolverError`, `GetTokenByExchangeProfileError`). Others are imported from `auth0_api_python.errors`. +Public errors meant for integrators are exported from `__init__.py` (`ApiError`, `AuthServerUnavailableError`, `ConfigurationError`, `DomainsResolverError`, `GetAccessTokenForConnectionError`, `GetClientCredentialsTokenError`, `GetTokenByExchangeProfileError`, `MissingOrganizationError`, `OrganizationNotAllowedError`, `VerifyAccessTokenError`). Others are imported from `auth0_api_python.errors`. ## Patterns - **Options object:** all configuration goes through `ApiClientOptions` and is validated in `ApiClient.__init__`, raising `ConfigurationError` or `MissingRequiredArgumentError` at construction rather than at first use. - **Async I/O:** network calls use `httpx.AsyncClient`, or the integrator's `custom_fetch` when provided. Public verification and token methods are `async def`. - **Pluggable cache:** `CacheAdapter` ABC with `get` / `set` / `delete` / `clear`. `InMemoryCache` is the default. Entry TTL is `min(Cache-Control max-age, cache_ttl_seconds)` when the discovery/JWKS response sends `max-age`. +- **Token store:** `AbstractTokenStore` ABC with `get` / `set` / `delete` plus `encrypt` / `decrypt`, separate from `CacheAdapter`. The helpers are opt-in: the SDK never calls them, so a store encrypts in its own `get` / `set`. `IndexedTokenStore` adds `add_index_member` / `list_index_members` for `scope_matching="non_strict"`. Cache classes and key builders live in `_internal/` and are not public. A store failure is logged and treated as a miss, never raised to the caller. - **Docstrings:** Google style with `Args:` / `Returns:` / `Raises:` sections on public methods. - **Placement:** `api_client.py` has one banner, `# ===== Private Methods =====`. New public methods go above it after the existing ones, new helpers below it. In files without banners, put new code next to related code or at the end, never at an arbitrary position. diff --git a/references/docs-update.md b/references/docs-update.md index a01c6e9..b33c6e1 100644 --- a/references/docs-update.md +++ b/references/docs-update.md @@ -6,10 +6,12 @@ A PR that changes public API, configuration, or integration patterns is not comp | Doc | Covers | Exists | |-----|--------|--------| -| `README.md` | Install, `ApiClient` setup, verification, connection tokens, custom token exchange, OBO, DPoP modes and options, MCD summary, anonymous callers | present | -| `EXAMPLES.md` | OBO exchange, delegation inspection, Bearer and DPoP verification, DPoP modes, anonymous caller patterns | present | +| `README.md` | Install, `ApiClient` setup, verification, connection tokens, custom token exchange, OBO, client credentials, DPoP modes and options, MCD summary, anonymous callers, organization policy | present | +| `EXAMPLES.md` | OBO exchange and caching, client credentials, Token Vault connection tokens, delegation inspection, Bearer and DPoP verification, auth server outage handling, DPoP modes, anonymous caller patterns | present | | `docs/Caching.md` | Cache defaults, TTL/max entries, custom `CacheAdapter`, tuning | present | | `docs/MultipleCustomDomain.md` | `domains` list and resolver modes, hybrid mode, resolver security requirements, MCD with DPoP, errors, migration | present | +| `docs/TokenStorage.md` | `token_store`, cache key shapes per flow, expiry buffer, implementing `AbstractTokenStore` and `IndexedTokenStore`, at-rest encryption, `scope_matching` | present | +| `docs/OrganizationPolicy.md` | `organization_policy` modes, `organization_id` allowlist, configuration and verification errors | present | ## When you change code, update these docs @@ -20,6 +22,9 @@ A PR that changes public API, configuration, or integration patterns is not comp | `CacheAdapter` interface or `InMemoryCache` behaviour | `docs/Caching.md` ("CacheAdapter API", "Default Behavior") | | Domain resolver context, MCD validation, or resolver errors | `docs/MultipleCustomDomain.md`, `README.md` section 7 | | Bearer/DPoP verification behaviour or error codes | `README.md` section 6, `EXAMPLES.md` (Bearer, DPoP sections) | -| Token exchange (connection, exchange profile, OBO) behaviour or errors | `README.md` sections 4 and 5, `EXAMPLES.md` "On Behalf Of Token Exchange" | +| Token exchange (connection, exchange profile, OBO, client credentials) behaviour or errors | `README.md` sections 4 and 5, `EXAMPLES.md` "On Behalf Of Token Exchange", "Client Credentials for Server-to-Server Calls", "Access Token for a Connection (Token Vault)" | +| `token_store`, `scope_matching`, the `AbstractTokenStore` / `IndexedTokenStore` interface, cache keys, or the expiry buffer | `docs/TokenStorage.md`, `README.md` sections 4 and 5, `EXAMPLES.md` caching samples | +| `organization_policy` / `organization_id` behaviour or their errors | `docs/OrganizationPolicy.md`, `README.md` section 9 | +| Discovery or JWKS outage handling (`AuthServerUnavailableError`) | `README.md` section 3, `docs/MultipleCustomDomain.md` "Issuer Rejection", `EXAMPLES.md` "Telling an Outage from an Invalid Token" | | New exported symbol in `__init__.py` | `EXAMPLES.md` (add a usage sample) | | Supported Python version or install method | `README.md` "Install the SDK" | diff --git a/references/flow-map.md b/references/flow-map.md index f857eed..ce8428c 100644 --- a/references/flow-map.md +++ b/references/flow-map.md @@ -5,16 +5,21 @@ Before changing a flow, read its entry points and supporting helpers. Public met | Flow | Entry points | Also read | Docs | |------|--------------|-----------|------| | Request verification (Bearer + DPoP) | `verify_request` | `verify_access_token`, `verify_dpop_proof`, `_prepare_error`, `_build_www_authenticate`, `errors.py` | `README.md` sections 3 and 6, `EXAMPLES.md` "Bearer Authentication", "DPoP Authentication" | -| Access token verification | `verify_access_token` | `_resolve_allowed_domains`, `_discover`, `_fetch_jwks`, `_validate_claims_presence`, `utils.normalize_domain`, `get_unverified_header` / `get_unverified_payload` | `README.md` section 3 | +| Access token verification | `verify_access_token` | `_resolve_allowed_domains`, `_discover`, `_fetch_jwks`, `_validate_claims_presence`, `utils.normalize_domain`, `utils.is_auth_server_outage`, `get_unverified_header` / `get_unverified_payload`, `AuthServerUnavailableError` | `README.md` section 3, `EXAMPLES.md` "Telling an Outage from an Invalid Token" | +| Organization policy | `verify_access_token`, options `organization_policy` / `organization_id` | `ApiClient.__init__` validation, `MissingOrganizationError`, `OrganizationNotAllowedError` | `docs/OrganizationPolicy.md`, `README.md` section 9 | | DPoP proof verification | `verify_dpop_proof` | `utils.normalize_url_for_htu`, `sha256_base64url`, `calculate_jwk_thumbprint`, options `dpop_enabled` / `dpop_required` / `dpop_iat_leeway` / `dpop_iat_offset` | `README.md` section 6, `EXAMPLES.md` "Direct DPoP Proof Verification" | -| Connection access token (Token Vault) | `get_access_token_for_connection` | `GetAccessTokenForConnectionError`, `ApiError`, `_discover` (token endpoint) | `README.md` section 4 | +| Connection access token (Token Vault) | `get_access_token_for_connection` | `GetAccessTokenForConnectionError`, `ApiError`, `_discover` (token endpoint), `_internal/token_vault_cache.py`, `VerifiedToken` (`verified=`) | `README.md` section 4, `EXAMPLES.md` "Access Token for a Connection (Token Vault)", `docs/TokenStorage.md` | | Custom token exchange | `get_token_by_exchange_profile` | `_apply_extra`, `RESERVED_PARAMS`, `MAX_ARRAY_VALUES_PER_KEY`, `GetTokenByExchangeProfileError` | `README.md` section 5 | -| On Behalf Of exchange | `get_token_on_behalf_of` | delegates to `get_token_by_exchange_profile`, returns `types.OnBehalfOfTokenResult` | `README.md` "On Behalf Of Token Exchange", `EXAMPLES.md` "On Behalf Of Token Exchange" | +| On Behalf Of exchange | `get_token_on_behalf_of` | delegates to `get_token_by_exchange_profile`, returns `types.OnBehalfOfTokenResult`, `_internal/obo_cache.py`, `_internal/cache_keys.py`, `VerifiedToken` (`verified=`), option `scope_matching` | `README.md` "On Behalf Of Token Exchange", `EXAMPLES.md` "On Behalf Of Token Exchange", `docs/TokenStorage.md` | +| Client credentials (M2M) | `get_client_credentials_token` | `GetClientCredentialsTokenError`, `ApiError`, `_internal/cache_keys.m2m_cache_key`, `types.ClientCredentialsTokenResult` | `README.md` "Client Credentials for Server-to-Server Calls", `EXAMPLES.md` "Client Credentials for Server-to-Server Calls", `docs/TokenStorage.md` | +| Token store (minted-token caching) | option `token_store` (`AbstractTokenStore`, `IndexedTokenStore`) | `token_store.py`, `encryption.py`, `_internal/cache_expiry.py`, `TokenSet`, `VerifiedToken` | `docs/TokenStorage.md` | | Delegation inspection | `get_current_actor`, `get_delegation_chain` (`act.py`) | `types.ActClaim` | `README.md` / `EXAMPLES.md` "Inspecting Delegation After Token Verification" | | Multiple Custom Domains | `ApiClientOptions(domains=...)` | `_resolve_allowed_domains`, `utils.normalize_domain`, `types.DomainsResolverContext`, `DomainsResolverError` | `docs/MultipleCustomDomain.md`, `README.md` section 7 | | Discovery / JWKS caching | `CacheAdapter`, `InMemoryCache` (`cache.py`) | `_discover`, `_fetch_jwks`, `utils.parse_cache_control_max_age` | `docs/Caching.md` | | Anonymous callers | no dedicated code, verified like any token | `act.py` is unrelated, `sub` starts with `anon@` | `README.md` section 8, `EXAMPLES.md` "Anonymous Callers" | -Tests: `tests/test_api_client.py` covers every `ApiClient` flow, `tests/test_act.py` the delegation helpers, `tests/test_cache.py` the cache, `tests/test_utils.py` the utils. +Tests: `tests/test_api_client.py` covers every `ApiClient` flow, `tests/test_act.py` the delegation helpers, `tests/test_cache.py` the cache, `tests/test_utils.py` the utils, `tests/test_token_store.py` the token store, encryption, and cache keys. + +With a `token_store`, `get_token_on_behalf_of` and `get_access_token_for_connection` call `verify_access_token` without `request_url` or `request_headers` when `verified=` is omitted, so a dynamic MCD resolver receives `None` for both. Pass `verified=` from a prior `verify_request` or `verify_access_token` call in that case. Two rules cut across every flow: never fetch from a token-derived host unless it passed the allowlist (single-domain mode only fetches the configured `domain`), and fail closed with a typed error on every failure path. diff --git a/references/pitfalls.md b/references/pitfalls.md index 8ac3730..dbc19c7 100644 --- a/references/pitfalls.md +++ b/references/pitfalls.md @@ -3,7 +3,7 @@ 1. **Do not run `ruff format .`.** CI runs only `ruff check .`. The codebase is not ruff-formatted (`ruff format --check .` reports 13 files that would be reformatted), so a repo-wide format produces a large unrelated diff. Match surrounding formatting by hand. 2. **Python 3.9 compatibility.** CI tests 3.9 and `.ruff.toml` targets `py39`. Avoid `X | Y` type unions at runtime, `match` statements, and other 3.10+ syntax. `ada-url` has separate version constraints above and below 3.10 in `pyproject.toml`. 3. **Async tests need `@pytest.mark.asyncio`.** No `asyncio_mode` is set in `pyproject.toml`, so pytest-asyncio runs in strict mode and ignores an unmarked async test. Depending on the pytest version it then fails with an "async def functions are not natively supported" message or is skipped with a warning. If a new async test fails that way or shows as skipped, check the marker first. -4. **Two dependency lists.** `pyproject.toml` + `poetry.lock` drive installs. `requirements.txt` is a hand-maintained copy that only the Snyk SCA workflow reads. Changing one without the other leaves the scan out of date. -5. **`custom_fetch` covers only discovery and JWKS.** `fetch_oidc_metadata` and `fetch_jwks` (`utils.py`) use the integrator's `custom_fetch` when set. The token endpoint calls in `get_access_token_for_connection` and `get_token_by_exchange_profile` always build their own `httpx.AsyncClient` with `options.timeout`. Do not assume a new HTTP path inherits either behaviour, pick the matching pattern deliberately. -6. **`.ruff.toml` uses deprecated top-level keys.** ruff warns that `select` / `ignore` / `per-file-ignores` belong under `[lint]`. The warning is harmless. Migrating the file is a separate change, do not fold it into unrelated work. +4. **Two dependency lists.** `pyproject.toml` + `poetry.lock` drive installs. `requirements.txt` is a hand-maintained copy that only the Snyk SCA workflow reads. Changing one without the other leaves the scan out of date. Today `cryptography` and `jwcrypto` are in `pyproject.toml` but not in `requirements.txt`, so the scan does not see them. +5. **`custom_fetch` covers only discovery and JWKS.** `fetch_oidc_metadata` and `fetch_jwks` (`utils.py`) use the integrator's `custom_fetch` when set. The token endpoint calls in `get_access_token_for_connection`, `get_token_by_exchange_profile`, and `get_client_credentials_token` always build their own `httpx.AsyncClient` with `options.timeout`. Do not assume a new HTTP path inherits either behaviour, pick the matching pattern deliberately. +6. **Imports must be at module top level.** `.ruff.toml` enforces `PLC0415`, so an import inside a function fails `ruff check .`. Move it to the top of the file. For a circular import, use `if TYPE_CHECKING:` as `config.py` does. 7. **Stale local venvs.** A hand-made venv in the working tree (anything other than Poetry's) may not have the package or dev dependencies installed. Use `poetry install` then `poetry run ...` so you test the same environment CI does. diff --git a/references/testing.md b/references/testing.md index 8d634ca..b4b4015 100644 --- a/references/testing.md +++ b/references/testing.md @@ -25,7 +25,9 @@ poetry run pytest tests/test_api_client.py -k dpop # filter by name ## Mocking and test utilities - `tests/conftest.py` provides: - - `api_client_confidential`: an `ApiClient` with `domain="auth0.local"` and placeholder `client_id` / `client_secret` + - `api_client_confidential`: an `ApiClient` with `domain="auth0.local"`, placeholder `client_id` / `client_secret`, and an `InMemoryTokenStore`, so OBO and Token Vault calls verify the token and cache + - `api_client_confidential_no_store`: the same client without a `token_store`, for exchanges that must not verify or cache + - `InMemoryTokenStore`, `InMemoryIndexedTokenStore`: test-only stores (never for production), the latter for `scope_matching="non_strict"` - `mock_discovery`: registers the discovery response - `mock_token_response(...)`, `token_success(...)`, `last_form(...)`, `last_auth_header(...)`: token endpoint mocking and request inspection - `assert_api_error(...)`, `assert_http_basic_auth(...)`, `assert_form_post(...)`, `assert_no_requests(...)`: shared assertions. Use `assert_api_error` rather than hand-rolling `.code` / status checks From 9d05a16dbc37caf3ad6f3dced61e0fe871f412c3 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 29 Oct 2026 06:23:09 +0530 Subject: [PATCH 3/7] docs: sync claude.md with #151 and read python floor from pyproject Co-Authored-By: Claude --- CLAUDE.md | 13 +++++++------ references/code-style.md | 4 ++-- references/commands.md | 2 +- references/pitfalls.md | 2 +- references/testing.md | 1 + 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac0c37b..6954c8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), ### Always Do -- Run `poetry run pytest` and `poetry run ruff check .` before committing (both run in CI on Python 3.9 to 3.12). +- Run `poetry run pytest` and `poetry run ruff check .` before committing (both run in CI across the Python matrix in `.github/workflows/test.yml`). - Make surgical changes: touch only what the request requires. Do not refactor or reformat adjacent code. - Follow existing style and naming. Add new public `ApiClient` methods after the existing public methods, above the `# ===== Private Methods =====` banner in `api_client.py`. Add new private helpers below it. Never reorder existing declarations. - Add unit tests for new functionality, marked `@pytest.mark.asyncio` when async, mocking HTTP with `pytest-httpx` (no real network calls). @@ -63,7 +63,7 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - Export any new public symbol through `__init__.py` `__all__`. - Update `README.md` and `EXAMPLES.md` (and `docs/*.md` when caching or MCD behaviour changes) in the same PR when changing the public API, `ApiClientOptions`, or supported integration patterns. - When adding or bumping a runtime dependency in `pyproject.toml`, update `requirements.txt` to match. The SCA scan installs from it, not from Poetry. -- Keep code compatible with Python 3.9 (`target-version = "py39"`, CI matrix includes 3.9). +- Keep code compatible with the Python floor set by `python` under `[tool.poetry.dependencies]` in `pyproject.toml`. Read it there, do not assume a version. `.ruff.toml` `target-version` and the CI matrix lowest version track that floor. ### Ask First @@ -98,10 +98,11 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - **Domain normalization:** `normalize_domain` rejects `http://`, credentials, paths, queries, and fragments. Route every domain or issuer comparison through it. - **MCD resolver input is untrusted:** `request_url`, `request_headers`, and `unverified_iss` in `DomainsResolverContext` are attacker-influenced. Resolver failures raise `DomainsResolverError`, never fall back to accepting the token (see `docs/MultipleCustomDomain.md` "Security Requirements"). - **Claims:** `iss`, `aud`, `exp`, `iat` are always validated, and `nbf` is validated when present (non-numeric or future values are rejected). `required_claims` adds to them, never replaces them. -- **Organization policy:** `organization_policy="required"` rejects tokens without an `org_id` claim (`MissingOrganizationError`). `organization_id` is an allowlist valid only with `required` (`OrganizationNotAllowedError`). Both subclass `VerifyAccessTokenError`. +- **Organization policy:** `organization_policy="required"` rejects tokens without an `org_id` claim (`MissingOrganizationError`). `organization_id` is an allowlist valid only with `required` (`OrganizationNotAllowedError`). Both subclass `VerifyAccessTokenError`. The policy is enforced only at verification. `get_token_on_behalf_of` and `get_access_token_for_connection` verify, and so apply it, only with a `token_store` and no `verified=`. Otherwise they exchange the token unchecked, so integrators call `verify_access_token` first (`docs/OrganizationPolicy.md` "Token Exchange"). +- **Unverified JWT values are attacker-controlled:** type-check every header and payload value before use (header `alg` must be a `str`, DPoP `htm` a `str`, `iat` a finite number). `get_unverified_header` / `get_unverified_payload` raise `ValueError` for malformed, non-object, or deeply nested segments. Wrap it in the flow's typed error. - **DPoP:** proofs must carry `iat`, `ath`, `htm`, `htu`, `jti`. `htu` is compared via `normalize_url_for_htu`, `ath` must match the access token hash, and `cnf.jkt` must match the proof key thumbprint. `dpop_required=True` rejects Bearer. `jti` is checked for presence and type only. There is no replay store, so replay is bounded only by the `dpop_iat_offset` / `dpop_iat_leeway` window. - **Token exchange:** `RESERVED_PARAMS` blocks overriding OAuth parameters via `extra`, and `MAX_ARRAY_VALUES_PER_KEY` caps array values. Do not weaken either. -- **Token store caching:** off unless `token_store` is set. With a store, `get_token_on_behalf_of` and `get_access_token_for_connection` verify the token first (unless `verified=` is passed), then key the cache from the verified claims. `AbstractTokenStore` supplies `encrypt` / `decrypt` JWE helpers (HKDF-SHA256, `dir`, `A256CBC-HS512`) and a 32-character minimum secret, but the SDK never calls them. A store must encrypt in its own `get` / `set`, otherwise tokens are stored in plaintext. A cached token is reused only with more than 30 seconds left (`EXPIRY_BUFFER_SECONDS`), a store read failure is a cache miss, and a write failure is logged while the fresh token is still returned. Store exceptions are logged with their message, so a store must keep token values out of exception text. Keys are per flow: OBO folds in issuer, incoming client, exchange tenant and client, audience, `org_id`, session and scopes. M2M uses tenant, client, audience and scopes. Token Vault uses tenant, client, `sub`, connection and `login_hint`. +- **Token store caching:** off unless `token_store` is set. With a store, `get_token_on_behalf_of` and `get_access_token_for_connection` verify the token first (unless `verified=` is passed), then key the cache from the verified claims. `AbstractTokenStore` supplies `encrypt` / `decrypt` JWE helpers (HKDF-SHA256, `dir`, `A256CBC-HS512`) and a 32-character minimum secret, but the SDK never calls them. A store must encrypt in its own `get` / `set`, otherwise tokens are stored in plaintext. A cached token is reused only with more than 30 seconds left (`EXPIRY_BUFFER_SECONDS`), a store read failure is a cache miss, and a write failure is logged while the fresh token is still returned. Store exceptions are logged with their message, so a store must keep token values out of exception text. Keys are per flow: OBO is the caller's `sub` in plain text, then a hash of issuer, incoming client, exchange tenant and client, audience, `org_id`, session and scopes, so a store reader can see which callers have entries (index keys and the `token_key` in index members have the same form). M2M hashes tenant, client, audience and scopes. Token Vault hashes tenant, client, `sub`, connection and `login_hint`. - **`verified=` is a trust boundary:** a supplied `VerifiedToken` is not re-verified. Only its `access_token` is compared to the token being exchanged, and its claims then build the cache key. Pass only claims returned by `verify_access_token`. - **Errors:** `_prepare_error` builds `WWW-Authenticate` headers and leaves them empty for status 500 and above (`AuthServerUnavailableError`, 503, is the one that reaches it via `verify_request`). Error messages reach HTTP responses, so keep them generic and free of token contents. - **Anonymous callers:** tokens with `sub` starting `anon@` verify like any token. Authorization is the integrator's call, the SDK must not silently accept or reject them. @@ -122,7 +123,7 @@ pytest + pytest-asyncio + pytest-httpx, in `tests/`. The default `poetry run pyt ## Code Style -CI-enforced via `.ruff.toml`: rule sets `E, W, F, I, B, C4, UP, S, PLC0415` (imports at module top level), isort import order, py39 target, `S101`/`S105`/`S106` allowed only under `tests/`. New non-secret string constants in `src/` use `# noqa: S105` with a reason (existing sites predate this and omit it). See [references/code-style.md](references/code-style.md) for naming, error patterns, and examples. Read it before adding a class, method, or error type. +CI-enforced via `.ruff.toml`: rule sets `E, W, F, I, B, C4, UP, S, PLC0415` (imports at module top level), isort import order, `target-version` matching the `pyproject.toml` Python floor, `S101`/`S105`/`S106` allowed only under `tests/`. `B904` is ignored, so `raise ... from e` is a convention, not lint-enforced. New non-secret string constants in `src/` use `# noqa: S105` with a reason (existing sites predate this and omit it). See [references/code-style.md](references/code-style.md) for naming, error patterns, and examples. Read it before adding a class, method, or error type. ## Git Workflow @@ -130,7 +131,7 @@ Branches `feat/`, `fix/`, `docs/`, `chore/`, `release/`. Commits use Co ## Common Pitfalls -The ones that bite most: the Python 3.9 floor (no `X | None`), `ruff format` is not a CI gate, a missing `@pytest.mark.asyncio`, function-level imports (`PLC0415`), and `custom_fetch` covering only discovery/JWKS. See [references/pitfalls.md](references/pitfalls.md). Read it when a command, test, or CI job behaves unexpectedly. +The ones that bite most: the `pyproject.toml` Python floor (no `X | None` while it is below 3.10), `ruff format` is not a CI gate, a missing `@pytest.mark.asyncio`, function-level imports (`PLC0415`), and `custom_fetch` covering only discovery/JWKS. See [references/pitfalls.md](references/pitfalls.md). Read it when a command, test, or CI job behaves unexpectedly. ## Docs Update Rules diff --git a/references/code-style.md b/references/code-style.md index 450ed3c..eca557f 100644 --- a/references/code-style.md +++ b/references/code-style.md @@ -5,7 +5,7 @@ - `snake_case` functions, methods, and options. `PascalCase` classes. `UPPER_SNAKE` module constants (`RESERVED_PARAMS`, `TOKEN_EXCHANGE_GRANT_TYPE`). - Private helpers carry a leading underscore (`_discover`, `_fetch_jwks`, `_prepare_error`) and sit together after the public methods of `ApiClient`. The one pre-existing exception is `_resolve_allowed_domains`, above `verify_request`. Leave it there. - Error classes end in `Error` and live in `errors.py`. -- Type hints use builtin generics (`dict[str, Any]`, `list[str]`) with `Optional` / `Union` from `typing`. ruff `UP` targets py39, so do not use `X | Y` syntax. +- Type hints use builtin generics (`dict[str, Any]`, `list[str]`) with `Optional` / `Union` from `typing`. ruff `UP` targets the `pyproject.toml` Python floor, so do not use `X | Y` syntax while that floor is below 3.10. ## Errors @@ -70,5 +70,5 @@ Enforced on every change (code, tests, docs, commit messages): 2. **Docstring format.** One-line description, then `Args`, then `Returns` / `Raises`. No design rationale, that belongs in the PR description. 3. **Plain hyphens only.** No em or en dashes in comments, docstrings, commit messages, or Markdown. 4. **No semicolon clause-splicing.** Split independent clauses into separate sentences. -5. **No internal references.** No ticket numbers, planning-doc paths, internal links, or cross-SDK comparisons ("same as the Node SDK"). +5. **No internal references.** No ticket numbers, planning-doc paths, internal links, or references to other SDKs as a comparison or justification. 6. **Helpers stay grouped.** Never interleave a private helper between public methods. diff --git a/references/commands.md b/references/commands.md index f7c811a..b1dc125 100644 --- a/references/commands.md +++ b/references/commands.md @@ -1,6 +1,6 @@ # Commands -These match `.github/workflows/test.yml` (Poetry 2.2.1, Python 3.9 to 3.12) and `CONTRIBUTING.md`. +These match `.github/workflows/test.yml` (Poetry and Python versions are set there) and `CONTRIBUTING.md`. ```bash # Install dependencies and the package (CI runs both steps) diff --git a/references/pitfalls.md b/references/pitfalls.md index dbc19c7..24de046 100644 --- a/references/pitfalls.md +++ b/references/pitfalls.md @@ -1,7 +1,7 @@ # Common Pitfalls 1. **Do not run `ruff format .`.** CI runs only `ruff check .`. The codebase is not ruff-formatted (`ruff format --check .` reports 13 files that would be reformatted), so a repo-wide format produces a large unrelated diff. Match surrounding formatting by hand. -2. **Python 3.9 compatibility.** CI tests 3.9 and `.ruff.toml` targets `py39`. Avoid `X | Y` type unions at runtime, `match` statements, and other 3.10+ syntax. `ada-url` has separate version constraints above and below 3.10 in `pyproject.toml`. +2. **Python floor compatibility.** The floor is the `python` constraint under `[tool.poetry.dependencies]` in `pyproject.toml`. CI tests it as the lowest matrix version and `.ruff.toml` `target-version` matches it. Avoid syntax newer than the floor. While it is below 3.10, that means no `X | Y` type unions at runtime and no `match` statements. `ada-url` has separate version constraints above and below 3.10 in `pyproject.toml`. 3. **Async tests need `@pytest.mark.asyncio`.** No `asyncio_mode` is set in `pyproject.toml`, so pytest-asyncio runs in strict mode and ignores an unmarked async test. Depending on the pytest version it then fails with an "async def functions are not natively supported" message or is skipped with a warning. If a new async test fails that way or shows as skipped, check the marker first. 4. **Two dependency lists.** `pyproject.toml` + `poetry.lock` drive installs. `requirements.txt` is a hand-maintained copy that only the Snyk SCA workflow reads. Changing one without the other leaves the scan out of date. Today `cryptography` and `jwcrypto` are in `pyproject.toml` but not in `requirements.txt`, so the scan does not see them. 5. **`custom_fetch` covers only discovery and JWKS.** `fetch_oidc_metadata` and `fetch_jwks` (`utils.py`) use the integrator's `custom_fetch` when set. The token endpoint calls in `get_access_token_for_connection`, `get_token_by_exchange_profile`, and `get_client_credentials_token` always build their own `httpx.AsyncClient` with `options.timeout`. Do not assume a new HTTP path inherits either behaviour, pick the matching pattern deliberately. diff --git a/references/testing.md b/references/testing.md index b4b4015..0d94086 100644 --- a/references/testing.md +++ b/references/testing.md @@ -30,6 +30,7 @@ poetry run pytest tests/test_api_client.py -k dpop # filter by name - `InMemoryTokenStore`, `InMemoryIndexedTokenStore`: test-only stores (never for production), the latter for `scope_matching="non_strict"` - `mock_discovery`: registers the discovery response - `mock_token_response(...)`, `token_success(...)`, `last_form(...)`, `last_auth_header(...)`: token endpoint mocking and request inspection + - `unsigned_jwt(header, payload)`: a JWT-shaped string from any JSON values with a dummy signature, for malformed header and payload tests - `assert_api_error(...)`, `assert_http_basic_auth(...)`, `assert_form_post(...)`, `assert_no_requests(...)`: shared assertions. Use `assert_api_error` rather than hand-rolling `.code` / status checks - `src/auth0_api_python/token_utils.py` provides `generate_token`, `generate_dpop_proof`, `generate_token_with_cnf`, signed with a synthetic test key. Use these instead of hand-building JWTs. - Use the constants `DISCOVERY_URL`, `JWKS_URL`, `TOKEN_ENDPOINT` from `conftest.py` rather than repeating URLs. From 93e5c773f83c913e46352896c490a6f1848d8715 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 29 Oct 2026 07:22:38 +0530 Subject: [PATCH 4/7] docs: correct jti check and drop point-in-time counts Co-Authored-By: Claude --- CLAUDE.md | 2 +- references/pitfalls.md | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6954c8c..62039b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - **Claims:** `iss`, `aud`, `exp`, `iat` are always validated, and `nbf` is validated when present (non-numeric or future values are rejected). `required_claims` adds to them, never replaces them. - **Organization policy:** `organization_policy="required"` rejects tokens without an `org_id` claim (`MissingOrganizationError`). `organization_id` is an allowlist valid only with `required` (`OrganizationNotAllowedError`). Both subclass `VerifyAccessTokenError`. The policy is enforced only at verification. `get_token_on_behalf_of` and `get_access_token_for_connection` verify, and so apply it, only with a `token_store` and no `verified=`. Otherwise they exchange the token unchecked, so integrators call `verify_access_token` first (`docs/OrganizationPolicy.md` "Token Exchange"). - **Unverified JWT values are attacker-controlled:** type-check every header and payload value before use (header `alg` must be a `str`, DPoP `htm` a `str`, `iat` a finite number). `get_unverified_header` / `get_unverified_payload` raise `ValueError` for malformed, non-object, or deeply nested segments. Wrap it in the flow's typed error. -- **DPoP:** proofs must carry `iat`, `ath`, `htm`, `htu`, `jti`. `htu` is compared via `normalize_url_for_htu`, `ath` must match the access token hash, and `cnf.jkt` must match the proof key thumbprint. `dpop_required=True` rejects Bearer. `jti` is checked for presence and type only. There is no replay store, so replay is bounded only by the `dpop_iat_offset` / `dpop_iat_leeway` window. +- **DPoP:** proofs must carry `iat`, `ath`, `htm`, `htu`, `jti`. `htu` is compared via `normalize_url_for_htu`, `ath` must match the access token hash, and `cnf.jkt` must match the proof key thumbprint. `dpop_required=True` rejects Bearer. `jti` is checked for presence, string type, and non-blank only. There is no replay store, so replay is bounded only by the `dpop_iat_offset` / `dpop_iat_leeway` window. - **Token exchange:** `RESERVED_PARAMS` blocks overriding OAuth parameters via `extra`, and `MAX_ARRAY_VALUES_PER_KEY` caps array values. Do not weaken either. - **Token store caching:** off unless `token_store` is set. With a store, `get_token_on_behalf_of` and `get_access_token_for_connection` verify the token first (unless `verified=` is passed), then key the cache from the verified claims. `AbstractTokenStore` supplies `encrypt` / `decrypt` JWE helpers (HKDF-SHA256, `dir`, `A256CBC-HS512`) and a 32-character minimum secret, but the SDK never calls them. A store must encrypt in its own `get` / `set`, otherwise tokens are stored in plaintext. A cached token is reused only with more than 30 seconds left (`EXPIRY_BUFFER_SECONDS`), a store read failure is a cache miss, and a write failure is logged while the fresh token is still returned. Store exceptions are logged with their message, so a store must keep token values out of exception text. Keys are per flow: OBO is the caller's `sub` in plain text, then a hash of issuer, incoming client, exchange tenant and client, audience, `org_id`, session and scopes, so a store reader can see which callers have entries (index keys and the `token_key` in index members have the same form). M2M hashes tenant, client, audience and scopes. Token Vault hashes tenant, client, `sub`, connection and `login_hint`. - **`verified=` is a trust boundary:** a supplied `VerifiedToken` is not re-verified. Only its `access_token` is compared to the token being exchanged, and its claims then build the cache key. Pass only claims returned by `verify_access_token`. diff --git a/references/pitfalls.md b/references/pitfalls.md index 24de046..838c843 100644 --- a/references/pitfalls.md +++ b/references/pitfalls.md @@ -1,9 +1,8 @@ # Common Pitfalls -1. **Do not run `ruff format .`.** CI runs only `ruff check .`. The codebase is not ruff-formatted (`ruff format --check .` reports 13 files that would be reformatted), so a repo-wide format produces a large unrelated diff. Match surrounding formatting by hand. +1. **Do not run `ruff format .`.** CI runs only `ruff check .`. The codebase is not ruff-formatted, so a repo-wide format produces a large unrelated diff. Match surrounding formatting by hand. 2. **Python floor compatibility.** The floor is the `python` constraint under `[tool.poetry.dependencies]` in `pyproject.toml`. CI tests it as the lowest matrix version and `.ruff.toml` `target-version` matches it. Avoid syntax newer than the floor. While it is below 3.10, that means no `X | Y` type unions at runtime and no `match` statements. `ada-url` has separate version constraints above and below 3.10 in `pyproject.toml`. 3. **Async tests need `@pytest.mark.asyncio`.** No `asyncio_mode` is set in `pyproject.toml`, so pytest-asyncio runs in strict mode and ignores an unmarked async test. Depending on the pytest version it then fails with an "async def functions are not natively supported" message or is skipped with a warning. If a new async test fails that way or shows as skipped, check the marker first. -4. **Two dependency lists.** `pyproject.toml` + `poetry.lock` drive installs. `requirements.txt` is a hand-maintained copy that only the Snyk SCA workflow reads. Changing one without the other leaves the scan out of date. Today `cryptography` and `jwcrypto` are in `pyproject.toml` but not in `requirements.txt`, so the scan does not see them. -5. **`custom_fetch` covers only discovery and JWKS.** `fetch_oidc_metadata` and `fetch_jwks` (`utils.py`) use the integrator's `custom_fetch` when set. The token endpoint calls in `get_access_token_for_connection`, `get_token_by_exchange_profile`, and `get_client_credentials_token` always build their own `httpx.AsyncClient` with `options.timeout`. Do not assume a new HTTP path inherits either behaviour, pick the matching pattern deliberately. +4. **Two dependency lists.** `pyproject.toml` + `poetry.lock` drive installs. `requirements.txt` is a hand-maintained copy that only the Snyk SCA workflow reads. Changing one without the other leaves the scan out of date.5. **`custom_fetch` covers only discovery and JWKS.** `fetch_oidc_metadata` and `fetch_jwks` (`utils.py`) use the integrator's `custom_fetch` when set. The token endpoint calls in `get_access_token_for_connection`, `get_token_by_exchange_profile`, and `get_client_credentials_token` always build their own `httpx.AsyncClient` with `options.timeout`. Do not assume a new HTTP path inherits either behaviour, pick the matching pattern deliberately. 6. **Imports must be at module top level.** `.ruff.toml` enforces `PLC0415`, so an import inside a function fails `ruff check .`. Move it to the top of the file. For a circular import, use `if TYPE_CHECKING:` as `config.py` does. 7. **Stale local venvs.** A hand-made venv in the working tree (anything other than Poetry's) may not have the package or dev dependencies installed. Use `poetry install` then `poetry run ...` so you test the same environment CI does. From c606ffecc5b0aa219f2cde75c370c9aa963b2a08 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 29 Oct 2026 12:15:41 +0530 Subject: [PATCH 5/7] docs: correct imprecise claims in claude.md and references Co-Authored-By: Claude --- CLAUDE.md | 6 +++--- references/code-style.md | 4 ++-- references/commands.md | 2 +- references/docs-update.md | 4 ++-- references/flow-map.md | 2 +- references/pitfalls.md | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 62039b1..e405dcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ You are a Python SDK engineer on auth0-api-python, the async library APIs use to ``` src/auth0_api_python/ __init__.py # Public API: everything in __all__ is the supported surface - api_client.py # ApiClient: verify_request, verify_access_token, verify_dpop_proof, + api_client.py # ApiClient: is_dpop_required, verify_request, verify_access_token, verify_dpop_proof, # get_access_token_for_connection, get_token_by_exchange_profile, # get_token_on_behalf_of, get_client_credentials_token; private helpers (_discover, _fetch_jwks, ...) at the end, # except _resolve_allowed_domains, which sits above verify_request (leave it there) @@ -29,10 +29,10 @@ src/auth0_api_python/ cache.py # CacheAdapter ABC + InMemoryCache (LRU + TTL) for discovery/JWKS token_store.py # AbstractTokenStore / IndexedTokenStore ABCs, TokenSet, VerifiedToken (caches tokens the SDK mints) encryption.py # JWE encrypt/decrypt used by AbstractTokenStore - _internal/ # Cache key builders and OBO / Token Vault cache classes. Private, not in __all__ + _internal/ # Cache key builders, OBO / Token Vault cache classes, cache_expiry (EXPIRY_BUFFER_SECONDS). Private, not in __all__ utils.py # normalize_domain, OIDC/JWKS fetch, unverified header/payload decode, htu normalization act.py # get_current_actor / get_delegation_chain (RFC 8693 act claim) - types.py # TypedDicts: DomainsResolverContext, OnBehalfOfTokenResult, ActClaim + types.py # TypedDicts: DomainsResolverContext, OnBehalfOfTokenResult, ClientCredentialsTokenResult, ActClaim. DomainsResolver callable alias token_utils.py # Test helpers (token/DPoP proof generators) with a synthetic test key. Not in __all__, but packaged and importable tests/ conftest.py # Shared fixtures (api_client_confidential, mock_discovery) and httpx mock helpers diff --git a/references/code-style.md b/references/code-style.md index eca557f..8bbcabf 100644 --- a/references/code-style.md +++ b/references/code-style.md @@ -9,7 +9,7 @@ ## Errors -Every error subclasses `BaseAuthError` and defines its HTTP mapping: +Every error subclasses `BaseAuthError`. A new error category defines its HTTP mapping. A specialization of an existing category (`MissingOrganizationError`, `OrganizationNotAllowedError` under `VerifyAccessTokenError`) inherits it: ```python class DomainsResolverError(BaseAuthError): @@ -59,7 +59,7 @@ Public errors meant for integrators are exported from `__init__.py` (`ApiError`, - **Async I/O:** network calls use `httpx.AsyncClient`, or the integrator's `custom_fetch` when provided. Public verification and token methods are `async def`. - **Pluggable cache:** `CacheAdapter` ABC with `get` / `set` / `delete` / `clear`. `InMemoryCache` is the default. Entry TTL is `min(Cache-Control max-age, cache_ttl_seconds)` when the discovery/JWKS response sends `max-age`. - **Token store:** `AbstractTokenStore` ABC with `get` / `set` / `delete` plus `encrypt` / `decrypt`, separate from `CacheAdapter`. The helpers are opt-in: the SDK never calls them, so a store encrypts in its own `get` / `set`. `IndexedTokenStore` adds `add_index_member` / `list_index_members` for `scope_matching="non_strict"`. Cache classes and key builders live in `_internal/` and are not public. A store failure is logged and treated as a miss, never raised to the caller. -- **Docstrings:** Google style with `Args:` / `Returns:` / `Raises:` sections on public methods. +- **Docstrings:** Google style with `Args:` / `Returns:` / `Raises:` sections on public methods. Note: `verify_dpop_proof` predates this convention and uses a numbered list instead. - **Placement:** `api_client.py` has one banner, `# ===== Private Methods =====`. New public methods go above it after the existing ones, new helpers below it. In files without banners, put new code next to related code or at the end, never at an arbitrary position. ## Comments and docstrings diff --git a/references/commands.md b/references/commands.md index b1dc125..98891a8 100644 --- a/references/commands.md +++ b/references/commands.md @@ -1,6 +1,6 @@ # Commands -These match `.github/workflows/test.yml` (Poetry and Python versions are set there) and `CONTRIBUTING.md`. +The install, CI test, and lint commands match `.github/workflows/test.yml` (Poetry and Python versions are set there). `poetry install`, `poetry run pytest`, and `poetry build` also appear in `CONTRIBUTING.md`. The rest are local conveniences that no CI step runs. ```bash # Install dependencies and the package (CI runs both steps) diff --git a/references/docs-update.md b/references/docs-update.md index b33c6e1..cd6dfcd 100644 --- a/references/docs-update.md +++ b/references/docs-update.md @@ -11,7 +11,7 @@ A PR that changes public API, configuration, or integration patterns is not comp | `docs/Caching.md` | Cache defaults, TTL/max entries, custom `CacheAdapter`, tuning | present | | `docs/MultipleCustomDomain.md` | `domains` list and resolver modes, hybrid mode, resolver security requirements, MCD with DPoP, errors, migration | present | | `docs/TokenStorage.md` | `token_store`, cache key shapes per flow, expiry buffer, implementing `AbstractTokenStore` and `IndexedTokenStore`, at-rest encryption, `scope_matching` | present | -| `docs/OrganizationPolicy.md` | `organization_policy` modes, `organization_id` allowlist, configuration and verification errors | present | +| `docs/OrganizationPolicy.md` | `organization_policy` modes, `organization_id` allowlist, configuration and verification errors, enforcement on token exchange | present | ## When you change code, update these docs @@ -24,7 +24,7 @@ A PR that changes public API, configuration, or integration patterns is not comp | Bearer/DPoP verification behaviour or error codes | `README.md` section 6, `EXAMPLES.md` (Bearer, DPoP sections) | | Token exchange (connection, exchange profile, OBO, client credentials) behaviour or errors | `README.md` sections 4 and 5, `EXAMPLES.md` "On Behalf Of Token Exchange", "Client Credentials for Server-to-Server Calls", "Access Token for a Connection (Token Vault)" | | `token_store`, `scope_matching`, the `AbstractTokenStore` / `IndexedTokenStore` interface, cache keys, or the expiry buffer | `docs/TokenStorage.md`, `README.md` sections 4 and 5, `EXAMPLES.md` caching samples | -| `organization_policy` / `organization_id` behaviour or their errors | `docs/OrganizationPolicy.md`, `README.md` section 9 | +| `organization_policy` / `organization_id` behaviour or their errors, or when `get_token_on_behalf_of` / `get_access_token_for_connection` verify the incoming token | `docs/OrganizationPolicy.md` (incl. "Token Exchange"), `README.md` section 9 | | Discovery or JWKS outage handling (`AuthServerUnavailableError`) | `README.md` section 3, `docs/MultipleCustomDomain.md` "Issuer Rejection", `EXAMPLES.md` "Telling an Outage from an Invalid Token" | | New exported symbol in `__init__.py` | `EXAMPLES.md` (add a usage sample) | | Supported Python version or install method | `README.md` "Install the SDK" | diff --git a/references/flow-map.md b/references/flow-map.md index ce8428c..11e8535 100644 --- a/references/flow-map.md +++ b/references/flow-map.md @@ -6,7 +6,7 @@ Before changing a flow, read its entry points and supporting helpers. Public met |------|--------------|-----------|------| | Request verification (Bearer + DPoP) | `verify_request` | `verify_access_token`, `verify_dpop_proof`, `_prepare_error`, `_build_www_authenticate`, `errors.py` | `README.md` sections 3 and 6, `EXAMPLES.md` "Bearer Authentication", "DPoP Authentication" | | Access token verification | `verify_access_token` | `_resolve_allowed_domains`, `_discover`, `_fetch_jwks`, `_validate_claims_presence`, `utils.normalize_domain`, `utils.is_auth_server_outage`, `get_unverified_header` / `get_unverified_payload`, `AuthServerUnavailableError` | `README.md` section 3, `EXAMPLES.md` "Telling an Outage from an Invalid Token" | -| Organization policy | `verify_access_token`, options `organization_policy` / `organization_id` | `ApiClient.__init__` validation, `MissingOrganizationError`, `OrganizationNotAllowedError` | `docs/OrganizationPolicy.md`, `README.md` section 9 | +| Organization policy | `verify_access_token`, options `organization_policy` / `organization_id` | `ApiClient.__init__` validation, `MissingOrganizationError`, `OrganizationNotAllowedError`. `get_token_on_behalf_of` / `get_access_token_for_connection` apply it only with a `token_store` and no `verified=` | `docs/OrganizationPolicy.md` (incl. "Token Exchange"), `README.md` section 9 | | DPoP proof verification | `verify_dpop_proof` | `utils.normalize_url_for_htu`, `sha256_base64url`, `calculate_jwk_thumbprint`, options `dpop_enabled` / `dpop_required` / `dpop_iat_leeway` / `dpop_iat_offset` | `README.md` section 6, `EXAMPLES.md` "Direct DPoP Proof Verification" | | Connection access token (Token Vault) | `get_access_token_for_connection` | `GetAccessTokenForConnectionError`, `ApiError`, `_discover` (token endpoint), `_internal/token_vault_cache.py`, `VerifiedToken` (`verified=`) | `README.md` section 4, `EXAMPLES.md` "Access Token for a Connection (Token Vault)", `docs/TokenStorage.md` | | Custom token exchange | `get_token_by_exchange_profile` | `_apply_extra`, `RESERVED_PARAMS`, `MAX_ARRAY_VALUES_PER_KEY`, `GetTokenByExchangeProfileError` | `README.md` section 5 | diff --git a/references/pitfalls.md b/references/pitfalls.md index 838c843..2b8d1b0 100644 --- a/references/pitfalls.md +++ b/references/pitfalls.md @@ -1,8 +1,8 @@ # Common Pitfalls 1. **Do not run `ruff format .`.** CI runs only `ruff check .`. The codebase is not ruff-formatted, so a repo-wide format produces a large unrelated diff. Match surrounding formatting by hand. -2. **Python floor compatibility.** The floor is the `python` constraint under `[tool.poetry.dependencies]` in `pyproject.toml`. CI tests it as the lowest matrix version and `.ruff.toml` `target-version` matches it. Avoid syntax newer than the floor. While it is below 3.10, that means no `X | Y` type unions at runtime and no `match` statements. `ada-url` has separate version constraints above and below 3.10 in `pyproject.toml`. +2. **Python floor compatibility.** The floor is the `python` constraint under `[tool.poetry.dependencies]` in `pyproject.toml`. `.ruff.toml` `target-version` and the `test.yml` matrix hard-code their own versions and do not follow it, so check all three agree, and raise a mismatch rather than fixing CI or ruff config yourself (Ask First). The CI matrix pins the minor version only, so it tests the latest patch release, not the exact floor patch. Avoid syntax newer than the floor. While it is below 3.10, that means no `X | Y` type unions at runtime and no `match` statements. `ada-url` has separate version constraints above and below 3.10 in `pyproject.toml`. 3. **Async tests need `@pytest.mark.asyncio`.** No `asyncio_mode` is set in `pyproject.toml`, so pytest-asyncio runs in strict mode and ignores an unmarked async test. Depending on the pytest version it then fails with an "async def functions are not natively supported" message or is skipped with a warning. If a new async test fails that way or shows as skipped, check the marker first. 4. **Two dependency lists.** `pyproject.toml` + `poetry.lock` drive installs. `requirements.txt` is a hand-maintained copy that only the Snyk SCA workflow reads. Changing one without the other leaves the scan out of date.5. **`custom_fetch` covers only discovery and JWKS.** `fetch_oidc_metadata` and `fetch_jwks` (`utils.py`) use the integrator's `custom_fetch` when set. The token endpoint calls in `get_access_token_for_connection`, `get_token_by_exchange_profile`, and `get_client_credentials_token` always build their own `httpx.AsyncClient` with `options.timeout`. Do not assume a new HTTP path inherits either behaviour, pick the matching pattern deliberately. -6. **Imports must be at module top level.** `.ruff.toml` enforces `PLC0415`, so an import inside a function fails `ruff check .`. Move it to the top of the file. For a circular import, use `if TYPE_CHECKING:` as `config.py` does. +6. **Imports must be at module top level.** `.ruff.toml` enforces `PLC0415`, so an import inside a function fails `ruff check .`. Move it to the top of the file. For an import needed only in type annotations, use `if TYPE_CHECKING:` as `config.py` does. 7. **Stale local venvs.** A hand-made venv in the working tree (anything other than Poetry's) may not have the package or dev dependencies installed. Use `poetry install` then `poetry run ...` so you test the same environment CI does. From 951c8db848ba17d9d5887c67f3b322d993f9487c Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 4 Nov 2026 13:47:14 +0530 Subject: [PATCH 6/7] docs: align claude.md and references with source behavior --- AGENTS.md | 2 ++ CLAUDE.md | 53 +++++++++++++++++++++----------------- references/code-style.md | 12 ++++----- references/commands.md | 2 +- references/flow-map.md | 4 +-- references/git-workflow.md | 8 +++--- references/pitfalls.md | 3 ++- references/testing.md | 4 +-- 8 files changed, 49 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e7c9c08..a341984 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,5 @@ # AI Agent Guidelines for auth0-api-python +All guidelines live in `CLAUDE.md` in this directory. Tools that do not expand `@` imports should read that file first. + @./CLAUDE.md for all coding guidelines, commands, project structure, code style, testing conventions, and boundaries. diff --git a/CLAUDE.md b/CLAUDE.md index e405dcc..c3312d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,15 +19,17 @@ You are a Python SDK engineer on auth0-api-python, the async library APIs use to ``` src/auth0_api_python/ - __init__.py # Public API: everything in __all__ is the supported surface + __init__.py # Public API: everything in __all__ is the supported surface. + # Not every public error is exported: BaseAuthError, InvalidDpopProofError, MissingRequiredArgumentError, + # InvalidAuthSchemeError and MissingAuthorizationError are imported from auth0_api_python.errors api_client.py # ApiClient: is_dpop_required, verify_request, verify_access_token, verify_dpop_proof, # get_access_token_for_connection, get_token_by_exchange_profile, - # get_token_on_behalf_of, get_client_credentials_token; private helpers (_discover, _fetch_jwks, ...) at the end, + # get_token_on_behalf_of, get_client_credentials_token. Private helpers (_discover, _fetch_jwks, ...) at the end, # except _resolve_allowed_domains, which sits above verify_request (leave it there) config.py # ApiClientOptions (all constructor options and defaults) errors.py # BaseAuthError hierarchy (status code, OAuth error code, WWW-Authenticate headers) cache.py # CacheAdapter ABC + InMemoryCache (LRU + TTL) for discovery/JWKS - token_store.py # AbstractTokenStore / IndexedTokenStore ABCs, TokenSet, VerifiedToken (caches tokens the SDK mints) + token_store.py # AbstractTokenStore / IndexedTokenStore ABCs, TokenSet, TokenIndexMember, VerifiedToken (caches tokens the SDK mints) encryption.py # JWE encrypt/decrypt used by AbstractTokenStore _internal/ # Cache key builders, OBO / Token Vault cache classes, cache_expiry (EXPIRY_BUFFER_SECONDS). Private, not in __all__ utils.py # normalize_domain, OIDC/JWKS fetch, unverified header/payload decode, htu normalization @@ -39,8 +41,8 @@ tests/ test_api_client.py, test_cache.py, test_utils.py, test_act.py, test_token_store.py docs/ # Caching.md, MultipleCustomDomain.md, TokenStorage.md, OrganizationPolicy.md (hand-written guides, linked from README) README.md, EXAMPLES.md -pyproject.toml # Poetry manifest; source of truth for dependencies -requirements.txt # Hand-maintained runtime + dev dep list for the Snyk SCA workflow (pins can differ from pyproject.toml) +pyproject.toml # Poetry manifest and source of truth for dependencies +requirements.txt # Hand-maintained partial dep list for the Snyk SCA workflow (pins can differ from pyproject.toml) .version, .shiprc # Release version source (.shiprc keeps .version and pyproject.toml in sync) .ruff.toml # Lint config (CI-enforced) .snyk # Snyk ignores, including license waivers (e.g. jwcrypto LGPL-3.0) @@ -59,17 +61,16 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - Follow existing style and naming. Add new public `ApiClient` methods after the existing public methods, above the `# ===== Private Methods =====` banner in `api_client.py`. Add new private helpers below it. Never reorder existing declarations. - Add unit tests for new functionality, marked `@pytest.mark.asyncio` when async, mocking HTTP with `pytest-httpx` (no real network calls). - Raise typed errors from `errors.py` (subclasses of `BaseAuthError` with `get_status_code()` / `get_error_code()`). Wrap third-party failures in a typed error with `raise ... from e`. Inside a catch-all, re-raise this SDK's own errors first (`except VerifyAccessTokenError: raise`, as `verify_access_token` does) so they are not double-wrapped. Never return `None` or a bare `dict` to signal failure, and never leak raw `httpx`/`authlib` errors from public methods. -- Route discovery and JWKS fetches through `fetch_oidc_metadata` / `fetch_jwks` so `custom_fetch` and caching apply. Token endpoint calls use `httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout))` and map `httpx.TimeoutException` to `ApiError` 504 and `httpx.HTTPError` to 502, as `get_access_token_for_connection` and `get_client_credentials_token` do. A discovery or JWKS outage during verification (connection error, timeout, HTTP 5xx or 429, classified by `is_auth_server_outage`) raises `AuthServerUnavailableError` (503), not a 401. +- Route discovery and JWKS fetches through `ApiClient._discover` / `_fetch_jwks`, which call `fetch_oidc_metadata` / `fetch_jwks`, so both `custom_fetch` and caching apply. Token endpoint calls use `httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout))`, map `httpx.TimeoutException` to `ApiError` 504 and `httpx.HTTPError` to 502, and reject a negative `expires_in` with `ApiError("invalid_response", ..., 502)`. Copy `get_token_by_exchange_profile` for error-body and invalid-JSON handling, since it guards the error parse and uses 502. `get_access_token_for_connection` calls `response.json()` on the error path unguarded and raises `invalid_json` with the default status 500. In all three token methods `_discover()` runs before the `try`, so a discovery outage with a cold cache raises a raw `httpx` error instead of an `ApiError`. A discovery or JWKS outage during verification (connection error, timeout, HTTP 5xx or 429, classified by `is_auth_server_outage`) raises `AuthServerUnavailableError` (503), not a 401. - Export any new public symbol through `__init__.py` `__all__`. - Update `README.md` and `EXAMPLES.md` (and `docs/*.md` when caching or MCD behaviour changes) in the same PR when changing the public API, `ApiClientOptions`, or supported integration patterns. -- When adding or bumping a runtime dependency in `pyproject.toml`, update `requirements.txt` to match. The SCA scan installs from it, not from Poetry. -- Keep code compatible with the Python floor set by `python` under `[tool.poetry.dependencies]` in `pyproject.toml`. Read it there, do not assume a version. `.ruff.toml` `target-version` and the CI matrix lowest version track that floor. +- Keep code compatible with the Python floor set by `python` under `[tool.poetry.dependencies]` in `pyproject.toml`. Read it there. Do not assume a version. `.ruff.toml` `target-version` and the CI matrix lowest version are set separately and must equal that floor. ### Ask First -- Any breaking change. Never make one on your own initiative, stop and ask the maintainer before writing it. This includes a renamed or removed public method, a changed signature or default, a new required option, or a raised Python floor. +- Any breaking change. Never make one on your own initiative. Stop and ask the maintainer before writing it. This includes a renamed or removed public method, a changed signature or default, a new required option, or a raised Python floor. - Changing an existing error's `get_error_code()` string, `ApiError.code`, or `get_status_code()`. Integrators branch on these and they surface in `WWW-Authenticate`, so treat them as public API. Adding a new error code is not breaking. -- Adding or bumping dependencies (`pyproject.toml`, `poetry.lock`, `requirements.txt`). Include a license check, since `.snyk` waives `jwcrypto` (LGPL-3.0) by name. +- Adding or bumping dependencies (`pyproject.toml`, `poetry.lock`, `requirements.txt`). Include a license check, since `.snyk` waives `jwcrypto` (LGPL-3.0) by name. After approval, update `requirements.txt` in the same change for any package it lists. The SCA scan installs from it, not from Poetry, and it is a partial list (`authlib`, `httpx`, `ada-url`, and the pytest packages, without `cryptography`, `jwcrypto`, `requests` or `ruff`). - Modifying public API signatures or `ApiClientOptions` defaults (e.g. `dpop_enabled`, `dpop_required`, `organization_policy`, `scope_matching`, cache TTL/size, `timeout`). - Changes to token, DPoP, issuer, or domain validation logic, the allowed algorithm lists, `RESERVED_PARAMS`, or `normalize_domain`. - Changes to CI/CD configuration (`.github/workflows/`, `.github/actions/`), `.ruff.toml` rule selection, or `.snyk` suppressions. @@ -78,14 +79,14 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), ### Never Do - Commit secrets, client secrets, real tokens, or private keys. Test fixtures use placeholder values (`cid`, `csecret`, `auth0.local`). -- Hand-edit `poetry.lock` or `CHANGELOG.md`, or edit `.version` / the `pyproject.toml` version (the release flow owns them via `.shiprc`). +- Hand-edit `poetry.lock` or `CHANGELOG.md`, or edit `.version` / the `pyproject.toml` version (the release flow owns the version files via `.shiprc`, and `CHANGELOG.md` is edited at release time). - Remove or skip a failing test instead of fixing it, or weaken an assertion to make it pass. - Fail open. A validation, resolver, discovery, JWKS, or token endpoint failure must raise a typed error, never fall through to a permissive default (no default domain, no "assume valid"). -- Compare secrets or token-derived values with `==` in new code. Use `hmac.compare_digest`. +- Compare secrets or token-derived values with `==` in new code. Use `hmac.compare_digest`. Do not copy the `!=` used for the `cnf.jkt` and `ath` checks and for `verified.access_token` against the token being exchanged. - Run `ruff format .` repo-wide. It is not a CI gate and the tree is not format-clean (see Common Pitfalls). - Modify `.venv*/`, `dist/`, `__pycache__/`, `.coverage`, `coverage.xml`. - Import `token_utils` from library code, or put a real key in it. -- Log, print, or include access tokens, DPoP proofs, client secrets, exchanged tokens, token store values, or full token endpoint response bodies in error messages or logs. The SDK logs through module loggers (`logging.getLogger(__name__)`) and none may emit these. The one documented logged value is the rejected `org_id` warning (`docs/OrganizationPolicy.md`). +- Log, print, or include access tokens, DPoP proofs, client secrets, exchanged tokens, token store values, or full token endpoint response bodies in error messages or logs. The SDK logs through module loggers (`logging.getLogger(__name__)`) and none may emit these. The only token claim that is logged is the rejected `org_id` (`docs/OrganizationPolicy.md`). Token store failures are logged with the exception message. - Import `_internal` from outside the package, or lower the 32-character `AbstractTokenStore` secret minimum. - Break backward compatibility without asking first and getting explicit approval. @@ -95,17 +96,23 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), - **Algorithm pinning:** access tokens verify with `RS256` only (`self._jwt = JsonWebToken(["RS256"])`), DPoP proofs with `ES256` only. Never add `none`, `HS*`, or derive the algorithm from the token header. - **Never fetch from a token-derived host:** in MCD mode (`domains`), the unverified `iss` is normalized and must pass `_resolve_allowed_domains` before `_discover(issuer=...)` runs. In single-domain mode, discovery always targets the configured `domain`, and the token `iss` is matched against the discovery issuer afterwards. Never pass a token-derived issuer or URL to a fetch without the allowlist check. That turns token input into SSRF. -- **Domain normalization:** `normalize_domain` rejects `http://`, credentials, paths, queries, and fragments. Route every domain or issuer comparison through it. +- **Domain normalization:** `normalize_domain` rejects `http://`, credentials, paths, queries, and fragments. Route every new domain or issuer comparison through it. Do not copy the post-signature `iss` check, which compares the raw claim to the discovery issuer with `!=`, or the manual scheme and trailing slash stripping in `_discover`. - **MCD resolver input is untrusted:** `request_url`, `request_headers`, and `unverified_iss` in `DomainsResolverContext` are attacker-influenced. Resolver failures raise `DomainsResolverError`, never fall back to accepting the token (see `docs/MultipleCustomDomain.md` "Security Requirements"). -- **Claims:** `iss`, `aud`, `exp`, `iat` are always validated, and `nbf` is validated when present (non-numeric or future values are rejected). `required_claims` adds to them, never replaces them. -- **Organization policy:** `organization_policy="required"` rejects tokens without an `org_id` claim (`MissingOrganizationError`). `organization_id` is an allowlist valid only with `required` (`OrganizationNotAllowedError`). Both subclass `VerifyAccessTokenError`. The policy is enforced only at verification. `get_token_on_behalf_of` and `get_access_token_for_connection` verify, and so apply it, only with a `token_store` and no `verified=`. Otherwise they exchange the token unchecked, so integrators call `verify_access_token` first (`docs/OrganizationPolicy.md` "Token Exchange"). -- **Unverified JWT values are attacker-controlled:** type-check every header and payload value before use (header `alg` must be a `str`, DPoP `htm` a `str`, `iat` a finite number). `get_unverified_header` / `get_unverified_payload` raise `ValueError` for malformed, non-object, or deeply nested segments. Wrap it in the flow's typed error. -- **DPoP:** proofs must carry `iat`, `ath`, `htm`, `htu`, `jti`. `htu` is compared via `normalize_url_for_htu`, `ath` must match the access token hash, and `cnf.jkt` must match the proof key thumbprint. `dpop_required=True` rejects Bearer. `jti` is checked for presence, string type, and non-blank only. There is no replay store, so replay is bounded only by the `dpop_iat_offset` / `dpop_iat_leeway` window. +- **Claims:** `iss` and `aud` must match, `exp` is compared with the clock (with no type check), `nbf` is typed and compared when present (non-numeric or future values are rejected), and `iat` must be present. `required_claims` adds to these, never replaces them. +- **Organization policy:** `organization_policy="required"` rejects tokens without an `org_id` claim (`MissingOrganizationError`). `organization_id` is an allowlist valid only with `required`, and setting it otherwise raises `ConfigurationError` at construction. A token whose `org_id` is not on the list raises `OrganizationNotAllowedError`. `MissingOrganizationError` and `OrganizationNotAllowedError` both subclass `VerifyAccessTokenError`. The policy is enforced only at verification. `get_token_on_behalf_of` and `get_access_token_for_connection` verify, and so apply it, only with a `token_store` and no `verified=`. Otherwise they exchange the token unchecked, so integrators call `verify_access_token` first (`docs/OrganizationPolicy.md` "Token Exchange"). +- **Unverified JWT values are attacker-controlled:** type-check every header and payload value before use (header `alg` must be a `str`, DPoP `htm` a `str`, `iat` an `int` or finite `float`). The DPoP `iat` check accepts a `bool`, unlike the access-token `nbf` check, so exclude `bool` explicitly in new numeric checks. `get_unverified_header` / `get_unverified_payload` raise `ValueError` for malformed, non-object, or deeply nested segments. Wrap it in the flow's typed error. +- **DPoP:** `verify_dpop_proof` checks the proof itself, which must carry `iat`, `ath`, `htm`, `htu`, `jti`. `htu` is compared via `normalize_url_for_htu` and `ath` must match the access token hash. `verify_request` additionally enforces that `cnf.jkt` matches the proof key thumbprint, so code that calls `verify_dpop_proof` directly must do that binding check itself. `dpop_required=True` rejects Bearer. `jti` is checked for presence, string type, and non-blank only. There is no replay store, so replay is bounded only by the `dpop_iat_offset` / `dpop_iat_leeway` window. - **Token exchange:** `RESERVED_PARAMS` blocks overriding OAuth parameters via `extra`, and `MAX_ARRAY_VALUES_PER_KEY` caps array values. Do not weaken either. -- **Token store caching:** off unless `token_store` is set. With a store, `get_token_on_behalf_of` and `get_access_token_for_connection` verify the token first (unless `verified=` is passed), then key the cache from the verified claims. `AbstractTokenStore` supplies `encrypt` / `decrypt` JWE helpers (HKDF-SHA256, `dir`, `A256CBC-HS512`) and a 32-character minimum secret, but the SDK never calls them. A store must encrypt in its own `get` / `set`, otherwise tokens are stored in plaintext. A cached token is reused only with more than 30 seconds left (`EXPIRY_BUFFER_SECONDS`), a store read failure is a cache miss, and a write failure is logged while the fresh token is still returned. Store exceptions are logged with their message, so a store must keep token values out of exception text. Keys are per flow: OBO is the caller's `sub` in plain text, then a hash of issuer, incoming client, exchange tenant and client, audience, `org_id`, session and scopes, so a store reader can see which callers have entries (index keys and the `token_key` in index members have the same form). M2M hashes tenant, client, audience and scopes. Token Vault hashes tenant, client, `sub`, connection and `login_hint`. +- **Token store caching:** off unless `token_store` is set. + - **Verification:** with a store, `get_token_on_behalf_of` and `get_access_token_for_connection` verify the token first (unless `verified=` is passed), then key the cache from the verified claims. + - **Encryption:** `AbstractTokenStore` supplies `encrypt` / `decrypt` JWE helpers (HKDF-SHA256, `dir`, `A256CBC-HS512`) and a 32-character minimum secret, but the SDK never calls them. A store must encrypt in its own `get` / `set`, otherwise tokens are stored in plaintext. + - **Reuse and failure:** a cached token is reused only with more than 30 seconds left (`EXPIRY_BUFFER_SECONDS`). A store read failure is a cache miss, and a write failure is logged while the fresh token is still returned. Store exceptions are logged with their message, so a store must keep token values out of exception text. + - **OBO keys:** the caller's `sub` in plain text, then a hash of issuer, incoming client, exchange tenant and client, audience, `org_id`, session and scopes. The session value is the token's `sid`, then its `jti`, and if neither is a usable string (or the payload cannot be parsed) a SHA-256 of the token itself. A store reader can see which callers have entries. Index keys and the `token_key` in index members have the same form. + - **M2M keys:** a hash of tenant, client, audience and scopes. + - **Token Vault keys:** a hash of tenant, client, `sub`, connection and `login_hint`. - **`verified=` is a trust boundary:** a supplied `VerifiedToken` is not re-verified. Only its `access_token` is compared to the token being exchanged, and its claims then build the cache key. Pass only claims returned by `verify_access_token`. -- **Errors:** `_prepare_error` builds `WWW-Authenticate` headers and leaves them empty for status 500 and above (`AuthServerUnavailableError`, 503, is the one that reaches it via `verify_request`). Error messages reach HTTP responses, so keep them generic and free of token contents. -- **Anonymous callers:** tokens with `sub` starting `anon@` verify like any token. Authorization is the integrator's call, the SDK must not silently accept or reject them. +- **Errors:** `_prepare_error` builds `WWW-Authenticate` headers and leaves them empty for status 500 and above. `AuthServerUnavailableError` (503) reaches it via `verify_request`. `DomainsResolverError` (500) does not, because `verify_request` only catches `VerifyAccessTokenError` and `InvalidDpopProofError`. Error messages reach HTTP responses, so keep them generic and free of token contents. One existing exception: the symmetric-algorithm rejection in `verify_access_token` puts the token header's `alg` into the message, and `_build_www_authenticate` writes the description into the header without escaping. Do not copy that pattern. +- **Anonymous callers:** tokens with `sub` starting `anon@` verify like any token. Authorization is the integrator's call. The SDK must not silently accept or reject them. - **Dependency scanning:** Snyk (`sca_scan.yml`, from `requirements.txt`, on push to `main` only), CodeQL (`codeql.yml`), and ruff `S` (bandit) rules in CI. - **Vulnerability reports:** go through Auth0's Responsible Disclosure Program (README "Vulnerability Reporting"), never a public issue or PR. @@ -123,7 +130,7 @@ pytest + pytest-asyncio + pytest-httpx, in `tests/`. The default `poetry run pyt ## Code Style -CI-enforced via `.ruff.toml`: rule sets `E, W, F, I, B, C4, UP, S, PLC0415` (imports at module top level), isort import order, `target-version` matching the `pyproject.toml` Python floor, `S101`/`S105`/`S106` allowed only under `tests/`. `B904` is ignored, so `raise ... from e` is a convention, not lint-enforced. New non-secret string constants in `src/` use `# noqa: S105` with a reason (existing sites predate this and omit it). See [references/code-style.md](references/code-style.md) for naming, error patterns, and examples. Read it before adding a class, method, or error type. +CI-enforced via `.ruff.toml`: rule sets `E, W, F, I, B, C4, UP, S, PLC0415` (imports at module top level), isort import order, `target-version` kept equal to the `pyproject.toml` Python floor, `S101`/`S105`/`S106` allowed only under `tests/`. `E501` is ignored, so `line-length = 100` is not enforced. `B904` is ignored, so `raise ... from e` is a convention, not lint-enforced. New non-secret string constants in `src/` use `# noqa: S105` with a reason (existing sites predate this and omit it). See [references/code-style.md](references/code-style.md) for naming, error patterns, and examples. Read it before adding a class, method, or error type. ## Git Workflow @@ -131,7 +138,7 @@ Branches `feat/`, `fix/`, `docs/`, `chore/`, `release/`. Commits use Co ## Common Pitfalls -The ones that bite most: the `pyproject.toml` Python floor (no `X | None` while it is below 3.10), `ruff format` is not a CI gate, a missing `@pytest.mark.asyncio`, function-level imports (`PLC0415`), and `custom_fetch` covering only discovery/JWKS. See [references/pitfalls.md](references/pitfalls.md). Read it when a command, test, or CI job behaves unexpectedly. +The ones that bite most: the `pyproject.toml` Python floor (no `X | None` while it is below 3.10), `ruff format` is not a CI gate, a missing `@pytest.mark.asyncio`, function-level imports (`PLC0415`), and `custom_fetch` covering only discovery/JWKS (those fetches also have no `options.timeout`). See [references/pitfalls.md](references/pitfalls.md). Read it when a command, test, or CI job behaves unexpectedly. ## Docs Update Rules diff --git a/references/code-style.md b/references/code-style.md index 8bbcabf..2a319d0 100644 --- a/references/code-style.md +++ b/references/code-style.md @@ -5,7 +5,7 @@ - `snake_case` functions, methods, and options. `PascalCase` classes. `UPPER_SNAKE` module constants (`RESERVED_PARAMS`, `TOKEN_EXCHANGE_GRANT_TYPE`). - Private helpers carry a leading underscore (`_discover`, `_fetch_jwks`, `_prepare_error`) and sit together after the public methods of `ApiClient`. The one pre-existing exception is `_resolve_allowed_domains`, above `verify_request`. Leave it there. - Error classes end in `Error` and live in `errors.py`. -- Type hints use builtin generics (`dict[str, Any]`, `list[str]`) with `Optional` / `Union` from `typing`. ruff `UP` targets the `pyproject.toml` Python floor, so do not use `X | Y` syntax while that floor is below 3.10. +- Type hints use builtin generics (`dict[str, Any]`, `list[str]`) with `Optional` / `Union` from `typing`. ruff `UP` `target-version` is kept equal to the `pyproject.toml` Python floor, so do not use `X | Y` syntax while that floor is below 3.10. ## Errors @@ -55,19 +55,19 @@ Public errors meant for integrators are exported from `__init__.py` (`ApiError`, ## Patterns -- **Options object:** all configuration goes through `ApiClientOptions` and is validated in `ApiClient.__init__`, raising `ConfigurationError` or `MissingRequiredArgumentError` at construction rather than at first use. +- **Options object:** all configuration goes through `ApiClientOptions`. `ApiClient.__init__` raises `ConfigurationError` or `MissingRequiredArgumentError` at construction for `audience`, the `domain` / `domains` combination, `client_id` without `domain`, the cache settings, `organization_policy` / `organization_id`, and `scope_matching`. Not validated there: `timeout`, `dpop_iat_leeway`, `dpop_iat_offset`, and the format of a single `domain`. A bad entry in a `domains` list raises a plain `ValueError` from `normalize_domain`. - **Async I/O:** network calls use `httpx.AsyncClient`, or the integrator's `custom_fetch` when provided. Public verification and token methods are `async def`. - **Pluggable cache:** `CacheAdapter` ABC with `get` / `set` / `delete` / `clear`. `InMemoryCache` is the default. Entry TTL is `min(Cache-Control max-age, cache_ttl_seconds)` when the discovery/JWKS response sends `max-age`. -- **Token store:** `AbstractTokenStore` ABC with `get` / `set` / `delete` plus `encrypt` / `decrypt`, separate from `CacheAdapter`. The helpers are opt-in: the SDK never calls them, so a store encrypts in its own `get` / `set`. `IndexedTokenStore` adds `add_index_member` / `list_index_members` for `scope_matching="non_strict"`. Cache classes and key builders live in `_internal/` and are not public. A store failure is logged and treated as a miss, never raised to the caller. -- **Docstrings:** Google style with `Args:` / `Returns:` / `Raises:` sections on public methods. Note: `verify_dpop_proof` predates this convention and uses a numbered list instead. -- **Placement:** `api_client.py` has one banner, `# ===== Private Methods =====`. New public methods go above it after the existing ones, new helpers below it. In files without banners, put new code next to related code or at the end, never at an arbitrary position. +- **Token store:** `AbstractTokenStore` ABC with `get` / `set` / `delete` plus `encrypt` / `decrypt`, separate from `CacheAdapter`. The helpers are opt-in: the SDK never calls them, so a store encrypts in its own `get` / `set`. `IndexedTokenStore` adds `add_index_member` / `list_index_members` for `scope_matching="non_strict"`. Cache classes and key builders live in `_internal/` and are not public. A store `get` / `set` failure is logged and treated as a miss. A malformed entry from a custom store can still raise, since `expires_at` reaches `is_fresh` outside the `try` in the client credentials and OBO paths, while the Token Vault cache checks its type first. +- **Docstrings:** Google style with `Args:` / `Returns:` / `Raises:` sections on public methods. `verify_dpop_proof` predates this convention and uses a numbered list instead. +- **Placement:** `api_client.py` has the banner `# ===== Private Methods =====`, and `_internal/obo_cache.py` has a lowercase `# ===== Private methods =====`. In `api_client.py`, new public methods go above the banner after the existing ones, and new helpers below it. In files without a banner, put new code next to related code or at the end, never at an arbitrary position. ## Comments and docstrings Enforced on every change (code, tests, docs, commit messages): 1. **Comments earn their place.** Only a non-obvious why: a protocol citation (`# RFC 9449`), a security decision, an intentional omission. Never restate the line below. Default to one sharp line or none. -2. **Docstring format.** One-line description, then `Args`, then `Returns` / `Raises`. No design rationale, that belongs in the PR description. +2. **Docstring format.** One-line description, then `Args`, then `Returns` / `Raises`. No design rationale. That belongs in the PR description. 3. **Plain hyphens only.** No em or en dashes in comments, docstrings, commit messages, or Markdown. 4. **No semicolon clause-splicing.** Split independent clauses into separate sentences. 5. **No internal references.** No ticket numbers, planning-doc paths, internal links, or references to other SDKs as a comparison or justification. diff --git a/references/commands.md b/references/commands.md index 98891a8..3c6b9ce 100644 --- a/references/commands.md +++ b/references/commands.md @@ -10,7 +10,7 @@ poetry install --no-interaction # Run all unit tests (CI command) poetry run pytest -v --cov=src --cov-report=term-missing --cov-report=xml -# Run all unit tests (short form; pyproject addopts already adds --cov=src) +# Run all unit tests (short form, pyproject addopts already adds --cov=src) poetry run pytest # Fast loop without coverage diff --git a/references/flow-map.md b/references/flow-map.md index 11e8535..c045ed2 100644 --- a/references/flow-map.md +++ b/references/flow-map.md @@ -7,13 +7,13 @@ Before changing a flow, read its entry points and supporting helpers. Public met | Request verification (Bearer + DPoP) | `verify_request` | `verify_access_token`, `verify_dpop_proof`, `_prepare_error`, `_build_www_authenticate`, `errors.py` | `README.md` sections 3 and 6, `EXAMPLES.md` "Bearer Authentication", "DPoP Authentication" | | Access token verification | `verify_access_token` | `_resolve_allowed_domains`, `_discover`, `_fetch_jwks`, `_validate_claims_presence`, `utils.normalize_domain`, `utils.is_auth_server_outage`, `get_unverified_header` / `get_unverified_payload`, `AuthServerUnavailableError` | `README.md` section 3, `EXAMPLES.md` "Telling an Outage from an Invalid Token" | | Organization policy | `verify_access_token`, options `organization_policy` / `organization_id` | `ApiClient.__init__` validation, `MissingOrganizationError`, `OrganizationNotAllowedError`. `get_token_on_behalf_of` / `get_access_token_for_connection` apply it only with a `token_store` and no `verified=` | `docs/OrganizationPolicy.md` (incl. "Token Exchange"), `README.md` section 9 | -| DPoP proof verification | `verify_dpop_proof` | `utils.normalize_url_for_htu`, `sha256_base64url`, `calculate_jwk_thumbprint`, options `dpop_enabled` / `dpop_required` / `dpop_iat_leeway` / `dpop_iat_offset` | `README.md` section 6, `EXAMPLES.md` "Direct DPoP Proof Verification" | +| DPoP proof verification | `verify_dpop_proof` (checks the proof only, `verify_request` enforces `cnf.jkt`) | `utils.normalize_url_for_htu`, `sha256_base64url`, `calculate_jwk_thumbprint`, options `dpop_enabled` / `dpop_required` / `dpop_iat_leeway` / `dpop_iat_offset` | `README.md` section 6, `EXAMPLES.md` "Direct DPoP Proof Verification" | | Connection access token (Token Vault) | `get_access_token_for_connection` | `GetAccessTokenForConnectionError`, `ApiError`, `_discover` (token endpoint), `_internal/token_vault_cache.py`, `VerifiedToken` (`verified=`) | `README.md` section 4, `EXAMPLES.md` "Access Token for a Connection (Token Vault)", `docs/TokenStorage.md` | | Custom token exchange | `get_token_by_exchange_profile` | `_apply_extra`, `RESERVED_PARAMS`, `MAX_ARRAY_VALUES_PER_KEY`, `GetTokenByExchangeProfileError` | `README.md` section 5 | | On Behalf Of exchange | `get_token_on_behalf_of` | delegates to `get_token_by_exchange_profile`, returns `types.OnBehalfOfTokenResult`, `_internal/obo_cache.py`, `_internal/cache_keys.py`, `VerifiedToken` (`verified=`), option `scope_matching` | `README.md` "On Behalf Of Token Exchange", `EXAMPLES.md` "On Behalf Of Token Exchange", `docs/TokenStorage.md` | | Client credentials (M2M) | `get_client_credentials_token` | `GetClientCredentialsTokenError`, `ApiError`, `_internal/cache_keys.m2m_cache_key`, `types.ClientCredentialsTokenResult` | `README.md` "Client Credentials for Server-to-Server Calls", `EXAMPLES.md` "Client Credentials for Server-to-Server Calls", `docs/TokenStorage.md` | | Token store (minted-token caching) | option `token_store` (`AbstractTokenStore`, `IndexedTokenStore`) | `token_store.py`, `encryption.py`, `_internal/cache_expiry.py`, `TokenSet`, `VerifiedToken` | `docs/TokenStorage.md` | -| Delegation inspection | `get_current_actor`, `get_delegation_chain` (`act.py`) | `types.ActClaim` | `README.md` / `EXAMPLES.md` "Inspecting Delegation After Token Verification" | +| Delegation inspection | `get_current_actor`, `get_delegation_chain` (`act.py`) | none (`act.py` does not use `types.ActClaim`) | `README.md` / `EXAMPLES.md` "Inspecting Delegation After Token Verification" | | Multiple Custom Domains | `ApiClientOptions(domains=...)` | `_resolve_allowed_domains`, `utils.normalize_domain`, `types.DomainsResolverContext`, `DomainsResolverError` | `docs/MultipleCustomDomain.md`, `README.md` section 7 | | Discovery / JWKS caching | `CacheAdapter`, `InMemoryCache` (`cache.py`) | `_discover`, `_fetch_jwks`, `utils.parse_cache_control_max_age` | `docs/Caching.md` | | Anonymous callers | no dedicated code, verified like any token | `act.py` is unrelated, `sub` starts with `anon@` | `README.md` section 8, `EXAMPLES.md` "Anonymous Callers" | diff --git a/references/git-workflow.md b/references/git-workflow.md index 31ffa74..e466d13 100644 --- a/references/git-workflow.md +++ b/references/git-workflow.md @@ -6,13 +6,13 @@ From merged PR history: `feat/`, `fix/`, `docs/`, `chore/.py` per source module +- **Location:** `tests/`, grouped by area (`test_api_client.py`, `test_cache.py`, `test_utils.py`, `test_act.py`, `test_token_store.py`), not one file per source module - **Coverage:** pytest-cov. `pyproject.toml` `addopts` always applies `--cov=src --cov-report=term-missing:skip-covered --cov-report=xml`. No minimum threshold is configured. -The default `poetry run pytest` suite needs no credentials. All Auth0 HTTP calls go through `httpx_mock` against `https://auth0.local`. +The default `poetry run pytest` suite needs no credentials. All Auth0 HTTP calls go through `httpx_mock`. Fixtures use `https://auth0.local`, and `test_api_client.py` also uses `tenant1.auth0.com`. ## Running tests From dce605820004bba0a14d6094901c10d5abd9b2fd Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 5 Nov 2026 09:25:53 +0530 Subject: [PATCH 7/7] docs: split colon-joined clause in claude.md Co-Authored-By: Claude --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c3312d5..3437553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Before changing a flow (Bearer/DPoP verification, token exchange, MCD, caching), ### Always Do - Run `poetry run pytest` and `poetry run ruff check .` before committing (both run in CI across the Python matrix in `.github/workflows/test.yml`). -- Make surgical changes: touch only what the request requires. Do not refactor or reformat adjacent code. +- Make surgical changes. Touch only what the request requires. Do not refactor or reformat adjacent code. - Follow existing style and naming. Add new public `ApiClient` methods after the existing public methods, above the `# ===== Private Methods =====` banner in `api_client.py`. Add new private helpers below it. Never reorder existing declarations. - Add unit tests for new functionality, marked `@pytest.mark.asyncio` when async, mocking HTTP with `pytest-httpx` (no real network calls). - Raise typed errors from `errors.py` (subclasses of `BaseAuthError` with `get_status_code()` / `get_error_code()`). Wrap third-party failures in a typed error with `raise ... from e`. Inside a catch-all, re-raise this SDK's own errors first (`except VerifyAccessTokenError: raise`, as `verify_access_token` does) so they are not double-wrapped. Never return `None` or a bare `dict` to signal failure, and never leak raw `httpx`/`authlib` errors from public methods.