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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any