feat: multi-issuer OIDC support with config consolidation
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 1m15s

- 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:
Jeroen Schweitzer
2026-01-08 13:09:29 +01:00
co-authored by Claude Opus 4.5
parent c4d32952db
commit 6243f29aae
16 changed files with 137 additions and 191 deletions
+16
View File
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.10.7] - 2026-01-08
### Added
- **Multi-issuer OIDC support** - Accept tokens from multiple OAuth providers
- Changed `oidc_issuer` (string) to `oidc_issuers` (list)
- Each issuer has its own JWKS endpoint, now cached per-issuer
- Validates token issuer against allowed list before fetching JWKS
- Supports tokens from: `core-api`, `tatlock-ui`, `tatlock` OAuth applications
- Completes fix for environment endpoint user resolution
### Removed
- Deprecated `src/config.py` - consolidated to `src/shared/config.py`
- Deprecated `src/security.py` - consolidated to `src/shared/security.py`
## [1.10.6] - 2026-01-08 ## [1.10.6] - 2026-01-08
### Fixed ### Fixed
+2 -2
View File
@@ -160,8 +160,8 @@ docker run -p 8083:8083 core-code:latest
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` | | `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - | | `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
| `OIDC_ENABLED` | Enable OIDC auth | `false` | | `OIDC_ENABLED` | Enable OIDC auth | `false` |
| `OIDC_ISSUER` | OIDC issuer URL | - | | `OIDC_ISSUERS` | OIDC issuer URLs (comma-separated) | See config.py |
| `OIDC_AUDIENCE` | OIDC audience | - | | `OIDC_AUDIENCES` | OIDC audiences (comma-separated) | See config.py |
## API Documentation ## API Documentation
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "core-api" name = "core-api"
version = "1.10.6" version = "1.10.7"
description = "Core Code API - Infrastructure management and tools API" description = "Core Code API - Infrastructure management and tools API"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+50 -18
View File
@@ -22,29 +22,42 @@ class OIDCConfig:
def __init__(self): def __init__(self):
# These will be set from environment variables in config.py # These will be set from environment variables in config.py
self.enabled = False self.enabled = False
self.issuer = "" self.issuers: list[str] = []
self.audiences: 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""" """Configure OIDC settings"""
self.enabled = enabled self.enabled = enabled
self.issuer = issuer self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
self.audiences = audiences self.audiences = audiences
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/" logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}, 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 # Global OIDC config instance
oidc_config = OIDCConfig() oidc_config = OIDCConfig()
@lru_cache(maxsize=1) # Per-issuer JWKS cache
def get_jwks() -> Dict: _jwks_cache: Dict[str, Dict] = {}
"""
Fetch JSON Web Key Set (JWKS) from Authentik
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: Returns:
JWKS dictionary containing public keys for token verification JWKS dictionary containing public keys for token verification
@@ -55,15 +68,24 @@ def get_jwks() -> Dict:
if not oidc_config.enabled: if not oidc_config.enabled:
return {} 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: try:
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}") logger.debug(f"Fetching JWKS from {jwks_uri}")
response = httpx.get(oidc_config.jwks_uri, timeout=10.0) response = httpx.get(jwks_uri, timeout=10.0)
response.raise_for_status() response.raise_for_status()
jwks = response.json() 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 return jwks
except Exception as e: 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( raise HTTPException(
status_code=503, status_code=503,
detail="Authentication service unavailable" detail="Authentication service unavailable"
@@ -109,6 +131,15 @@ async def get_current_user(
token = credentials.credentials token = credentials.credentials
try: 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 # Decode token header to get key ID
unverified_header = jwt.get_unverified_header(token) unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid") kid = unverified_header.get("kid")
@@ -116,8 +147,8 @@ async def get_current_user(
if not kid: if not kid:
raise HTTPException(status_code=401, detail="Invalid token format") raise HTTPException(status_code=401, detail="Invalid token format")
# Find matching key in JWKS # Find matching key in JWKS for this specific issuer
jwks = get_jwks() jwks = get_jwks_for_issuer(token_issuer)
rsa_key = None rsa_key = None
for key in jwks.get("keys", []): for key in jwks.get("keys", []):
@@ -130,12 +161,13 @@ async def get_current_user(
raise HTTPException(status_code=401, detail="Invalid token key") raise HTTPException(status_code=401, detail="Invalid token key")
# Verify and decode token (accepts any of the configured audiences) # 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( payload = jwt.decode(
token, token,
rsa_key, rsa_key,
algorithms=["RS256"], algorithms=["RS256"],
audience=oidc_config.audiences, audience=oidc_config.audiences,
issuer=oidc_config.issuer, issuer=token_issuer,
) )
user_email = payload.get("email", "unknown") user_email = payload.get("email", "unknown")
+1 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.logging_config import get_logger
from src.db.models import User, Role, UserPreferences, Group from src.db.models import User, Role, UserPreferences, Group
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
+1 -1
View File
@@ -10,7 +10,7 @@ import json
from typing import Optional, Dict, List, Any from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from src.logging_config import get_logger from src.logging_config import get_logger
from src.config import get_settings from src.shared.config import get_settings
logger = get_logger(__name__) logger = get_logger(__name__)
settings = get_settings() settings = get_settings()
+1 -1
View File
@@ -7,7 +7,7 @@ import httpx
from typing import Optional, Dict, List, Any from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.logging_config import get_logger from src.logging_config import get_logger
from src.config import get_settings from src.shared.config import get_settings
logger = get_logger(__name__) logger = get_logger(__name__)
settings = get_settings() settings = get_settings()
+1 -1
View File
@@ -8,7 +8,7 @@ import httpx
import json import json
from typing import Optional, Dict, List, Any from typing import Optional, Dict, List, Any
from src.logging_config import get_logger from src.logging_config import get_logger
from src.config import get_settings from src.shared.config import get_settings
logger = get_logger(__name__) logger = get_logger(__name__)
settings = get_settings() settings = get_settings()
-105
View File
@@ -1,105 +0,0 @@
"""
Global configuration for Core Code API
All configuration is loaded from environment variables or .env file.
See .env.example for available settings.
"""
import tomllib
from pathlib import Path
from pydantic_settings import BaseSettings
from functools import lru_cache
def _get_version_from_pyproject() -> str:
"""Load version from pyproject.toml"""
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
try:
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
return data.get("project", {}).get("version", "0.0.0")
except FileNotFoundError:
return "0.0.0"
__version__ = _get_version_from_pyproject()
class Settings(BaseSettings):
"""Global application settings"""
# Application
app_name: str = "Core Code API"
app_version: str = __version__
debug: bool = False
# Server
host: str = "0.0.0.0"
port: int = 8083
# CORS
cors_origins: list[str] = ["*"]
cors_credentials: bool = True
cors_methods: list[str] = ["*"]
cors_headers: list[str] = ["*"]
# Logging
log_level: str = "DEBUG"
# Qdrant Configuration
qdrant_host: str = "qdrant"
qdrant_port: int = 6333
qdrant_collection_conversations: str = "core_api_conversations"
qdrant_collection_documents: str = "core_api_documents"
qdrant_collection_user_facts: str = "core_api_user_facts"
# Search Configuration
search_provider: str = "searxng"
searxng_url: str # Required - set SEARXNG_URL in .env
# Infrastructure Management (Portainer)
portainer_url: str # Required - set PORTAINER_URL in .env
portainer_api_key: str # Required - set PORTAINER_API_KEY in .env
# Infrastructure Management (Nginx Proxy Manager)
npm_url: str # Required - set NPM_URL in .env
npm_email: str # Required - set NPM_EMAIL in .env
npm_password: str # Required - set NPM_PASSWORD in .env
# Home Assistant Configuration
homeassistant_url: str # Required - set HOMEASSISTANT_URL in .env
homeassistant_token: str # Required - set HOMEASSISTANT_TOKEN in .env
homeassistant_timeout: int = 30
# PostgreSQL Database
postgres_host: str # Required - set POSTGRES_HOST in .env (e.g., localhost:5432)
postgres_user: str = "core_api"
postgres_password: str # Required - set POSTGRES_PASSWORD in .env
postgres_database: str = "core_api"
@property
def database_url(self) -> str:
"""Construct database URL from components"""
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}/{self.postgres_database}"
# OIDC Authentication (Authentik)
oidc_enabled: bool = False # Set to True to require authentication
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
# Accept tokens from multiple clients (core-api, tatlock-ui, tatlock)
oidc_audiences: list[str] = ["core-api", "tatlock-ui", "tatlock"]
# Authentik API (for token validation and user management)
# Must use domain name (not IP) when AUTHENTIK_COOKIE_DOMAIN is set
authentik_url: str = "https://auth.schweitz.net" # Authentik base URL
authentik_username: str = "" # Admin username for API access (AUTHENTIK_USERNAME env var)
authentik_password: str = "" # Admin password for API access (AUTHENTIK_PASSWORD env var)
class Config:
env_file = ".env"
case_sensitive = False
extra = "ignore" # Ignore extra env vars not defined in Settings
@lru_cache()
def get_settings() -> Settings:
"""Cached settings instance"""
return Settings()
+1 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from src.controllers.base import BaseController from src.controllers.base import BaseController
from src.config import get_settings from src.shared.config import get_settings
from src.logging_config import get_logger from src.logging_config import get_logger
from src.db import get_database from src.db import get_database
+1 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from src.config import get_settings from src.shared.config import get_settings
from src.logging_config import get_logger from src.logging_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
+51 -21
View File
@@ -63,29 +63,42 @@ class OIDCConfig:
def __init__(self): def __init__(self):
# These will be set from environment variables in config.py # These will be set from environment variables in config.py
self.enabled = False self.enabled = False
self.issuer = "" self.issuers: list[str] = []
self.audiences: 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""" """Configure OIDC settings"""
self.enabled = enabled self.enabled = enabled
self.issuer = issuer self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
self.audiences = audiences self.audiences = audiences
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/" logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}, 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 # Global OIDC config instance
oidc_config = OIDCConfig() oidc_config = OIDCConfig()
@lru_cache(maxsize=1) # Per-issuer JWKS cache
def get_jwks() -> Dict: _jwks_cache: Dict[str, Dict] = {}
"""
Fetch JSON Web Key Set (JWKS) from Authentik
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: Returns:
JWKS dictionary containing public keys for token verification JWKS dictionary containing public keys for token verification
@@ -96,15 +109,24 @@ def get_jwks() -> Dict:
if not oidc_config.enabled: if not oidc_config.enabled:
return {} 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: try:
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}") logger.debug(f"Fetching JWKS from {jwks_uri}")
response = httpx.get(oidc_config.jwks_uri, timeout=10.0) response = httpx.get(jwks_uri, timeout=10.0)
response.raise_for_status() response.raise_for_status()
jwks = response.json() 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 return jwks
except Exception as e: 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( raise HTTPException(
status_code=503, status_code=503,
detail="Authentication service unavailable" detail="Authentication service unavailable"
@@ -157,15 +179,23 @@ async def get_current_user(
token = credentials.credentials token = credentials.credentials
try: try:
# Decode token header to get key ID # First, decode token without verification to get issuer and key ID
unverified_header = jwt.get_unverified_header(token) unverified_header = jwt.get_unverified_header(token)
unverified_claims = jwt.get_unverified_claims(token)
kid = unverified_header.get("kid") kid = unverified_header.get("kid")
token_issuer = unverified_claims.get("iss", "")
if not kid: if not kid:
raise HTTPException(status_code=401, detail="Invalid token format") raise HTTPException(status_code=401, detail="Invalid token format")
# Find matching key in JWKS # Validate issuer is in allowed list
jwks = get_jwks() 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")
# Get JWKS for this specific issuer
jwks = get_jwks_for_issuer(token_issuer)
rsa_key = None rsa_key = None
for key in jwks.get("keys", []): for key in jwks.get("keys", []):
@@ -177,17 +207,17 @@ async def get_current_user(
logger.warning(f"No matching key found for kid: {kid}") logger.warning(f"No matching key found for kid: {kid}")
raise HTTPException(status_code=401, detail="Invalid token key") raise HTTPException(status_code=401, detail="Invalid token key")
# Verify and decode token (accepts any of the configured audiences) # Verify and decode token using the token's actual issuer
payload = jwt.decode( payload = jwt.decode(
token, token,
rsa_key, rsa_key,
algorithms=["RS256"], algorithms=["RS256"],
audience=oidc_config.audiences, audience=oidc_config.audiences,
issuer=oidc_config.issuer, issuer=token_issuer, # Use the token's issuer for validation
) )
user_email = payload.get("email", "unknown") user_email = payload.get("email", "unknown")
logger.info(f"Authenticated user: {user_email}") logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
return payload return payload
-32
View File
@@ -1,32 +0,0 @@
"""
Security initialization module
Handles OIDC configuration and authentication setup
"""
from src.config import Settings
from src.auth.oidc import oidc_config
from src.logging_config import get_logger
logger = get_logger(__name__)
def initialize_oidc(settings: Settings) -> None:
"""
Initialize OIDC authentication configuration
Configures the global oidc_config instance with settings from environment.
If OIDC is enabled, logs the issuer URL for verification.
Args:
settings: Application settings containing OIDC configuration
"""
oidc_config.configure(
enabled=settings.oidc_enabled,
issuer=settings.oidc_issuer,
audience=settings.oidc_audience
)
if settings.oidc_enabled:
logger.info(f"✓ OIDC authentication enabled (issuer: {settings.oidc_issuer})")
else:
logger.info("○ OIDC authentication disabled - API is publicly accessible")
+7 -2
View File
@@ -90,8 +90,13 @@ class Settings(BaseSettings):
# OIDC Authentication (Authentik) # OIDC Authentication (Authentik)
oidc_enabled: bool = False oidc_enabled: bool = False
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/" # Accept tokens from multiple OAuth providers (each has its own issuer/JWKS)
# Accept tokens from multiple clients (core-api, tatlock-ui, tatlock) oidc_issuers: list[str] = [
"https://auth.schweitz.net/application/o/core-api/",
"https://auth.schweitz.net/application/o/tatlock-ui/",
"https://auth.schweitz.net/application/o/tatlock/",
]
# Accept tokens from multiple clients
oidc_audiences: list[str] = ["core-api", "tatlock-ui", "tatlock"] oidc_audiences: list[str] = ["core-api", "tatlock-ui", "tatlock"]
# Authentik API (for token validation and user management) # Authentik API (for token validation and user management)
+3 -3
View File
@@ -23,17 +23,17 @@ def initialize_oidc(settings: Settings) -> None:
auth_oidc_config.configure( auth_oidc_config.configure(
enabled=settings.oidc_enabled, enabled=settings.oidc_enabled,
issuer=settings.oidc_issuer, issuers=settings.oidc_issuers,
audiences=settings.oidc_audiences audiences=settings.oidc_audiences
) )
domains_oidc_config.configure( domains_oidc_config.configure(
enabled=settings.oidc_enabled, enabled=settings.oidc_enabled,
issuer=settings.oidc_issuer, issuers=settings.oidc_issuers,
audiences=settings.oidc_audiences audiences=settings.oidc_audiences
) )
if settings.oidc_enabled: if settings.oidc_enabled:
logger.info(f"OIDC authentication enabled (issuer: {settings.oidc_issuer})") logger.info(f"OIDC authentication enabled (issuers: {settings.oidc_issuers})")
else: else:
logger.info("OIDC authentication disabled - API is publicly accessible") logger.info("OIDC authentication disabled - API is publicly accessible")
+1 -1
View File
@@ -1,6 +1,6 @@
"""Tests for config module.""" """Tests for config module."""
import pytest import pytest
from src.config import ( from src.shared.config import (
__version__, __version__,
Settings, Settings,
get_settings, get_settings,