Skip to content

Commit 9084f87

Browse files
authored
Merge pull request #1 from open-experiments/feature/sprint-01-security-foundation
Feature/sprint 01 security foundation
2 parents aa225d4 + 801e451 commit 9084f87

28 files changed

Lines changed: 3854 additions & 16 deletions

src/api-gateway/app/main.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,21 @@
55

66
from __future__ import annotations
77

8+
from collections.abc import AsyncGenerator
89
from contextlib import asynccontextmanager
9-
from typing import AsyncGenerator
1010

1111
import httpx
12-
from fastapi import FastAPI, Request, Response
12+
from fastapi import FastAPI, HTTPException, Request
1313
from fastapi.middleware.cors import CORSMiddleware
1414
from fastapi.responses import JSONResponse
15+
from starlette.middleware.base import BaseHTTPMiddleware
1516

1617
from shared.config import get_settings
1718
from shared.observability import get_logger
1819
from shared.redis_client import RedisClient
1920

2021
from .api import health, proxy
22+
from .middleware.oauth import oauth_middleware
2123
from .middleware.rate_limit import RateLimitMiddleware
2224

2325
logger = get_logger(__name__)
@@ -68,6 +70,40 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
6870
await redis.close()
6971

7072

73+
class AuthenticationMiddleware(BaseHTTPMiddleware):
74+
"""Global authentication middleware.
75+
76+
Spec Reference: specs/06-api-gateway.md Section 3.1
77+
78+
Validates OAuth tokens for all requests except health endpoints.
79+
Skips authentication if OAuth is not configured (development mode).
80+
"""
81+
82+
async def dispatch(self, request: Request, call_next):
83+
"""Process request through authentication."""
84+
settings = get_settings()
85+
86+
# Skip authentication for certain paths
87+
skip_paths = ["/health", "/ready", "/metrics", "/docs", "/openapi.json", "/redoc"]
88+
89+
if request.url.path in skip_paths:
90+
return await call_next(request)
91+
92+
# Skip authentication if OAuth is not configured (development mode)
93+
if not settings.oauth.issuer:
94+
return await call_next(request)
95+
96+
try:
97+
await oauth_middleware(request)
98+
except HTTPException as e:
99+
return JSONResponse(
100+
status_code=e.status_code,
101+
content={"detail": e.detail},
102+
)
103+
104+
return await call_next(request)
105+
106+
71107
def create_app() -> FastAPI:
72108
"""Create and configure the FastAPI application."""
73109
settings = get_settings()
@@ -91,6 +127,10 @@ def create_app() -> FastAPI:
91127
allow_headers=["*"],
92128
)
93129

130+
# Authentication middleware (OAuth 2.0)
131+
# Spec Reference: specs/06-api-gateway.md Section 3.1
132+
app.add_middleware(AuthenticationMiddleware)
133+
94134
# Rate limiting middleware
95135
# Spec Reference: specs/06-api-gateway.md Section 7
96136
app.add_middleware(RateLimitMiddleware)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,17 @@
11
"""Middleware for API Gateway."""
2+
3+
from .oauth import (
4+
OAuthMiddleware,
5+
TokenPayload,
6+
get_current_user,
7+
oauth_middleware,
8+
)
9+
from .rate_limit import RateLimitMiddleware
10+
11+
__all__ = [
12+
"OAuthMiddleware",
13+
"TokenPayload",
14+
"get_current_user",
15+
"oauth_middleware",
16+
"RateLimitMiddleware",
17+
]
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""OAuth 2.0 Authentication Middleware.
2+
3+
Spec Reference: specs/06-api-gateway.md Section 3.1
4+
"""
5+
6+
import time
7+
8+
import httpx
9+
from fastapi import HTTPException, Request, status
10+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
11+
from jose import JWTError, jwt
12+
from pydantic import BaseModel
13+
14+
from shared.config import get_settings
15+
from shared.observability import get_logger
16+
17+
logger = get_logger(__name__)
18+
security = HTTPBearer(auto_error=False)
19+
20+
21+
class TokenPayload(BaseModel):
22+
"""JWT token payload from OpenShift OAuth."""
23+
24+
sub: str # User ID
25+
preferred_username: str
26+
email: str | None = None
27+
groups: list[str] = []
28+
exp: int
29+
iat: int
30+
iss: str
31+
32+
33+
class OAuthConfig(BaseModel):
34+
"""OAuth provider configuration."""
35+
36+
issuer: str
37+
authorization_endpoint: str
38+
token_endpoint: str
39+
userinfo_endpoint: str
40+
jwks_uri: str
41+
42+
43+
class OAuthMiddleware:
44+
"""OAuth 2.0 authentication middleware for OpenShift integration."""
45+
46+
def __init__(self):
47+
self.settings = get_settings()
48+
self._jwks_cache: dict | None = None
49+
self._jwks_cache_time: float = 0
50+
self._jwks_cache_ttl: int = 3600 # 1 hour
51+
self._config_cache: OAuthConfig | None = None
52+
53+
async def get_oauth_config(self) -> OAuthConfig:
54+
"""Fetch OAuth provider configuration from well-known endpoint."""
55+
if self._config_cache:
56+
return self._config_cache
57+
58+
well_known_url = f"{self.settings.oauth.issuer}/.well-known/oauth-authorization-server"
59+
60+
async with httpx.AsyncClient(verify=True, timeout=10.0) as client:
61+
try:
62+
response = await client.get(well_known_url)
63+
response.raise_for_status()
64+
data = response.json()
65+
66+
self._config_cache = OAuthConfig(
67+
issuer=data["issuer"],
68+
authorization_endpoint=data["authorization_endpoint"],
69+
token_endpoint=data["token_endpoint"],
70+
userinfo_endpoint=data["userinfo_endpoint"],
71+
jwks_uri=data["jwks_uri"],
72+
)
73+
return self._config_cache
74+
except httpx.HTTPError as e:
75+
logger.error("Failed to fetch OAuth config", error=str(e))
76+
raise HTTPException(
77+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
78+
detail="OAuth provider unavailable",
79+
) from e
80+
81+
async def get_jwks(self) -> dict:
82+
"""Fetch and cache JWKS from OAuth provider."""
83+
now = time.time()
84+
85+
if self._jwks_cache and (now - self._jwks_cache_time) < self._jwks_cache_ttl:
86+
return self._jwks_cache
87+
88+
config = await self.get_oauth_config()
89+
90+
async with httpx.AsyncClient(verify=True, timeout=10.0) as client:
91+
try:
92+
response = await client.get(config.jwks_uri)
93+
response.raise_for_status()
94+
self._jwks_cache = response.json()
95+
self._jwks_cache_time = now
96+
return self._jwks_cache
97+
except httpx.HTTPError as e:
98+
logger.error("Failed to fetch JWKS", error=str(e))
99+
raise HTTPException(
100+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
101+
detail="Unable to validate token",
102+
) from e
103+
104+
async def validate_token(self, token: str) -> TokenPayload:
105+
"""Validate JWT token and return payload."""
106+
try:
107+
# Get JWKS for signature verification
108+
jwks = await self.get_jwks()
109+
110+
# Decode header to get key ID
111+
unverified_header = jwt.get_unverified_header(token)
112+
kid = unverified_header.get("kid")
113+
114+
# Find matching key
115+
rsa_key = None
116+
for key in jwks.get("keys", []):
117+
if key.get("kid") == kid:
118+
rsa_key = key
119+
break
120+
121+
if not rsa_key:
122+
raise HTTPException(
123+
status_code=status.HTTP_401_UNAUTHORIZED,
124+
detail="Unable to find appropriate key",
125+
)
126+
127+
# Verify and decode token
128+
payload = jwt.decode(
129+
token,
130+
rsa_key,
131+
algorithms=["RS256"],
132+
issuer=self.settings.oauth.issuer,
133+
options={"verify_aud": False}, # OpenShift may not include aud
134+
)
135+
136+
return TokenPayload(**payload)
137+
138+
except JWTError as e:
139+
logger.warning("JWT validation failed", error=str(e))
140+
raise HTTPException(
141+
status_code=status.HTTP_401_UNAUTHORIZED,
142+
detail="Invalid or expired token",
143+
) from e
144+
145+
async def __call__(self, request: Request) -> TokenPayload | None:
146+
"""Extract and validate token from request."""
147+
# Skip auth for health endpoints
148+
if request.url.path in ["/health", "/ready", "/metrics"]:
149+
return None
150+
151+
# Get authorization header
152+
auth: HTTPAuthorizationCredentials | None = await security(request)
153+
154+
if not auth:
155+
raise HTTPException(
156+
status_code=status.HTTP_401_UNAUTHORIZED,
157+
detail="Missing authorization header",
158+
headers={"WWW-Authenticate": "Bearer"},
159+
)
160+
161+
# Validate token
162+
token_payload = await self.validate_token(auth.credentials)
163+
164+
# Attach user info to request state
165+
request.state.user = token_payload
166+
request.state.user_id = token_payload.sub
167+
request.state.username = token_payload.preferred_username
168+
request.state.groups = token_payload.groups
169+
170+
logger.info(
171+
"User authenticated",
172+
user_id=token_payload.sub,
173+
username=token_payload.preferred_username,
174+
)
175+
176+
return token_payload
177+
178+
179+
# Singleton instance
180+
oauth_middleware = OAuthMiddleware()
181+
182+
183+
async def get_current_user(request: Request) -> TokenPayload:
184+
"""Dependency to get current authenticated user."""
185+
return await oauth_middleware(request)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Services for API Gateway."""
2+
3+
from .rbac import (
4+
Permission,
5+
RBACService,
6+
Role,
7+
UserContext,
8+
get_user_context,
9+
rbac_service,
10+
require_permission,
11+
require_role,
12+
)
13+
14+
__all__ = [
15+
"Permission",
16+
"RBACService",
17+
"Role",
18+
"UserContext",
19+
"get_user_context",
20+
"rbac_service",
21+
"require_permission",
22+
"require_role",
23+
]

0 commit comments

Comments
 (0)