ccp4i2-api 0.3.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.
ccp4i2_api/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Shared API contract for CCP4i2 and consumers.
2
+
3
+ This package is the Python half of a bilingual API library; the TypeScript
4
+ half is published as ``@ccp4/ccp4i2-api``. Both halves implement the same
5
+ canonical bearer-token format, 401 response shape, and typed payloads
6
+ carried over the authenticated channel.
7
+
8
+ See the package README for current status and
9
+ ``docs/CCP4I2_SERVICE_CONTRACT.md`` in the ccp4i2 monorepo for the
10
+ contract specification.
11
+ """
12
+
13
+ __version__ = "0.3.0"
ccp4i2_api/drf.py ADDED
@@ -0,0 +1,58 @@
1
+ """DRF authentication classes for CCP4i2.
2
+
3
+ These classes work in tandem with the middleware in
4
+ ``ccp4i2_api.middleware`` — the middleware validates the bearer token
5
+ and sets ``request.user`` plus the ``REQUEST_FLAG_ATTR`` trust flag; the
6
+ DRF auth class checks the flag and surfaces ``request.user`` to DRF's
7
+ ``IsAuthenticated`` permission.
8
+ """
9
+
10
+ from rest_framework.authentication import BaseAuthentication
11
+
12
+ from .middleware.base import REQUEST_FLAG_ATTR
13
+
14
+
15
+ class AzureADAuthentication(BaseAuthentication):
16
+ """
17
+ DRF authentication class that uses the user set by AzureADAuthMiddleware.
18
+
19
+ This allows DRF's IsAuthenticated permission to work with our middleware.
20
+ The middleware does the actual JWT validation; this class just passes
21
+ the authenticated user to DRF.
22
+
23
+ In dev/Electron mode (CCP4I2_REQUIRE_AUTH not set), the middleware
24
+ auto-assigns a dev_admin user, which this class also recognizes.
25
+
26
+ Security: Only trusts users when our middleware has explicitly processed
27
+ the request (marked by ``REQUEST_FLAG_ATTR`` attribute). This prevents
28
+ spoofing attacks where a request might have ``request.user`` set by
29
+ some other means.
30
+
31
+ Note: this class is bound to the trust flag, not to the AzureAD chain
32
+ specifically; it works equally for any middleware that inherits from
33
+ ``BaseAuthMiddleware`` (e.g., LocalSessionAuthMiddleware in desktop
34
+ mode), because they all set the same flag.
35
+ """
36
+
37
+ def authenticate(self, request):
38
+ """
39
+ Return the user if already authenticated by middleware, None otherwise.
40
+
41
+ Returns:
42
+ Tuple of (user, None) if authenticated, None if not.
43
+ """
44
+ # Check if middleware already validated and set user
45
+ # The middleware sets these attributes on the underlying Django request
46
+ django_request = getattr(request, '_request', request)
47
+
48
+ # Security check: only trust users set by our middleware
49
+ # This prevents spoofing where request.user might be set elsewhere
50
+ if not getattr(django_request, REQUEST_FLAG_ATTR, False):
51
+ return None
52
+
53
+ # Middleware ran - trust the user it set
54
+ user = getattr(django_request, 'user', None)
55
+ if user and user.is_authenticated and not user.is_anonymous:
56
+ return (user, None)
57
+
58
+ return None
@@ -0,0 +1,18 @@
1
+ """Exceptions used by the shared auth middleware contract."""
2
+
3
+
4
+ class AuthenticationFailed(Exception):
5
+ """Raised by ``BaseAuthMiddleware.authenticate()`` to signal a 401.
6
+
7
+ The exception's first argument is the message returned in the canonical
8
+ 401 response body (``{"success": false, "error": "..."}``).
9
+ """
10
+
11
+
12
+ class AuthorizationFailed(Exception):
13
+ """Raised by ``BaseAuthMiddleware.authenticate()`` to signal a 403.
14
+
15
+ Use when the request is *authenticated* (we know who is calling) but
16
+ *not authorized* (e.g., not a member of an allowed group). The first
17
+ argument is the message returned in the canonical 403 response body.
18
+ """
@@ -0,0 +1,23 @@
1
+ """Django middleware shipped by the shared auth package.
2
+
3
+ Each module in this package implements one auth scheme; consumers select
4
+ which to enable via Django ``MIDDLEWARE`` settings. All non-dev schemes
5
+ inherit from ``BaseAuthMiddleware`` (or will, after the AzureAD refactor),
6
+ which defines the canonical 401 response shape and the ``REQUEST_FLAG_ATTR``
7
+ trust signal.
8
+ """
9
+
10
+ from .azure_ad import AzureADAuthMiddleware
11
+ from .base import REQUEST_FLAG_ATTR, BaseAuthMiddleware
12
+ from .dev import DevAuthMiddleware
13
+ from .dev_admin import DevAdminMiddleware
14
+ from .local_session import LocalSessionAuthMiddleware
15
+
16
+ __all__ = [
17
+ "AzureADAuthMiddleware",
18
+ "BaseAuthMiddleware",
19
+ "DevAdminMiddleware",
20
+ "DevAuthMiddleware",
21
+ "LocalSessionAuthMiddleware",
22
+ "REQUEST_FLAG_ATTR",
23
+ ]
@@ -0,0 +1,413 @@
1
+ """
2
+ Azure AD JWT validation middleware for CCP4i2.
3
+
4
+ This middleware validates Azure AD JWT tokens on incoming requests when
5
+ CCP4I2_REQUIRE_AUTH=true is set. It supports both:
6
+
7
+ 1. Authorization header: "Bearer <token>"
8
+ 2. X-MS-TOKEN-AAD-ACCESS-TOKEN header (set by Azure Container Apps Easy Auth)
9
+
10
+ Configuration environment variables:
11
+ - CCP4I2_REQUIRE_AUTH: Set to "true" to enable authentication (default: false)
12
+ - AZURE_AD_TENANT_ID: Your Azure AD tenant ID
13
+ - AZURE_AD_CLIENT_ID: Your Azure AD app registration client ID
14
+
15
+ The middleware validates:
16
+ - Token signature (using Azure AD's public keys)
17
+ - Token expiration
18
+ - Audience (must match client ID)
19
+ - Issuer (must match Azure AD tenant)
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import ssl
26
+ import time
27
+ from typing import Optional, Tuple
28
+ from urllib.error import URLError
29
+ from urllib.request import urlopen
30
+
31
+ import certifi
32
+ from django.contrib.auth import get_user_model
33
+ from django.http import HttpRequest, HttpResponse
34
+
35
+ from ..exceptions import AuthenticationFailed, AuthorizationFailed
36
+ from .base import BaseAuthMiddleware
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class AzureADTokenValidator:
42
+ """Validates Azure AD JWT tokens."""
43
+
44
+ def __init__(self, tenant_id: str, client_id: str):
45
+ self.tenant_id = tenant_id
46
+ self.client_id = client_id
47
+ self.issuer = f"https://login.microsoftonline.com/{tenant_id}/v2.0"
48
+ self.jwks_uri = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
49
+ self._keys_cache: Optional[dict] = None
50
+ self._keys_cache_time: float = 0
51
+ self._keys_cache_ttl: float = 3600 # 1 hour
52
+
53
+ def _get_signing_keys(self) -> dict:
54
+ """Fetch Azure AD's public signing keys (JWKS)."""
55
+ now = time.time()
56
+
57
+ # Return cached keys if still valid
58
+ if self._keys_cache and (now - self._keys_cache_time) < self._keys_cache_ttl:
59
+ return self._keys_cache
60
+
61
+ try:
62
+ # Use certifi's certificate bundle for SSL verification
63
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
64
+ with urlopen(self.jwks_uri, timeout=10, context=ssl_context) as response:
65
+ jwks = json.loads(response.read().decode("utf-8"))
66
+ self._keys_cache = {key["kid"]: key for key in jwks.get("keys", [])}
67
+ self._keys_cache_time = now
68
+ logger.debug(f"Fetched {len(self._keys_cache)} signing keys from Azure AD")
69
+ return self._keys_cache
70
+ except URLError as e:
71
+ logger.error(f"Failed to fetch Azure AD signing keys: {e}")
72
+ # Return stale cache if available, otherwise raise
73
+ if self._keys_cache:
74
+ logger.warning("Using stale signing keys cache")
75
+ return self._keys_cache
76
+ raise
77
+
78
+ def validate_token(self, token: str) -> Tuple[bool, Optional[dict], Optional[str]]:
79
+ """
80
+ Validate a JWT token.
81
+
82
+ Returns:
83
+ Tuple of (is_valid, claims_dict, error_message)
84
+ """
85
+ try:
86
+ import jwt
87
+ from jwt import PyJWK
88
+ except ImportError:
89
+ logger.error("PyJWT not installed. Run: pip install PyJWT[crypto]")
90
+ return False, None, "Server configuration error: PyJWT not installed"
91
+
92
+ try:
93
+ # Decode header to get key ID
94
+ unverified_header = jwt.get_unverified_header(token)
95
+ kid = unverified_header.get("kid")
96
+
97
+ if not kid:
98
+ return False, None, "Token missing key ID (kid)"
99
+
100
+ # Get the signing key
101
+ keys = self._get_signing_keys()
102
+ if kid not in keys:
103
+ # Key not found - try refreshing the cache
104
+ self._keys_cache = None
105
+ keys = self._get_signing_keys()
106
+ if kid not in keys:
107
+ return False, None, f"Unknown signing key: {kid}"
108
+
109
+ key_data = keys[kid]
110
+
111
+ # Construct signing key directly from cached JWK data
112
+ # (avoids PyJWKClient making its own HTTPS request)
113
+ jwk = PyJWK.from_dict(key_data)
114
+ signing_key = jwk.key
115
+
116
+ # Decode and validate the token
117
+ claims = jwt.decode(
118
+ token,
119
+ signing_key,
120
+ algorithms=["RS256"],
121
+ audience=self.client_id,
122
+ issuer=self.issuer,
123
+ options={
124
+ "verify_signature": True,
125
+ "verify_exp": True,
126
+ "verify_aud": True,
127
+ "verify_iss": True,
128
+ },
129
+ )
130
+
131
+ logger.debug(f"Token validated for subject: {claims.get('sub', 'unknown')}")
132
+ return True, claims, None
133
+
134
+ except jwt.ExpiredSignatureError:
135
+ return False, None, "Token has expired"
136
+ except jwt.InvalidAudienceError:
137
+ return False, None, "Invalid token audience"
138
+ except jwt.InvalidIssuerError:
139
+ return False, None, "Invalid token issuer"
140
+ except jwt.InvalidSignatureError:
141
+ return False, None, "Invalid token signature"
142
+ except jwt.DecodeError as e:
143
+ return False, None, f"Token decode error: {e}"
144
+ except Exception as e:
145
+ logger.exception("Unexpected error validating token")
146
+ return False, None, f"Token validation error: {e}"
147
+
148
+
149
+ # Singleton validator instance
150
+ _validator: Optional[AzureADTokenValidator] = None
151
+
152
+
153
+ def get_validator() -> Optional[AzureADTokenValidator]:
154
+ """Get or create the token validator instance."""
155
+ global _validator
156
+
157
+ if _validator is not None:
158
+ return _validator
159
+
160
+ tenant_id = os.environ.get("AZURE_AD_TENANT_ID")
161
+ client_id = os.environ.get("AZURE_AD_CLIENT_ID")
162
+
163
+ if not tenant_id or not client_id:
164
+ logger.warning(
165
+ "AZURE_AD_TENANT_ID and AZURE_AD_CLIENT_ID must be set for authentication. "
166
+ "Authentication is disabled."
167
+ )
168
+ return None
169
+
170
+ _validator = AzureADTokenValidator(tenant_id, client_id)
171
+ return _validator
172
+
173
+
174
+ def is_auth_required() -> bool:
175
+ """Check if authentication is required."""
176
+ return os.environ.get("CCP4I2_REQUIRE_AUTH", "").lower() in ("true", "1", "yes")
177
+
178
+
179
+ def extract_token(request: HttpRequest) -> Optional[str]:
180
+ """
181
+ Extract JWT token from request.
182
+
183
+ Checks in order:
184
+ 1. Authorization header (Bearer token)
185
+ 2. X-MS-TOKEN-AAD-ACCESS-TOKEN header (Azure Easy Auth)
186
+ 3. Query parameter access_token (for file downloads/anchor links)
187
+ """
188
+ # Check Authorization header
189
+ auth_header = request.headers.get("Authorization", "")
190
+ if auth_header.startswith("Bearer "):
191
+ return auth_header[7:]
192
+
193
+ # Check Azure Easy Auth header
194
+ easy_auth_token = request.headers.get("X-MS-TOKEN-AAD-ACCESS-TOKEN")
195
+ if easy_auth_token:
196
+ return easy_auth_token
197
+
198
+ # Check query parameter (for file downloads - anchor links don't send headers)
199
+ query_token = request.GET.get("access_token")
200
+ if query_token:
201
+ return query_token
202
+
203
+ return None
204
+
205
+
206
+ class AzureADAuthMiddleware(BaseAuthMiddleware):
207
+ """
208
+ Django middleware for Azure AD JWT validation. Cloud auth path.
209
+
210
+ Activates when ``CCP4I2_REQUIRE_AUTH=true`` (otherwise no-op, deferring
211
+ to whatever middleware comes next in the chain). When active, every
212
+ non-exempt request must carry a valid Azure AD JWT bearer token; the
213
+ middleware validates it, optionally enforces group-membership rules,
214
+ and gets-or-creates a Django user keyed on the cryptographic ``sub``.
215
+
216
+ The dev_admin auto-login path was deliberately split out into a
217
+ separate ``DevAdminMiddleware`` so a misconfigured cloud deploy
218
+ (REQUIRE_AUTH unset) cannot fall through to creating a superuser
219
+ automatically. See ``ccp4i2_api.middleware.dev_admin`` for the dev
220
+ path; the CCP4i2 settings module is responsible for picking exactly
221
+ one auth middleware based on the deployment shape.
222
+ """
223
+
224
+ # Paths that don't require authentication even when this middleware is active.
225
+ EXEMPT_PATHS = [
226
+ "/health",
227
+ "/healthz",
228
+ "/ready",
229
+ "/api/health",
230
+ "/api/ccp4i2/health",
231
+ "/api/ccp4i2/version",
232
+ ]
233
+
234
+ def __init__(self, get_response):
235
+ super().__init__(get_response)
236
+ if self.is_active():
237
+ logger.info("Azure AD authentication is ENABLED")
238
+ validator = get_validator()
239
+ if not validator:
240
+ logger.error(
241
+ "Authentication required but AZURE_AD_TENANT_ID/AZURE_AD_CLIENT_ID not set!"
242
+ )
243
+ else:
244
+ logger.info("Azure AD authentication is DISABLED (CCP4I2_REQUIRE_AUTH not set)")
245
+
246
+ def is_active(self) -> bool:
247
+ return is_auth_required()
248
+
249
+ def __call__(self, request: HttpRequest) -> HttpResponse:
250
+ # Filter exempt paths *before* delegating to BaseAuthMiddleware so
251
+ # health checks and version probes always bypass authentication.
252
+ if self.is_active():
253
+ path = request.path.rstrip("/")
254
+ if any(path == exempt.rstrip("/") for exempt in self.EXEMPT_PATHS):
255
+ return self.get_response(request)
256
+ return super().__call__(request)
257
+
258
+ def authenticate(self, request: HttpRequest):
259
+ token = extract_token(request)
260
+ if not token:
261
+ raise AuthenticationFailed(
262
+ "Authentication required. Provide Authorization: Bearer <token>"
263
+ )
264
+
265
+ validator = get_validator()
266
+ if not validator:
267
+ # Server misconfiguration — AZURE_AD_TENANT_ID/CLIENT_ID missing.
268
+ # We surface this as a 401 because to the *caller* the result is
269
+ # the same as a token rejection: no auth happened, retry doesn't
270
+ # help. The error message and the operator-facing log line above
271
+ # tell the operator what to fix.
272
+ raise AuthenticationFailed("Server authentication not configured")
273
+
274
+ is_valid, claims, error = validator.validate_token(token)
275
+ if not is_valid:
276
+ raise AuthenticationFailed(error)
277
+
278
+ # Attach claims to request for downstream use.
279
+ request.azure_ad_claims = claims
280
+ azure_ad_sub = claims.get("sub")
281
+ request.azure_ad_user_id = azure_ad_sub
282
+
283
+ # Groups / Teams membership authorization.
284
+ self._enforce_group_membership(claims, azure_ad_sub)
285
+
286
+ email = self._extract_email(claims, request, azure_ad_sub)
287
+ request.azure_ad_email = email
288
+
289
+ return self._get_or_create_user(claims, azure_ad_sub, email)
290
+
291
+ # --- helper extractions kept private ------------------------------------
292
+
293
+ @staticmethod
294
+ def _enforce_group_membership(claims: dict, azure_ad_sub: str) -> None:
295
+ """Raise AuthorizationFailed if ALLOWED_AZURE_AD_GROUPS is set and
296
+ the user is not a member of any allowed group.
297
+
298
+ Requires:
299
+ 1. Azure AD app configured to emit 'groups' claim (Token Configuration).
300
+ 2. ALLOWED_AZURE_AD_GROUPS env var with comma-separated group IDs.
301
+ 3. Azure AD Premium P1/P2 (for groups claim in tokens).
302
+ """
303
+ allowed_groups_str = os.environ.get("ALLOWED_AZURE_AD_GROUPS", "")
304
+ allowed_groups = [g.strip() for g in allowed_groups_str.split(",") if g.strip()]
305
+ if not allowed_groups:
306
+ logger.debug("Groups authorization not configured (ALLOWED_AZURE_AD_GROUPS not set)")
307
+ return
308
+
309
+ logger.debug(f"Groups authorization enabled. Allowed groups: {allowed_groups}")
310
+
311
+ # Group claims overage — user is in >200 groups, Azure AD substitutes
312
+ # _claim_names/_claim_sources for the full list. We can't validate
313
+ # locally; surface a friendly 403 so an admin can move the user to a
314
+ # dedicated app-access group with fewer members.
315
+ if "_claim_names" in claims or "_claim_sources" in claims:
316
+ logger.warning(
317
+ f"Group claims overage detected for user {azure_ad_sub[:8]}. "
318
+ "User is in >200 groups - cannot validate Teams membership from token. "
319
+ "Consider using a dedicated app access group with fewer members."
320
+ )
321
+ raise AuthorizationFailed(
322
+ "Your account has too many group memberships to verify "
323
+ "automatically. Please contact your administrator to be "
324
+ "added to a dedicated application access group."
325
+ )
326
+
327
+ user_groups = claims.get("groups", [])
328
+ logger.debug(f"User {azure_ad_sub[:8]}... has groups: {user_groups}")
329
+
330
+ if not any(group_id in allowed_groups for group_id in user_groups):
331
+ logger.warning(
332
+ f"Access denied for user {azure_ad_sub[:8]}... - not in authorized groups. "
333
+ f"User groups: {user_groups[:5]}{'...' if len(user_groups) > 5 else ''}, "
334
+ f"Required: {allowed_groups}"
335
+ )
336
+ raise AuthorizationFailed(
337
+ "You are not a member of an authorized group. This application "
338
+ "requires membership in the Newcastle Drug Discovery Unit team. "
339
+ "Please contact your administrator to request access."
340
+ )
341
+
342
+ logger.info(f"User {azure_ad_sub[:8]}... authorized via Teams/Groups membership")
343
+
344
+ @staticmethod
345
+ def _extract_email(claims: dict, request: HttpRequest, azure_ad_sub: str) -> str:
346
+ # Try multiple claim fields where email might be found.
347
+ email = (
348
+ claims.get("email")
349
+ or claims.get("preferred_username")
350
+ or claims.get("upn") # User Principal Name
351
+ or claims.get("unique_name") # Legacy claim
352
+ )
353
+
354
+ # Fallback: X-User-Email header (sent by frontend from MSAL account info).
355
+ # Secure because (1) JWT is already validated so we know WHO via 'sub';
356
+ # (2) 'sub' is the primary key, not email; (3) email is just for display.
357
+ if not email:
358
+ header_email = request.headers.get("X-User-Email")
359
+ if header_email:
360
+ logger.info(f"Using X-User-Email header for sub={azure_ad_sub[:8]}...")
361
+ email = header_email
362
+
363
+ if not email:
364
+ logger.warning(f"No email found for sub={azure_ad_sub}. Claims: {list(claims.keys())}")
365
+ email = f"user_{azure_ad_sub[:8]}@azuread.local"
366
+
367
+ return email
368
+
369
+ @staticmethod
370
+ def _get_or_create_user(claims: dict, azure_ad_sub: str, email: str):
371
+ # Extract name from claims (priority: given_name/family_name → 'name' → empty).
372
+ first_name = claims.get("given_name", "")
373
+ last_name = claims.get("family_name", "")
374
+ if not first_name and not last_name:
375
+ full_name = claims.get("name", "")
376
+ if full_name:
377
+ name_parts = full_name.strip().split()
378
+ if len(name_parts) >= 2:
379
+ first_name = name_parts[0]
380
+ last_name = " ".join(name_parts[1:])
381
+ elif len(name_parts) == 1:
382
+ first_name = name_parts[0]
383
+
384
+ # Use 'sub' as the unique identifier (cryptographically verified) —
385
+ # prevents email-header spoofing from enabling impersonation.
386
+ User = get_user_model()
387
+ username = f"aad_{azure_ad_sub[:32]}" # Stable username from sub
388
+ user, created = User.objects.get_or_create(
389
+ username=username,
390
+ defaults={
391
+ "email": email.lower(),
392
+ "first_name": first_name,
393
+ "last_name": last_name,
394
+ },
395
+ )
396
+ if created:
397
+ logger.info(f"Created user from Azure AD: {email} (sub={azure_ad_sub[:8]}...)")
398
+
399
+ # Update email and name if they changed or were initially missing.
400
+ updated = False
401
+ if user.email != email.lower():
402
+ user.email = email.lower()
403
+ updated = True
404
+ if first_name and user.first_name != first_name:
405
+ user.first_name = first_name
406
+ updated = True
407
+ if last_name and user.last_name != last_name:
408
+ user.last_name = last_name
409
+ updated = True
410
+ if updated:
411
+ user.save(update_fields=["email", "first_name", "last_name"])
412
+
413
+ return user
@@ -0,0 +1,69 @@
1
+ """Common base class for CCP4i2 auth middleware.
2
+
3
+ Subclasses implement ``is_active()`` (whether this middleware is configured
4
+ to operate in the current process) and ``authenticate(request)`` (the
5
+ auth-specific token-validation logic). The base class handles the rest of
6
+ the request lifecycle: the canonical 401 response shape, setting
7
+ ``request.user``, and setting the trust flag that the matching DRF
8
+ authentication class checks before honouring ``request.user`` (anti-
9
+ spoofing, mirroring the existing AzureADAuthMiddleware contract).
10
+ """
11
+
12
+ from django.http import HttpRequest, HttpResponse, JsonResponse
13
+
14
+ from ..exceptions import AuthenticationFailed, AuthorizationFailed
15
+
16
+
17
+ # Attribute name set on ``request`` after a successful authentication.
18
+ # The DRF authentication class trusts ``request.user`` only when this is
19
+ # set, preventing spoofing via direct attribute writes from other code.
20
+ REQUEST_FLAG_ATTR = "_ccp4i2_api_middleware_ran"
21
+
22
+
23
+ class BaseAuthMiddleware:
24
+ """Abstract base for CCP4i2 auth middleware.
25
+
26
+ Subclasses must implement:
27
+
28
+ * ``is_active(self) -> bool`` — return True iff this middleware should
29
+ attempt to authenticate. Typical implementation checks an env var
30
+ or Django setting.
31
+ * ``authenticate(self, request) -> User`` — return a Django User on
32
+ successful auth. Raise ``AuthenticationFailed`` to signal a 401.
33
+
34
+ When ``is_active()`` is False, the middleware is a no-op (the request
35
+ flows to the next middleware unchanged). This lets multiple subclasses
36
+ coexist in ``MIDDLEWARE`` without coupling between them; deployments
37
+ activate the right one via configuration (env var presence).
38
+ """
39
+
40
+ def __init__(self, get_response):
41
+ self.get_response = get_response
42
+
43
+ def __call__(self, request: HttpRequest) -> HttpResponse:
44
+ if not self.is_active():
45
+ return self.get_response(request)
46
+ try:
47
+ user = self.authenticate(request)
48
+ except AuthenticationFailed as exc:
49
+ return self._error_response(str(exc), status=401)
50
+ except AuthorizationFailed as exc:
51
+ return self._error_response(str(exc), status=403, prefix="Access denied")
52
+ request.user = user
53
+ setattr(request, REQUEST_FLAG_ATTR, True)
54
+ return self.get_response(request)
55
+
56
+ def is_active(self) -> bool:
57
+ raise NotImplementedError
58
+
59
+ def authenticate(self, request: HttpRequest):
60
+ raise NotImplementedError
61
+
62
+ @staticmethod
63
+ def _error_response(
64
+ message: str, status: int, prefix: str = "Authentication failed"
65
+ ) -> JsonResponse:
66
+ return JsonResponse(
67
+ {"success": False, "error": f"{prefix}: {message}"},
68
+ status=status,
69
+ )
@@ -0,0 +1,48 @@
1
+ """
2
+ Development authentication middleware.
3
+
4
+ Auto-authenticates requests in DEBUG mode for local development.
5
+ This middleware should ONLY be used in development environments.
6
+ """
7
+
8
+ from django.conf import settings
9
+ from django.contrib.auth import get_user_model
10
+
11
+
12
+ class DevAuthMiddleware:
13
+ """
14
+ Middleware that auto-authenticates requests in development mode.
15
+
16
+ If DEBUG=True and the user is not authenticated, this middleware
17
+ will automatically log them in as the dev user (configured via
18
+ DEV_USER_EMAIL setting).
19
+ """
20
+
21
+ def __init__(self, get_response):
22
+ self.get_response = get_response
23
+
24
+ def __call__(self, request):
25
+ # Only run in DEBUG mode and if user is not already authenticated
26
+ # Check hasattr because this may run before AuthenticationMiddleware
27
+ if settings.DEBUG:
28
+ user = getattr(request, 'user', None)
29
+ if user is None or not user.is_authenticated:
30
+ User = get_user_model()
31
+ dev_email = getattr(settings, 'DEV_USER_EMAIL', 'dev@localhost')
32
+
33
+ # Get or create the dev user
34
+ user, created = User.objects.get_or_create(
35
+ email=dev_email,
36
+ defaults={
37
+ 'username': dev_email.split('@')[0],
38
+ 'is_staff': True,
39
+ 'is_superuser': True,
40
+ }
41
+ )
42
+
43
+ if created:
44
+ print(f"Created dev user: {dev_email}")
45
+
46
+ request.user = user
47
+
48
+ return self.get_response(request)
@@ -0,0 +1,65 @@
1
+ """Development-only auto-login middleware.
2
+
3
+ Auto-assigns a ``dev_admin`` Django superuser to every request — useful
4
+ for local Docker Compose development where you want CCP4i2 fully usable
5
+ without any token machinery. **Never enable this in production.**
6
+
7
+ Two defensive measures protect against accidental production exposure:
8
+
9
+ 1. ``is_active()`` returns False unless ``settings.DEBUG`` is True. Even
10
+ if this middleware is mistakenly listed in a production ``MIDDLEWARE``
11
+ setting, it refuses to activate.
12
+ 2. The CCP4i2 settings module only inserts this middleware as the
13
+ *fallback* branch (after LocalSession + AzureAD env-var checks) and
14
+ only when ``DEBUG`` is True. A production-shaped deploy with no auth
15
+ env vars set falls through to *no auth middleware at all*, leaving
16
+ requests as ``AnonymousUser`` — DRF's ``IsAuthenticated`` then 401s
17
+ them. This is strictly safer than the previous "auto-create dev_admin
18
+ on missing config" behaviour.
19
+
20
+ This middleware deliberately does *not* mirror the previous AzureAD
21
+ fallback's permissive default; it is opt-in via ``MIDDLEWARE`` and
22
+ DEBUG-gated.
23
+ """
24
+
25
+ import logging
26
+
27
+ from django.conf import settings
28
+ from django.contrib.auth import get_user_model
29
+ from django.http import HttpRequest
30
+
31
+ from .base import BaseAuthMiddleware
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ DEV_ADMIN_USERNAME = "dev_admin"
36
+ DEV_ADMIN_EMAIL = "dev_admin@localhost"
37
+
38
+
39
+ class DevAdminMiddleware(BaseAuthMiddleware):
40
+ """Auto-assigns a dev_admin superuser when DEBUG is True."""
41
+
42
+ def is_active(self) -> bool:
43
+ return getattr(settings, "DEBUG", False)
44
+
45
+ def authenticate(self, request: HttpRequest):
46
+ User = get_user_model()
47
+ user, created = User.objects.get_or_create(
48
+ username=DEV_ADMIN_USERNAME,
49
+ defaults={
50
+ "email": DEV_ADMIN_EMAIL,
51
+ "first_name": "Dev",
52
+ "last_name": "Admin",
53
+ "is_staff": True,
54
+ "is_superuser": True,
55
+ },
56
+ )
57
+ if created:
58
+ logger.info("Created dev_admin superuser for DEBUG mode")
59
+ # If a previous run created the user without admin flags, top them up
60
+ # so the dev experience stays consistent.
61
+ if not user.is_staff or not user.is_superuser:
62
+ user.is_staff = True
63
+ user.is_superuser = True
64
+ user.save(update_fields=["is_staff", "is_superuser"])
65
+ return user
@@ -0,0 +1,65 @@
1
+ """Per-launch token middleware for the CCP4i2 desktop app.
2
+
3
+ Validates ``Authorization: Bearer <token>`` against the secret in
4
+ ``CCP4I2_LOCAL_SESSION_TOKEN``, set by the Electron main process when
5
+ spawning Django. If the env var is unset, the middleware is a no-op —
6
+ cloud deployments use ``AzureADAuthMiddleware`` instead.
7
+
8
+ Identity: the request is authenticated as the OS user who launched the
9
+ desktop app. The OS-user-derived email is computed by Electron and passed
10
+ via ``CCP4I2_LOCAL_USER_EMAIL`` (Electron sanitises the username so
11
+ domain-joined Windows boxes don't produce malformed emails). If that env
12
+ var is unset, the middleware falls back to a fixed default that is
13
+ well-formed across all platforms.
14
+ """
15
+
16
+ import hmac
17
+ import os
18
+
19
+ from django.contrib.auth import get_user_model
20
+ from django.http import HttpRequest
21
+
22
+ from ..exceptions import AuthenticationFailed
23
+ from .base import BaseAuthMiddleware
24
+
25
+
26
+ # RFC 6761 reserves ``.invalid`` for guaranteed-non-resolvable identifiers,
27
+ # so this email is well-formed under Django's EmailValidator and cannot
28
+ # collide with a real account on any platform.
29
+ DEFAULT_DESKTOP_EMAIL = "desktop@ccp4i2.invalid"
30
+
31
+
32
+ class LocalSessionAuthMiddleware(BaseAuthMiddleware):
33
+
34
+ def __init__(self, get_response):
35
+ super().__init__(get_response)
36
+ self.expected_token = os.environ.get("CCP4I2_LOCAL_SESSION_TOKEN")
37
+
38
+ def is_active(self) -> bool:
39
+ return self.expected_token is not None
40
+
41
+ def authenticate(self, request: HttpRequest):
42
+ auth = request.META.get("HTTP_AUTHORIZATION", "")
43
+ if not auth.startswith("Bearer "):
44
+ raise AuthenticationFailed("Missing Bearer token")
45
+ provided = auth[len("Bearer "):]
46
+ # Constant-time compare against length-extension / timing attacks.
47
+ # Loopback-only is unlikely to be exploitable, but the precedent
48
+ # is cheap to set for future cloud-side providers.
49
+ if not hmac.compare_digest(provided, self.expected_token):
50
+ raise AuthenticationFailed("Invalid local-session token")
51
+ return self._desktop_user()
52
+
53
+ @staticmethod
54
+ def _desktop_user():
55
+ User = get_user_model()
56
+ email = os.environ.get("CCP4I2_LOCAL_USER_EMAIL", DEFAULT_DESKTOP_EMAIL)
57
+ user, _ = User.objects.get_or_create(
58
+ email=email,
59
+ defaults={
60
+ "username": email.split("@")[0],
61
+ "is_staff": True,
62
+ "is_superuser": True,
63
+ },
64
+ )
65
+ return user
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: ccp4i2-api
3
+ Version: 0.3.0
4
+ Summary: Shared API contract (auth, api-fetch, request/response types) for CCP4i2 and consumers
5
+ Project-URL: Homepage, https://github.com/ccp4/ccp4i2/tree/django-sliced/packages/ccp4i2-api
6
+ Project-URL: Repository, https://github.com/ccp4/ccp4i2
7
+ Project-URL: Issues, https://github.com/ccp4/ccp4i2/issues
8
+ Author: CCP4i2 contributors
9
+ License: LGPL-3.0-or-later
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: certifi>=2024.0.0
12
+ Requires-Dist: django<5.0,>=4.2
13
+ Requires-Dist: djangorestframework>=3.14
14
+ Requires-Dist: pyjwt[crypto]>=2.10
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest; extra == 'test'
17
+ Requires-Dist: pytest-django; extra == 'test'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # `@ccp4/ccp4i2-api` / `ccp4i2-api`
21
+
22
+ Shared API contract for CCP4i2 and consumers (CCP4i2 Compounds, third-party
23
+ integrators) — auth handshake, api-fetch helpers, and request/response
24
+ types. One package, two language artifacts: TypeScript on the client side
25
+ (browser, Electron) and Python on the server side (Django middleware, DRF
26
+ authentication). Both halves agree on the canonical bearer-token format,
27
+ 401 response shape, and the typed payloads carried over the authenticated
28
+ channel.
29
+
30
+ ## Status
31
+
32
+ **Draft v0 — published to npm + PyPI from the in-tree workspace.** Source
33
+ of truth lives at
34
+ [`packages/ccp4i2-api/`](https://github.com/ccp4/ccp4i2/tree/django-sliced/packages/ccp4i2-api)
35
+ inside the `ccp4/ccp4i2` monorepo on the `django-sliced` branch and stays
36
+ there. The companion `ccp4/ccp4i2-api` GitHub repo (created for the npm
37
+ namespace and external visibility) does not host source.
38
+
39
+ Versions `0.1.0`–`0.3.0` were published under the previous name
40
+ `@ccp4/ccp4i2-auth` / `ccp4i2-auth`; the package was renamed at `0.3.0`
41
+ because its scope had grown beyond auth to cover the broader API contract.
42
+ The old name is unpublished/yanked; consumers should depend on
43
+ `@ccp4/ccp4i2-api` and `ccp4i2-api` from `0.3.0` onward.
44
+
45
+ Versioning follows semver from `0.x.y` onwards. The v0 contract is
46
+ documented in
47
+ [`docs/CCP4I2_SERVICE_CONTRACT.md`](https://github.com/ccp4/ccp4i2/blob/django-sliced/docs/CCP4I2_SERVICE_CONTRACT.md);
48
+ field stability promises take effect from this version.
49
+
50
+ ## Layout
51
+
52
+ | Path | Purpose |
53
+ |---|---|
54
+ | `src/` | TypeScript source. Built to `lib/` via `npm run build`. |
55
+ | `lib/` | Built TypeScript output. Generated; gitignored. |
56
+ | `dist/` | Python distribution output (`python -m build`). Generated; gitignored. Kept distinct from `lib/` so `twine upload dist/*` doesn't accidentally pick up TypeScript artefacts. |
57
+ | `ccp4i2_api/` | Python source. Installed editable via `pip install -e .`. |
58
+ | `tests/js/` | TypeScript tests (vitest, when added). |
59
+ | `tests/python/` | Python tests (pytest, when added). |
60
+
61
+ ## Consumer wiring
62
+
63
+ In-monorepo consumers can reference this package by local path for fast
64
+ iteration; out-of-monorepo consumers pull the published versions.
65
+
66
+ **TypeScript** — in-monorepo (`client/package.json`):
67
+
68
+ ```json
69
+ "dependencies": {
70
+ "@ccp4/ccp4i2-api": "file:../packages/ccp4i2-api"
71
+ }
72
+ ```
73
+
74
+ (Path depth varies by consumer location.) Out-of-monorepo consumers use the
75
+ published range, e.g. `"@ccp4/ccp4i2-api": "^0.3.0"`.
76
+
77
+ **Python** — in-monorepo (`Docker/server/Dockerfile`, local dev setup):
78
+
79
+ ```bash
80
+ pip install -e packages/ccp4i2-api/
81
+ ```
82
+
83
+ Out-of-monorepo consumers `pip install ccp4i2-api>=0.3`.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ # TypeScript
89
+ cd packages/ccp4i2-api
90
+ npm install
91
+ npm run build # produces lib/
92
+ npm run watch # rebuilds on change
93
+
94
+ # Python
95
+ cd packages/ccp4i2-api
96
+ ccp4-python -m pip install -e .
97
+ ccp4-python -c "import ccp4i2_api; print(ccp4i2_api.__version__)"
98
+ ```
@@ -0,0 +1,12 @@
1
+ ccp4i2_api/__init__.py,sha256=mVW2MQ8KF7wj8BwkSabwR13tZW7EBXIzMRB5cav-yTc,474
2
+ ccp4i2_api/drf.py,sha256=xmhbkxIMKUdQkXUBiKz8iUycGbAF3c5S5wVKBhnN_sM,2329
3
+ ccp4i2_api/exceptions.py,sha256=JnqIyRqDm2qOJAYIitaS1Wvhfvuetu8e5xAeL7MJsvc,664
4
+ ccp4i2_api/middleware/__init__.py,sha256=xhRvvZ7FNF-gTyhAzLO6EWnvqMNsIJL9UxY3kqsvZhc,785
5
+ ccp4i2_api/middleware/azure_ad.py,sha256=rIYl1A92CUAhpyCNAB8UoF2MIL3mI6Xl2R8VNOko4nI,16308
6
+ ccp4i2_api/middleware/base.py,sha256=CwTXbZnzd-aywXyQnTgimmK5bD3nFMcIk45H7MWJL00,2726
7
+ ccp4i2_api/middleware/dev.py,sha256=cOJuFNnPOnYQyJA4olDBlxD5tRPaH7xObI-5-8xAp-Q,1590
8
+ ccp4i2_api/middleware/dev_admin.py,sha256=dP5qKgN5mSpQOtOaVMw7AVVHjsf3CU01SO2oxz22mmg,2444
9
+ ccp4i2_api/middleware/local_session.py,sha256=ULTvKtMC3Db9bN3R0aOc6FbKn3Dfa6akLEV7XMg-lDQ,2497
10
+ ccp4i2_api-0.3.0.dist-info/METADATA,sha256=vb95GqwsU2VIG95YbJeI_3EuNB1pY5ZmAJRJlLeIRNk,3660
11
+ ccp4i2_api-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ ccp4i2_api-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any