Metadata-Version: 2.4
Name: cc-auth
Version: 0.1.0
Summary: Cloudflare Access JWT validation + RBAC for Python backends
Project-URL: Homepage, https://github.com/computacenter-ro/cc-auth
Project-URL: Repository, https://github.com/computacenter-ro/cc-auth
Project-URL: Bug Tracker, https://github.com/computacenter-ro/cc-auth/issues
License: MIT
Keywords: authentication,cloudflare,cloudflare-access,fastapi,jwt,rbac
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: pyjwt[crypto]>=2.8.0
Requires-Dist: requests>=2.31.0
Provides-Extra: dev
Requires-Dist: cryptography>=42.0; extra == 'dev'
Requires-Dist: fastapi>=0.100.0; extra == 'dev'
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: starlette>=0.27.0; extra == 'dev'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
Requires-Dist: starlette>=0.27.0; extra == 'fastapi'
Description-Content-Type: text/markdown

# cc-auth

Python library for validating [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) JWTs and enforcing role-based access control (RBAC) in backend services.

Every service behind a Cloudflare Access Application receives a signed JWT on every request (`CF-Access-Jwt-Assertion` header / `CF_Authorization` cookie). This library:

1. **Validates** the JWT signature against Cloudflare's public keys (JWKS)
2. **Extracts** identity — email, name, Entra ID group memberships
3. **Maps** Entra ID groups → application roles
4. Provides **FastAPI middleware and dependencies** so routes declare their role requirements in one line

---

## Documentation

| Document | Description |
|---|---|
| [Azure + Cloudflare group sources](docs/azure-cloudflare-group-sources.md) | Deep dive into the two mechanisms that supply group membership (Microsoft Graph vs ID Token claim), why both exist, and which one `cc-auth` relies on |
| [ADR-001 — Group claims strategy](docs/adr/001-group-claims-strategy.md) | Architecture Decision Record: why Source A (Support Groups) is the canonical group source, and the trade-offs accepted |

---

## Architecture

```
Microsoft Entra ID
      │
      │  User authenticates (OIDC)
      ▼
Cloudflare Zero Trust (Access Application)
      │
      │  Issues signed JWT containing:
      │    { "email": "user@cc.com",
      │      "groups": ["group-id-1", "group-id-2"],
      │      "iss": "https://<team-domain>" }
      │
      │  JWT travels as header + cookie on every request
      ▼
Your FastAPI backend  ◄──── cc-auth validates + maps groups → roles
      │
      │  Route reads User.roles for RBAC decisions
      ▼
Business logic
```

**Important:** the backend must always validate the JWT itself. Cloudflare validates at the edge, but if your origin IP is ever reachable directly (bypassing Cloudflare), only your backend's validation stands.

---

## Installation

```bash
pip install cc-auth
# or with FastAPI middleware helpers:
pip install "cc-auth[fastapi]"
```

Pin a specific version for reproducible production builds:
```
cc-auth[fastapi]==0.1.0
```

---

## Quickstart (FastAPI)

### 1. Enable group claims (one-time, per Zero Trust account)

In Zero Trust → Settings → Authentication → your Azure AD identity provider → **Edit**:
- Enable **"Support groups"**

This makes Cloudflare include the user's Entra ID group Object IDs in every JWT.

### 2. Add the library to your app

```python
# main.py
from fastapi import FastAPI
from cc_auth import CloudflareAuth

auth = CloudflareAuth.from_env()

app = FastAPI()
app.add_middleware(auth.middleware(exclude_paths=["/health"]))
```

No configuration is required to get started. Add `CF_ACCESS_GROUP_MAPPING` when you
need role-based access control (see [Environment variables](#environment-variables)).

### 3. Use in routes

Most routes need **nothing extra** — the middleware already blocks unauthenticated
requests. Only add `Depends(...)` when you need the user object or role checks inside
the handler:

```python
from cc_auth import User

# Protected automatically by middleware — no Depends needed
@app.get("/items")
async def list_items():
    return [...]

# Need to know who the user is
@app.get("/me")
async def me(user: User = Depends(auth.current_user)):
    return {"email": user.email, "roles": user.roles}

# Restrict to admins only
@app.delete("/items/{id}")
async def delete_item(id: str, user: User = Depends(auth.require_role("admin"))):
    ...

# Restrict to admins or editors
@app.post("/items")
async def create_item(user: User = Depends(auth.require_any_role(["admin", "editor"]))):
    ...

# Excluded from auth — open to anyone
@app.get("/health")
async def health():
    return {"status": "ok"}
```

---

## Configuration reference

### `CloudflareAuth(team_domain, role_groups=None)`

| Parameter | Type | Required | Description |
|---|---|---|---|
| `team_domain` | `str` | **Yes** | Your Cloudflare Zero Trust team domain, e.g. `your-org.cloudflareaccess.com` |
| `role_groups` | `dict[str, str]` | No | Map of Entra ID group Object ID → role name |

---

### `User` object

| Field | Type | Description |
|---|---|---|
| `sub` | `str` | Cloudflare user identifier (stable across sessions) |
| `email` | `str` | User's email address |
| `name` | `str` | Display name (falls back to email) |
| `groups` | `list[str]` | Entra ID group Object IDs — raw, from JWT |
| `roles` | `list[str]` | Role names mapped from groups via `role_groups` |
| `raw` | `dict` | Full decoded JWT payload |

Helper methods:
- `user.has_role("admin")` → `bool`
- `user.has_any_role(["admin", "editor"])` → `bool`
- `user.has_all_roles(["admin", "editor"])` → `bool`

---

## Minimal setup (no RBAC)

If your app only needs SSO (any authenticated user), no environment variables are needed:

```python
auth = CloudflareAuth.from_env()

app.add_middleware(auth.middleware())   # protects every route

@app.get("/data")
async def data():
    return [...]

@app.get("/me")
async def me(user: User = Depends(auth.current_user)):
    return {"email": user.email}
```

---

## Health checks and excluded paths

Skip auth for paths that must be publicly reachable:

```python
app.add_middleware(auth.middleware(exclude_paths=["/health", "/metrics"]))
```

Path matching is prefix-based: `/health` excludes `/health`, `/health/live`, `/healthz`, etc.

---

## Environment variables

`CloudflareAuth.from_env()` reads the following environment variables:

| Variable | Description |
|---|---|
| `CF_ACCESS_GROUP_MAPPING` | JSON `{"role_name": "group-object-id", ...}`. Role name is the key; group Object ID is the value. The library inverts this internally. |
| `CF_ACCESS_REQUIRED_GROUPS` | Comma-separated group Object IDs. Each maps to the built-in role `"member"`. Use for simple allow/deny without named roles. |
| `CF_TEAM_DOMAIN` | **Required for `from_env()`**. Your Cloudflare Zero Trust team domain, e.g. `your-org.cloudflareaccess.com`. |

Both `CF_ACCESS_GROUP_MAPPING` and `CF_ACCESS_REQUIRED_GROUPS` may be set at the same
time. If the same group Object ID appears in both, `CF_ACCESS_GROUP_MAPPING` wins.

### Simple allow/deny (no named roles)

```bash
CF_ACCESS_REQUIRED_GROUPS=4fb32c31-b135-43d5-a00e-9e566e6aceff,6543851f-fd97-40e8-b097-ab5a71e44ef2
```

```python
auth = CloudflareAuth.from_env()

@app.get("/data")
async def data(user: User = Depends(auth.require_role("member"))):
    ...
```

### Named roles

```bash
CF_ACCESS_GROUP_MAPPING={"admins":"4fb32c31-...","developers":"6543851f-..."}
```

```python
auth = CloudflareAuth.from_env()

@app.delete("/items/{id}")
async def delete(id: str, user: User = Depends(auth.require_role("admins"))):
    ...
```

---

## How it works under the hood

1. On the first request, `PyJWKClient` fetches `https://<team-domain>/cdn-cgi/access/certs` and caches the RSA public keys in memory.
2. For each request: the token's `kid` header selects the right public key from the cache; `PyJWT` verifies the RS256 signature, `iss`, and `exp`.
3. Groups are read from the `groups` claim — an array of Entra ID group Object IDs added by Cloudflare when "Support groups" is enabled on the Azure AD identity provider.
4. Role mapping is a simple dict lookup: O(1) per group.

---

## Testing

```bash
pip install -e ".[dev]"
pytest
```

---

## Adding to `requirements.txt`

```
cc-auth[fastapi]==0.1.0
```

Pin to a specific version for reproducible production builds. Check [PyPI](https://pypi.org/project/cc-auth/) for the latest version.
