cc-auth 0.1.0__py3-none-any.whl
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/__init__.py +30 -0
- cc_auth/core.py +278 -0
- cc_auth/exceptions.py +22 -0
- cc_auth/fastapi.py +92 -0
- cc_auth/identity.py +44 -0
- cc_auth/middleware.py +70 -0
- cc_auth/validator.py +85 -0
- cc_auth-0.1.0.dist-info/METADATA +291 -0
- cc_auth-0.1.0.dist-info/RECORD +10 -0
- cc_auth-0.1.0.dist-info/WHEEL +4 -0
cc_auth/__init__.py
ADDED
|
@@ -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"
|
cc_auth/core.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CloudflareAuth — the main entry point for the library.
|
|
3
|
+
|
|
4
|
+
Instantiate once at application startup and pass it to your framework
|
|
5
|
+
integration (middleware or dependency).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from .exceptions import AuthError
|
|
14
|
+
from .identity import User
|
|
15
|
+
from .validator import TokenValidator
|
|
16
|
+
|
|
17
|
+
# Header and cookie names Cloudflare uses to carry the JWT.
|
|
18
|
+
_CF_HEADER = "cf-access-jwt-assertion" # lowercase after ASGI normalisation
|
|
19
|
+
_CF_HEADER_RAW = "CF-Access-Jwt-Assertion" # as sent by browsers / curl
|
|
20
|
+
_CF_COOKIE = "CF_Authorization"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CloudflareAuth:
|
|
24
|
+
"""
|
|
25
|
+
Validates Cloudflare Access JWTs and maps group memberships to roles.
|
|
26
|
+
|
|
27
|
+
Typical usage::
|
|
28
|
+
|
|
29
|
+
auth = CloudflareAuth.from_env() # reads CF_ACCESS_GROUP_MAPPING
|
|
30
|
+
|
|
31
|
+
app.add_middleware(auth.middleware(exclude_paths=["/health"]))
|
|
32
|
+
|
|
33
|
+
@router.get("/me")
|
|
34
|
+
async def me(user: User = Depends(auth.current_user)):
|
|
35
|
+
return {"email": user.email, "roles": user.roles}
|
|
36
|
+
|
|
37
|
+
@router.delete("/item/{id}")
|
|
38
|
+
async def delete(id: str, user: User = Depends(auth.require_role("admin"))):
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
The same instance should be reused for the lifetime of the application
|
|
42
|
+
so the JWKS key cache is shared across requests.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
team_domain: str,
|
|
48
|
+
role_groups: Optional[Dict[str, str]] = None,
|
|
49
|
+
_jwks_client=None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Args:
|
|
53
|
+
team_domain: Your Cloudflare Zero Trust team domain,
|
|
54
|
+
e.g. ``your-org.cloudflareaccess.com``.
|
|
55
|
+
Use :meth:`from_env` to read this from the
|
|
56
|
+
``CF_TEAM_DOMAIN`` environment variable instead.
|
|
57
|
+
role_groups: Optional mapping of Entra ID group Object ID → role name.
|
|
58
|
+
Users whose group memberships match a key receive the
|
|
59
|
+
corresponding role in ``User.roles``. Users in groups not
|
|
60
|
+
listed here are still authenticated; they just have no roles.
|
|
61
|
+
_jwks_client: For testing only — inject a pre-built ``PyJWKClient``
|
|
62
|
+
(or any object with a ``get_signing_key_from_jwt`` method)
|
|
63
|
+
to bypass the live JWKS fetch.
|
|
64
|
+
"""
|
|
65
|
+
if not team_domain:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"team_domain is required. Pass your Cloudflare Zero Trust team domain "
|
|
68
|
+
"(e.g. 'your-org.cloudflareaccess.com') or set the CF_TEAM_DOMAIN "
|
|
69
|
+
"environment variable and use CloudflareAuth.from_env()."
|
|
70
|
+
)
|
|
71
|
+
self._validator = TokenValidator(team_domain, jwks_client=_jwks_client)
|
|
72
|
+
self._role_groups: Dict[str, str] = role_groups or {}
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Factory
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_env(
|
|
80
|
+
cls,
|
|
81
|
+
*,
|
|
82
|
+
group_mapping_env: str = "CF_ACCESS_GROUP_MAPPING",
|
|
83
|
+
required_groups_env: str = "CF_ACCESS_REQUIRED_GROUPS",
|
|
84
|
+
team_domain_env: str = "CF_TEAM_DOMAIN",
|
|
85
|
+
_jwks_client=None,
|
|
86
|
+
) -> "CloudflareAuth":
|
|
87
|
+
"""
|
|
88
|
+
Create a :class:`CloudflareAuth` instance from environment variables.
|
|
89
|
+
|
|
90
|
+
This is the recommended construction path for production services.
|
|
91
|
+
It keeps Entra ID group Object IDs out of source code and lets each
|
|
92
|
+
deployment choose its own allowed groups and roles.
|
|
93
|
+
|
|
94
|
+
Environment variables read:
|
|
95
|
+
|
|
96
|
+
``CF_ACCESS_GROUP_MAPPING`` *(optional)*
|
|
97
|
+
JSON object mapping **role name → group Object ID**.
|
|
98
|
+
Role names are chosen by the application; they can be anything.
|
|
99
|
+
Note the order: role name comes first (human-readable), group ID
|
|
100
|
+
comes second (opaque). The library inverts this internally.
|
|
101
|
+
|
|
102
|
+
Example::
|
|
103
|
+
|
|
104
|
+
CF_ACCESS_GROUP_MAPPING={"admins":"4fb32c31-...","developers":"6543851f-..."}
|
|
105
|
+
|
|
106
|
+
``CF_ACCESS_REQUIRED_GROUPS`` *(optional)*
|
|
107
|
+
Comma-separated list of group Object IDs that are allowed to
|
|
108
|
+
access this application. Each group is mapped to the role
|
|
109
|
+
``"member"``. Use this when you need allow/deny without
|
|
110
|
+
distinct named roles, e.g.::
|
|
111
|
+
|
|
112
|
+
CF_ACCESS_REQUIRED_GROUPS=4fb32c31-...,6543851f-...
|
|
113
|
+
|
|
114
|
+
Then enforce access with::
|
|
115
|
+
|
|
116
|
+
Depends(auth.require_role("member"))
|
|
117
|
+
|
|
118
|
+
``CF_TEAM_DOMAIN`` *(required)*
|
|
119
|
+
Your Cloudflare Zero Trust team domain,
|
|
120
|
+
e.g. ``your-org.cloudflareaccess.com``.
|
|
121
|
+
|
|
122
|
+
Both ``CF_ACCESS_GROUP_MAPPING`` and ``CF_ACCESS_REQUIRED_GROUPS``
|
|
123
|
+
may be set simultaneously; their entries are merged. If the same
|
|
124
|
+
group Object ID appears in both, ``CF_ACCESS_GROUP_MAPPING`` wins
|
|
125
|
+
(its role name takes precedence over ``"member"``).
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: If ``CF_ACCESS_GROUP_MAPPING`` is not valid JSON.
|
|
129
|
+
"""
|
|
130
|
+
team_domain_override = os.getenv(team_domain_env, "").strip()
|
|
131
|
+
|
|
132
|
+
role_groups: Dict[str, str] = {}
|
|
133
|
+
|
|
134
|
+
required_raw = os.getenv(required_groups_env, "").strip()
|
|
135
|
+
if required_raw:
|
|
136
|
+
for gid in (g.strip() for g in required_raw.split(",") if g.strip()):
|
|
137
|
+
role_groups[gid] = "member"
|
|
138
|
+
|
|
139
|
+
mapping_raw = os.getenv(group_mapping_env, "").strip()
|
|
140
|
+
if mapping_raw:
|
|
141
|
+
try:
|
|
142
|
+
mapping: Dict[str, str] = json.loads(mapping_raw)
|
|
143
|
+
except json.JSONDecodeError as exc:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"{group_mapping_env} is not valid JSON: {exc}"
|
|
146
|
+
) from exc
|
|
147
|
+
if not isinstance(mapping, dict):
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"{group_mapping_env} must be a JSON object, got {type(mapping).__name__}"
|
|
150
|
+
)
|
|
151
|
+
# Env var format is {role_name: group_id}; internal format is {group_id: role_name}.
|
|
152
|
+
for role_name, group_id in mapping.items():
|
|
153
|
+
role_groups[str(group_id)] = str(role_name)
|
|
154
|
+
|
|
155
|
+
if not team_domain_override:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
"CF_TEAM_DOMAIN environment variable is not set. "
|
|
158
|
+
"Set it to your Cloudflare Zero Trust team domain, "
|
|
159
|
+
"e.g. 'your-org.cloudflareaccess.com'."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return cls(
|
|
163
|
+
team_domain=team_domain_override,
|
|
164
|
+
role_groups=role_groups or None,
|
|
165
|
+
_jwks_client=_jwks_client,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Core public API
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def authenticate(self, token: str) -> User:
|
|
173
|
+
"""
|
|
174
|
+
Validate ``token`` and return a :class:`User`.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
AuthError: If the token is invalid for any reason.
|
|
178
|
+
"""
|
|
179
|
+
payload = self._validator.decode(token)
|
|
180
|
+
|
|
181
|
+
groups: List[str] = payload.get("groups") or []
|
|
182
|
+
roles: List[str] = list({
|
|
183
|
+
self._role_groups[g]
|
|
184
|
+
for g in groups
|
|
185
|
+
if g in self._role_groups
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
return User(
|
|
189
|
+
sub=payload.get("sub", ""),
|
|
190
|
+
email=payload.get("email", ""),
|
|
191
|
+
name=payload.get("name") or payload.get("email", ""),
|
|
192
|
+
groups=groups,
|
|
193
|
+
roles=roles,
|
|
194
|
+
raw=payload,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def extract_token(
|
|
198
|
+
self,
|
|
199
|
+
headers: Dict[str, str],
|
|
200
|
+
cookies: Optional[Dict[str, str]] = None,
|
|
201
|
+
) -> Optional[str]:
|
|
202
|
+
"""
|
|
203
|
+
Pull the JWT from ``CF-Access-Jwt-Assertion`` header (preferred, used by
|
|
204
|
+
API clients and Cloudflare service-to-service calls) or the
|
|
205
|
+
``CF_Authorization`` cookie (set by Cloudflare for browser sessions).
|
|
206
|
+
|
|
207
|
+
Returns ``None`` if neither is present.
|
|
208
|
+
"""
|
|
209
|
+
# ASGI headers are lowercase bytes; accept both forms.
|
|
210
|
+
token = (
|
|
211
|
+
headers.get(_CF_HEADER)
|
|
212
|
+
or headers.get(_CF_HEADER_RAW)
|
|
213
|
+
)
|
|
214
|
+
if not token and cookies:
|
|
215
|
+
token = cookies.get(_CF_COOKIE)
|
|
216
|
+
return token or None
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
# Framework integrations (imported lazily to avoid hard deps)
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def middleware(self, exclude_paths=None):
|
|
223
|
+
"""
|
|
224
|
+
Return an ASGI middleware class that validates every request.
|
|
225
|
+
|
|
226
|
+
Every route is protected automatically — no ``Depends(...)`` needed
|
|
227
|
+
on individual endpoints. Routes in ``exclude_paths`` bypass auth
|
|
228
|
+
entirely (useful for ``/health``, ``/metrics``, webhooks, etc.).
|
|
229
|
+
|
|
230
|
+
Usage::
|
|
231
|
+
|
|
232
|
+
# Protect everything
|
|
233
|
+
app.add_middleware(auth.middleware())
|
|
234
|
+
|
|
235
|
+
# Protect everything except /health and /metrics
|
|
236
|
+
app.add_middleware(auth.middleware(exclude_paths=["/health", "/metrics"]))
|
|
237
|
+
|
|
238
|
+
Routes that need the ``User`` object or role checks still use
|
|
239
|
+
``Depends(auth.current_user)`` / ``Depends(auth.require_role(...))``,
|
|
240
|
+
but those are for accessing identity inside the handler — not for
|
|
241
|
+
protection, which the middleware already provides.
|
|
242
|
+
"""
|
|
243
|
+
from .middleware import build_middleware
|
|
244
|
+
return build_middleware(self, exclude_paths=exclude_paths)
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def current_user(self):
|
|
248
|
+
"""
|
|
249
|
+
FastAPI dependency that returns the authenticated :class:`User`.
|
|
250
|
+
|
|
251
|
+
Usage::
|
|
252
|
+
|
|
253
|
+
@router.get("/me")
|
|
254
|
+
async def me(user: User = Depends(auth.current_user)):
|
|
255
|
+
return {"email": user.email}
|
|
256
|
+
"""
|
|
257
|
+
from .fastapi import build_current_user_dep
|
|
258
|
+
return build_current_user_dep(self)
|
|
259
|
+
|
|
260
|
+
def require_role(self, role: str):
|
|
261
|
+
"""
|
|
262
|
+
FastAPI dependency that requires the user to hold ``role``.
|
|
263
|
+
|
|
264
|
+
Usage::
|
|
265
|
+
|
|
266
|
+
@router.delete("/item/{id}")
|
|
267
|
+
async def delete(id: str, user: User = Depends(auth.require_role("admin"))):
|
|
268
|
+
...
|
|
269
|
+
"""
|
|
270
|
+
from .fastapi import build_require_role_dep
|
|
271
|
+
return build_require_role_dep(self, role)
|
|
272
|
+
|
|
273
|
+
def require_any_role(self, roles: List[str]):
|
|
274
|
+
"""
|
|
275
|
+
FastAPI dependency that requires the user to hold at least one of ``roles``.
|
|
276
|
+
"""
|
|
277
|
+
from .fastapi import build_require_any_role_dep
|
|
278
|
+
return build_require_any_role_dep(self, roles)
|
cc_auth/exceptions.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exceptions raised by cc-auth.
|
|
3
|
+
|
|
4
|
+
These are thin wrappers so callers can catch auth errors specifically
|
|
5
|
+
without importing from jwt or any other third-party library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthError(Exception):
|
|
10
|
+
"""Raised when a token cannot be verified (missing, expired, bad signature, wrong audience…)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, status_code: int = 401):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PermissionError(Exception):
|
|
18
|
+
"""Raised when the authenticated user does not hold the required role."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str = "Insufficient permissions", status_code: int = 403):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status_code = status_code
|
cc_auth/fastapi.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI dependency factories.
|
|
3
|
+
|
|
4
|
+
These are returned by CloudflareAuth.current_user, .require_role(), and
|
|
5
|
+
.require_any_role() so callers never import from this module directly.
|
|
6
|
+
|
|
7
|
+
This module is only loaded when the FastAPI integration is actually used
|
|
8
|
+
(core.py imports it lazily), so top-level FastAPI/Starlette imports here
|
|
9
|
+
are safe — they won't affect users who don't have FastAPI installed.
|
|
10
|
+
|
|
11
|
+
Two usage patterns are supported:
|
|
12
|
+
|
|
13
|
+
1. Middleware-first (recommended for most apps):
|
|
14
|
+
Add auth.middleware to the app; use auth.current_user / auth.require_role
|
|
15
|
+
in route dependencies. The middleware has already validated the token and
|
|
16
|
+
stored the User on request.state.user — the dependency just reads it.
|
|
17
|
+
|
|
18
|
+
2. Dependency-only (useful for testing or selective protection):
|
|
19
|
+
Skip the middleware; use auth.require_role as a route dependency.
|
|
20
|
+
The dependency extracts and validates the token itself.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING, List
|
|
25
|
+
|
|
26
|
+
# Top-level imports so that FastAPI's get_type_hints() can resolve annotations
|
|
27
|
+
# on dependency functions defined in this module. This file is only imported
|
|
28
|
+
# when the user is actually using the FastAPI integration (core.py lazy-loads it).
|
|
29
|
+
from fastapi import Depends, Request
|
|
30
|
+
from fastapi.exceptions import HTTPException
|
|
31
|
+
|
|
32
|
+
from .exceptions import AuthError
|
|
33
|
+
from .identity import User
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from .core import CloudflareAuth
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_current_user_dep(auth: "CloudflareAuth"):
|
|
40
|
+
"""Return a FastAPI dependency that resolves the current :class:`User`."""
|
|
41
|
+
|
|
42
|
+
async def current_user(request: Request):
|
|
43
|
+
# If middleware already resolved the user, reuse it.
|
|
44
|
+
user = getattr(request.state, "user", None)
|
|
45
|
+
if user is not None:
|
|
46
|
+
return user
|
|
47
|
+
|
|
48
|
+
# Dependency-only mode: extract and validate here.
|
|
49
|
+
headers = dict(request.headers)
|
|
50
|
+
cookies = dict(request.cookies)
|
|
51
|
+
token = auth.extract_token(headers, cookies)
|
|
52
|
+
if not token:
|
|
53
|
+
raise HTTPException(status_code=401, detail="Missing Cloudflare Access token")
|
|
54
|
+
try:
|
|
55
|
+
return auth.authenticate(token)
|
|
56
|
+
except AuthError as exc:
|
|
57
|
+
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
|
58
|
+
|
|
59
|
+
return current_user
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_require_role_dep(auth: "CloudflareAuth", role: str):
|
|
63
|
+
"""Return a FastAPI dependency that requires the user to hold `role`."""
|
|
64
|
+
|
|
65
|
+
current_user = build_current_user_dep(auth)
|
|
66
|
+
|
|
67
|
+
async def require_role(user: User = Depends(current_user)):
|
|
68
|
+
if not user.has_role(role):
|
|
69
|
+
raise HTTPException(
|
|
70
|
+
status_code=403,
|
|
71
|
+
detail=f"Role '{role}' required",
|
|
72
|
+
)
|
|
73
|
+
return user
|
|
74
|
+
|
|
75
|
+
return require_role
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_require_any_role_dep(auth: "CloudflareAuth", roles: List[str]):
|
|
79
|
+
"""Return a FastAPI dependency that requires the user to hold at least one of `roles`."""
|
|
80
|
+
|
|
81
|
+
current_user = build_current_user_dep(auth)
|
|
82
|
+
|
|
83
|
+
async def require_any_role(user: User = Depends(current_user)):
|
|
84
|
+
if not user.has_any_role(roles):
|
|
85
|
+
roles_str = ", ".join(f"'{r}'" for r in roles)
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=403,
|
|
88
|
+
detail=f"One of these roles required: {roles_str}",
|
|
89
|
+
)
|
|
90
|
+
return user
|
|
91
|
+
|
|
92
|
+
return require_any_role
|
cc_auth/identity.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User identity resolved from a validated Cloudflare Access JWT.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Dict, List
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class User:
|
|
12
|
+
"""
|
|
13
|
+
Represents an authenticated user extracted from a Cloudflare Access JWT.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
sub: Unique user identifier (Cloudflare subject claim).
|
|
17
|
+
email: User's email address.
|
|
18
|
+
name: Display name (falls back to email if not present in token).
|
|
19
|
+
groups: List of Microsoft Entra ID group Object IDs the user belongs to.
|
|
20
|
+
Only populated when "Support groups" is enabled on the Azure AD
|
|
21
|
+
identity provider in Cloudflare Zero Trust.
|
|
22
|
+
roles: Application-level role names mapped from groups by CloudflareAuth.
|
|
23
|
+
Empty if no role_groups mapping was provided.
|
|
24
|
+
raw: Full decoded JWT payload — use for any claims not surfaced above.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
sub: str
|
|
28
|
+
email: str
|
|
29
|
+
name: str
|
|
30
|
+
groups: List[str] = field(default_factory=list)
|
|
31
|
+
roles: List[str] = field(default_factory=list)
|
|
32
|
+
raw: Dict = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def has_role(self, role: str) -> bool:
|
|
35
|
+
"""Return True if the user holds `role`."""
|
|
36
|
+
return role in self.roles
|
|
37
|
+
|
|
38
|
+
def has_any_role(self, roles: List[str]) -> bool:
|
|
39
|
+
"""Return True if the user holds at least one of the given roles."""
|
|
40
|
+
return bool(set(roles) & set(self.roles))
|
|
41
|
+
|
|
42
|
+
def has_all_roles(self, roles: List[str]) -> bool:
|
|
43
|
+
"""Return True if the user holds every one of the given roles."""
|
|
44
|
+
return set(roles).issubset(set(self.roles))
|
cc_auth/middleware.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASGI middleware for Starlette / FastAPI.
|
|
3
|
+
|
|
4
|
+
Validates the Cloudflare Access JWT on every incoming request and stores
|
|
5
|
+
the resolved User on ``request.state.user``. Routes that don't need
|
|
6
|
+
authentication can ignore request.state.user entirely.
|
|
7
|
+
|
|
8
|
+
Requests that carry an invalid token receive a 401 JSON response.
|
|
9
|
+
Requests with no token at all also receive a 401 — if you need to allow
|
|
10
|
+
unauthenticated requests for specific paths (e.g. health checks), exclude
|
|
11
|
+
those paths with the ``exclude_paths`` option.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import TYPE_CHECKING, Iterable, Optional
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .core import CloudflareAuth
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_middleware(auth: "CloudflareAuth", exclude_paths: Optional[Iterable[str]] = None):
|
|
23
|
+
"""
|
|
24
|
+
Return an ASGI middleware class bound to `auth`.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
auth: The :class:`~cc_auth.CloudflareAuth` instance.
|
|
28
|
+
exclude_paths: Optional iterable of path prefixes that bypass auth
|
|
29
|
+
(e.g. ``["/health", "/metrics"]``).
|
|
30
|
+
"""
|
|
31
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
32
|
+
from starlette.requests import Request
|
|
33
|
+
from starlette.responses import JSONResponse
|
|
34
|
+
|
|
35
|
+
from .exceptions import AuthError
|
|
36
|
+
|
|
37
|
+
_excluded = list(exclude_paths or [])
|
|
38
|
+
|
|
39
|
+
class CloudflareAccessMiddleware(BaseHTTPMiddleware):
|
|
40
|
+
async def dispatch(self, request: Request, call_next):
|
|
41
|
+
# Skip auth for excluded paths.
|
|
42
|
+
for prefix in _excluded:
|
|
43
|
+
if request.url.path.startswith(prefix):
|
|
44
|
+
return await call_next(request)
|
|
45
|
+
|
|
46
|
+
# Build header dict (ASGI headers are bytes).
|
|
47
|
+
headers = {
|
|
48
|
+
k.decode(): v.decode()
|
|
49
|
+
for k, v in request.headers.raw
|
|
50
|
+
}
|
|
51
|
+
cookies = dict(request.cookies)
|
|
52
|
+
|
|
53
|
+
token = auth.extract_token(headers, cookies)
|
|
54
|
+
if not token:
|
|
55
|
+
return JSONResponse(
|
|
56
|
+
{"detail": "Missing Cloudflare Access token"},
|
|
57
|
+
status_code=401,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
request.state.user = auth.authenticate(token)
|
|
62
|
+
except AuthError as exc:
|
|
63
|
+
return JSONResponse(
|
|
64
|
+
{"detail": str(exc)},
|
|
65
|
+
status_code=exc.status_code,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return await call_next(request)
|
|
69
|
+
|
|
70
|
+
return CloudflareAccessMiddleware
|
cc_auth/validator.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JWT validation against Cloudflare Access JWKS.
|
|
3
|
+
|
|
4
|
+
Cloudflare signs Access tokens with RS256. The public keys are published at:
|
|
5
|
+
https://<team-domain>/cdn-cgi/access/certs
|
|
6
|
+
|
|
7
|
+
PyJWT's PyJWKClient fetches and caches these automatically.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
import jwt
|
|
14
|
+
from jwt import ExpiredSignatureError, InvalidIssuerError, PyJWKClient
|
|
15
|
+
|
|
16
|
+
from .exceptions import AuthError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TokenValidator:
|
|
20
|
+
"""
|
|
21
|
+
Validates a Cloudflare Access JWT and returns the decoded payload.
|
|
22
|
+
|
|
23
|
+
A single instance should be shared for the lifetime of the application
|
|
24
|
+
so the JWKS key cache is reused across requests.
|
|
25
|
+
|
|
26
|
+
Validates: RS256 signature, issuer (``iss``), expiry (``exp``).
|
|
27
|
+
The audience (``aud``) claim is present in every Cloudflare JWT but is
|
|
28
|
+
not checked — all services share the same Zero Trust tenant.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
team_domain: str,
|
|
34
|
+
jwks_client: Optional[PyJWKClient] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Args:
|
|
38
|
+
team_domain: Your Cloudflare Zero Trust team domain,
|
|
39
|
+
e.g. ``your-org.cloudflareaccess.com``
|
|
40
|
+
(without ``https://``).
|
|
41
|
+
jwks_client: Optional pre-built ``PyJWKClient`` — primarily for
|
|
42
|
+
testing. When omitted a client is created automatically
|
|
43
|
+
pointing at ``<team_domain>/cdn-cgi/access/certs``.
|
|
44
|
+
"""
|
|
45
|
+
self._team_domain = team_domain.rstrip("/")
|
|
46
|
+
self._issuer = f"https://{self._team_domain}"
|
|
47
|
+
certs_url = f"{self._issuer}/cdn-cgi/access/certs"
|
|
48
|
+
|
|
49
|
+
# cache_keys=True keeps the JWKS in memory; lifespan=300s is the default.
|
|
50
|
+
self._jwks_client = jwks_client or PyJWKClient(
|
|
51
|
+
certs_url, cache_keys=True, max_cached_keys=16
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def decode(self, token: str) -> Dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
Verify ``token`` and return the decoded payload.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
AuthError: If the token is missing, expired, has a bad signature,
|
|
60
|
+
wrong issuer, or any other validation failure.
|
|
61
|
+
"""
|
|
62
|
+
if not token:
|
|
63
|
+
raise AuthError("No token provided")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
raise AuthError(f"Could not fetch signing key: {exc}") from exc
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
payload: Dict[str, Any] = jwt.decode(
|
|
72
|
+
token,
|
|
73
|
+
signing_key.key,
|
|
74
|
+
algorithms=["RS256"],
|
|
75
|
+
issuer=self._issuer,
|
|
76
|
+
options={"verify_aud": False},
|
|
77
|
+
)
|
|
78
|
+
except ExpiredSignatureError:
|
|
79
|
+
raise AuthError("Token has expired") from None
|
|
80
|
+
except InvalidIssuerError:
|
|
81
|
+
raise AuthError("Token issuer is not trusted") from None
|
|
82
|
+
except jwt.PyJWTError as exc:
|
|
83
|
+
raise AuthError(f"Token validation failed: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
return payload
|
|
@@ -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.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cc_auth/__init__.py,sha256=b0-vQmiFjokGqWHxBXvVBtL2TDT3_UrEhCUzsSrJemg,892
|
|
2
|
+
cc_auth/core.py,sha256=8Huys6QOej0MbceOIWlbTAtMi98MeFpCbuEaYEJRmAY,10493
|
|
3
|
+
cc_auth/exceptions.py,sha256=QOSX5OlSD0PaHUgln_6FXP-WeE88WwQJIqpvKdvGss8,718
|
|
4
|
+
cc_auth/fastapi.py,sha256=yksGmaoVU_ai4roxuHdNnTM5E2vvlnmlL7zlHh-9fg0,3331
|
|
5
|
+
cc_auth/identity.py,sha256=0iYiA05mjO-uoiEvWWRRTioTI66DtgmZtO15t7Th50I,1628
|
|
6
|
+
cc_auth/middleware.py,sha256=1ZCz2_uFcHouag0bAJMrHhyPEQ6qSFbCE0EOWMZB3Fc,2409
|
|
7
|
+
cc_auth/validator.py,sha256=aFHNKqGeVb2iuTjuzZITRmJBRpqWUAV8tL0IcswFKF8,3000
|
|
8
|
+
cc_auth-0.1.0.dist-info/METADATA,sha256=TfOMfPGl1voa6dK-XGMXQbpBIiQBx8NwoBJerNjKmyg,9125
|
|
9
|
+
cc_auth-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
cc_auth-0.1.0.dist-info/RECORD,,
|