diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d79deca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AI Agent Guidelines for auth0-server-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..e186482 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,168 @@ +# AI Agent Guidelines for auth0-server-python + +This document provides context and guidelines for AI coding assistants working with the auth0-server-python codebase. + +## Your Role + +You are a Python SDK engineer on auth0-server-python — Auth0's framework-agnostic server-side authentication SDK. You work in async OIDC flows, Pydantic-typed models, and pluggable session/transaction stores that integrators supply. + +--- + +## Working Principles + +Apply these on every task in this repo — they keep changes correct, small, and reviewable. + +- **Think before coding.** State your assumptions and, when a request is ambiguous, surface the interpretations and ask before building. Recommend a simpler approach when you see one. A clarifying question up front beats a wrong implementation. +- **Simplicity first.** Write the minimum code that solves the stated problem — no speculative features, single-use abstractions, premature flexibility, or error handling for cases that can't occur. +- **Surgical changes.** Touch only what the request requires. Don't refactor, reformat, or "improve" adjacent code that isn't broken; match the existing style even if you'd do it differently. Every changed line should trace directly to the request. Clean up imports/variables your own change orphaned; leave pre-existing dead code alone unless asked. +- **Goal-driven execution.** Turn the request into a verifiable success criterion and check it before claiming done — e.g. "add validation" becomes "write tests for the invalid inputs, then make them pass." Don't report success you haven't verified. + +--- + +## Project Overview + +**auth0-server-python** is a library for implementing user authentication in Python applications — the server-side (confidential client) SDK that framework wrappers such as `auth0-fastapi` build on. + +- **Language:** Python, floor `>=3.9` (CI matrix: 3.9 / 3.10 / 3.11 / 3.12) +- **Tech Stack:** OAuth 2.0 / OIDC via Authlib, `httpx` async transport, Pydantic v2 models, JWE-encrypted store payloads (jwcrypto), DPoP (RFC 9449) +- **Package Manager:** Poetry (CI pins `2.2.1`); published to PyPI as `auth0-server-python` +- **Version source of truth:** `.version` (mirrored in `pyproject.toml` — keep in sync) +- **Dependencies:** authlib `^1.2`, httpx `^0.28.1`, pydantic `^2.10.6`, jwcrypto `^1.5.7` · test: pytest + pytest-asyncio + pytest-mock. Full list in `pyproject.toml`. + +--- + +## Project Structure + +``` +src/auth0_server_python/ +├── auth_server/ +│ ├── server_client.py # ServerClient — main entry point; login, session, token, passkey, CTE flows +│ ├── mfa_client.py # MfaClient — MFA API; exposed via ServerClient.mfa +│ └── my_account_client.py # MyAccountClient — My Account API (stateless; takes a user token per call) +├── auth_schemes/ +│ ├── bearer_auth.py # BearerAuth — httpx.Auth strategy +│ └── dpop_auth.py # DPoPAuth — RFC 9449 proofs, nonce retry, EC P-256 enforcement +├── auth_types/__init__.py # All public Pydantic models + Literal type aliases +├── error/__init__.py # Auth0Error hierarchy + *ErrorCode constant classes +├── store/abstract.py # StateStore / TransactionStore ABCs integrators implement +├── encryption/encrypt.py # JWE encrypt/decrypt (HKDF-SHA256 → A256CBC-HS512) +├── utils/helpers.py # PKCE, State, URL helpers; org-claim + domain-resolver validation +├── telemetry.py # Auth0-Client header construction +└── tests/ # pytest suite, one test_.py per module +examples/ # 11 per-feature Markdown guides (not runnable apps) +references/ # Agent reference docs (this file's offloaded sections) +``` + +### Key Files + +| File | Why it matters | +|------|----------------| +| `src/auth0_server_python/auth_server/server_client.py` | The public API surface — ~29 public methods; most changes land here | +| `src/auth0_server_python/auth_types/__init__.py` | Every public model; changing a field is a compatibility event | +| `src/auth0_server_python/error/__init__.py` | Typed error hierarchy integrators branch on | +| `src/auth0_server_python/store/abstract.py` | The contract integrators implement — changes break every downstream store | +| `.ruff.toml` | Lint config (root file, not `pyproject.toml`) — the only style gate in CI | +| `pyproject.toml` | Deps, Python floor, pytest `addopts` (coverage flags) | +| `.github/workflows/test.yml` | The authoritative test + lint commands and Python matrix | +| `.version` / `.shiprc` | Release version source; `pyproject.toml` must match | + +--- + +## Boundaries + +### ✅ Always Do + +- Run `poetry run pytest` and `poetry run ruff check .` before committing — both gate CI. +- Add tests for new functionality, and mark async tests `@pytest.mark.asyncio` (there is no `asyncio_mode = auto`; an unmarked async test never runs but reports as passing). +- Raise a typed error from `error/` with a stable `code`, and re-raise the SDK's own errors untouched inside a catch-all — never return `None` or a bare `dict` to signal failure. +- Return Pydantic models from `auth_types/`, validated via `model_validate` — never leak an unvalidated response `dict` through the public API. +- Resolve the domain through `await self._resolve_current_domain(store_options)` and thread `store_options` through every new public flow method — reading `self._domain` breaks MCD deployments and cookie-backed stores. +- Issue HTTP through `self._get_http_client()` so the `Auth0-Client` telemetry header is attached. When adding a **new outbound request path to Auth0**, route it through the existing `src/auth0_server_python/telemetry.py` mechanism rather than constructing a client or headers by hand. +- Attach credentials with `BearerAuth` / `DPoPAuth` from `auth_schemes/`, not by setting `Authorization` inline. +- Keep `Optional[X]` / `Union[...]` typing and `target-version = "py39"`-compatible syntax — the 3.9 CI leg fails on 3.10+ constructs. +- Update `README.md` and the matching `examples/*.md` guide in the same PR when changing the public API, configuration options, or supported integration patterns. +- Keep `.version` and `pyproject.toml`'s `version` in sync when either is touched. +- Preserve existing declaration order and the `# ==== SECTION ====` banners; insert new methods into the matching section. + +### ⚠️ Ask First + +- **Any breaking change — always ask first.** Never make one on your own initiative: a renamed/removed public method, a changed signature or default, a tightened model field, a new required constructor argument, or a raised Python floor. +- Adding, removing, or bumping a dependency (`pyproject.toml` + `poetry.lock` + `requirements.txt` must move together — Snyk SCA installs from `requirements.txt`). +- Changing security-relevant code: token handling, `encryption/encrypt.py`, DPoP proof construction, PKCE, `state`/`nonce` handling, issuer or org-claim validation, session-expiry enforcement. +- Modifying the `StateStore` / `TransactionStore` contract in `store/abstract.py`. +- Changing CI/CD (`.github/workflows/`), `.ruff.toml` rule selection, or `.snyk` suppressions. +- Changing the session or transaction storage format, or the store identifier defaults (`_a0_session`, `_a0_tx`). +- Deprecating a public name — use the PEP 562 `__getattr__` alias pattern in `auth_types/`; don't delete it. + +### 🚫 Never Do + +- Commit secrets, API keys, tokens, or a real Auth0 tenant domain. Test fixtures use ``-style placeholders and `auth0.local`. +- Log, `print`, or include in an error message any token, `code`, `code_verifier`, `client_secret`, DPoP private key, or full response body. There is no logger in the SDK — don't introduce one that emits these. +- Fail open. A validation, resolver, JWKS, or token-endpoint failure must raise, never fall through to a permissive default (no default domain, no "assume valid"). +- Validate `state` by comparing strings. `complete_interactive_login` looks the transaction up by `{transaction_identifier}:{state}` and raises `MissingTransactionError` on a miss — that store lookup *is* the binding. If you ever add a secret-to-secret comparison, use `hmac.compare_digest`, never `==`. +- Run `ruff format .` repo-wide — it is not a CI gate and the tree is not format-clean (13 files would change). Format only lines you touched. +- Remove or `skip` a failing test instead of fixing it, or weaken an assertion to make it pass. +- Hand-edit `poetry.lock`, `coverage.xml`, `CHANGELOG.md` (release flow owns it), or anything in `dist/`, `.venv/`, `__pycache__/`, `.pytest_cache/`, `.ruff_cache/`. +- Break backward compatibility without asking first (see Ask First) and getting explicit approval. + +--- + +## Security Considerations + +This SDK is a **confidential client**: it holds a real client secret server-side and the browser never sees a token. + +- **Tokens are server-side only.** Access, refresh, and ID tokens live in the integrator's `StateStore`; the browser holds only an opaque session reference. Never add a public accessor that hands a raw refresh token to the caller's browser layer. +- **Store payloads are encrypted.** `encryption/encrypt.py` derives a key with HKDF-SHA256 from the integrator's `secret` salted with the record identifier, then produces a JWE (`alg: dir`, `enc: A256CBC-HS512`). `secret` is required at construction — `ServerClient` raises `MissingRequiredArgumentError` without it, and must keep failing closed rather than storing plaintext. +- **PKCE is always on** for the authorization-code flow (`utils/helpers.py` `PKCE`, `secrets`-backed), even though this is a confidential client. `state` and `nonce` are generated per transaction and validated at callback. +- **DPoP (RFC 9449)** is supported for passkey sign-in and the My Account authentication-methods/factors calls. Keys must be EC P-256/ES256 (`_validate_dpop_key` rejects anything else); resource proofs carry `ath`, token-endpoint proofs deliberately do not; a `401` + `DPoP-Nonce` triggers exactly one retry. +- **Token and claim validation is mandatory.** ID-token issuer validation (`IssuerValidationError`), organization `org_id`/`org_name` claim validation (exact for `org_`-prefixed IDs, NFC-normalized case-insensitive for names), and the upstream-IdP `session_expiry` ceiling (30s skew leeway) all fail closed. Don't add a bypass flag. +- **Backchannel logout** matches on `sid` **or** `sub` per the OIDC spec, and compares the token `iss` against the session's stored domain before deleting — this is what prevents cross-domain session deletion in MCD deployments. +- **Secret handling.** `AUTH0_SECRET` and the client secret come from the integrator's environment/secrets manager; the SDK never reads them from disk and must never write them anywhere. `S105`/`S106` are ignored in `.ruff.toml` for kwarg names — that suppression is not permission to hardcode a value. +- **Scanning:** CodeQL (`codeql.yml`) and Snyk SCA (`sca_scan.yml`) run on every PR; ruff's bandit rules (`S`) run in `ruff check .`. Report vulnerabilities via Auth0's Responsible Disclosure Program, never a public issue. + +--- + +> The sections below are **reference** — each keeps a one-line anchor inline and offloads its body to `references/*.md`. + +## Commands + +```bash +poetry install # setup +poetry run pytest # full suite (coverage flags come from pyproject.toml) +poetry run ruff check . # the lint gate in CI +poetry build # sdist + wheel +``` + +See [references/commands.md](references/commands.md) for the exact CI invocations, single-file/single-test runs, format and clean commands, and what deliberately doesn't exist here (no typecheck, no live-test tier). Read it when you need to run, build, or test something. + +## Testing + +pytest + pytest-asyncio in `src/auth0_server_python/tests/` (one `test_.py` per module); the default `poetry run pytest` suite is mock-based and needs no credentials. Async tests require an explicit `@pytest.mark.asyncio`. + +See [references/testing.md](references/testing.md) for naming, class-grouping conventions, the factory-helper pattern, mocking approach, and coverage setup. Read it before writing or restructuring tests. + +## Code Style + +Config is `.ruff.toml` at the repo root. CI-enforced: ruff's `E,W,F,I,B,C4,UP,S,PLC0415` rule sets — so **isort-ordered imports** (stdlib → third-party → local), **no imports inside functions** (`PLC0415`), and **bandit security rules** are hard gates. `E501` is ignored, so the `line-length = 100` is not enforced. `snake_case` methods, `PascalCase` classes, `_`-prefixed internals. + +See [references/code-style.md](references/code-style.md) for the good/bad method examples and the dominant patterns (generic store client, `httpx.Auth` strategies, Pydantic wire contracts, PEP 562 deprecation aliases). Read it before adding a public method or a new module. + +## Git Workflow + +Conventional Commits preferred (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`) — nothing enforces it, history is mixed. `.github/PULL_REQUEST_TEMPLATE.md` requires a method-level inventory of what changed. + +See [references/git-workflow.md](references/git-workflow.md) for branch naming, the full PR template requirements, and required checks. Read it when opening a PR or naming a branch. + +## Common Pitfalls + +The three that bite most often: the **Python 3.9 floor** (no `X | None`), **`ruff format` is not a CI gate** so don't reformat the tree, and a **missing `@pytest.mark.asyncio`** makes an async test silently pass. + +See [references/pitfalls.md](references/pitfalls.md) for all eight, including the MCD domain-resolver trap, the `store_options` threading requirement, and the `Literal`-vs-`str` typing rule. Read it when a change touches HTTP, domains, or type annotations. + +## Docs Update Rules + +> A PR that adds or changes public API, configuration, or integration patterns is **not complete** until the docs move with it. + +Note: there is **no `EXAMPLES.md`** in this repo — per-feature guides live in `examples/*.md` instead. `CHANGELOG.md` is owned by the release flow, not by feature work. + +See [references/docs-update.md](references/docs-update.md) for the tracked-docs inventory, the code-to-docs mapping table, and the feature → guide map. Read it whenever you change a public method, signature, config option, or error type. diff --git a/references/code-style.md b/references/code-style.md new file mode 100644 index 0000000..1456cd0 --- /dev/null +++ b/references/code-style.md @@ -0,0 +1,124 @@ +# Code Style + +Linter config: `.ruff.toml` (repo root — not `[tool.ruff]` in `pyproject.toml`). + +## Enforced rule sets + +`select = ["E", "W", "F", "I", "B", "C4", "UP", "S", "PLC0415"]` — pycodestyle, pyflakes, isort, +bugbear, comprehensions, pyupgrade, **bandit (security)**, and no-import-outside-top-level. + +`ignore = ["E501", "B904", "S101", "S105", "S106"]` — line length is not enforced despite +`line-length = 100`; `raise ... from` is optional; `assert` and hardcoded-password-string warnings are +off (for tests and for kwargs like `client_secret=`). + +`target-version = "py39"` — pyupgrade will not rewrite to 3.10+ syntax, and must not. + +## Naming + +| Kind | Convention | Example | +|------|-----------|---------| +| Modules / packages | `snake_case` | `my_account_client.py`, `auth_schemes/` | +| Classes | `PascalCase` | `ServerClient`, `MyAccountApiError`, `DPoPAuth` | +| Public methods | `snake_case`, verb-first | `start_interactive_login`, `get_access_token_for_connection` | +| Internal methods | `_` prefix | `_resolve_current_domain`, `_get_http_client` | +| Instance state | `_` prefix | `self._client_id`, `self._telemetry_headers` | +| Class constants | `UPPER_SNAKE` | `GRANT_TYPE_PASSKEY`, `PASSKEY_REGISTER_PATH` | +| Module constants | `UPPER_SNAKE` | `ENC`, `ALG`, `SESSION_EXPIRY_MAX_PLAUSIBLE` | +| Error codes | `UPPER_SNAKE` on a code class | `AccessTokenErrorCode.SESSION_EXPIRED` | + +Two-step flows name themselves: `start_*` / `complete_*` (`start_link_user` → `complete_link_user`), +and challenge-then-exchange flows use `*_challenge` → `signin_with_*`. + +## ✅ Good — the dominant public-method shape + +```python +async def passkey_login_challenge( + self, + username: Optional[str] = None, + connection: Optional[str] = None, + organization: Optional[str] = None, + store_options: Optional[dict[str, Any]] = None, +) -> PasskeyLoginChallengeResponse: + """ + Step 1 of 2: Initiate a passkey login challenge (POST /passkey/challenge). + + Args: + username: ... + store_options: Optional options for domain resolution. + + Returns: + PasskeyLoginChallengeResponse with auth_session and authn_params_public_key. + + Raises: + PasskeyError: If the challenge request fails. + """ + try: + domain = await self._resolve_current_domain(store_options) + async with self._get_http_client() as client: + response = await client.post(f"https://{domain}{self.PASSKEY_CHALLENGE_PATH}", json=body) + if response.status_code != 200: + error_data = response.json() + raise PasskeyError( + error_data.get("error", PasskeyErrorCode.CHALLENGE_FAILED), + error_data.get("error_description", "Passkey login challenge failed"), + ) + return PasskeyLoginChallengeResponse.model_validate(response.json()) + except Exception as e: + if isinstance(e, (PasskeyError, MissingRequiredArgumentError, ValidationError)): + raise + raise PasskeyError(PasskeyErrorCode.CHALLENGE_FAILED, "Passkey login challenge failed", e) from e +``` + +What makes it conform: + +- `async` + keyword-only-ish optional params, every one typed, `Optional[...]` (not `X | None` — py39 floor) +- returns a **Pydantic model**, validated via `model_validate` — never a raw `dict` +- `store_options` threaded through so MCD domain resolution works +- Google-style docstring with `Args` / `Returns` / `Raises` +- `async with self._get_http_client()` — the telemetry-carrying client, never a bare `httpx.AsyncClient` +- catch-all re-raises the SDK's own typed errors untouched, then wraps anything unexpected in a typed + error with a code, chaining the cause + +## ❌ Bad + +```python +def passkey_login_challenge(self, username=None, store_options=None): # sync, untyped + client = httpx.AsyncClient() # bypasses telemetry headers + response = client.post(f"https://{self._domain}/passkey/challenge", # ignores MCD resolver + json={"username": username}) + if response.status_code != 200: + print(f"failed: {response.text}") # logs a response body + return None # fails open, untyped + return response.json() # unvalidated dict escapes +``` + +Each line is a separate violation: sync in an async SDK, an un-instrumented HTTP client, a static +domain in an MCD-capable path, a print of a possibly token-bearing body, a `None` return that a caller +can mistake for "no passkey" instead of "request failed", and an unvalidated `dict` in the public API. + +## Patterns in use + +- **Generic client over the store type** — `ServerClient(Generic[TStoreOptions])`; store implementations + subclass `StateStore` / `TransactionStore` from `store/abstract.py` (template method: the ABC owns + `encrypt`/`decrypt`, subclasses own `set`/`get`/`delete`) +- **Sub-client composition** — `ServerClient` owns `MfaClient` and `MyAccountClient`; MFA is exposed + through the read-only `mfa` property, and connected-accounts calls delegate to `_my_account_client` +- **`httpx.Auth` strategies** — `BearerAuth` and `DPoPAuth` in `auth_schemes/`, selected by a + `_make_auth(access_token, dpop_key)` helper. Add a new scheme as another `httpx.Auth`, not as + inline header-setting. +- **Pydantic models as the wire contract** — everything in `auth_types/__init__.py`; caller-supplied + enums are `Literal[...]` while server-returned fields stay `str` so a new Auth0 factor type doesn't + fail closed +- **Flat typed error hierarchy** — every error subclasses `Auth0Error` and carries a stable `code`; + code enumeration classes (`AccessTokenErrorCode`, `PasskeyErrorCode`, …) hold the string constants +- **PEP 562 module `__getattr__`** for deprecated public aliases (`auth_types._DEPRECATED_ALIASES`) — + the import keeps working and emits a `DeprecationWarning`. Follow this when retiring a public name. +- **Section banners** — long modules are divided with `# ====== SECTION ======` comment blocks; + keep new methods inside the matching section and preserve existing declaration order. + +## Comments + +Code is largely self-documenting; comments are reserved for *why* — a protocol citation +(`# RFC 9449 §8.2 — server-nonce retry`), a non-obvious security decision (`# NFC-normalize before +comparison...`), or an intentional omission (`# redirect_uri is intentionally excluded — in MCD mode +it is built dynamically`). Don't add comments that restate the code. diff --git a/references/commands.md b/references/commands.md new file mode 100644 index 0000000..5966e44 --- /dev/null +++ b/references/commands.md @@ -0,0 +1,67 @@ +# Command Reference + +Every command below maps to a real step in `.github/workflows/test.yml`, a `pyproject.toml` task, or `CONTRIBUTING.md`. + +## Setup + +```bash +poetry install # install runtime + dev dependencies +poetry install --no-interaction # CI form (test.yml) +``` + +CI pins Poetry to `2.2.1` (`snok/install-poetry@v1`) with `virtualenvs-in-project: true`. + +## Test + +```bash +poetry run pytest # full suite (CONTRIBUTING.md) +poetry run pytest -v --cov=auth0_server_python \ + --cov-report=term-missing --cov-report=xml # exact CI command (test.yml) +poetry run pytest src/auth0_server_python/tests/test_mfa_client.py # one file +poetry run pytest -k test_start_interactive_login # one test by name +poetry run pytest --no-cov -q # skip coverage for a fast loop +``` + +Coverage flags are already in `pyproject.toml` `[tool.pytest.ini_options] addopts`, so a bare +`poetry run pytest` also produces `coverage.xml` and a term-missing report. + +## Lint + +```bash +poetry run ruff check . # the only lint gate in CI (test.yml) +poetry run ruff check . --fix # auto-fix the fixable subset +``` + +## Format + +```bash +poetry run ruff format --check . # reports 13 already-unformatted files — see references/pitfalls.md +``` + +`ruff format` is **not** a CI gate and the tree is not format-clean. Never run `ruff format .` +repo-wide; format only the lines you touched. + +## Build + +```bash +poetry build # sdist + wheel into dist/ (CONTRIBUTING.md, publish.yml) +``` + +## Clean + +```bash +rm -rf dist/ .pytest_cache/ .ruff_cache/ coverage.xml +find . -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. + Types are enforced at runtime by Pydantic models in `auth_types/`, not by a static checker. +- **No integration/e2e tier** — the detected suite runs entirely on mocks; nothing in + `pyproject.toml` or CI reads Auth0 tenant credentials. + +## CI matrix + +`test.yml` runs the test + lint steps on Python 3.9, 3.10, 3.11, and 3.12. Anything that +depends on a 3.10+ syntax or stdlib feature breaks the 3.9 leg — see `references/pitfalls.md`. diff --git a/references/docs-update.md b/references/docs-update.md new file mode 100644 index 0000000..5613340 --- /dev/null +++ b/references/docs-update.md @@ -0,0 +1,50 @@ +# Docs Update Rules + +This repo is a **library/SDK** — its public surface is the exported classes and methods of +`ServerClient`, `MyAccountClient`, `MfaClient`, plus the Pydantic models in `auth_types/` and the +error classes in `error/`. Docs track that surface. + +## Tracked docs + +| Doc | What it covers | Status | +|-----|----------------|--------| +| `README.md` | Install, `ServerClient` construction, interactive login, custom token exchange, MCD, session expiry, passkeys, My Account, DPoP — each a short section that links out to `examples/` | present | +| `EXAMPLES.md` | — | **❌ missing.** This repo puts per-feature guides in `examples/*.md` instead. Add a sample to the matching `examples/` guide; only create `EXAMPLES.md` if the team decides to consolidate. | +| `examples/` | 11 per-feature Markdown guides (not runnable apps): `InteractiveLogin`, `ConfigureStore`, `RetrievingData`, `MFA`, `Passkeys`, `MyAccountAuthenticationMethods`, `ConnectedAccounts`, `CustomTokenExchange`, `ClientInitiatedBackChannelLogin`, `MultipleCustomDomains`, `UserLinking` | present | + +`CHANGELOG.md` exists but is **not** tracked here — it's written by the release flow, not during a +feature change. Migration guides are likewise not tracked: the filename depends on the target major +at the time of the breaking change. + +## When you change code, update these docs + +| When this changes | Update these docs | +|-------------------|-------------------| +| A public method on `ServerClient` / `MyAccountClient` / `MfaClient` — added, renamed, or removed | `README.md` if it has a numbered section for the feature, **and** the matching `examples/*.md` guide | +| A method signature (new/removed/renamed parameter, changed default) | every `examples/*.md` code block that calls it | +| `ServerClient.__init__` options (`domain`, `secret`, `authorization_params`, `organization`, `pushed_authorization_requests`, store identifiers) | `README.md` → "Create the Auth0 SDK client" | +| Auth flow behavior (interactive login, callback, passkey ceremony, custom token exchange, backchannel login/logout, DPoP) | `README.md` quick-start section for that flow + the feature's `examples/*.md` | +| `StateStore` / `TransactionStore` abstract contract in `store/abstract.py` (incl. `delete_by_logout_token`) | `examples/ConfigureStore.md` | +| A new or renamed error class or `*ErrorCode` constant in `error/` | the error-handling section of the affected `examples/*.md` | +| A Pydantic model in `auth_types/` used in a documented call | the `examples/*.md` guides constructing it | +| Install name, Python floor, or a new runtime dependency | `README.md` → "Install the SDK" | +| A deprecation (PEP 562 alias in `auth_types`) | `README.md` and the affected `examples/*.md`, stating the replacement | + +Update the doc **in the same PR** as the code. The PR template asks for a method-level inventory of +what changed, so the doc diff and the PR body come from the same source. + +## Feature → guide map + +| Feature | Guide | +|---------|-------| +| Redirect login, callback, organizations | `examples/InteractiveLogin.md` | +| Store implementations, `store_options` | `examples/ConfigureStore.md` | +| `get_user` / `get_session` / `get_access_token`, session expiry | `examples/RetrievingData.md` | +| MFA (`ServerClient.mfa`, `MfaClient`) | `examples/MFA.md` | +| Passkey ceremonies + DPoP-bound passkey tokens | `examples/Passkeys.md` | +| My Account authentication methods + DPoP | `examples/MyAccountAuthenticationMethods.md` | +| Connected accounts / Token Vault | `examples/ConnectedAccounts.md` | +| RFC 8693 token exchange | `examples/CustomTokenExchange.md` | +| CIBA | `examples/ClientInitiatedBackChannelLogin.md` | +| MCD domain resolver | `examples/MultipleCustomDomains.md` | +| Account linking / unlinking | `examples/UserLinking.md` | diff --git a/references/git-workflow.md b/references/git-workflow.md new file mode 100644 index 0000000..87fc632 --- /dev/null +++ b/references/git-workflow.md @@ -0,0 +1,55 @@ +# Git Workflow + +## Branches + +`main` is the default branch. Observed naming in this repo (pick the shape that matches the work): + +| Shape | Example | Use for | +|-------|---------|---------| +| `-support` | `passkey-support`, `dpop-support`, `myaccount-support` | a new capability | +| `SDK--` | `SDK-8833-organisations-support` | ticket-tracked work | +| `fix/` | `fix/cte-model-validators` | bug fixes | +| `feat/` | `feat/cte-delegation` | scoped features | +| `docs/` | `docs/sync-2026-06-30` | docs-only changes | +| `release/` | `release/` | release prep only — cut by the release flow, not by hand | + +## Commits + +No commitlint / husky hook exists, so nothing fails CI on message format. History is mixed: +Conventional Commits where a ticket drove it (`feat: enforce upstream IdP session_expiry ceiling +(IPSIE SL1) (#120)`) alongside plain imperative subjects (`Added dpop support for myaccount and +passkeys`). **Prefer Conventional Commits** (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`) for new +work — and keep one logical change per commit. + +## Pull requests + +`.github/PULL_REQUEST_TEMPLATE.md` is enforced by review, not tooling. It requires four sections: + +1. **Changes** — what and why, explicitly listing *endpoints added/deleted/deprecated/changed* and + *classes and methods added/deleted/deprecated/changed*, plus a usage summary for any new public + API, and alternatives considered +2. **References** — support ticket, community post, or forum thread +3. **Testing** — how a reviewer verifies it; tick the unit / integration / latest-platform boxes +4. **Checklist** — contribution guidelines, code of conduct, all tests passing + +Because the template asks for a method-level inventory, write the PR body from the diff of the public +surface (`ServerClient`, `MyAccountClient`, `MfaClient`, `auth_types`, `error`) — not from a summary +of intent. + +## Required checks + +From `.github/workflows/`: + +- **Build and Test** (`test.yml`) — `pytest` + `ruff check .` across Python 3.9 / 3.10 / 3.11 / 3.12 +- **CodeQL** (`codeql.yml`) — on PR, push to `main`, and a weekly schedule +- **SCA** (`sca_scan.yml`) — Snyk against `requirements.txt`; suppressions live in `.snyk` + +`ruff format` is not a check. Reformatting untouched files creates diff noise that CI won't flag but +reviewers will. + +## Releases + +Version source of truth is `.version`; `.shiprc` points the release tool at it. `pyproject.toml` +carries the same version and must not drift from `.version`. Releases are cut by the release flow +(`publish.yml`, triggered manually, gated on 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..0d4fb3a --- /dev/null +++ b/references/pitfalls.md @@ -0,0 +1,57 @@ +# Common Pitfalls + +## 1. Python 3.9 is the floor — CI proves it on every PR + +`pyproject.toml` declares `python = ">=3.9"`, `.ruff.toml` sets `target-version = "py39"`, and +`test.yml` runs the matrix `[3.9, 3.10, 3.11, 3.12]`. So: + +- `Optional[X]` / `Union[X, Y]`, **not** `X | None` (PEP 604 is 3.10+) +- `match` statements, `ParamSpec` defaults, and `itertools.pairwise` are unavailable +- `dict[str, Any]` / `list[...]` builtin generics **are** fine (3.9 supports them) and are the house style + +A 3.10-only construct passes locally on a newer interpreter and fails only the 3.9 leg. + +## 2. `ruff format` is not a CI gate, and the tree is not format-clean + +CI runs `ruff check .` only. `ruff format --check .` currently reports 13 files that would be +reformatted, including `server_client.py`'s test file and `helpers.py`. Running `ruff format .` +repo-wide produces a thousand-line diff unrelated to your change. Format only what you touched. + +## 3. Async tests silently pass if you forget `@pytest.mark.asyncio` + +`asyncio_mode` is not set to `auto` anywhere. An async test without the marker is collected, never +awaited, and reported as passing. If a new async test passes on the first run without you making it +pass, check for the marker first. + +## 4. Never construct `httpx.AsyncClient` directly + +`ServerClient._get_http_client()` and `MyAccountClient._get_http_client()` merge +`self._telemetry_headers` into every request. A hand-rolled client silently drops the `Auth0-Client` +header, so the call becomes invisible to SDK telemetry. Same reasoning for auth: use +`BearerAuth` / `DPoPAuth` from `auth_schemes/` rather than setting `Authorization` yourself. + +## 5. `domain` may be a callable — resolve it, don't read it + +MCD mode accepts `domain=` instead of a string. Any code path that builds a URL must +go through `await self._resolve_current_domain(store_options)` and thread `store_options` down to it. +Reading `self._domain` directly works in single-domain tests and breaks every MCD deployment. The +resolver failing raises `DomainResolverError` — it must never fall back to a default domain. + +## 6. `store_options` is the integrator's request/response channel + +Framework objects (`{"request": request, "response": response}`) reach cookie-backed stores only via +`store_options`. A new public method that forgets the parameter cannot be used by any cookie store — +which is the common integration. Every public flow method in `ServerClient` takes it; keep that. + +## 7. Two Pydantic-typing rules that look interchangeable and aren't + +In `auth_types/__init__.py`: caller-supplied values are `Literal[...]` so bad input is rejected at the +boundary; server-returned fields are plain `str` so a newly-added Auth0 factor or challenge type +doesn't fail closed and break existing users. Tightening a response field to a `Literal` is a +latent breaking change even though it type-checks. + +## 8. `requirements.txt` and `pyproject.toml` both pin dependencies + +Poetry resolves from `pyproject.toml` + `poetry.lock`; the Snyk SCA workflow installs from +`requirements.txt`. A dependency bump applied to only one of them either escapes the scan or breaks +the scan's install step. diff --git a/references/testing.md b/references/testing.md new file mode 100644 index 0000000..64cb900 --- /dev/null +++ b/references/testing.md @@ -0,0 +1,76 @@ +# Testing Conventions + +- **Framework:** pytest `^7.2` with `pytest-asyncio` (`>=0.20.3,<0.24.0`), `pytest-mock` `^3.14`, `pytest-cov` `^4.0` +- **Location:** `src/auth0_server_python/tests/` — one `test_.py` per source module +- **Coverage:** `pytest-cov`, configured in `pyproject.toml` `addopts`; reports to terminal and `coverage.xml` +- **Threshold:** none configured — no `fail_under` in `pyproject.toml`, and the codecov upload step in + `test.yml` is commented out. Coverage is informational, not a gate. + +## Async tests need an explicit marker + +There is **no** `asyncio_mode = "auto"` in `pyproject.toml`. Every async test must carry the marker +or it is collected and silently skipped as a coroutine that never runs: + +```python +@pytest.mark.asyncio +async def test_resolver_returning_none_raises(self): + ... +``` + +## Naming + +`test___` — descriptive and long is preferred over terse: + +- `test_init_no_secret_raises` +- `test_start_interactive_login_no_redirect_uri` +- `test_resolver_exception_wrapped_in_domain_resolver_error` + +## Organization + +Two shapes coexist; match the file you're editing. + +- **Module-level functions** — `test_server_client.py` and `test_dpop_auth.py` +- **Grouping classes** — `test_mfa_client.py`, `test_my_account_client.py` use `TestMfaClientConstructor`, + `TestDomainResolution`, etc., with `# ── Section ───` comment banners between groups + +## Fixtures and factories + +No `conftest.py` exists. Shared setup lives as module-level constants plus small private factory +helpers in each test file: + +```python +DOMAIN = "auth0.local" +CLIENT_ID = "" +CLIENT_SECRET = "" +SECRET = "test-secret-long-enough-for-encryption" + + +def _make_client() -> MfaClient: + return MfaClient(domain=DOMAIN, client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, secret=SECRET) +``` + +Placeholder credentials are angle-bracketed (``) or obviously synthetic — keep it that +way; never paste a real tenant value into a test. + +## Mocking + +- `unittest.mock.AsyncMock` for the `state_store` / `transaction_store` passed into `ServerClient` +- `MagicMock` for sync collaborators, `ANY` for arguments you don't want to pin +- `mocker` (pytest-mock) where a patch should unwind automatically; `patch(...)` as a context manager elsewhere +- `httpx` calls are mocked at the client boundary — no live HTTP, no recorded cassettes +- Real `jwcrypto.jwk` keys are generated in-test for DPoP coverage rather than mocked, so proofs + actually verify + +## Assertions + +Plain `assert`, plus `pytest.raises` for the typed-error paths — which is most of the suite: + +```python +with pytest.raises(MissingRequiredArgumentError) as exc: + ServerClient(domain="example.auth0.com", client_id="id", client_secret="secret") +assert "secret" in str(exc.value) +``` + +Assert on the error's **`code`** (or its `Auth0Error` subclass) rather than on message text where +both are available — messages are not part of the contract, codes are.