Skip to content

Commit e68ff9e

Browse files
amosttAygentic
andcommitted
feat(core): add Supabase client, Clerk auth, and HTTP client with typed deps [AYG-67]
Implement the three core infrastructure dependencies for the microservice template: - Supabase client: factory function + FastAPI dependency from app.state - Clerk JWT auth: validate Bearer tokens, extract Principal with roles, map error reasons to structured error codes (AUTH_MISSING_TOKEN, AUTH_EXPIRED_TOKEN, AUTH_INVALID_TOKEN) - HTTP client wrapper: async httpx with retry on 502/503/504, exponential backoff, circuit breaker (5 failures/60s window), X-Request-ID/X-Correlation-ID header propagation from structlog - Typed FastAPI dependencies: SupabaseDep, PrincipalDep, HttpClientDep, RequestIdDep via Annotated[T, Depends()] - Lifespan context manager: init Supabase + HttpClient at startup, close httpx pool on shutdown - Added session_id field to Principal model 44 new unit tests (113 total), all passing. Fixes AYG-67 🤖 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com>
1 parent f57968b commit e68ff9e

File tree

12 files changed

+2672
-110
lines changed

12 files changed

+2672
-110
lines changed

backend/app/api/deps.py

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,42 @@
1-
from collections.abc import Generator
2-
from typing import Annotated
1+
"""Typed FastAPI dependency declarations.
32
4-
import jwt
5-
from fastapi import Depends, HTTPException, status
6-
from fastapi.security import OAuth2PasswordBearer
7-
from jwt.exceptions import InvalidTokenError
8-
from pydantic import ValidationError
9-
from sqlmodel import Session
3+
All cross-cutting concerns (database, auth, HTTP client, request context) are
4+
injected via ``Annotated[T, Depends(...)]`` types. Route handlers declare what
5+
they need through parameter type annotations and FastAPI resolves the
6+
dependency chain automatically.
107
11-
from app.core import security
12-
from app.core.config import settings
13-
from app.core.db import engine
14-
from app.models import TokenPayload, User
8+
Every dependency listed here is overridable in tests via
9+
``app.dependency_overrides[fn] = mock_fn``.
10+
"""
1511

16-
reusable_oauth2 = OAuth2PasswordBearer(
17-
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
18-
)
12+
from typing import Annotated
1913

14+
from fastapi import Depends, Request
15+
from supabase import Client as SupabaseClient
2016

21-
def get_db() -> Generator[Session, None, None]:
22-
with Session(engine) as session:
23-
yield session
17+
from app.core.auth import get_current_principal
18+
from app.core.http_client import HttpClient, get_http_client
19+
from app.core.supabase import get_supabase
20+
from app.models.auth import Principal
2421

2522

26-
SessionDep = Annotated[Session, Depends(get_db)]
27-
TokenDep = Annotated[str, Depends(reusable_oauth2)]
23+
def get_request_id(request: Request) -> str:
24+
"""Return the current request ID from request state.
2825
26+
The request_id is set by RequestPipelineMiddleware on every request.
27+
Falls back to an empty string if middleware has not run (e.g. in tests).
28+
"""
29+
return getattr(request.state, "request_id", "")
2930

30-
def get_current_user(session: SessionDep, token: TokenDep) -> User:
31-
try:
32-
payload = jwt.decode(
33-
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
34-
)
35-
token_data = TokenPayload(**payload)
36-
except (InvalidTokenError, ValidationError):
37-
raise HTTPException(
38-
status_code=status.HTTP_403_FORBIDDEN,
39-
detail="Could not validate credentials",
40-
)
41-
user = session.get(User, token_data.sub)
42-
if not user:
43-
raise HTTPException(status_code=404, detail="User not found")
44-
if not user.is_active:
45-
raise HTTPException(status_code=400, detail="Inactive user")
46-
return user
4731

32+
SupabaseDep = Annotated[SupabaseClient, Depends(get_supabase)]
33+
"""Supabase client instance, initialised at app startup."""
4834

49-
CurrentUser = Annotated[User, Depends(get_current_user)]
35+
PrincipalDep = Annotated[Principal, Depends(get_current_principal)]
36+
"""Authenticated user principal extracted from Clerk JWT."""
5037

38+
HttpClientDep = Annotated[HttpClient, Depends(get_http_client)]
39+
"""Shared async HTTP client with retry, circuit breaker, header propagation."""
5140

52-
def get_current_active_superuser(current_user: CurrentUser) -> User:
53-
if not current_user.is_superuser:
54-
raise HTTPException(
55-
status_code=403, detail="The user doesn't have enough privileges"
56-
)
57-
return current_user
41+
RequestIdDep = Annotated[str, Depends(get_request_id)]
42+
"""Current request UUID from the request pipeline middleware."""

backend/app/core/auth.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Clerk JWT authentication dependency.
2+
3+
Provides get_current_principal(), a FastAPI dependency that validates the
4+
Bearer token in the Authorization header using the Clerk SDK and returns
5+
the authenticated Principal.
6+
7+
Error codes:
8+
AUTH_MISSING_TOKEN — no session token in request
9+
AUTH_EXPIRED_TOKEN — token has expired
10+
AUTH_INVALID_TOKEN — signature bad, wrong party, or other failure
11+
"""
12+
13+
from typing import Any
14+
15+
import httpx
16+
from clerk_backend_api import AuthenticateRequestOptions, Clerk
17+
from clerk_backend_api.jwks_helpers import AuthErrorReason, TokenVerificationErrorReason
18+
from fastapi import Request
19+
20+
from app.core.errors import ServiceError
21+
from app.models.auth import Principal
22+
23+
24+
def _get_clerk_sdk() -> Clerk:
25+
"""Return a Clerk SDK instance initialised with the current secret key.
26+
27+
Deferred import of settings avoids module-level instantiation failure
28+
during tests where the real environment is not set up.
29+
"""
30+
from app.core.config import settings
31+
32+
return Clerk(bearer_auth=settings.CLERK_SECRET_KEY.get_secret_value())
33+
34+
35+
def _get_authorized_parties() -> list[str]:
36+
"""Return the configured list of authorized parties.
37+
38+
Deferred import keeps settings out of module-level scope.
39+
"""
40+
from app.core.config import settings
41+
42+
return settings.CLERK_AUTHORIZED_PARTIES
43+
44+
45+
def _convert_request(request: Request) -> httpx.Request:
46+
"""Convert a FastAPI/Starlette Request to an httpx.Request for the Clerk SDK."""
47+
return httpx.Request(
48+
method=request.method,
49+
url=str(request.url),
50+
headers=dict(request.headers),
51+
)
52+
53+
54+
def _map_error_reason(reason: Any) -> tuple[str, str]:
55+
"""Map a Clerk error reason to (message, error_code).
56+
57+
Returns a (message, code) tuple suitable for ServiceError.
58+
"""
59+
if reason is AuthErrorReason.SESSION_TOKEN_MISSING:
60+
return ("Missing authentication token", "AUTH_MISSING_TOKEN")
61+
if reason is TokenVerificationErrorReason.TOKEN_EXPIRED:
62+
return ("Token expired", "AUTH_EXPIRED_TOKEN")
63+
if reason is TokenVerificationErrorReason.TOKEN_INVALID_SIGNATURE:
64+
return ("Invalid token signature", "AUTH_INVALID_TOKEN")
65+
if reason is TokenVerificationErrorReason.TOKEN_INVALID_AUTHORIZED_PARTIES:
66+
return ("Token not from authorized party", "AUTH_INVALID_TOKEN")
67+
return ("Authentication failed", "AUTH_INVALID_TOKEN")
68+
69+
70+
def _extract_roles(payload: dict[str, Any]) -> list[str]:
71+
"""Extract roles from the Clerk JWT payload.
72+
73+
Clerk encodes the active organisation role under the 'o' claim:
74+
payload["o"]["rol"] -> e.g. "org:admin"
75+
76+
Falls back to an empty list when the user has no active organisation
77+
or the claim is absent.
78+
"""
79+
org_data = payload.get("o")
80+
if not isinstance(org_data, dict):
81+
return []
82+
role = org_data.get("rol")
83+
if not role:
84+
return []
85+
return [role] if isinstance(role, str) else list(role)
86+
87+
88+
async def get_current_principal(request: Request) -> Principal:
89+
"""FastAPI dependency: validate the Clerk session token and return Principal.
90+
91+
Raises:
92+
ServiceError(401, ...) for any authentication failure.
93+
"""
94+
try:
95+
httpx_request = _convert_request(request)
96+
options = AuthenticateRequestOptions(
97+
authorized_parties=_get_authorized_parties(),
98+
)
99+
request_state = _get_clerk_sdk().authenticate_request(httpx_request, options)
100+
except Exception as exc:
101+
raise ServiceError(
102+
status_code=401,
103+
message="Authentication failed",
104+
code="AUTH_INVALID_TOKEN",
105+
) from exc
106+
107+
if not request_state.is_signed_in:
108+
message, code = _map_error_reason(request_state.reason)
109+
raise ServiceError(status_code=401, message=message, code=code)
110+
111+
payload: dict[str, Any] = request_state.payload or {}
112+
113+
user_id = payload.get("sub")
114+
session_id = payload.get("sid")
115+
if not user_id or not session_id:
116+
raise ServiceError(
117+
status_code=401,
118+
message="Authentication failed",
119+
code="AUTH_INVALID_TOKEN",
120+
)
121+
122+
principal = Principal(
123+
user_id=user_id,
124+
session_id=session_id,
125+
org_id=payload.get("org_id"),
126+
roles=_extract_roles(payload),
127+
)
128+
129+
# Store user_id on request state so logging middleware can include it.
130+
request.state.user_id = principal.user_id
131+
132+
return principal

0 commit comments

Comments
 (0)