Add OIDC/SSO authentication support

Backend:
- Add OIDCConfig, OIDCConfigService, OIDCService classes for OIDC flow
- Add OIDC endpoints: status, config (get/update), login, callback
- Support authorization code flow with PKCE-compatible state parameter
- JWKS-based ID token validation with RS256 support
- Auto-provisioning of users from OIDC claims
- Admin group mapping for automatic admin role assignment

Frontend:
- Add SSO login button on login page (conditionally shown when enabled)
- Add OIDC admin configuration page (/admin/oidc)
- Add SSO Configuration link in admin menu
- Add OIDC types and API functions

Security:
- CSRF protection via state parameter in secure cookie
- Secure cookie settings (httponly, secure, samesite=lax)
- Client secret stored encrypted in database
- Token validation using provider's JWKS endpoint
This commit is contained in:
Mondo Diaz
2026-01-09 15:05:04 -06:00
parent 3ebdf51105
commit 1c31fe79cd
11 changed files with 1584 additions and 15 deletions

View File

@@ -112,6 +112,10 @@ from .schemas import (
AccessPermissionCreate,
AccessPermissionUpdate,
AccessPermissionResponse,
OIDCConfigResponse,
OIDCConfigUpdate,
OIDCStatusResponse,
OIDCLoginResponse,
)
from .metadata import extract_metadata
from .config import get_settings
@@ -631,6 +635,276 @@ def delete_api_key(
return {"message": "API key deleted successfully"}
# --- OIDC Configuration Routes ---
@router.get("/api/v1/auth/oidc/status")
def get_oidc_status(
db: Session = Depends(get_db),
):
"""
Get OIDC status (public endpoint).
Returns whether OIDC is enabled and the issuer URL if so.
"""
from .auth import OIDCConfigService
from .schemas import OIDCStatusResponse
oidc_service = OIDCConfigService(db)
config = oidc_service.get_config()
if config.enabled and config.issuer_url and config.client_id:
return OIDCStatusResponse(enabled=True, issuer_url=config.issuer_url)
return OIDCStatusResponse(enabled=False)
@router.get("/api/v1/auth/oidc/config")
def get_oidc_config(
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""
Get OIDC configuration (admin only).
Client secret is not exposed.
"""
from .auth import OIDCConfigService
from .schemas import OIDCConfigResponse
oidc_service = OIDCConfigService(db)
config = oidc_service.get_config()
return OIDCConfigResponse(
enabled=config.enabled,
issuer_url=config.issuer_url,
client_id=config.client_id,
has_client_secret=bool(config.client_secret),
scopes=config.scopes,
auto_create_users=config.auto_create_users,
admin_group=config.admin_group,
)
@router.put("/api/v1/auth/oidc/config")
def update_oidc_config(
config_update: "OIDCConfigUpdate",
request: Request,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""
Update OIDC configuration (admin only).
"""
from .auth import OIDCConfigService, OIDCConfig
from .schemas import OIDCConfigUpdate, OIDCConfigResponse
oidc_service = OIDCConfigService(db)
current_config = oidc_service.get_config()
# Update only provided fields
new_config = OIDCConfig(
enabled=config_update.enabled if config_update.enabled is not None else current_config.enabled,
issuer_url=config_update.issuer_url if config_update.issuer_url is not None else current_config.issuer_url,
client_id=config_update.client_id if config_update.client_id is not None else current_config.client_id,
client_secret=config_update.client_secret if config_update.client_secret is not None else current_config.client_secret,
scopes=config_update.scopes if config_update.scopes is not None else current_config.scopes,
auto_create_users=config_update.auto_create_users if config_update.auto_create_users is not None else current_config.auto_create_users,
admin_group=config_update.admin_group if config_update.admin_group is not None else current_config.admin_group,
)
oidc_service.save_config(new_config)
# Log audit
_log_audit(
db,
"auth.oidc_config_update",
"oidc_config",
current_user.username,
request,
{"enabled": new_config.enabled, "issuer_url": new_config.issuer_url},
)
return OIDCConfigResponse(
enabled=new_config.enabled,
issuer_url=new_config.issuer_url,
client_id=new_config.client_id,
has_client_secret=bool(new_config.client_secret),
scopes=new_config.scopes,
auto_create_users=new_config.auto_create_users,
admin_group=new_config.admin_group,
)
@router.get("/api/v1/auth/oidc/login")
def oidc_login(
request: Request,
redirect_uri: Optional[str] = Query(None, description="Override redirect URI"),
db: Session = Depends(get_db),
):
"""
Initiate OIDC login flow.
Redirects to the OIDC provider's authorization endpoint.
"""
from .auth import OIDCConfigService, OIDCService
oidc_config_service = OIDCConfigService(db)
config = oidc_config_service.get_config()
if not config.enabled or not config.issuer_url or not config.client_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OIDC is not configured",
)
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
# Determine redirect URI
if not redirect_uri:
# Use the request's base URL
base_url = str(request.base_url).rstrip("/")
redirect_uri = f"{base_url}/api/v1/auth/oidc/callback"
# Store state in session (using a simple cookie for now)
oidc_service = OIDCService(db, config)
auth_url = oidc_service.get_authorization_url(redirect_uri, state)
if not auth_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate authorization URL",
)
# Return redirect response with state cookie
response = RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
response.set_cookie(
key="oidc_state",
value=state,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
max_age=600, # 10 minutes
)
response.set_cookie(
key="oidc_redirect_uri",
value=redirect_uri,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
max_age=600,
)
return response
@router.get("/api/v1/auth/oidc/callback")
def oidc_callback(
request: Request,
code: str = Query(..., description="Authorization code"),
state: str = Query(..., description="State parameter"),
oidc_state: Optional[str] = Cookie(None),
oidc_redirect_uri: Optional[str] = Cookie(None),
db: Session = Depends(get_db),
):
"""
Handle OIDC callback after user authenticates.
Exchanges the authorization code for tokens and creates a session.
"""
from .auth import OIDCConfigService, OIDCService, AuthService
# Verify state
if not oidc_state or state != oidc_state:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid state parameter",
)
oidc_config_service = OIDCConfigService(db)
config = oidc_config_service.get_config()
if not config.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OIDC is not configured",
)
# Determine redirect URI (must match what was used in login)
if not oidc_redirect_uri:
base_url = str(request.base_url).rstrip("/")
oidc_redirect_uri = f"{base_url}/api/v1/auth/oidc/callback"
oidc_service = OIDCService(db, config)
# Exchange code for tokens
tokens = oidc_service.exchange_code_for_tokens(code, oidc_redirect_uri)
if not tokens:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code",
)
id_token = tokens.get("id_token")
if not id_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No ID token in response",
)
# Validate ID token
claims = oidc_service.validate_id_token(id_token)
if not claims:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ID token",
)
# Get or create user
user = oidc_service.get_or_create_user(claims)
if not user:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User creation not allowed",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
# Create session
auth_service = AuthService(db)
session, token = auth_service.create_session(
user,
user_agent=request.headers.get("User-Agent"),
ip_address=request.client.host if request.client else None,
)
# Log audit
_log_audit(
db,
"auth.oidc_login",
f"user:{user.id}",
user.username,
request,
{"oidc_subject": claims.get("sub")},
)
# Redirect to frontend with session cookie
response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
max_age=SESSION_DURATION_HOURS * 3600,
)
# Clear OIDC state cookies
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_redirect_uri")
return response
# --- Admin User Management Routes ---