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

53 KiB

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:32is_available() always True
  • app/auth/providers/google.py:24is_available() returns True when GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET set
  • app/auth/providers/email.py:31is_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. keboolahttps://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.pyCloudflareAccessMiddleware 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:

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

@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:

@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
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:

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:

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

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:

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

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:

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

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:

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

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:

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

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:

@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:

                {% 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
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:

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

- [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
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:

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:

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):

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
rm -rf tmp-data tmp-data2 tmp-data3
  • Step 6: Final commit and branch status
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).