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:
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user