Coverage for gco/services/auth_middleware.py: 92.74%

196 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1""" 

2Authentication middleware for validating requests from API Gateway. 

3 

4Except for explicit health and metrics probes, every request must carry the 

5short-lived HMAC envelope generated by a trusted API Gateway proxy Lambda. The 

6envelope binds the signature version, timestamp, random nonce, HTTP method, 

7exact path and query string, and SHA-256 body digest. The middleware validates 

8freshness and integrity and rejects process-local nonce replays. 

9 

10Security Flow: 

11 1. API Gateway validates client IAM credentials (SigV4) 

12 2. Lambda signs the exact backend request with the shared signing key 

13 3. This middleware validates the HMAC envelope and consumes its nonce 

14 4. Invalid, stale, tampered, or replayed envelopes result in 403 Forbidden 

15 

16Secret Rotation Support: 

17 During rotation, signatures are validated against both AWSCURRENT and 

18 AWSPENDING signing keys for zero-downtime rotation. Successful refreshes 

19 are cached with a bounded stale grace period; expired caches fail closed. 

20 

21Environment Variables: 

22 AUTH_SECRET_ARN: ARN of the Secrets Manager secret containing the signing key 

23 GCO_DEV_MODE: Set to "true" to allow unauthenticated requests when no 

24 secret is configured. Without this flag, missing AUTH_SECRET_ARN 

25 causes 503 errors (fail-closed). This prevents accidental 

26 unauthenticated deployments due to misconfiguration. 

27""" 

28 

29from __future__ import annotations 

30 

31import hashlib 

32import hmac 

33import json 

34import logging 

35import os 

36import re 

37import threading 

38import time 

39from collections.abc import Awaitable, Callable 

40from typing import Any 

41 

42import boto3 

43from fastapi import Request 

44from starlette.middleware.base import BaseHTTPMiddleware 

45from starlette.responses import JSONResponse, Response 

46from starlette.types import ASGIApp 

47 

48# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

49# Generated at (UTC): 2026-07-18T01:03:40Z 

50# Flowchart(s) generated from this file: 

51# * ``AuthenticationMiddleware.dispatch`` -> ``diagrams/code_diagrams/gco/services/auth_middleware.AuthenticationMiddleware_dispatch.html`` 

52# (PNG: ``diagrams/code_diagrams/gco/services/auth_middleware.AuthenticationMiddleware_dispatch.png``) 

53# Regenerate with ``python diagrams/code_diagrams/generate.py``. 

54# <pyflowchart-code-diagram> END 

55 

56 

57logger = logging.getLogger(__name__) 

58 

59# Module-level cache for secret signing keys and replay nonces. 

60_cached_tokens: set[str] = set() 

61_token_expirations: dict[str, float] = {} 

62_cache_timestamp = 0.0 

63_last_successful_refresh = 0.0 

64_last_refresh_attempt = 0.0 

65_secrets_client = None 

66_nonce_lock = threading.Lock() 

67_seen_nonces: dict[str, float] = {} 

68_NONCE_PATTERN = re.compile(r"^[0-9a-f]{32}$") 

69 

70 

71def _bounded_env_float(name: str, default: float, minimum: float, maximum: float) -> float: 

72 try: 

73 value = float(os.getenv(name, str(default))) 

74 except ValueError: 

75 return default 

76 return value if minimum <= value <= maximum else default 

77 

78 

79CACHE_TTL_SECONDS = _bounded_env_float("AUTH_CACHE_TTL_SECONDS", 300.0, 1.0, 3600.0) 

80CACHE_MAX_STALE_SECONDS = max( 

81 CACHE_TTL_SECONDS, 

82 _bounded_env_float("AUTH_CACHE_MAX_STALE_SECONDS", 900.0, 1.0, 7200.0), 

83) 

84CACHE_RETRY_SECONDS = _bounded_env_float("AUTH_CACHE_RETRY_SECONDS", 5.0, 0.1, 60.0) 

85SIGNATURE_MAX_AGE_SECONDS = _bounded_env_float("AUTH_SIGNATURE_MAX_AGE_SECONDS", 30.0, 5.0, 300.0) 

86_MAX_TRACKED_NONCES = 10_000 

87 

88# Endpoints that bypass authentication (health checks for load balancers and 

89# Global Accelerator). /api/v1/health is included so GA can perform HTTP 

90# health checks for intelligent routing without an HMAC envelope. 

91UNAUTHENTICATED_PATHS = frozenset(["/healthz", "/readyz", "/metrics", "/api/v1/health"]) 

92 

93 

94def get_secrets_client() -> Any: 

95 """ 

96 Get Secrets Manager client with lazy initialization. 

97 

98 The client is configured to use the region from the AUTH_SECRET_ARN 

99 environment variable, which may be different from the default region. 

100 

101 Returns: 

102 boto3 Secrets Manager client instance 

103 """ 

104 global _secrets_client 

105 if _secrets_client is None: 

106 # Extract region from the secret ARN 

107 # Format: arn:aws:secretsmanager:REGION:ACCOUNT:secret:NAME 

108 secret_arn = os.environ.get("AUTH_SECRET_ARN", "") 

109 region = None 

110 if secret_arn: 

111 parts = secret_arn.split(":") 

112 if len(parts) >= 4: 112 ↛ 114line 112 didn't jump to line 114 because the condition on line 112 was always true

113 region = parts[3] 

114 _secrets_client = boto3.client("secretsmanager", region_name=region) 

115 return _secrets_client 

116 

117 

118def _is_cache_valid() -> bool: 

119 """Return whether keys are still inside the normal refresh TTL.""" 

120 return bool(_cached_tokens) and (time.monotonic() - _last_successful_refresh) < ( 

121 CACHE_TTL_SECONDS 

122 ) 

123 

124 

125def _previous_token_valid_until(secrets_client: Any, secret_arn: str) -> float | None: 

126 """Return the fixed AWSPREVIOUS deadline from rotation completion metadata. 

127 

128 ``LastRotatedDate`` is set when Secrets Manager finishes rotation. Deriving 

129 the deadline from it prevents successful cache refreshes from renewing a 

130 displaced key indefinitely. Missing or malformed metadata fails closed for 

131 AWSPREVIOUS without making AWSCURRENT or AWSPENDING unavailable. 

132 """ 

133 try: 

134 metadata = secrets_client.describe_secret(SecretId=secret_arn) 

135 last_rotated = metadata.get("LastRotatedDate") 

136 timestamp = getattr(last_rotated, "timestamp", None) 

137 if not callable(timestamp): 

138 raise ValueError("LastRotatedDate is missing") 

139 return float(timestamp()) + CACHE_MAX_STALE_SECONDS 

140 except Exception: 

141 logger.debug("AWSPREVIOUS rotation metadata is unavailable") 

142 return None 

143 

144 

145def _refresh_cache() -> bool: 

146 """Refresh current and overlap keys without extending stale lifetime on failure.""" 

147 global _cached_tokens, _token_expirations 

148 global _cache_timestamp, _last_successful_refresh, _last_refresh_attempt 

149 

150 secret_arn = os.environ.get("AUTH_SECRET_ARN") 

151 if not secret_arn: 

152 return False 

153 

154 now = time.monotonic() 

155 _last_refresh_attempt = now 

156 try: 

157 secrets_client = get_secrets_client() 

158 response = secrets_client.get_secret_value( 

159 SecretId=secret_arn, 

160 VersionStage="AWSCURRENT", 

161 ) 

162 secret_data = json.loads(response["SecretString"]) 

163 current = secret_data.get("token") 

164 if not isinstance(current, str) or not current: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true

165 raise ValueError("AWSCURRENT token is missing") 

166 new_tokens = {current} 

167 new_token_expirations: dict[str, float] = {} 

168 

169 # Signers cache AWSCURRENT independently for up to 

170 # CACHE_MAX_STALE_SECONDS. During rotation, Secrets Manager moves the 

171 # displaced current version to AWSPREVIOUS, so validators must retain 

172 # both optional overlap stages while a warm signer can still use them. 

173 # AWSPREVIOUS gets a fixed wall-clock deadline from LastRotatedDate; 

174 # unlike the aggregate cache TTL, successful refreshes cannot renew it. 

175 for version_stage in ("AWSPENDING", "AWSPREVIOUS"): 

176 try: 

177 response = secrets_client.get_secret_value( 

178 SecretId=secret_arn, 

179 VersionStage=version_stage, 

180 ) 

181 overlap_data = json.loads(response["SecretString"]) 

182 overlap = overlap_data.get("token") 

183 if not isinstance(overlap, str) or not overlap: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 continue 

185 if version_stage == "AWSPREVIOUS" and overlap not in new_tokens: 

186 valid_until = _previous_token_valid_until(secrets_client, secret_arn) 

187 if valid_until is None or time.time() > valid_until: 

188 logger.info("AWSPREVIOUS signing key is outside its overlap window") 

189 continue 

190 new_token_expirations[overlap] = valid_until 

191 new_tokens.add(overlap) 

192 except secrets_client.exceptions.ResourceNotFoundException: 

193 pass 

194 except Exception: 

195 logger.debug("%s signing key is unavailable", version_stage) 

196 except Exception: 

197 logger.exception("Failed to refresh authentication signing keys") 

198 return False 

199 

200 _cached_tokens = new_tokens 

201 _token_expirations = new_token_expirations 

202 _last_successful_refresh = now 

203 _cache_timestamp = now 

204 logger.info("Authentication signing-key cache refreshed") 

205 return True 

206 

207 

208def get_valid_tokens() -> set[str]: 

209 """Return current signing keys with a strictly bounded stale grace period.""" 

210 now = time.monotonic() 

211 if not _is_cache_valid() and now - _last_refresh_attempt >= CACHE_RETRY_SECONDS: 

212 _refresh_cache() 

213 age = time.monotonic() - _last_successful_refresh 

214 if _cached_tokens and age <= CACHE_MAX_STALE_SECONDS: 

215 wall_clock = time.time() 

216 return { 

217 token 

218 for token in _cached_tokens 

219 if token not in _token_expirations or wall_clock <= _token_expirations[token] 

220 } 

221 return set() 

222 

223 

224def get_secret_token() -> str | None: 

225 """Return one primary signing key for compatibility callers. 

226 

227 HMAC validation should use :func:`get_valid_tokens` so both current and 

228 pending rotation keys are considered. 

229 """ 

230 tokens = get_valid_tokens() 

231 return next(iter(tokens), None) if tokens else None 

232 

233 

234def clear_token_cache() -> None: 

235 """Clear signing-key and replay caches, forcing a refresh.""" 

236 global _cached_tokens, _token_expirations 

237 global _cache_timestamp, _last_successful_refresh, _last_refresh_attempt 

238 _cached_tokens = set() 

239 _token_expirations = {} 

240 _cache_timestamp = 0.0 

241 _last_successful_refresh = 0.0 

242 _last_refresh_attempt = 0.0 

243 with _nonce_lock: 

244 _seen_nonces.clear() 

245 logger.info("Authentication signing-key cache cleared") 

246 

247 

248def _request_target(request: Request) -> str: 

249 raw_path = request.scope.get("raw_path") 

250 path = raw_path.decode("latin-1") if isinstance(raw_path, bytes) else request.url.path 

251 raw_query = request.scope.get("query_string", b"") 

252 query = raw_query.decode("latin-1") if isinstance(raw_query, bytes) else str(raw_query) 

253 return path + (f"?{query}" if query else "") 

254 

255 

256def _accept_nonce(nonce: str, now: float) -> bool: 

257 """Reject process-local replays and keep the nonce cache strictly bounded.""" 

258 expires_at = now + SIGNATURE_MAX_AGE_SECONDS 

259 with _nonce_lock: 

260 expired = [key for key, expiry in _seen_nonces.items() if expiry < now] 

261 for key in expired: 261 ↛ 262line 261 didn't jump to line 262 because the loop on line 261 never started

262 _seen_nonces.pop(key, None) 

263 if nonce in _seen_nonces: 

264 return False 

265 if len(_seen_nonces) >= _MAX_TRACKED_NONCES: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true

266 oldest = min(_seen_nonces, key=_seen_nonces.__getitem__) 

267 _seen_nonces.pop(oldest, None) 

268 _seen_nonces[nonce] = expires_at 

269 return True 

270 

271 

272async def _has_valid_signature(request: Request, signing_keys: set[str]) -> bool: 

273 """Validate the short-lived HMAC envelope added by the trusted Lambda.""" 

274 headers = request.headers 

275 if headers.get("x-gco-signature-version") != "v1": 

276 return False 

277 signature = headers.get("x-gco-signature", "") 

278 timestamp_value = headers.get("x-gco-timestamp", "") 

279 nonce = headers.get("x-gco-nonce", "") 

280 claimed_content_hash = headers.get("x-gco-content-sha256", "") 

281 if ( 

282 len(signature) != 64 

283 or len(claimed_content_hash) != 64 

284 or _NONCE_PATTERN.fullmatch(nonce) is None 

285 ): 

286 return False 

287 try: 

288 timestamp = int(timestamp_value) 

289 except ValueError: 

290 return False 

291 now = time.time() 

292 if abs(now - timestamp) > SIGNATURE_MAX_AGE_SECONDS: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true

293 return False 

294 

295 body = await request.body() 

296 actual_content_hash = hashlib.sha256(body).hexdigest() 

297 if not hmac.compare_digest(actual_content_hash, claimed_content_hash): 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 return False 

299 canonical = "\n".join( 

300 [ 

301 "v1", 

302 timestamp_value, 

303 nonce, 

304 request.method.upper(), 

305 _request_target(request), 

306 actual_content_hash, 

307 ] 

308 ) 

309 valid = any( 

310 hmac.compare_digest( 

311 signature, 

312 hmac.new( 

313 key.encode("utf-8"), 

314 canonical.encode("utf-8"), 

315 hashlib.sha256, 

316 ).hexdigest(), 

317 ) 

318 for key in signing_keys 

319 ) 

320 return valid and _accept_nonce(nonce, now) 

321 

322 

323class AuthenticationMiddleware(BaseHTTPMiddleware): 

324 """Validate the API Gateway proxy's short-lived HMAC request envelope. 

325 

326 Health-check endpoints are excluded for load balancer probes. During key 

327 rotation, signatures from both AWSCURRENT and AWSPENDING are accepted. 

328 """ 

329 

330 def __init__(self, app: ASGIApp) -> None: 

331 super().__init__(app) 

332 # Startup-time configuration check — surface misconfigurations early 

333 secret_arn = os.environ.get("AUTH_SECRET_ARN") 

334 if not secret_arn: 

335 dev_mode = os.environ.get("GCO_DEV_MODE", "").lower() == "true" 

336 if dev_mode: 

337 logger.warning( 

338 "GCO_DEV_MODE=true with no AUTH_SECRET_ARN — " 

339 "authentication is bypassed. Do NOT use in production." 

340 ) 

341 else: 

342 logger.error( 

343 "AUTH_SECRET_ARN is not configured and GCO_DEV_MODE is not enabled. " 

344 "All non-health-check requests will be denied with 503." 

345 ) 

346 

347 async def dispatch( 

348 self, 

349 request: Request, 

350 call_next: Callable[[Request], Awaitable[Response]], 

351 ) -> Response: 

352 """ 

353 Process incoming request and validate authentication. 

354 

355 Args: 

356 request: The incoming FastAPI request 

357 call_next: The next middleware/handler in the chain 

358 

359 Returns: 

360 Response from the next handler, or a bounded JSON authentication error. 

361 """ 

362 # Skip authentication for health check endpoints 

363 if request.url.path in UNAUTHENTICATED_PATHS: 

364 return await call_next(request) 

365 

366 valid_tokens = get_valid_tokens() 

367 

368 # No tokens available — determine whether to fail open or closed 

369 if not valid_tokens: 

370 secret_arn = os.environ.get("AUTH_SECRET_ARN") 

371 if not secret_arn: 

372 # No secret configured. Only allow requests if the operator 

373 # explicitly opted into dev mode. This prevents accidental 

374 # unauthenticated deployments due to misconfiguration. 

375 dev_mode = os.environ.get("GCO_DEV_MODE", "").lower() == "true" 

376 if dev_mode: 

377 logger.warning( 

378 "Authentication bypassed - GCO_DEV_MODE=true, no secret configured" 

379 ) 

380 return await call_next(request) 

381 # Fail closed: no secret + no dev mode = deny 

382 logger.error( 

383 "No AUTH_SECRET_ARN configured and GCO_DEV_MODE is not enabled. " 

384 "Set AUTH_SECRET_ARN for production or GCO_DEV_MODE=true for local development." 

385 ) 

386 return JSONResponse( 

387 status_code=503, 

388 content={"detail": "Service unavailable - authentication not configured"}, 

389 ) 

390 # Secret configured but couldn't load - deny access 

391 logger.error("Failed to load authentication tokens") 

392 return JSONResponse( 

393 status_code=503, 

394 content={"detail": "Service temporarily unavailable - authentication error"}, 

395 ) 

396 

397 if not await _has_valid_signature(request, valid_tokens): 

398 client_ip = request.client.host if request.client else "unknown" 

399 logger.warning( 

400 "Invalid backend signature from %s for %s", 

401 client_ip, 

402 request.url.path, 

403 ) 

404 return JSONResponse( 

405 status_code=403, 

406 content={ 

407 "detail": ("Forbidden - requests must come through authenticated API Gateway") 

408 }, 

409 ) 

410 

411 return await call_next(request)