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..a341984 --- /dev/null +++ b/AGENTS.md @@ -0,0 +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 new file mode 100644 index 0000000..3437553 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,145 @@ +# 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. + # 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, + # 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, 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 + act.py # get_current_actor / get_delegation_chain (RFC 8693 act claim) + 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 + 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 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) +``` + +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 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). +- 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 `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. +- 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. +- 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. 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. +- 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 + +- 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 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`. 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 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. + +--- + +## 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 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` 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. + - **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) 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. + +--- + +> 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, 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 + +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 `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 + +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..2a319d0 --- /dev/null +++ b/references/code-style.md @@ -0,0 +1,74 @@ +# 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` `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 + +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): + """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`, `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`. `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 `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. +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. +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..3c6b9ce --- /dev/null +++ b/references/commands.md @@ -0,0 +1,41 @@ +# Commands + +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) +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..cd6dfcd --- /dev/null +++ b/references/docs-update.md @@ -0,0 +1,30 @@ +# 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, 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, enforcement on token exchange | 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, 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, 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 new file mode 100644 index 0000000..c045ed2 --- /dev/null +++ b/references/flow-map.md @@ -0,0 +1,25 @@ +# 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`, `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` (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`) | 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" | + +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/git-workflow.md b/references/git-workflow.md new file mode 100644 index 0000000..e466d13 --- /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:`) with an optional scope such as `fix(token-vault):`, 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, endpoints added or changed, classes/methods added, changed, or deprecated, screenshots of UI changes, a usage summary for public API changes, and alternative designs considered +- **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 PR Review** (`claude-code-review.yml`): on issue comments, PR review comments, and submitted reviews + +`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. Nothing in `.github/workflows/`, `.github/actions/`, or `.shiprc` writes `CHANGELOG.md`. Release PRs change it together with the version files. diff --git a/references/pitfalls.md b/references/pitfalls.md new file mode 100644 index 0000000..238c834 --- /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, 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`. `.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 partial list (`authlib`, `httpx`, `ada-url`, and the pytest packages, without `cryptography`, `jwcrypto`, `requests` or `ruff`) 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. Otherwise they create a plain `httpx.AsyncClient()` with no timeout, so `options.timeout` does not apply to them. 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 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. diff --git a/references/testing.md b/references/testing.md new file mode 100644 index 0000000..c07c7ae --- /dev/null +++ b/references/testing.md @@ -0,0 +1,36 @@ +# Testing + +- **Framework:** pytest, with pytest-asyncio, pytest-httpx, pytest-mock, freezegun (versions in `pyproject.toml` dev group) +- **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`. Fixtures use `https://auth0.local`, and `test_api_client.py` also uses `tenant1.auth0.com`. + +## 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"`, 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 + - `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.