Skip to content
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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @./CLAUDE.md import is Claude Code syntax. Other tools that read AGENTS.md may show the line as plain text and never load CLAUDE.md. There is also a feat/add-agents-file branch on the repo with a different AGENTS.md (a guide for integrators), so it might be worth checking the two don't clash.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feat/add-agents-file branch is very old and stale. Better to use the new one as a cleaner approach.

@sanchitmehtagit sanchitmehtagit Nov 5, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://developer.android.com/studio/gemini/agent-files#modularize-agent-files Gemini does support the syntax @./CLAUDE.md. Codex doesnt call out this syntax as supported there was open gh issue too on this, somehow using codex cli ,llm was able to infer it and it made a read tool call just as how reference file work and not as inline instructions when i tested it..
I think we would anyways have single agents.md from now on we would not need to maintain multiple files @rmad17 you can raise a new PR after this with only agents.md. i have also updated the skill to only generate agents.md

145 changes: 145 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions references/code-style.md
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Six of the seven code lines in the 'Bad' snippet have a trailing comment, which sits against the rule further down in this file ('one sharp line or none'). The paragraph right after the snippet lists the same points again.

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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

client_info is also validated at construction (an invalid value raises ConfigurationError), but it isn't in this list of validated options.

Shall we add it?

- **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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed store set is called a miss here, but only a failed get is a miss. A failed set is logged and the fresh token is still returned, which is how the 'Reuse and failure' line in CLAUDE.md puts it.

Shall we make the two agree?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bullet covers the store ABCs, the opt-in helpers, the index, the key builders and the failure behaviour. 'The helpers are opt-in: the SDK never calls them' also joins two clauses with a colon.

Shall we split it into sub-bullets?

- **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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'No ticket numbers' can be read as covering PR bodies too, but git-workflow.md says the template's References section is where tickets and links go.

Shall we make clear that this rule is about code, tests, docs and commit messages, and that PR bodies follow the template?

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

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'CI runs both steps' isn't quite right. In test.yml the --no-root install only runs when the venv cache misses, and the plain poetry install runs every time.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

publish.yml also creates the GitHub release, before it builds and publishes to PyPI, which this one-line summary leaves out.

30 changes: 30 additions & 0 deletions references/docs-update.md
Original file line number Diff line number Diff line change
@@ -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) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bearer verification is documented in README.md section 3 as well (the flow map points at sections 3 and 6), but this row sends changes to section 6 only.

| 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" |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No row sends telemetry or client_info changes to README.md section 10, where the headers are documented, and the README.md entry in the tracked docs table doesn't list client telemetry either.

Shall we add one?

25 changes: 25 additions & 0 deletions references/flow-map.md
Original file line number Diff line number Diff line change
@@ -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" |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no row for client telemetry (telemetry.py, the client_info option, tests/test_telemetry.py, README section 10), so a change to the headers has nowhere to point to in this map.

Shall we add one?


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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test list doesn't include tests/test_telemetry.py.


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.
37 changes: 37 additions & 0 deletions references/git-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Git Workflow

## Branch naming

From merged PR history: `feat/<topic>`, `fix/<topic>`, `docs/<topic>`, `chore/<topic>`, `release/<version>` (e.g. `release/1.0.0b10`). Dependabot opens `dependabot/...` branches. Base branch is `main`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'From merged PR history' is tighter than the history. Merged PR branches also include ci/ and feature/, and some have no prefix at all.


## 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test: is listed, but no commit on main uses that prefix.


## 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The template says 'added, deleted, deprecated, or changed' for both endpoints and classes/methods. Here endpoints only get 'added or changed' and classes/methods lose 'deleted'.

- **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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

publish.yml also creates the GitHub release, so 'cut by publish.yml' covers more than the publish step. It might be worth a half sentence, since this is where an agent would look to see what a release touches.

Loading
Loading