cc-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cc_auth-0.1.0/.github/workflows/publish.yml +32 -0
- cc_auth-0.1.0/.github/workflows/test.yml +28 -0
- cc_auth-0.1.0/.gitignore +12 -0
- cc_auth-0.1.0/PKG-INFO +291 -0
- cc_auth-0.1.0/README.md +255 -0
- cc_auth-0.1.0/cc_auth/__init__.py +30 -0
- cc_auth-0.1.0/cc_auth/core.py +278 -0
- cc_auth-0.1.0/cc_auth/exceptions.py +22 -0
- cc_auth-0.1.0/cc_auth/fastapi.py +92 -0
- cc_auth-0.1.0/cc_auth/identity.py +44 -0
- cc_auth-0.1.0/cc_auth/middleware.py +70 -0
- cc_auth-0.1.0/cc_auth/validator.py +85 -0
- cc_auth-0.1.0/docs/adr/001-group-claims-strategy.md +139 -0
- cc_auth-0.1.0/docs/azure-cloudflare-group-sources.md +202 -0
- cc_auth-0.1.0/pyproject.toml +59 -0
- cc_auth-0.1.0/tests/__init__.py +0 -0
- cc_auth-0.1.0/tests/conftest.py +92 -0
- cc_auth-0.1.0/tests/test_core.py +225 -0
- cc_auth-0.1.0/tests/test_fastapi.py +153 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
name: Build & publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: pypi
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
id-token: write # required for trusted publishing (no API token needed)
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Set up Python
|
|
21
|
+
uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: '3.11'
|
|
24
|
+
|
|
25
|
+
- name: Install build tools
|
|
26
|
+
run: pip install build
|
|
27
|
+
|
|
28
|
+
- name: Build package
|
|
29
|
+
run: python -m build
|
|
30
|
+
|
|
31
|
+
- name: Publish to PyPI
|
|
32
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
name: Python ${{ matrix.python-version }}
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: pip install -e ".[dev]"
|
|
26
|
+
|
|
27
|
+
- name: Run tests
|
|
28
|
+
run: pytest --tb=short -v
|
cc_auth-0.1.0/.gitignore
ADDED
cc_auth-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cc-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cloudflare Access JWT validation + RBAC for Python backends
|
|
5
|
+
Project-URL: Homepage, https://github.com/computacenter-ro/cc-auth
|
|
6
|
+
Project-URL: Repository, https://github.com/computacenter-ro/cc-auth
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/computacenter-ro/cc-auth/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: authentication,cloudflare,cloudflare-access,fastapi,jwt,rbac
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: pyjwt[crypto]>=2.8.0
|
|
24
|
+
Requires-Dist: requests>=2.31.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: cryptography>=42.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: starlette>=0.27.0; extra == 'dev'
|
|
32
|
+
Provides-Extra: fastapi
|
|
33
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
|
|
34
|
+
Requires-Dist: starlette>=0.27.0; extra == 'fastapi'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# cc-auth
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
Every service behind a Cloudflare Access Application receives a signed JWT on every request (`CF-Access-Jwt-Assertion` header / `CF_Authorization` cookie). This library:
|
|
42
|
+
|
|
43
|
+
1. **Validates** the JWT signature against Cloudflare's public keys (JWKS)
|
|
44
|
+
2. **Extracts** identity — email, name, Entra ID group memberships
|
|
45
|
+
3. **Maps** Entra ID groups → application roles
|
|
46
|
+
4. Provides **FastAPI middleware and dependencies** so routes declare their role requirements in one line
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Documentation
|
|
51
|
+
|
|
52
|
+
| Document | Description |
|
|
53
|
+
|---|---|
|
|
54
|
+
| [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 |
|
|
55
|
+
| [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 |
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Architecture
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
Microsoft Entra ID
|
|
63
|
+
│
|
|
64
|
+
│ User authenticates (OIDC)
|
|
65
|
+
▼
|
|
66
|
+
Cloudflare Zero Trust (Access Application)
|
|
67
|
+
│
|
|
68
|
+
│ Issues signed JWT containing:
|
|
69
|
+
│ { "email": "user@cc.com",
|
|
70
|
+
│ "groups": ["group-id-1", "group-id-2"],
|
|
71
|
+
│ "iss": "https://<team-domain>" }
|
|
72
|
+
│
|
|
73
|
+
│ JWT travels as header + cookie on every request
|
|
74
|
+
▼
|
|
75
|
+
Your FastAPI backend ◄──── cc-auth validates + maps groups → roles
|
|
76
|
+
│
|
|
77
|
+
│ Route reads User.roles for RBAC decisions
|
|
78
|
+
▼
|
|
79
|
+
Business logic
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**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.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Installation
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install cc-auth
|
|
90
|
+
# or with FastAPI middleware helpers:
|
|
91
|
+
pip install "cc-auth[fastapi]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Pin a specific version for reproducible production builds:
|
|
95
|
+
```
|
|
96
|
+
cc-auth[fastapi]==0.1.0
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Quickstart (FastAPI)
|
|
102
|
+
|
|
103
|
+
### 1. Enable group claims (one-time, per Zero Trust account)
|
|
104
|
+
|
|
105
|
+
In Zero Trust → Settings → Authentication → your Azure AD identity provider → **Edit**:
|
|
106
|
+
- Enable **"Support groups"**
|
|
107
|
+
|
|
108
|
+
This makes Cloudflare include the user's Entra ID group Object IDs in every JWT.
|
|
109
|
+
|
|
110
|
+
### 2. Add the library to your app
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# main.py
|
|
114
|
+
from fastapi import FastAPI
|
|
115
|
+
from cc_auth import CloudflareAuth
|
|
116
|
+
|
|
117
|
+
auth = CloudflareAuth.from_env()
|
|
118
|
+
|
|
119
|
+
app = FastAPI()
|
|
120
|
+
app.add_middleware(auth.middleware(exclude_paths=["/health"]))
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
No configuration is required to get started. Add `CF_ACCESS_GROUP_MAPPING` when you
|
|
124
|
+
need role-based access control (see [Environment variables](#environment-variables)).
|
|
125
|
+
|
|
126
|
+
### 3. Use in routes
|
|
127
|
+
|
|
128
|
+
Most routes need **nothing extra** — the middleware already blocks unauthenticated
|
|
129
|
+
requests. Only add `Depends(...)` when you need the user object or role checks inside
|
|
130
|
+
the handler:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from cc_auth import User
|
|
134
|
+
|
|
135
|
+
# Protected automatically by middleware — no Depends needed
|
|
136
|
+
@app.get("/items")
|
|
137
|
+
async def list_items():
|
|
138
|
+
return [...]
|
|
139
|
+
|
|
140
|
+
# Need to know who the user is
|
|
141
|
+
@app.get("/me")
|
|
142
|
+
async def me(user: User = Depends(auth.current_user)):
|
|
143
|
+
return {"email": user.email, "roles": user.roles}
|
|
144
|
+
|
|
145
|
+
# Restrict to admins only
|
|
146
|
+
@app.delete("/items/{id}")
|
|
147
|
+
async def delete_item(id: str, user: User = Depends(auth.require_role("admin"))):
|
|
148
|
+
...
|
|
149
|
+
|
|
150
|
+
# Restrict to admins or editors
|
|
151
|
+
@app.post("/items")
|
|
152
|
+
async def create_item(user: User = Depends(auth.require_any_role(["admin", "editor"]))):
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
# Excluded from auth — open to anyone
|
|
156
|
+
@app.get("/health")
|
|
157
|
+
async def health():
|
|
158
|
+
return {"status": "ok"}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Configuration reference
|
|
164
|
+
|
|
165
|
+
### `CloudflareAuth(team_domain, role_groups=None)`
|
|
166
|
+
|
|
167
|
+
| Parameter | Type | Required | Description |
|
|
168
|
+
|---|---|---|---|
|
|
169
|
+
| `team_domain` | `str` | **Yes** | Your Cloudflare Zero Trust team domain, e.g. `your-org.cloudflareaccess.com` |
|
|
170
|
+
| `role_groups` | `dict[str, str]` | No | Map of Entra ID group Object ID → role name |
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
### `User` object
|
|
175
|
+
|
|
176
|
+
| Field | Type | Description |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `sub` | `str` | Cloudflare user identifier (stable across sessions) |
|
|
179
|
+
| `email` | `str` | User's email address |
|
|
180
|
+
| `name` | `str` | Display name (falls back to email) |
|
|
181
|
+
| `groups` | `list[str]` | Entra ID group Object IDs — raw, from JWT |
|
|
182
|
+
| `roles` | `list[str]` | Role names mapped from groups via `role_groups` |
|
|
183
|
+
| `raw` | `dict` | Full decoded JWT payload |
|
|
184
|
+
|
|
185
|
+
Helper methods:
|
|
186
|
+
- `user.has_role("admin")` → `bool`
|
|
187
|
+
- `user.has_any_role(["admin", "editor"])` → `bool`
|
|
188
|
+
- `user.has_all_roles(["admin", "editor"])` → `bool`
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Minimal setup (no RBAC)
|
|
193
|
+
|
|
194
|
+
If your app only needs SSO (any authenticated user), no environment variables are needed:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
auth = CloudflareAuth.from_env()
|
|
198
|
+
|
|
199
|
+
app.add_middleware(auth.middleware()) # protects every route
|
|
200
|
+
|
|
201
|
+
@app.get("/data")
|
|
202
|
+
async def data():
|
|
203
|
+
return [...]
|
|
204
|
+
|
|
205
|
+
@app.get("/me")
|
|
206
|
+
async def me(user: User = Depends(auth.current_user)):
|
|
207
|
+
return {"email": user.email}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Health checks and excluded paths
|
|
213
|
+
|
|
214
|
+
Skip auth for paths that must be publicly reachable:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
app.add_middleware(auth.middleware(exclude_paths=["/health", "/metrics"]))
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Path matching is prefix-based: `/health` excludes `/health`, `/health/live`, `/healthz`, etc.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Environment variables
|
|
225
|
+
|
|
226
|
+
`CloudflareAuth.from_env()` reads the following environment variables:
|
|
227
|
+
|
|
228
|
+
| Variable | Description |
|
|
229
|
+
|---|---|
|
|
230
|
+
| `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. |
|
|
231
|
+
| `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. |
|
|
232
|
+
| `CF_TEAM_DOMAIN` | **Required for `from_env()`**. Your Cloudflare Zero Trust team domain, e.g. `your-org.cloudflareaccess.com`. |
|
|
233
|
+
|
|
234
|
+
Both `CF_ACCESS_GROUP_MAPPING` and `CF_ACCESS_REQUIRED_GROUPS` may be set at the same
|
|
235
|
+
time. If the same group Object ID appears in both, `CF_ACCESS_GROUP_MAPPING` wins.
|
|
236
|
+
|
|
237
|
+
### Simple allow/deny (no named roles)
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
CF_ACCESS_REQUIRED_GROUPS=4fb32c31-b135-43d5-a00e-9e566e6aceff,6543851f-fd97-40e8-b097-ab5a71e44ef2
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
auth = CloudflareAuth.from_env()
|
|
245
|
+
|
|
246
|
+
@app.get("/data")
|
|
247
|
+
async def data(user: User = Depends(auth.require_role("member"))):
|
|
248
|
+
...
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Named roles
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
CF_ACCESS_GROUP_MAPPING={"admins":"4fb32c31-...","developers":"6543851f-..."}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
auth = CloudflareAuth.from_env()
|
|
259
|
+
|
|
260
|
+
@app.delete("/items/{id}")
|
|
261
|
+
async def delete(id: str, user: User = Depends(auth.require_role("admins"))):
|
|
262
|
+
...
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## How it works under the hood
|
|
268
|
+
|
|
269
|
+
1. On the first request, `PyJWKClient` fetches `https://<team-domain>/cdn-cgi/access/certs` and caches the RSA public keys in memory.
|
|
270
|
+
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`.
|
|
271
|
+
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.
|
|
272
|
+
4. Role mapping is a simple dict lookup: O(1) per group.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Testing
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
pip install -e ".[dev]"
|
|
280
|
+
pytest
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Adding to `requirements.txt`
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
cc-auth[fastapi]==0.1.0
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Pin to a specific version for reproducible production builds. Check [PyPI](https://pypi.org/project/cc-auth/) for the latest version.
|
cc_auth-0.1.0/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# cc-auth
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
Every service behind a Cloudflare Access Application receives a signed JWT on every request (`CF-Access-Jwt-Assertion` header / `CF_Authorization` cookie). This library:
|
|
6
|
+
|
|
7
|
+
1. **Validates** the JWT signature against Cloudflare's public keys (JWKS)
|
|
8
|
+
2. **Extracts** identity — email, name, Entra ID group memberships
|
|
9
|
+
3. **Maps** Entra ID groups → application roles
|
|
10
|
+
4. Provides **FastAPI middleware and dependencies** so routes declare their role requirements in one line
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Documentation
|
|
15
|
+
|
|
16
|
+
| Document | Description |
|
|
17
|
+
|---|---|
|
|
18
|
+
| [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 |
|
|
19
|
+
| [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 |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Microsoft Entra ID
|
|
27
|
+
│
|
|
28
|
+
│ User authenticates (OIDC)
|
|
29
|
+
▼
|
|
30
|
+
Cloudflare Zero Trust (Access Application)
|
|
31
|
+
│
|
|
32
|
+
│ Issues signed JWT containing:
|
|
33
|
+
│ { "email": "user@cc.com",
|
|
34
|
+
│ "groups": ["group-id-1", "group-id-2"],
|
|
35
|
+
│ "iss": "https://<team-domain>" }
|
|
36
|
+
│
|
|
37
|
+
│ JWT travels as header + cookie on every request
|
|
38
|
+
▼
|
|
39
|
+
Your FastAPI backend ◄──── cc-auth validates + maps groups → roles
|
|
40
|
+
│
|
|
41
|
+
│ Route reads User.roles for RBAC decisions
|
|
42
|
+
▼
|
|
43
|
+
Business logic
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**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.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install cc-auth
|
|
54
|
+
# or with FastAPI middleware helpers:
|
|
55
|
+
pip install "cc-auth[fastapi]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Pin a specific version for reproducible production builds:
|
|
59
|
+
```
|
|
60
|
+
cc-auth[fastapi]==0.1.0
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Quickstart (FastAPI)
|
|
66
|
+
|
|
67
|
+
### 1. Enable group claims (one-time, per Zero Trust account)
|
|
68
|
+
|
|
69
|
+
In Zero Trust → Settings → Authentication → your Azure AD identity provider → **Edit**:
|
|
70
|
+
- Enable **"Support groups"**
|
|
71
|
+
|
|
72
|
+
This makes Cloudflare include the user's Entra ID group Object IDs in every JWT.
|
|
73
|
+
|
|
74
|
+
### 2. Add the library to your app
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# main.py
|
|
78
|
+
from fastapi import FastAPI
|
|
79
|
+
from cc_auth import CloudflareAuth
|
|
80
|
+
|
|
81
|
+
auth = CloudflareAuth.from_env()
|
|
82
|
+
|
|
83
|
+
app = FastAPI()
|
|
84
|
+
app.add_middleware(auth.middleware(exclude_paths=["/health"]))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
No configuration is required to get started. Add `CF_ACCESS_GROUP_MAPPING` when you
|
|
88
|
+
need role-based access control (see [Environment variables](#environment-variables)).
|
|
89
|
+
|
|
90
|
+
### 3. Use in routes
|
|
91
|
+
|
|
92
|
+
Most routes need **nothing extra** — the middleware already blocks unauthenticated
|
|
93
|
+
requests. Only add `Depends(...)` when you need the user object or role checks inside
|
|
94
|
+
the handler:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from cc_auth import User
|
|
98
|
+
|
|
99
|
+
# Protected automatically by middleware — no Depends needed
|
|
100
|
+
@app.get("/items")
|
|
101
|
+
async def list_items():
|
|
102
|
+
return [...]
|
|
103
|
+
|
|
104
|
+
# Need to know who the user is
|
|
105
|
+
@app.get("/me")
|
|
106
|
+
async def me(user: User = Depends(auth.current_user)):
|
|
107
|
+
return {"email": user.email, "roles": user.roles}
|
|
108
|
+
|
|
109
|
+
# Restrict to admins only
|
|
110
|
+
@app.delete("/items/{id}")
|
|
111
|
+
async def delete_item(id: str, user: User = Depends(auth.require_role("admin"))):
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
# Restrict to admins or editors
|
|
115
|
+
@app.post("/items")
|
|
116
|
+
async def create_item(user: User = Depends(auth.require_any_role(["admin", "editor"]))):
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
# Excluded from auth — open to anyone
|
|
120
|
+
@app.get("/health")
|
|
121
|
+
async def health():
|
|
122
|
+
return {"status": "ok"}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Configuration reference
|
|
128
|
+
|
|
129
|
+
### `CloudflareAuth(team_domain, role_groups=None)`
|
|
130
|
+
|
|
131
|
+
| Parameter | Type | Required | Description |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| `team_domain` | `str` | **Yes** | Your Cloudflare Zero Trust team domain, e.g. `your-org.cloudflareaccess.com` |
|
|
134
|
+
| `role_groups` | `dict[str, str]` | No | Map of Entra ID group Object ID → role name |
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
### `User` object
|
|
139
|
+
|
|
140
|
+
| Field | Type | Description |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `sub` | `str` | Cloudflare user identifier (stable across sessions) |
|
|
143
|
+
| `email` | `str` | User's email address |
|
|
144
|
+
| `name` | `str` | Display name (falls back to email) |
|
|
145
|
+
| `groups` | `list[str]` | Entra ID group Object IDs — raw, from JWT |
|
|
146
|
+
| `roles` | `list[str]` | Role names mapped from groups via `role_groups` |
|
|
147
|
+
| `raw` | `dict` | Full decoded JWT payload |
|
|
148
|
+
|
|
149
|
+
Helper methods:
|
|
150
|
+
- `user.has_role("admin")` → `bool`
|
|
151
|
+
- `user.has_any_role(["admin", "editor"])` → `bool`
|
|
152
|
+
- `user.has_all_roles(["admin", "editor"])` → `bool`
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Minimal setup (no RBAC)
|
|
157
|
+
|
|
158
|
+
If your app only needs SSO (any authenticated user), no environment variables are needed:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
auth = CloudflareAuth.from_env()
|
|
162
|
+
|
|
163
|
+
app.add_middleware(auth.middleware()) # protects every route
|
|
164
|
+
|
|
165
|
+
@app.get("/data")
|
|
166
|
+
async def data():
|
|
167
|
+
return [...]
|
|
168
|
+
|
|
169
|
+
@app.get("/me")
|
|
170
|
+
async def me(user: User = Depends(auth.current_user)):
|
|
171
|
+
return {"email": user.email}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Health checks and excluded paths
|
|
177
|
+
|
|
178
|
+
Skip auth for paths that must be publicly reachable:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
app.add_middleware(auth.middleware(exclude_paths=["/health", "/metrics"]))
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Path matching is prefix-based: `/health` excludes `/health`, `/health/live`, `/healthz`, etc.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Environment variables
|
|
189
|
+
|
|
190
|
+
`CloudflareAuth.from_env()` reads the following environment variables:
|
|
191
|
+
|
|
192
|
+
| Variable | Description |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `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. |
|
|
195
|
+
| `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. |
|
|
196
|
+
| `CF_TEAM_DOMAIN` | **Required for `from_env()`**. Your Cloudflare Zero Trust team domain, e.g. `your-org.cloudflareaccess.com`. |
|
|
197
|
+
|
|
198
|
+
Both `CF_ACCESS_GROUP_MAPPING` and `CF_ACCESS_REQUIRED_GROUPS` may be set at the same
|
|
199
|
+
time. If the same group Object ID appears in both, `CF_ACCESS_GROUP_MAPPING` wins.
|
|
200
|
+
|
|
201
|
+
### Simple allow/deny (no named roles)
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
CF_ACCESS_REQUIRED_GROUPS=4fb32c31-b135-43d5-a00e-9e566e6aceff,6543851f-fd97-40e8-b097-ab5a71e44ef2
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
auth = CloudflareAuth.from_env()
|
|
209
|
+
|
|
210
|
+
@app.get("/data")
|
|
211
|
+
async def data(user: User = Depends(auth.require_role("member"))):
|
|
212
|
+
...
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Named roles
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
CF_ACCESS_GROUP_MAPPING={"admins":"4fb32c31-...","developers":"6543851f-..."}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
auth = CloudflareAuth.from_env()
|
|
223
|
+
|
|
224
|
+
@app.delete("/items/{id}")
|
|
225
|
+
async def delete(id: str, user: User = Depends(auth.require_role("admins"))):
|
|
226
|
+
...
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## How it works under the hood
|
|
232
|
+
|
|
233
|
+
1. On the first request, `PyJWKClient` fetches `https://<team-domain>/cdn-cgi/access/certs` and caches the RSA public keys in memory.
|
|
234
|
+
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`.
|
|
235
|
+
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.
|
|
236
|
+
4. Role mapping is a simple dict lookup: O(1) per group.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Testing
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
pip install -e ".[dev]"
|
|
244
|
+
pytest
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Adding to `requirements.txt`
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
cc-auth[fastapi]==0.1.0
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Pin to a specific version for reproducible production builds. Check [PyPI](https://pypi.org/project/cc-auth/) for the latest version.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cc-auth — Cloudflare Access JWT validation + RBAC for Python backends.
|
|
3
|
+
|
|
4
|
+
Quickstart::
|
|
5
|
+
|
|
6
|
+
from cc_auth import CloudflareAuth, User
|
|
7
|
+
|
|
8
|
+
# No configuration required to get started.
|
|
9
|
+
# Set CF_ACCESS_GROUP_MAPPING for role-based access control.
|
|
10
|
+
auth = CloudflareAuth.from_env()
|
|
11
|
+
|
|
12
|
+
# FastAPI
|
|
13
|
+
app.add_middleware(auth.middleware(exclude_paths=["/health"]))
|
|
14
|
+
|
|
15
|
+
@router.get("/me")
|
|
16
|
+
async def me(user: User = Depends(auth.current_user)):
|
|
17
|
+
return {"email": user.email, "roles": user.roles}
|
|
18
|
+
|
|
19
|
+
@router.delete("/item/{id}")
|
|
20
|
+
async def delete(id: str, user: User = Depends(auth.require_role("admin"))):
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .core import CloudflareAuth
|
|
25
|
+
from .exceptions import AuthError
|
|
26
|
+
from .exceptions import PermissionError as CCPermissionError
|
|
27
|
+
from .identity import User
|
|
28
|
+
|
|
29
|
+
__all__ = ["CloudflareAuth", "User", "AuthError", "CCPermissionError"]
|
|
30
|
+
__version__ = "0.1.0"
|