Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ __pycache__/
#Environments
.env
.venv
.venv-*
env/

#Session Cache
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
129 changes: 129 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<version>`. 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/`.
73 changes: 73 additions & 0 deletions references/code-style.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 41 additions & 0 deletions references/commands.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions references/docs-update.md
Original file line number Diff line number Diff line change
@@ -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" |
Loading
Loading