Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf5c8ccee | ||
|
|
768cea2c89 | ||
|
|
26ecc3e5fd | ||
|
|
5ff4ba0a43 | ||
|
|
bb438e22d6 | ||
|
|
3bb3b01dbd | ||
|
|
ce761a9d2c | ||
|
|
6243f29aae | ||
|
|
c4d32952db | ||
|
|
4c45f139d9 | ||
|
|
67b33314fe | ||
|
|
6ce34cc016 |
@@ -5,6 +5,93 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.11.0] - 2026-01-08
|
||||
|
||||
### Added
|
||||
|
||||
- **News Headlines API** - New endpoint for news ticker integration
|
||||
- `GET /tools/news` - Fetch news headlines from user's volatile collection
|
||||
- Returns headlines with title, description, source, and URL
|
||||
- Data sourced from `volatile_{user}` Qdrant collection (news namespace)
|
||||
- Uses `preferred_username` from OIDC, falls back to `default`
|
||||
- News subdomain under tools (`src/domains/tools/news/`)
|
||||
- `NewsHeadline` and `NewsResponse` Pydantic schemas
|
||||
- `NewsService` for parsing news data from Qdrant
|
||||
|
||||
## [1.10.12] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Forecast data retrieval** - Pass raw_data to service instead of extracting wrong field
|
||||
- Qdrant client was extracting `days` (integer 7) instead of `daily` (list)
|
||||
- Now passes full raw_data for service to parse correctly
|
||||
|
||||
## [1.10.11] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Weather wind direction** - Convert integer degrees to cardinal direction string
|
||||
- Scheduler stores wind_direction as degrees (e.g., 135)
|
||||
- Schema expects string (e.g., "SE")
|
||||
- Added `_degrees_to_cardinal()` conversion
|
||||
|
||||
## [1.10.10] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Environment data parsing** - Fix parsing of scheduler-generated Qdrant data
|
||||
- Forecast: Check `daily` key first (scheduler stores day count in `days`, list in `daily`)
|
||||
- Sun times: Use `sunrise_iso`/`sunset_iso` fields, handle time-only format fallback
|
||||
- Sun times: Calculate daylight from `daylight_duration_seconds` or `daylight_hours`
|
||||
- Air quality: Support `aqi_us`/`aqi_european` and `nitrogen_dioxide` field names
|
||||
|
||||
## [1.10.9] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Environment user ID cleanup** - Strip email domain from user identifier
|
||||
- If `preferred_username` is an email, extract just the username part
|
||||
- Ensures Qdrant collection name matches (e.g., `volatile_jpmschweitzer` not `volatile_jpmschweitzer@gmail.com`)
|
||||
|
||||
## [1.10.8] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OIDC audience validation** - python-jose requires string audience, not list
|
||||
- Extract and validate audience from unverified claims first
|
||||
- Use token's actual audience for JWT decode (after validating it's allowed)
|
||||
- Fixes "audience must be a string or None" error
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OIDC audience mismatch** - Accept tokens from multiple clients
|
||||
- Changed `oidc_audience` (string) to `oidc_audiences` (list)
|
||||
- Now accepts tokens with audience: `core-api`, `tatlock-ui`, or `tatlock`
|
||||
- Fixes environment endpoint returning "default" user instead of authenticated username
|
||||
- Fixed main.py to use `oidc_audiences[0]` for Swagger UI OAuth client
|
||||
|
||||
### Changed
|
||||
|
||||
- Documentation cleanup in README
|
||||
|
||||
## [1.10.4] - 2026-01-07
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -97,10 +97,6 @@ src/
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy credentials template
|
||||
cp src/credentials.example.py src/credentials.py
|
||||
# Edit src/credentials.py with your values
|
||||
|
||||
# Run locally
|
||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
||||
```
|
||||
@@ -164,8 +160,8 @@ docker run -p 8083:8083 core-code:latest
|
||||
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
|
||||
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
|
||||
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
|
||||
| `OIDC_ISSUER` | OIDC issuer URL | - |
|
||||
| `OIDC_AUDIENCE` | OIDC audience | - |
|
||||
| `OIDC_ISSUERS` | OIDC issuer URLs (comma-separated) | See config.py |
|
||||
| `OIDC_AUDIENCES` | OIDC audiences (comma-separated) | See config.py |
|
||||
|
||||
## API Documentation
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "core-api"
|
||||
version = "1.10.4"
|
||||
version = "1.11.0"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+60
-23
@@ -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.audience = ""
|
||||
self.jwks_uri = ""
|
||||
self.issuers: list[str] = []
|
||||
self.audiences: list[str] = []
|
||||
|
||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
||||
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||
"""Configure OIDC settings"""
|
||||
self.enabled = enabled
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
||||
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||
self.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,21 @@ async def get_current_user(
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
# First, extract issuer and audience from unverified claims
|
||||
unverified_claims = jwt.get_unverified_claims(token)
|
||||
token_issuer = unverified_claims.get("iss", "")
|
||||
token_audience = unverified_claims.get("aud", "")
|
||||
|
||||
# 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")
|
||||
|
||||
# Validate audience is in our allowed list
|
||||
if token_audience not in oidc_config.audiences:
|
||||
logger.warning(f"Invalid token audience: {token_audience}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||
|
||||
# Decode token header to get key ID
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
@@ -116,8 +153,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", []):
|
||||
@@ -129,17 +166,17 @@ async def get_current_user(
|
||||
logger.warning(f"No matching key found for kid: {kid}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||
|
||||
# Verify and decode token
|
||||
# Verify and decode token using the token's actual issuer and audience
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=oidc_config.audience,
|
||||
issuer=oidc_config.issuer,
|
||||
audience=token_audience, # Use the token's audience (already validated)
|
||||
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -10,7 +10,7 @@ import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -7,7 +7,7 @@ import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -8,7 +8,7 @@ import httpx
|
||||
import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
-104
@@ -1,104 +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/"
|
||||
oidc_audience: str = "core-api"
|
||||
|
||||
# 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()
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
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.db import get_database
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
+65
-26
@@ -63,29 +63,42 @@ class OIDCConfig:
|
||||
def __init__(self):
|
||||
# These will be set from environment variables in config.py
|
||||
self.enabled = False
|
||||
self.issuer = ""
|
||||
self.audience = ""
|
||||
self.jwks_uri = ""
|
||||
self.issuers: list[str] = []
|
||||
self.audiences: list[str] = []
|
||||
|
||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
||||
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||
"""Configure OIDC settings"""
|
||||
self.enabled = enabled
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
||||
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||
self.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
|
||||
@@ -96,15 +109,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"
|
||||
@@ -157,15 +179,30 @@ async def get_current_user(
|
||||
token = credentials.credentials
|
||||
|
||||
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_claims = jwt.get_unverified_claims(token)
|
||||
|
||||
kid = unverified_header.get("kid")
|
||||
token_issuer = unverified_claims.get("iss", "")
|
||||
token_audience = unverified_claims.get("aud", "")
|
||||
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
# Find matching key in JWKS
|
||||
jwks = get_jwks()
|
||||
# Validate issuer is in allowed list
|
||||
logger.debug(f"Token issuer: {token_issuer}, allowed issuers: {oidc_config.issuers}")
|
||||
if not oidc_config.is_valid_issuer(token_issuer):
|
||||
logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})")
|
||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||
|
||||
# Validate audience is in allowed list
|
||||
if token_audience not in oidc_config.audiences:
|
||||
logger.warning(f"Invalid token audience: {token_audience} (allowed: {oidc_config.audiences})")
|
||||
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||
|
||||
# Get JWKS for this specific issuer
|
||||
jwks = get_jwks_for_issuer(token_issuer)
|
||||
rsa_key = None
|
||||
|
||||
for key in jwks.get("keys", []):
|
||||
@@ -177,17 +214,17 @@ async def get_current_user(
|
||||
logger.warning(f"No matching key found for kid: {kid}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||
|
||||
# Verify and decode token
|
||||
# Verify and decode token using the token's actual issuer and audience
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=oidc_config.audience,
|
||||
issuer=oidc_config.issuer,
|
||||
audience=token_audience, # Use the token's audience (already validated)
|
||||
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -285,12 +322,14 @@ async def get_optional_user(
|
||||
}
|
||||
|
||||
if not credentials:
|
||||
logger.debug("No credentials provided for optional auth")
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(credentials)
|
||||
except HTTPException:
|
||||
# Invalid token - return None instead of raising
|
||||
except HTTPException as e:
|
||||
# Invalid token - log and return None instead of raising
|
||||
logger.warning(f"Optional auth failed: {e.detail}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from src.domains.tools.system.schemas import SystemStatsResponse
|
||||
from src.domains.tools.system.service import SystemStatsService
|
||||
from src.domains.tools.environment.schemas import EnvironmentResponse
|
||||
from src.domains.tools.environment.service import EnvironmentService
|
||||
from src.domains.tools.news.schemas import NewsResponse
|
||||
from src.domains.tools.news.service import NewsService
|
||||
from src.domains.auth.oidc import get_optional_user
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -38,6 +40,7 @@ class ToolsController(BaseController):
|
||||
self.dns_service = DNSService()
|
||||
self.system_stats_service = SystemStatsService()
|
||||
self.environment_service = EnvironmentService()
|
||||
self.news_service = NewsService()
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the router"""
|
||||
@@ -198,6 +201,9 @@ class ToolsController(BaseController):
|
||||
user_id = "default"
|
||||
if user:
|
||||
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||
# Strip email domain if present (e.g., "user@example.com" -> "user")
|
||||
if "@" in user_id:
|
||||
user_id = user_id.split("@")[0]
|
||||
|
||||
logger.info(f"Fetching environment data for user: {user_id}")
|
||||
result = await self.environment_service.get_current(user_id)
|
||||
@@ -210,6 +216,65 @@ class ToolsController(BaseController):
|
||||
detail=f"Failed to fetch environment data: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/news",
|
||||
response_model=NewsResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Get news headlines",
|
||||
description="""
|
||||
Get news headlines for the authenticated user.
|
||||
|
||||
Fetches news data from the Qdrant volatile collection for the authenticated user.
|
||||
Falls back to 'default' user if not authenticated.
|
||||
|
||||
**Data Returned:**
|
||||
- **Headlines:** List of news headlines with title, description, source, url
|
||||
- **Category:** News category (general, technology, etc.)
|
||||
- **Sources:** List of news sources
|
||||
|
||||
**Data Source:** Qdrant volatile_{user} collection (news namespace)
|
||||
|
||||
**Use Cases:**
|
||||
- Dashboard news ticker
|
||||
- News feed widgets
|
||||
- Information display
|
||||
"""
|
||||
)
|
||||
async def get_news(
|
||||
user: Optional[Dict] = Depends(get_optional_user),
|
||||
) -> NewsResponse:
|
||||
"""
|
||||
Get news headlines
|
||||
|
||||
Args:
|
||||
user: Optional authenticated user from OIDC
|
||||
|
||||
Returns:
|
||||
News headlines response
|
||||
|
||||
Raises:
|
||||
HTTPException: 500 for processing errors
|
||||
"""
|
||||
try:
|
||||
# Get user identifier from OIDC claims, fallback to 'default'
|
||||
user_id = "default"
|
||||
if user:
|
||||
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||
# Strip email domain if present (e.g., "user@example.com" -> "user")
|
||||
if "@" in user_id:
|
||||
user_id = user_id.split("@")[0]
|
||||
|
||||
logger.info(f"Fetching news data for user: {user_id}")
|
||||
result = await self.news_service.get_news(user_id)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get news data: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch news data: {str(e)}"
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -43,13 +43,18 @@ class EnvironmentService:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Handle wind direction - convert degrees to cardinal if integer
|
||||
wind_dir = raw_data.get("wind_direction") or raw_data.get("wind_dir")
|
||||
if isinstance(wind_dir, (int, float)):
|
||||
wind_dir = self._degrees_to_cardinal(wind_dir)
|
||||
|
||||
return WeatherData(
|
||||
temperature=raw_data.get("temperature") or raw_data.get("temp"),
|
||||
feels_like=raw_data.get("feels_like") or raw_data.get("feelslike"),
|
||||
conditions=raw_data.get("conditions") or raw_data.get("weather") or raw_data.get("description"),
|
||||
humidity=raw_data.get("humidity"),
|
||||
wind_speed=raw_data.get("wind_speed") or raw_data.get("windspeed") or raw_data.get("wind"),
|
||||
wind_direction=raw_data.get("wind_direction") or raw_data.get("wind_dir"),
|
||||
wind_direction=wind_dir,
|
||||
pressure=raw_data.get("pressure"),
|
||||
visibility=raw_data.get("visibility"),
|
||||
uv_index=raw_data.get("uv_index") or raw_data.get("uv"),
|
||||
@@ -60,6 +65,13 @@ class EnvironmentService:
|
||||
logger.warning(f"Failed to parse weather data: {e}")
|
||||
return None
|
||||
|
||||
def _degrees_to_cardinal(self, degrees: float) -> str:
|
||||
"""Convert wind direction degrees to cardinal direction."""
|
||||
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
|
||||
index = round(degrees / 22.5) % 16
|
||||
return directions[index]
|
||||
|
||||
def _parse_forecast(self, raw_data: Any) -> Optional[List[ForecastDay]]:
|
||||
"""
|
||||
Parse raw forecast data into list of ForecastDay schemas.
|
||||
@@ -73,7 +85,15 @@ class EnvironmentService:
|
||||
# Normalize to list
|
||||
forecast_list = raw_data
|
||||
if isinstance(raw_data, dict):
|
||||
forecast_list = raw_data.get("days") or raw_data.get("forecast") or []
|
||||
# Check 'daily' first (scheduler format), then 'forecast', then 'days'
|
||||
# Note: 'days' might be an integer count, so check 'daily' first
|
||||
forecast_list = raw_data.get("daily") or raw_data.get("forecast")
|
||||
if forecast_list is None:
|
||||
days_value = raw_data.get("days")
|
||||
if isinstance(days_value, list):
|
||||
forecast_list = days_value
|
||||
else:
|
||||
forecast_list = []
|
||||
|
||||
if not isinstance(forecast_list, list):
|
||||
return None
|
||||
@@ -83,8 +103,8 @@ class EnvironmentService:
|
||||
if isinstance(day, dict):
|
||||
days.append(ForecastDay(
|
||||
date=day.get("date", ""),
|
||||
high=day.get("high") or day.get("maxtemp") or day.get("temp_max"),
|
||||
low=day.get("low") or day.get("mintemp") or day.get("temp_min"),
|
||||
high=day.get("high") or day.get("temp_high") or day.get("maxtemp") or day.get("temp_max"),
|
||||
low=day.get("low") or day.get("temp_low") or day.get("mintemp") or day.get("temp_min"),
|
||||
conditions=day.get("conditions") or day.get("weather") or day.get("description"),
|
||||
precipitation_chance=day.get("precipitation_chance") or day.get("pop") or day.get("precip"),
|
||||
icon=day.get("icon"),
|
||||
@@ -106,19 +126,39 @@ class EnvironmentService:
|
||||
return None
|
||||
|
||||
try:
|
||||
sunrise = raw_data.get("sunrise")
|
||||
sunset = raw_data.get("sunset")
|
||||
# Prefer ISO format fields (sunrise_iso, sunset_iso) over time-only fields
|
||||
sunrise = raw_data.get("sunrise_iso") or raw_data.get("sunrise")
|
||||
sunset = raw_data.get("sunset_iso") or raw_data.get("sunset")
|
||||
|
||||
# Parse datetime strings if needed
|
||||
if isinstance(sunrise, str):
|
||||
sunrise = datetime.fromisoformat(sunrise.replace("Z", "+00:00"))
|
||||
# Handle time-only format (HH:MM) by combining with today's date
|
||||
if len(sunrise) <= 5 and ":" in sunrise:
|
||||
today = datetime.now().date()
|
||||
sunrise = datetime.strptime(f"{today} {sunrise}", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
sunrise = datetime.fromisoformat(sunrise.replace("Z", "+00:00"))
|
||||
if isinstance(sunset, str):
|
||||
sunset = datetime.fromisoformat(sunset.replace("Z", "+00:00"))
|
||||
# Handle time-only format (HH:MM) by combining with today's date
|
||||
if len(sunset) <= 5 and ":" in sunset:
|
||||
today = datetime.now().date()
|
||||
sunset = datetime.strptime(f"{today} {sunset}", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
sunset = datetime.fromisoformat(sunset.replace("Z", "+00:00"))
|
||||
|
||||
# Calculate daylight minutes if not provided
|
||||
# Get daylight from various field names
|
||||
daylight_minutes = raw_data.get("daylight_minutes") or raw_data.get("daylight")
|
||||
if daylight_minutes is None and sunrise and sunset:
|
||||
daylight_minutes = int((sunset - sunrise).total_seconds() / 60)
|
||||
if daylight_minutes is None:
|
||||
# Try to calculate from daylight_duration_seconds or daylight_hours
|
||||
daylight_seconds = raw_data.get("daylight_duration_seconds")
|
||||
if daylight_seconds:
|
||||
daylight_minutes = int(daylight_seconds / 60)
|
||||
else:
|
||||
daylight_hours = raw_data.get("daylight_hours")
|
||||
if daylight_hours:
|
||||
daylight_minutes = int(daylight_hours * 60)
|
||||
elif sunrise and sunset:
|
||||
daylight_minutes = int((sunset - sunrise).total_seconds() / 60)
|
||||
|
||||
# Parse optional fields
|
||||
solar_noon = raw_data.get("solar_noon")
|
||||
@@ -167,7 +207,8 @@ class EnvironmentService:
|
||||
if not isinstance(raw_data, dict):
|
||||
return None
|
||||
|
||||
aqi = raw_data.get("aqi") or raw_data.get("index")
|
||||
# Try various AQI field names - prefer US AQI, then European, then generic
|
||||
aqi = raw_data.get("aqi") or raw_data.get("aqi_us") or raw_data.get("aqi_european") or raw_data.get("index")
|
||||
if isinstance(aqi, (int, float)):
|
||||
aqi = int(aqi)
|
||||
|
||||
@@ -177,7 +218,7 @@ class EnvironmentService:
|
||||
pm25=raw_data.get("pm25") or raw_data.get("pm2_5"),
|
||||
pm10=raw_data.get("pm10"),
|
||||
o3=raw_data.get("o3") or raw_data.get("ozone"),
|
||||
no2=raw_data.get("no2"),
|
||||
no2=raw_data.get("no2") or raw_data.get("nitrogen_dioxide"),
|
||||
location=raw_data.get("location"),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
News subdomain for Tools.
|
||||
|
||||
Provides news headlines from Qdrant volatile collection.
|
||||
"""
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
News data schemas for Tools domain.
|
||||
|
||||
Provides Pydantic models for news headlines retrieved from the Qdrant volatile collection.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import Field
|
||||
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
class NewsHeadline(BaseSchema):
|
||||
"""Single news headline."""
|
||||
|
||||
title: str = Field(
|
||||
...,
|
||||
description="Headline title"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
None,
|
||||
description="Brief description or summary"
|
||||
)
|
||||
source: Optional[str] = Field(
|
||||
None,
|
||||
description="News source name"
|
||||
)
|
||||
url: Optional[str] = Field(
|
||||
None,
|
||||
description="Link to full article"
|
||||
)
|
||||
|
||||
|
||||
class NewsResponse(BaseSchema):
|
||||
"""News headlines response."""
|
||||
|
||||
headlines: List[NewsHeadline] = Field(
|
||||
default_factory=list,
|
||||
description="List of news headlines"
|
||||
)
|
||||
category: Optional[str] = Field(
|
||||
None,
|
||||
description="News category (e.g., 'general', 'technology')"
|
||||
)
|
||||
sources: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="List of source names"
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow,
|
||||
description="Timestamp when data was fetched"
|
||||
)
|
||||
user: Optional[str] = Field(
|
||||
None,
|
||||
description="User identifier used for data lookup"
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
News data service for Tools domain.
|
||||
|
||||
Fetches news headlines from the Qdrant volatile collection.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from src.shared.logging import get_logger
|
||||
from src.shared.clients.qdrant_client import get_qdrant_client
|
||||
from src.domains.tools.news.schemas import (
|
||||
NewsHeadline,
|
||||
NewsResponse,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class NewsService:
|
||||
"""
|
||||
Service for fetching news data from Qdrant volatile collection.
|
||||
|
||||
Retrieves news headlines for a specific user.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize news service with Qdrant client."""
|
||||
self.qdrant = get_qdrant_client()
|
||||
|
||||
def _parse_headlines(self, raw_data: Any) -> List[NewsHeadline]:
|
||||
"""
|
||||
Parse raw news data into list of NewsHeadline schemas.
|
||||
|
||||
Handles various formats from different news sources.
|
||||
"""
|
||||
if not raw_data:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Handle dict with nested headlines list
|
||||
headlines_list = raw_data
|
||||
if isinstance(raw_data, dict):
|
||||
headlines_list = raw_data.get("headlines") or raw_data.get("articles") or []
|
||||
|
||||
if not isinstance(headlines_list, list):
|
||||
return []
|
||||
|
||||
headlines = []
|
||||
for item in headlines_list:
|
||||
if isinstance(item, dict):
|
||||
headlines.append(NewsHeadline(
|
||||
title=item.get("title", ""),
|
||||
description=item.get("description") or item.get("summary"),
|
||||
source=item.get("source") or item.get("provider"),
|
||||
url=item.get("url") or item.get("link"),
|
||||
))
|
||||
elif isinstance(item, str):
|
||||
# Simple string headlines
|
||||
headlines.append(NewsHeadline(title=item))
|
||||
|
||||
return headlines
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse news headlines: {e}")
|
||||
return []
|
||||
|
||||
async def get_news(self, user: str = "default") -> NewsResponse:
|
||||
"""
|
||||
Get news headlines for a user.
|
||||
|
||||
Fetches news from the user's volatile collection.
|
||||
|
||||
Args:
|
||||
user: User identifier (default: 'default')
|
||||
|
||||
Returns:
|
||||
NewsResponse with headlines
|
||||
"""
|
||||
logger.info(f"Fetching news data for user: {user}")
|
||||
|
||||
# Get raw data from Qdrant
|
||||
news_records = await self.qdrant.get_by_namespace(user, "news")
|
||||
|
||||
headlines = []
|
||||
category = None
|
||||
sources = None
|
||||
|
||||
if news_records:
|
||||
raw_data = news_records[0].get("raw_data", {})
|
||||
headlines = self._parse_headlines(raw_data)
|
||||
if isinstance(raw_data, dict):
|
||||
category = raw_data.get("category")
|
||||
sources = raw_data.get("sources")
|
||||
|
||||
return NewsResponse(
|
||||
headlines=headlines,
|
||||
category=category,
|
||||
sources=sources,
|
||||
updated_at=datetime.utcnow(),
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_news_service: Optional[NewsService] = None
|
||||
|
||||
|
||||
def get_news_service() -> NewsService:
|
||||
"""Get or create singleton news service instance."""
|
||||
global _news_service
|
||||
if _news_service is None:
|
||||
_news_service = NewsService()
|
||||
return _news_service
|
||||
+1
-1
@@ -83,7 +83,7 @@ See `/docs` for the full API reference.
|
||||
lifespan=lifespan,
|
||||
debug=settings.debug,
|
||||
swagger_ui_init_oauth={
|
||||
"clientId": settings.oidc_audience,
|
||||
"clientId": settings.oidc_audiences[0] if settings.oidc_audiences else "core-api",
|
||||
"usePkceWithAuthorizationCodeGrant": True,
|
||||
} if settings.oidc_enabled else None
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -190,19 +190,10 @@ class QdrantReadClient:
|
||||
if aqi:
|
||||
result["air_quality"] = aqi if isinstance(aqi, dict) else {"aqi": aqi}
|
||||
|
||||
# Fetch forecast data
|
||||
# Fetch forecast data - pass raw_data to service for parsing
|
||||
forecast_records = await self.get_by_namespace(user, "forecast")
|
||||
if forecast_records:
|
||||
# Forecast might be a single record with list or multiple records
|
||||
first_record = forecast_records[0].get("raw_data")
|
||||
if isinstance(first_record, list):
|
||||
result["forecast"] = first_record
|
||||
elif isinstance(first_record, dict):
|
||||
# Could be a dict with 'days' or 'forecast' key
|
||||
result["forecast"] = first_record.get(
|
||||
"days",
|
||||
first_record.get("forecast", [first_record])
|
||||
)
|
||||
result["forecast"] = forecast_records[0].get("raw_data")
|
||||
|
||||
# Fetch sun times data
|
||||
sun_records = await self.get_by_namespace(user, "sun")
|
||||
|
||||
@@ -90,8 +90,14 @@ class Settings(BaseSettings):
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
oidc_enabled: bool = False
|
||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
||||
oidc_audience: str = "core-api"
|
||||
# Accept tokens from multiple OAuth providers (each has its own issuer/JWKS)
|
||||
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"]
|
||||
|
||||
# Authentik API (for token validation and user management)
|
||||
authentik_url: str = "https://auth.schweitz.net"
|
||||
|
||||
@@ -23,17 +23,17 @@ def initialize_oidc(settings: Settings) -> None:
|
||||
|
||||
auth_oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuer=settings.oidc_issuer,
|
||||
audience=settings.oidc_audience
|
||||
issuers=settings.oidc_issuers,
|
||||
audiences=settings.oidc_audiences
|
||||
)
|
||||
|
||||
domains_oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuer=settings.oidc_issuer,
|
||||
audience=settings.oidc_audience
|
||||
issuers=settings.oidc_issuers,
|
||||
audiences=settings.oidc_audiences
|
||||
)
|
||||
|
||||
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:
|
||||
logger.info("OIDC authentication disabled - API is publicly accessible")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for config module."""
|
||||
import pytest
|
||||
from src.config import (
|
||||
from src.shared.config import (
|
||||
__version__,
|
||||
Settings,
|
||||
get_settings,
|
||||
|
||||
Reference in New Issue
Block a user