agnes-the-ai-analyst/docs/archive/superpowers/plans/2026-04-22-cloudflare-access-auth.md
ZdenekSrotyr a48524509a
docs: consolidate and de-clutter the documentation tree (#306)
CLAUDE.md rewritten (708 -> ~320 lines): four overlapping release
sections collapsed to one, stale v1->v35 schema history dropped (it
lives in CHANGELOG), marketplace endpoint internals and verbose
process sections moved out or tightened.

New focused docs:
- docs/RELEASING.md - release process, deploy workflows, CI quirks
  (RELEASE_TEMPLATE.md folded in as an appendix)
- docs/marketplace.md - marketplace ingestion + re-serving internals
- docs/README.md - documentation index by audience, linked from
  README.md and CLAUDE.md

Archived under docs/archive/: docs/superpowers/ (52 historical
planning artifacts), HACKATHON.md, pd-ps-comments.md,
security-audit-2026-04.md, future/NOTIFICATIONS.md.

Removed the docs/auto-install.md stub. Fixed dangling links in
connectors/jira/README.md and dev_docs/README.md, repointed
code/doc references to archived paths.
2026-05-14 18:54:22 +00:00

1411 lines
53 KiB
Markdown

# Cloudflare Access Auth Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add Cloudflare Access as a third authentication method that coexists with existing password and Google OAuth providers — users behind a Cloudflare Zero Trust tunnel are auto-logged-in via signed edge JWT; direct-access users keep password/Google/PAT flows.
**Architecture:** New provider module `app/auth/providers/cloudflare.py` exposes `is_available()` + `verify_cf_jwt()`. A starlette middleware (`app/auth/middleware.py`, wired in `app/main.py`) runs before route handlers, detects the `Cf-Access-Jwt-Assertion` header, verifies it against the Cloudflare team JWKS with audience check, and — on success — provisions the user and sets the standard `access_token` cookie. Middleware is a pure pass-through when the header is missing or verification fails, preserving all existing flows.
**Tech Stack:** PyJWT 2.12 (already in deps) with `PyJWKClient` for JWKS fetch + 5-min cache; FastAPI middleware decorator; existing `UserRepository` + `create_access_token()` helpers.
---
## Context — What's Already in the Codebase
Existing auth providers follow a common pattern:
- `app/auth/providers/password.py:32``is_available()` always True
- `app/auth/providers/google.py:24``is_available()` returns True when `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET` set
- `app/auth/providers/email.py:31``is_available()` returns True when `SMTP_HOST` or `SENDGRID_API_KEY` set
All three self-register via `is_available()` in `app/main.py:138-146`. Login page (`app/web/router.py:194-232`) iterates providers and builds `login_buttons` dynamically.
Cookie flow: `create_access_token()``response.set_cookie(key="access_token", ...)` (see `app/auth/providers/google.py:97-103` for the reference pattern). Downstream routes read via `app/auth/dependencies.py:33` → header first, cookie fallback.
Cloudflare Access differs from those providers — it's **not** a clickable login button. It's an edge gate that injects a signed JWT in the `Cf-Access-Jwt-Assertion` header on every request. The provider therefore lives as a **middleware** that runs before handlers and transparently exchanges that edge JWT for our session cookie.
## Env Vars (New)
| Var | Required | Purpose |
|-----|----------|---------|
| `CF_ACCESS_TEAM` | yes | Your Cloudflare team domain prefix (e.g. `keboola``https://keboola.cloudflareaccess.com/cdn-cgi/access/certs`) |
| `CF_ACCESS_AUD` | yes | Application AUD tag (from CF dashboard → Access → Applications → your app → Overview) |
| `CF_ACCESS_DOMAIN_ALLOW` | no | Comma-separated email domain allowlist; if unset, falls back to `instance.yaml` `allowed_domains` (same as Google) |
## Security Model
1. **Trust gate:** middleware only inspects the header when **both** `CF_ACCESS_TEAM` and `CF_ACCESS_AUD` are set. If either is unset, the header is ignored — this prevents header spoofing on deployments that don't sit behind CF.
2. **Audience check:** `jwt.decode(..., audience=CF_ACCESS_AUD)` — PyJWT raises on mismatch.
3. **Issuer check:** explicit `options={"require": ["iss"]}` and `issuer=f"https://{CF_ACCESS_TEAM}.cloudflareaccess.com"`.
4. **Signature:** JWKS public keys fetched from `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs` (cached by `PyJWKClient` with 5-min TTL).
5. **Failure is silent pass-through:** invalid/expired/missing JWT → middleware does nothing, request proceeds to route where normal auth (cookie/Bearer/login redirect) kicks in. Never returns 401 from middleware — that would break password/Google login paths.
6. **Domain allowlist:** same logic as `google.py:68-72` — reject email domains outside the configured allowlist.
## File Structure
**Create:**
- `app/auth/providers/cloudflare.py` — provider module (`is_available`, `verify_cf_jwt`, `get_or_create_user_from_cf`)
- `app/auth/middleware.py``CloudflareAccessMiddleware` starlette middleware class
- `tests/test_cloudflare_auth.py` — unit + integration tests
- `docs/auth-cloudflare.md` — ops doc (how to configure the CF tunnel + Access app)
**Modify:**
- `app/main.py:137-146` — register middleware between `SessionMiddleware` and route handlers
- `app/web/router.py:194-232` — add optional "Protected by Cloudflare Access" hint on login page when provider is available
---
## Task 1: Test scaffolding — fixtures for JWKS + signed tokens
Before writing any provider code, build the test plumbing. The rest of the plan depends on it.
**Files:**
- Create: `tests/test_cloudflare_auth.py`
- Test: `tests/test_cloudflare_auth.py`
- [ ] **Step 1: Write a fixture that generates an RSA keypair and a mock JWKS**
Add to `tests/test_cloudflare_auth.py`:
```python
"""Tests for Cloudflare Access auth provider and middleware."""
import json
import time
import uuid
from base64 import urlsafe_b64encode
from typing import Callable
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import jwt as pyjwt
def _b64url_uint(n: int) -> str:
"""Encode an integer as base64url per RFC 7518 §6.3.1."""
byte_length = (n.bit_length() + 7) // 8
return urlsafe_b64encode(n.to_bytes(byte_length, "big")).rstrip(b"=").decode()
@pytest.fixture
def cf_keypair():
"""Generate an RSA keypair for signing test CF Access JWTs."""
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub = priv.public_key()
pub_numbers = pub.public_numbers()
kid = "test-kid-1"
jwks = {
"keys": [
{
"kty": "RSA",
"kid": kid,
"use": "sig",
"alg": "RS256",
"n": _b64url_uint(pub_numbers.n),
"e": _b64url_uint(pub_numbers.e),
}
]
}
priv_pem = priv.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
return {"kid": kid, "jwks": jwks, "private_pem": priv_pem}
@pytest.fixture
def make_cf_jwt(cf_keypair) -> Callable[..., str]:
"""Factory: build a signed CF Access JWT with overridable claims."""
def _make(
email: str = "user@example.com",
aud: str = "test-aud-123",
iss: str = "https://testteam.cloudflareaccess.com",
exp_offset: int = 3600,
name: str = "Test User",
extra_claims: dict | None = None,
) -> str:
now = int(time.time())
claims = {
"email": email,
"name": name,
"aud": aud,
"iss": iss,
"iat": now,
"exp": now + exp_offset,
"sub": str(uuid.uuid4()),
}
if extra_claims:
claims.update(extra_claims)
return pyjwt.encode(
claims,
cf_keypair["private_pem"],
algorithm="RS256",
headers={"kid": cf_keypair["kid"]},
)
return _make
```
- [ ] **Step 2: Add fixtures — reset cached JWKS client + patch JWKS fetch**
Append to `tests/test_cloudflare_auth.py`:
```python
@pytest.fixture(autouse=True)
def _reset_cf_jwks_cache(monkeypatch):
"""Reset the module-level JWKS client so each test starts fresh.
Without this, a client built from a previous test's team/URL would persist.
"""
import sys
mod = sys.modules.get("app.auth.providers.cloudflare")
if mod is not None:
monkeypatch.setattr(mod, "_JWKS_CLIENT", None, raising=False)
monkeypatch.setattr(mod, "_JWKS_TEAM", None, raising=False)
@pytest.fixture
def patch_jwks(monkeypatch, cf_keypair):
"""Patch PyJWKClient so verify_cf_jwt reads our test key instead of hitting the network."""
from cryptography.hazmat.primitives import serialization as _ser
# Build a PyJWK-compatible signing key object from the public key
pub_pem = _ser.load_pem_private_key(cf_keypair["private_pem"], password=None).public_key().public_bytes(
encoding=_ser.Encoding.PEM,
format=_ser.PublicFormat.SubjectPublicKeyInfo,
)
class _FakeSigningKey:
def __init__(self, key_bytes: bytes):
from cryptography.hazmat.primitives.serialization import load_pem_public_key
self.key = load_pem_public_key(key_bytes)
def _fake_get_signing_key_from_jwt(self, token):
return _FakeSigningKey(pub_pem)
monkeypatch.setattr(
"jwt.PyJWKClient.get_signing_key_from_jwt",
_fake_get_signing_key_from_jwt,
)
```
- [ ] **Step 3: Add a client fixture with CF env configured**
Append to `tests/test_cloudflare_auth.py`:
```python
@pytest.fixture
def cf_client(tmp_path, monkeypatch, patch_jwks):
"""TestClient with CF_ACCESS_* env vars set so the provider is available."""
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-32chars-minimum!!!!!")
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app()
return TestClient(app)
@pytest.fixture
def no_cf_client(tmp_path, monkeypatch):
"""TestClient WITHOUT CF_ACCESS_* env — provider should be unavailable."""
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-32chars-minimum!!!!!")
monkeypatch.setenv("TESTING", "1")
monkeypatch.delenv("CF_ACCESS_TEAM", raising=False)
monkeypatch.delenv("CF_ACCESS_AUD", raising=False)
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app()
return TestClient(app)
```
- [ ] **Step 4: Run pytest to confirm fixtures import cleanly (no tests yet — the file should collect with 0 tests)**
Run: `pytest tests/test_cloudflare_auth.py -v`
Expected: `collected 0 items` (no tests defined yet, but no import/collection errors).
- [ ] **Step 5: Commit**
```bash
git add tests/test_cloudflare_auth.py
git commit -m "test(auth): add Cloudflare Access test scaffolding fixtures"
```
---
## Task 2: Cloudflare provider module — `is_available()`
**Files:**
- Create: `app/auth/providers/cloudflare.py`
- Test: `tests/test_cloudflare_auth.py`
- [ ] **Step 1: Write the failing `is_available()` tests**
Append to `tests/test_cloudflare_auth.py`:
```python
class TestCloudflareProviderAvailability:
def test_unavailable_without_env(self, monkeypatch):
monkeypatch.delenv("CF_ACCESS_TEAM", raising=False)
monkeypatch.delenv("CF_ACCESS_AUD", raising=False)
# Force re-import so module-level env reads are fresh
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
assert cf_mod.is_available() is False
def test_unavailable_with_only_team(self, monkeypatch):
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.delenv("CF_ACCESS_AUD", raising=False)
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
assert cf_mod.is_available() is False
def test_unavailable_with_only_aud(self, monkeypatch):
monkeypatch.delenv("CF_ACCESS_TEAM", raising=False)
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
assert cf_mod.is_available() is False
def test_available_with_both_env(self, monkeypatch):
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
assert cf_mod.is_available() is True
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_cloudflare_auth.py::TestCloudflareProviderAvailability -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.auth.providers.cloudflare'`
- [ ] **Step 3: Create `app/auth/providers/cloudflare.py` with minimal `is_available()`**
Create `app/auth/providers/cloudflare.py`:
```python
"""Cloudflare Access auth provider — verifies edge JWT from Cloudflare Zero Trust.
Unlike password/google/email providers, Cloudflare Access is NOT a clickable
login button. Cloudflare's edge gate injects a signed JWT in the
`Cf-Access-Jwt-Assertion` header on every request. The app trusts that JWT
(after verifying signature + audience) and auto-provisions the user, issuing
our standard `access_token` cookie so downstream route handlers work unchanged.
This module exposes pure functions; the request-interception logic lives in
`app/auth/middleware.py`.
"""
import logging
import os
logger = logging.getLogger(__name__)
def _team() -> str:
return os.environ.get("CF_ACCESS_TEAM", "")
def _aud() -> str:
return os.environ.get("CF_ACCESS_AUD", "")
def is_available() -> bool:
"""Provider is active only when BOTH team and aud are configured.
The two-env-var gate prevents header spoofing on deployments that don't
sit behind Cloudflare — an attacker could otherwise forge
`Cf-Access-Jwt-Assertion` and bypass auth.
Env vars are read at call time (not cached at import) so tests and
runtime env changes behave predictably.
"""
return bool(_team() and _aud())
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_cloudflare_auth.py::TestCloudflareProviderAvailability -v`
Expected: 4 passed.
- [ ] **Step 5: Commit**
```bash
git add app/auth/providers/cloudflare.py tests/test_cloudflare_auth.py
git commit -m "feat(auth): Cloudflare Access provider skeleton with is_available()"
```
---
## Task 3: JWT verification with JWKS
**Files:**
- Modify: `app/auth/providers/cloudflare.py`
- Test: `tests/test_cloudflare_auth.py`
- [ ] **Step 1: Write the failing `verify_cf_jwt` tests**
Append to `tests/test_cloudflare_auth.py`:
```python
class TestVerifyCfJwt:
def test_valid_token_returns_claims(self, monkeypatch, patch_jwks, make_cf_jwt):
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
token = make_cf_jwt(email="alice@example.com")
claims = cf_mod.verify_cf_jwt(token)
assert claims is not None
assert claims["email"] == "alice@example.com"
def test_wrong_audience_rejected(self, monkeypatch, patch_jwks, make_cf_jwt):
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
token = make_cf_jwt(aud="wrong-aud")
assert cf_mod.verify_cf_jwt(token) is None
def test_wrong_issuer_rejected(self, monkeypatch, patch_jwks, make_cf_jwt):
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
token = make_cf_jwt(iss="https://evil.example.com")
assert cf_mod.verify_cf_jwt(token) is None
def test_expired_token_rejected(self, monkeypatch, patch_jwks, make_cf_jwt):
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
token = make_cf_jwt(exp_offset=-60) # expired 60s ago
assert cf_mod.verify_cf_jwt(token) is None
def test_malformed_token_rejected(self, monkeypatch, patch_jwks):
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
assert cf_mod.verify_cf_jwt("not-a-jwt") is None
assert cf_mod.verify_cf_jwt("") is None
def test_verify_returns_none_when_unavailable(self, monkeypatch):
monkeypatch.delenv("CF_ACCESS_TEAM", raising=False)
monkeypatch.delenv("CF_ACCESS_AUD", raising=False)
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
assert cf_mod.verify_cf_jwt("anything") is None
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_cloudflare_auth.py::TestVerifyCfJwt -v`
Expected: FAIL with `AttributeError: module 'app.auth.providers.cloudflare' has no attribute 'verify_cf_jwt'`
- [ ] **Step 3: Implement `verify_cf_jwt` in `app/auth/providers/cloudflare.py`**
Replace the contents of `app/auth/providers/cloudflare.py` with:
```python
"""Cloudflare Access auth provider — verifies edge JWT from Cloudflare Zero Trust.
Unlike password/google/email providers, Cloudflare Access is NOT a clickable
login button. Cloudflare's edge gate injects a signed JWT in the
`Cf-Access-Jwt-Assertion` header on every request. The app trusts that JWT
(after verifying signature + audience) and auto-provisions the user, issuing
our standard `access_token` cookie so downstream route handlers work unchanged.
This module exposes pure functions; the request-interception logic lives in
`app/auth/middleware.py`.
"""
import logging
import os
from typing import Optional
import jwt as pyjwt
from jwt import PyJWKClient
logger = logging.getLogger(__name__)
_JWKS_CLIENT: Optional[PyJWKClient] = None
_JWKS_TEAM: Optional[str] = None # team string the cached client was built for
def _team() -> str:
return os.environ.get("CF_ACCESS_TEAM", "")
def _aud() -> str:
return os.environ.get("CF_ACCESS_AUD", "")
def is_available() -> bool:
"""Provider is active only when BOTH team and aud are configured."""
return bool(_team() and _aud())
def _jwks_url() -> str:
return f"https://{_team()}.cloudflareaccess.com/cdn-cgi/access/certs"
def _issuer() -> str:
return f"https://{_team()}.cloudflareaccess.com"
def _get_jwks_client() -> PyJWKClient:
"""Lazy-init JWKS client. PyJWKClient caches keys with 5-min TTL by default.
If `CF_ACCESS_TEAM` changes (e.g. between tests), rebuild the client.
"""
global _JWKS_CLIENT, _JWKS_TEAM
current_team = _team()
if _JWKS_CLIENT is None or _JWKS_TEAM != current_team:
_JWKS_CLIENT = PyJWKClient(_jwks_url(), cache_jwk_set=True, lifespan=300)
_JWKS_TEAM = current_team
return _JWKS_CLIENT
def verify_cf_jwt(token: str) -> Optional[dict]:
"""Verify a Cloudflare Access JWT. Returns claims dict on success, None on any failure.
Never raises — all exceptions are logged at debug and mapped to None so the
middleware can treat them as "pass through to normal auth."
"""
if not is_available():
return None
if not token:
return None
try:
signing_key = _get_jwks_client().get_signing_key_from_jwt(token)
claims = pyjwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=_aud(),
issuer=_issuer(),
options={"require": ["exp", "iat", "iss", "aud"]},
)
return claims
except pyjwt.InvalidTokenError as e:
logger.debug("CF Access JWT invalid: %s", e)
return None
except Exception as e:
# JWKS fetch failure, network error, etc. — never propagate
logger.warning("CF Access JWT verification error: %s", e)
return None
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_cloudflare_auth.py::TestVerifyCfJwt -v`
Expected: 6 passed.
- [ ] **Step 5: Commit**
```bash
git add app/auth/providers/cloudflare.py tests/test_cloudflare_auth.py
git commit -m "feat(auth): verify Cloudflare Access JWT with JWKS + audience + issuer"
```
---
## Task 4: User provisioning from Cloudflare identity
**Files:**
- Modify: `app/auth/providers/cloudflare.py`
- Test: `tests/test_cloudflare_auth.py`
- [ ] **Step 1: Write the failing user-provisioning tests**
Append to `tests/test_cloudflare_auth.py`:
```python
class TestGetOrCreateUserFromCf:
def test_creates_new_user(self, tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
from src.db import get_system_db
conn = get_system_db()
try:
user = cf_mod.get_or_create_user_from_cf(
email="new@example.com", name="New User", conn=conn,
)
assert user is not None
assert user["email"] == "new@example.com"
assert user["role"] == "analyst"
finally:
conn.close()
def test_returns_existing_user(self, tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
try:
UserRepository(conn).create(
id="existing-id", email="existing@example.com",
name="Existing", role="admin",
)
user = cf_mod.get_or_create_user_from_cf(
email="existing@example.com", name="Existing", conn=conn,
)
assert user["id"] == "existing-id"
assert user["role"] == "admin" # role preserved
finally:
conn.close()
def test_deactivated_user_rejected(self, tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
try:
UserRepository(conn).create(
id="deact-id", email="deact@example.com",
name="Deact", role="analyst",
)
UserRepository(conn).update(id="deact-id", active=False)
user = cf_mod.get_or_create_user_from_cf(
email="deact@example.com", name="Deact", conn=conn,
)
assert user is None
finally:
conn.close()
def test_domain_allowlist_rejects_outsider(self, tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
monkeypatch.setenv("CF_ACCESS_DOMAIN_ALLOW", "example.com")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
from src.db import get_system_db
conn = get_system_db()
try:
user = cf_mod.get_or_create_user_from_cf(
email="outsider@evil.com", name="Outsider", conn=conn,
)
assert user is None
finally:
conn.close()
def test_domain_allowlist_accepts_insider(self, tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("CF_ACCESS_TEAM", "testteam")
monkeypatch.setenv("CF_ACCESS_AUD", "test-aud-123")
monkeypatch.setenv("CF_ACCESS_DOMAIN_ALLOW", "example.com,partner.com")
import importlib
from app.auth.providers import cloudflare as cf_mod
importlib.reload(cf_mod)
from src.db import get_system_db
conn = get_system_db()
try:
user = cf_mod.get_or_create_user_from_cf(
email="ok@partner.com", name="Partner", conn=conn,
)
assert user is not None
assert user["email"] == "ok@partner.com"
finally:
conn.close()
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_cloudflare_auth.py::TestGetOrCreateUserFromCf -v`
Expected: FAIL with `AttributeError: ... has no attribute 'get_or_create_user_from_cf'`
- [ ] **Step 3: Add `get_or_create_user_from_cf` to `app/auth/providers/cloudflare.py`**
Append to `app/auth/providers/cloudflare.py`:
```python
import uuid
from typing import Any
import duckdb
from src.repositories.users import UserRepository
def _allowed_domains() -> list[str]:
"""Domain allowlist — CF_ACCESS_DOMAIN_ALLOW env wins, else instance.yaml."""
env = os.environ.get("CF_ACCESS_DOMAIN_ALLOW", "").strip()
if env:
return [d.strip().lower() for d in env.split(",") if d.strip()]
try:
from app.instance_config import get_allowed_domains
return [d.lower() for d in (get_allowed_domains() or [])]
except Exception:
return []
def get_or_create_user_from_cf(
email: str,
name: str,
conn: duckdb.DuckDBPyConnection,
) -> Optional[dict[str, Any]]:
"""Look up or provision a user from a verified CF Access identity.
Returns the user dict on success; returns None when:
- email domain is outside the allowlist
- user exists but is deactivated
New users default to `analyst` role (same default as Google OAuth).
"""
if not email or not isinstance(email, str):
return None
allow = _allowed_domains()
if allow:
domain = email.split("@")[-1].lower()
if domain not in allow:
logger.info("CF Access: rejecting email outside allowlist: %s", email)
return None
repo = UserRepository(conn)
user = repo.get_by_email(email)
if user is None:
user_id = str(uuid.uuid4())
repo.create(
id=user_id,
email=email,
name=name or email.split("@")[0],
role="analyst",
)
user = repo.get_by_email(email)
logger.info("CF Access: provisioned new user %s", email)
if not bool(user.get("active", True)):
logger.info("CF Access: rejecting deactivated user %s", email)
return None
return user
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_cloudflare_auth.py::TestGetOrCreateUserFromCf -v`
Expected: 5 passed.
- [ ] **Step 5: Commit**
```bash
git add app/auth/providers/cloudflare.py tests/test_cloudflare_auth.py
git commit -m "feat(auth): provision users from verified Cloudflare Access identity"
```
---
## Task 5: Middleware skeleton — pass-through
**Files:**
- Create: `app/auth/middleware.py`
- Test: `tests/test_cloudflare_auth.py`
- [ ] **Step 1: Write the failing pass-through tests**
Append to `tests/test_cloudflare_auth.py`:
```python
class TestMiddlewarePassthrough:
def test_no_header_no_cookie_redirects_to_login(self, cf_client):
"""Dashboard without any auth → normal 302 to /login (middleware must not interfere)."""
resp = cf_client.get("/dashboard", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers.get("location", "")
def test_invalid_cf_header_passes_through(self, cf_client):
"""Garbage CF header → middleware ignores it → normal 302 to login."""
resp = cf_client.get(
"/dashboard",
headers={"Cf-Access-Jwt-Assertion": "not-a-valid-jwt"},
follow_redirects=False,
)
assert resp.status_code == 302
assert "/login" in resp.headers.get("location", "")
def test_middleware_unavailable_when_env_missing(self, no_cf_client, make_cf_jwt):
"""Without CF_ACCESS_* env, middleware must be inert even if header is present."""
# Note: make_cf_jwt still produces a token but middleware should ignore it.
token = make_cf_jwt()
resp = no_cf_client.get(
"/dashboard",
headers={"Cf-Access-Jwt-Assertion": token},
follow_redirects=False,
)
# No cookie set, normal redirect to login
assert resp.status_code == 302
assert "access_token" not in resp.cookies
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_cloudflare_auth.py::TestMiddlewarePassthrough -v`
Expected: FAIL — most likely `ModuleNotFoundError: app.auth.middleware` once `create_app()` tries to import it (after Task 7 wires it in). At this stage they'll actually pass because middleware isn't registered yet. If they pass, that's fine — they are assertions of baseline behavior we must preserve.
(The tests explicitly verify the **absence** of CF-induced behavior, so they're a safety net against breaking existing flows once middleware is wired up.)
- [ ] **Step 3: Create `app/auth/middleware.py` with a pass-through middleware**
Create `app/auth/middleware.py`:
```python
"""Starlette middleware that transparently exchanges a verified Cloudflare Access
JWT for our standard `access_token` session cookie.
Runs before route handlers. On every request:
1. If the CF provider is not configured, pass through untouched.
2. If the request carries an `Authorization: Bearer` header (API/CLI/PAT
client), pass through — those clients don't need a cookie, and setting
one could leak into subsequent requests from shared clients.
3. If the request already has an `access_token` cookie, pass through
(don't overwrite an active session — user may have logged in manually).
4. If a `Cf-Access-Jwt-Assertion` header is present and verifies, provision
the user, mint our JWT, set the cookie, continue.
5. On any verification failure, pass through — the route handler will
apply its normal auth logic (cookie/Bearer/redirect).
Never returns 401 from the middleware itself — that would break password/Google
login flows on deployments that enable CF as *one of several* auth methods.
"""
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.auth.providers import cloudflare as cf
logger = logging.getLogger(__name__)
CF_HEADER = "Cf-Access-Jwt-Assertion"
COOKIE_NAME = "access_token"
COOKIE_MAX_AGE = 86400 # 24h — matches ACCESS_TOKEN_EXPIRE_HOURS in app/auth/jwt.py
class CloudflareAccessMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
if not cf.is_available():
return await call_next(request)
# Bearer clients (PATs, API scripts) manage their own auth — don't set a cookie on them.
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return await call_next(request)
if request.cookies.get(COOKIE_NAME):
return await call_next(request)
token = request.headers.get(CF_HEADER)
if not token:
return await call_next(request)
claims = cf.verify_cf_jwt(token)
if claims is None:
return await call_next(request)
# Import inside dispatch to avoid circular imports at module load time
from src.db import get_system_db
from app.auth.jwt import create_access_token
email = claims.get("email", "")
name = claims.get("name", "")
conn = get_system_db()
try:
user = cf.get_or_create_user_from_cf(email=email, name=name, conn=conn)
finally:
conn.close()
if user is None:
# Email outside allowlist or deactivated — pass through so the
# normal 401 → /login redirect tells the user why.
return await call_next(request)
app_jwt = create_access_token(
user_id=user["id"],
email=user["email"],
role=user["role"],
)
response = await call_next(request)
import os
use_secure = os.environ.get("TESTING", "").lower() not in ("1", "true")
response.set_cookie(
key=COOKIE_NAME,
value=app_jwt,
httponly=True,
max_age=COOKIE_MAX_AGE,
samesite="lax",
secure=use_secure,
)
# Stash on request.state so this-request handlers can see the identity.
# (Not strictly needed — the next request will use the cookie — but it
# makes the first CF-authenticated request behave identically to a
# cookie-authenticated one.)
request.state.cf_user = user
return response
```
- [ ] **Step 4: Run tests to verify they still pass (middleware not yet wired up — baseline behavior unchanged)**
Run: `pytest tests/test_cloudflare_auth.py::TestMiddlewarePassthrough -v`
Expected: 3 passed.
- [ ] **Step 5: Commit**
```bash
git add app/auth/middleware.py tests/test_cloudflare_auth.py
git commit -m "feat(auth): Cloudflare Access middleware skeleton (not yet wired)"
```
---
## Task 6: Wire the middleware into `create_app()`
**Files:**
- Modify: `app/main.py:137`
- Test: `tests/test_cloudflare_auth.py`
- [ ] **Step 1: Write the failing integration test (CF header → authenticated)**
Append to `tests/test_cloudflare_auth.py`:
```python
class TestMiddlewareAutoLogin:
def test_valid_cf_header_auto_logs_in(self, cf_client, make_cf_jwt):
"""Valid CF JWT on /dashboard request → middleware sets cookie → 200 (not 302)."""
token = make_cf_jwt(email="alice@example.com", name="Alice")
resp = cf_client.get(
"/dashboard",
headers={"Cf-Access-Jwt-Assertion": token},
follow_redirects=False,
)
# Middleware provisioned Alice + set cookie → dashboard renders
assert resp.status_code == 200, (
f"Expected 200, got {resp.status_code}: {resp.text[:300]}"
)
# Cookie was set on the response
assert "access_token" in resp.cookies
# User now exists
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
try:
user = UserRepository(conn).get_by_email("alice@example.com")
assert user is not None
assert user["role"] == "analyst"
finally:
conn.close()
def test_bearer_pat_passes_through_without_cookie(self, cf_client, make_cf_jwt):
"""A Bearer-authenticated client (PAT/API) must NOT get a cookie set,
even if a CF header is also present."""
from app.auth.jwt import create_access_token
from src.db import get_system_db
from src.repositories.users import UserRepository
import uuid as _uuid
conn = get_system_db()
try:
uid = str(_uuid.uuid4())
UserRepository(conn).create(
id=uid, email="pat@example.com", name="PAT User", role="analyst",
)
bearer = create_access_token(uid, "pat@example.com", "analyst")
finally:
conn.close()
cf_token = make_cf_jwt(email="spoofed@example.com")
resp = cf_client.get(
"/dashboard",
headers={
"Authorization": f"Bearer {bearer}",
"Cf-Access-Jwt-Assertion": cf_token,
},
follow_redirects=False,
)
# Bearer auth succeeds, middleware skipped → no cookie leaked
assert resp.status_code == 200
assert "access_token" not in resp.cookies
# Spoofed email must not have been provisioned
from src.db import get_system_db as _gdb
conn2 = _gdb()
try:
spoofed = UserRepository(conn2).get_by_email("spoofed@example.com")
assert spoofed is None
finally:
conn2.close()
def test_existing_cookie_wins_over_cf_header(self, cf_client, make_cf_jwt):
"""If the user already has an access_token cookie, middleware must not overwrite it."""
from app.auth.jwt import create_access_token
from src.db import get_system_db
from src.repositories.users import UserRepository
import uuid as _uuid
conn = get_system_db()
try:
uid = str(_uuid.uuid4())
UserRepository(conn).create(
id=uid, email="bob@example.com", name="Bob", role="admin",
)
existing_token = create_access_token(uid, "bob@example.com", "admin")
finally:
conn.close()
cf_client.cookies.set("access_token", existing_token)
cf_token = make_cf_jwt(email="carol@example.com", name="Carol")
resp = cf_client.get(
"/dashboard",
headers={"Cf-Access-Jwt-Assertion": cf_token},
follow_redirects=False,
)
assert resp.status_code == 200
# Carol must NOT have been provisioned — existing cookie session wins
from src.db import get_system_db as _gdb
conn2 = _gdb()
try:
carol = UserRepository(conn2).get_by_email("carol@example.com")
assert carol is None
finally:
conn2.close()
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_cloudflare_auth.py::TestMiddlewareAutoLogin -v`
Expected: FAIL — `test_valid_cf_header_auto_logs_in` gets 302 (login redirect) because middleware isn't registered yet.
- [ ] **Step 3: Register the middleware in `app/main.py`**
In `app/main.py`, locate lines 58-61 (SessionMiddleware setup) and insert the CF middleware registration immediately after the CORS block (after line 71). The new block goes between line 71 and line 73.
Edit `app/main.py` — add this block right before the `# Load .env_overlay` comment on line 73:
```python
# Cloudflare Access middleware — runs before route handlers to exchange
# a verified CF edge JWT for our session cookie. Inert unless
# CF_ACCESS_TEAM + CF_ACCESS_AUD are both set.
from app.auth.middleware import CloudflareAccessMiddleware
app.add_middleware(CloudflareAccessMiddleware)
```
(Starlette middleware runs in LIFO order; adding CF last means it runs *first* on inbound requests — exactly what we want.)
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_cloudflare_auth.py::TestMiddlewareAutoLogin tests/test_cloudflare_auth.py::TestMiddlewarePassthrough -v`
Expected: 5 passed.
- [ ] **Step 5: Run the full auth test suite to verify no regressions**
Run: `pytest tests/test_auth_providers.py tests/test_journey_bootstrap_auth.py tests/test_cloudflare_auth.py -v`
Expected: all pass (no existing auth test should break — CF is inert without env vars, and the non-CF client fixtures don't set them).
- [ ] **Step 6: Commit**
```bash
git add app/main.py tests/test_cloudflare_auth.py
git commit -m "feat(auth): wire Cloudflare Access middleware into FastAPI app"
```
---
## Task 7: Login page hint when CF is available
**Files:**
- Modify: `app/web/router.py:194-232`
- Test: `tests/test_cloudflare_auth.py`
- [ ] **Step 1: Write the failing login-page test**
Append to `tests/test_cloudflare_auth.py`:
```python
class TestLoginPageCfHint:
def test_login_page_shows_cf_hint_when_available(self, cf_client):
"""When CF provider is available, login page shows an informational hint."""
resp = cf_client.get("/login")
assert resp.status_code == 200
assert "Cloudflare Access" in resp.text
def test_login_page_no_cf_hint_when_unavailable(self, no_cf_client):
"""Without CF env, no hint on login page."""
resp = no_cf_client.get("/login")
assert resp.status_code == 200
assert "Cloudflare Access" not in resp.text
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_cloudflare_auth.py::TestLoginPageCfHint -v`
Expected: `test_login_page_shows_cf_hint_when_available` FAILs (hint not yet rendered).
- [ ] **Step 3: Add CF hint to the login page context**
In `app/web/router.py`, locate the `login_page` handler (starts around line 194). Replace the body of the function with:
```python
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
next_path = request.query_params.get("next", "")
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = ""
providers = []
try:
from app.auth.providers.google import is_available as google_available
if google_available():
providers.append({"name": "google", "display_name": "Google", "icon": "google"})
except Exception:
pass
providers.append({"name": "password", "display_name": "Email & Password", "icon": "key"})
try:
from app.auth.providers.email import is_available as email_available
if email_available():
providers.append({"name": "email", "display_name": "Email Link", "icon": "mail"})
except Exception:
pass
# Convert to login_buttons format expected by template
login_buttons = []
for p in providers:
if p["name"] == "google":
login_buttons.append({"url": "/auth/google/login", "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""})
elif p["name"] == "password":
_url = "/login/password"
if next_path:
_url += f"?next={quote(next_path, safe='')}"
login_buttons.append({"url": _url, "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""})
elif p["name"] == "email":
_url = "/login/email"
if next_path:
_url += f"?next={quote(next_path, safe='')}"
login_buttons.append({"url": _url, "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""})
cf_available = False
try:
from app.auth.providers.cloudflare import is_available as cf_is_available
cf_available = cf_is_available()
except Exception:
pass
ctx = _build_context(
request, providers=providers, login_buttons=login_buttons,
next_path=next_path, cf_available=cf_available,
)
return templates.TemplateResponse(request, "login.html", ctx)
```
- [ ] **Step 4: Add the hint to the login template**
In `app/web/templates/login.html`, locate the `{% if not login_buttons %}` block (around line 117) and insert the CF hint just before it:
```html
{% if cf_available %}
<p class="login-note" style="margin-top: 16px; font-size: 12px; opacity: 0.8;">
This deployment is protected by Cloudflare Access. If you expected to be
signed in automatically, please access via your configured Cloudflare URL.
</p>
{% endif %}
{% if not login_buttons %}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `pytest tests/test_cloudflare_auth.py::TestLoginPageCfHint -v`
Expected: 2 passed.
- [ ] **Step 6: Commit**
```bash
git add app/web/router.py app/web/templates/login.html tests/test_cloudflare_auth.py
git commit -m "feat(auth): show Cloudflare Access hint on login page when enabled"
```
---
## Task 8: Documentation
**Files:**
- Create: `docs/auth-cloudflare.md`
- Modify: `README.md` (add one-line pointer)
- [ ] **Step 1: Write the ops documentation**
Create `docs/auth-cloudflare.md`:
````markdown
# Cloudflare Access Authentication
Agnes can be deployed behind a Cloudflare Zero Trust tunnel with Access
protecting it as an SSO gate. When configured, users who pass CF's
identity check are automatically signed into Agnes — no second login.
This works **alongside** the built-in password and Google OAuth flows:
direct connections (e.g. local dev, CLI with PAT) still use those. Only
the CF-gated path auto-logs-in.
## Prerequisites
- A Cloudflare Zero Trust team (Free tier works for up to 50 users)
- A domain routed to Agnes via Cloudflare Tunnel (`cloudflared`) or CF proxy
- An Access Application configured in front of that domain
## Configure the Access Application
1. In the Cloudflare Zero Trust dashboard → **Access****Applications**
**Add an application****Self-hosted**
2. Application domain: the hostname routed to your Agnes instance
(e.g. `agnes.yourco.com`)
3. Identity providers: enable your IdP (Google Workspace, Okta, etc.)
4. Policies: add at least one Allow policy (e.g. email ending in `@yourco.com`)
5. After creation, open the app → **Overview** tab and copy the **Application
Audience (AUD) Tag**
## Configure Agnes
Set two environment variables in your deployment (`.env` or Secret Manager):
```bash
CF_ACCESS_TEAM=yourteam # from https://yourteam.cloudflareaccess.com
CF_ACCESS_AUD=abc123... # AUD Tag from the Application → Overview page
```
Optionally restrict which email domains can auto-provision:
```bash
CF_ACCESS_DOMAIN_ALLOW=yourco.com,partner.com
```
If unset, falls back to `allowed_domains` in `config/instance.yaml` (same
allowlist used by the Google OAuth provider).
Restart Agnes. That's it — requests arriving with a valid
`Cf-Access-Jwt-Assertion` header will auto-provision a new `analyst` user
and issue a session cookie.
## Security Model
- **Both env vars required**: if either `CF_ACCESS_TEAM` or `CF_ACCESS_AUD`
is unset, the middleware is completely inert and the header is ignored.
This prevents header spoofing on deployments that don't actually sit
behind Cloudflare.
- **JWT verification**: signature checked against the team's JWKS
(`https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`, cached 5 min);
`aud` and `iss` both validated; expired tokens rejected.
- **Never overwrites an existing session**: if the user already has an
`access_token` cookie, the middleware passes through — you can always
sign in explicitly with password/Google on a CF-protected deployment.
- **Never 401s from middleware**: if verification fails for any reason, the
request continues to the normal auth layer — users see the normal login
page rather than a confusing middleware error.
- **PAT/API (Bearer) clients are skipped**: requests carrying an
`Authorization: Bearer <token>` header bypass the middleware entirely —
no cookie is set. This preserves the clean stateless contract for
CLI tools, CI, and scripts.
## Logout Semantics
Clicking "log out" in Agnes clears the local `access_token` cookie.
**However, if the user is still behind Cloudflare Access**, the next
request will carry a fresh `Cf-Access-Jwt-Assertion` header and the
middleware will immediately re-issue a session cookie — logout appears
to have no effect.
To fully sign out on a CF-gated deployment, the user must also sign out
of their Cloudflare Access session by visiting:
```
https://<your-agnes-domain>/cdn-cgi/access/logout
```
Consider linking to this URL from Agnes's logout UI on CF-gated
deployments, or document it in your internal user guide.
## Troubleshooting
**Auto-login doesn't happen:**
- Check `CF_ACCESS_TEAM` matches the exact subdomain (no protocol, no path):
`keboola`, not `https://keboola.cloudflareaccess.com`
- Check `CF_ACCESS_AUD` is the **Application AUD Tag**, not the Access
Team ID
- Verify the request actually has the header:
`curl -I https://agnes.yourco.com/dashboard` behind CF should show
`Cf-Access-Jwt-Assertion` in the request (use `cloudflared access curl`
or browser dev tools)
- Check Agnes logs for `CF Access JWT invalid: ...` or
`CF Access JWT verification error: ...`
**"User deactivated" redirect:**
- Someone deactivated this user in Agnes's admin panel. CF Access passes
identity, but Agnes enforces the `active` flag.
**New users arrive with `analyst` role — how do I get admin access?**
- Same as Google OAuth: bootstrap the first admin manually
(`POST /auth/bootstrap`) or have an existing admin promote via the web UI.
````
- [ ] **Step 2: Add a pointer to `README.md`**
In `README.md`, locate the "Documentation" section (around line 134) and add one line to the list:
```markdown
- [Cloudflare Access Auth](docs/auth-cloudflare.md) — SSO via Cloudflare Zero Trust tunnel
```
Place it between the Onboarding Guide and Deployment Guide entries.
- [ ] **Step 3: Commit**
```bash
git add docs/auth-cloudflare.md README.md
git commit -m "docs(auth): Cloudflare Access setup + troubleshooting guide"
```
---
## Task 9: Final integration — full test sweep + manual smoke
**Files:**
- None (verification only)
- [ ] **Step 1: Run the full test suite**
Run: `pytest tests/ -v --timeout=60`
Expected: all tests pass (633+ existing + ~20 new CF tests).
- [ ] **Step 2: Start the app locally without CF env and verify unchanged behavior**
Run:
```bash
unset CF_ACCESS_TEAM CF_ACCESS_AUD
DATA_DIR=./tmp-data SEED_ADMIN_EMAIL=admin@local.test JWT_SECRET_KEY=$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))') uvicorn app.main:app --port 8001 &
sleep 2
curl -s -I http://localhost:8001/login | head -1
curl -s -I http://localhost:8001/dashboard | head -3 # should 302 → /login
kill %1
```
Expected: `HTTP/1.1 200 OK` for `/login`, `HTTP/1.1 302` for `/dashboard` with `location: /login?...`.
- [ ] **Step 3: Start the app with CF env and verify CF hint on login page**
Run:
```bash
DATA_DIR=./tmp-data2 SEED_ADMIN_EMAIL=admin@local.test JWT_SECRET_KEY=$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))') CF_ACCESS_TEAM=example CF_ACCESS_AUD=test-aud uvicorn app.main:app --port 8002 &
sleep 2
curl -s http://localhost:8002/login | grep -c "Cloudflare Access"
kill %1
```
Expected: `1` (hint present).
- [ ] **Step 4: Verify middleware is inert without env even when header is spoofed**
Run (re-use the no-CF server from step 2):
```bash
unset CF_ACCESS_TEAM CF_ACCESS_AUD
DATA_DIR=./tmp-data3 SEED_ADMIN_EMAIL=admin@local.test JWT_SECRET_KEY=$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))') uvicorn app.main:app --port 8003 &
sleep 2
# Forge a header — should be ignored
curl -s -o /dev/null -w "%{http_code}\n" -H "Cf-Access-Jwt-Assertion: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdHRhY2tlciJ9.fake" http://localhost:8003/dashboard
kill %1
```
Expected: `302` (redirect to login — header ignored because `CF_ACCESS_*` env is unset).
- [ ] **Step 5: Clean up tmp dirs**
```bash
rm -rf tmp-data tmp-data2 tmp-data3
```
- [ ] **Step 6: Final commit and branch status**
```bash
git log --oneline -10
git status # should be clean
```
---
## Self-Review Notes
**Spec coverage:**
- ✅ Three auth methods coexist (Task 6 verifies existing providers unchanged; Task 7 verifies login page works with/without CF)
- ✅ CF header verification with JWKS + aud + iss + exp (Task 3)
- ✅ User provisioning with domain allowlist (Task 4)
- ✅ Middleware is pass-through on failure (Task 5)
- ✅ Existing cookie wins over CF header (Task 6, `test_existing_cookie_wins_over_cf_header`)
- ✅ Header spoofing prevented when env unset (Task 5 `test_middleware_unavailable_when_env_missing` + Task 9 Step 4)
- ✅ Docs cover setup + security model + troubleshooting (Task 8)
**Non-goals (explicit):**
- No group/role mapping from CF claims — new users always get `analyst`, admins promote manually (same as Google)
- No CLI/PAT integration with CF — PATs remain for programmatic access
- No UI to configure CF from the admin panel — env-only
**Risks / edge cases:**
- JWKS network fetch fails → `verify_cf_jwt` returns None → pass-through. Users see login page. Acceptable.
- Clock skew > 5min → tokens reject as expired. PyJWT has no leeway by default; acceptable (CF tokens are short-lived, typically 24h).
- `TESTING=1` disables cookie `secure=True` (see middleware `use_secure` logic) — matches existing pattern in `google.py:96`.
**Review-driven refinements applied (pre-execution):**
- **Env read at call time, not import time.** `CF_ACCESS_TEAM` / `CF_ACCESS_AUD` are read via helper functions on each call (Task 3), making tests and runtime env changes predictable. `_JWKS_CLIENT` cache keyed by team string so it rebuilds when the team env changes.
- **Autouse fixture** `_reset_cf_jwks_cache` resets the module-level JWKS client + team cache between tests, eliminating cross-test leakage (Task 1 Step 2).
- **PAT Bearer pass-through.** Middleware skips requests carrying `Authorization: Bearer` so CLI / PAT clients don't have cookies silently set on them (Task 5 Step 3). Test `test_bearer_pat_passes_through_without_cookie` verifies (Task 6 Step 1).
- **Email type safety.** `get_or_create_user_from_cf` guards `not isinstance(email, str)` (Task 4 Step 3).
- **Logout semantics documented.** `docs/auth-cloudflare.md` has a dedicated "Logout Semantics" section explaining the CF-session logout URL required for full sign-out on CF-gated deployments (Task 8 Step 1).