feat: multi-issuer OIDC support with config consolidation
- Support multiple OAuth providers (core-api, tatlock-ui, tatlock) - Changed oidc_issuer (string) to oidc_issuers (list) - Per-issuer JWKS caching - Validates token issuer against allowed list - Consolidated config files (removed deprecated src/config.py, src/security.py) - Updated imports to use src/shared/config and src/shared/security 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
c4d32952db
commit
6243f29aae
+50
-18
@@ -22,29 +22,42 @@ class OIDCConfig:
|
||||
def __init__(self):
|
||||
# These will be set from environment variables in config.py
|
||||
self.enabled = False
|
||||
self.issuer = ""
|
||||
self.issuers: list[str] = []
|
||||
self.audiences: list[str] = []
|
||||
self.jwks_uri = ""
|
||||
|
||||
def configure(self, enabled: bool, issuer: str, audiences: list[str]):
|
||||
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||
"""Configure OIDC settings"""
|
||||
self.enabled = enabled
|
||||
self.issuer = issuer
|
||||
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||
self.audiences = audiences
|
||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}, audiences={audiences}")
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||
|
||||
def get_jwks_uri(self, issuer: str) -> str:
|
||||
"""Get JWKS URI for a specific issuer"""
|
||||
return f"{issuer.rstrip('/')}/jwks/"
|
||||
|
||||
def is_valid_issuer(self, issuer: str) -> bool:
|
||||
"""Check if issuer is in the allowed list"""
|
||||
normalized = issuer.rstrip('/')
|
||||
return normalized in self.issuers
|
||||
|
||||
|
||||
# Global OIDC config instance
|
||||
oidc_config = OIDCConfig()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jwks() -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
||||
# Per-issuer JWKS cache
|
||||
_jwks_cache: Dict[str, Dict] = {}
|
||||
|
||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||
|
||||
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
Args:
|
||||
issuer: The token issuer URL
|
||||
|
||||
Returns:
|
||||
JWKS dictionary containing public keys for token verification
|
||||
@@ -55,15 +68,24 @@ def get_jwks() -> Dict:
|
||||
if not oidc_config.enabled:
|
||||
return {}
|
||||
|
||||
normalized_issuer = issuer.rstrip('/')
|
||||
|
||||
# Return cached JWKS if available
|
||||
if normalized_issuer in _jwks_cache:
|
||||
return _jwks_cache[normalized_issuer]
|
||||
|
||||
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
||||
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||
response = httpx.get(jwks_uri, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
||||
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||
_jwks_cache[normalized_issuer] = jwks
|
||||
return jwks
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch JWKS: {e}")
|
||||
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable"
|
||||
@@ -109,6 +131,15 @@ async def get_current_user(
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
# First, extract issuer from unverified claims to know which JWKS to use
|
||||
unverified_claims = jwt.get_unverified_claims(token)
|
||||
token_issuer = unverified_claims.get("iss", "")
|
||||
|
||||
# Validate issuer is in our allowed list
|
||||
if not oidc_config.is_valid_issuer(token_issuer):
|
||||
logger.warning(f"Invalid token issuer: {token_issuer}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||
|
||||
# Decode token header to get key ID
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
@@ -116,8 +147,8 @@ async def get_current_user(
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
# Find matching key in JWKS
|
||||
jwks = get_jwks()
|
||||
# Find matching key in JWKS for this specific issuer
|
||||
jwks = get_jwks_for_issuer(token_issuer)
|
||||
rsa_key = None
|
||||
|
||||
for key in jwks.get("keys", []):
|
||||
@@ -130,12 +161,13 @@ async def get_current_user(
|
||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||
|
||||
# Verify and decode token (accepts any of the configured audiences)
|
||||
# Use the token's issuer for validation (already verified it's in our allowed list)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=oidc_config.audiences,
|
||||
issuer=oidc_config.issuer,
|
||||
issuer=token_issuer,
|
||||
)
|
||||
|
||||
user_email = payload.get("email", "unknown")
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.db.models import User, Role, UserPreferences, Group
|
||||
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
|
||||
|
||||
Reference in New Issue
Block a user