""" OIDC/OAuth2 Authentication Service This module provides OIDC/OAuth2 authentication with Nextcloud integration. """ import secrets import hashlib import jwt from datetime import datetime, timedelta from typing import Dict, Any, Optional, List, Tuple from urllib.parse import urlencode, quote import httpx import logging from sqlalchemy.orm import Session from fastapi import HTTPException, status from jose import JWTError, jwt as jose_jwt from ..models.user import User, Role, AuthProvider, VerificationStatus from ..models.user import Session as UserSession from ..config.settings import Settings from ..repositories.user import UserRepository from ..repositories.role import RoleRepository from ..utils.crypto import encrypt_token, decrypt_token logger = logging.getLogger(__name__) class OIDCConfig: """OIDC configuration for Nextcloud""" def __init__(self, settings: Settings): self.settings = settings self.issuer = settings.oidc.issuer # e.g., "https://nextcloud.example.com" self.client_id = settings.oidc.client_id self.client_secret = settings.oidc.client_secret self.redirect_uri = settings.oidc.redirect_uri self.scope = settings.oidc.scope or "openid profile email" # OIDC endpoints (Nextcloud specific) self.authorization_endpoint = f"{self.issuer}/index.php/apps/oauth2/authorize" self.token_endpoint = f"{self.issuer}/index.php/apps/oauth2/api/v1/token" self.userinfo_endpoint = f"{self.issuer}/ocs/v2.php/cloud/user" self.jwks_uri = f"{self.issuer}/index.php/apps/oauth2/jwks" # Alternative standard OIDC discovery self.discovery_endpoint = f"{self.issuer}/.well-known/openid-configuration" class OIDCAuthService: """Service for handling OIDC/OAuth2 authentication""" def __init__(self, db_session: Session, settings: Settings): self.db = db_session self.settings = settings self.config = OIDCConfig(settings) self.user_repo = UserRepository(db_session) self.role_repo = RoleRepository(db_session) self.http_client = httpx.AsyncClient() async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): await self.http_client.aclose() def generate_state_token(self) -> str: """Generate a secure state token for OIDC flow""" return secrets.token_urlsafe(32) def generate_nonce(self) -> str: """Generate a secure nonce for OIDC flow""" return secrets.token_urlsafe(32) def get_authorization_url(self, state: str, nonce: str) -> str: """Get the OIDC authorization URL""" params = { "client_id": self.config.client_id, "response_type": "code", "scope": self.config.scope, "redirect_uri": self.config.redirect_uri, "state": state, "nonce": nonce, } return f"{self.config.authorization_endpoint}?{urlencode(params)}" async def exchange_code_for_tokens(self, code: str) -> Dict[str, Any]: """Exchange authorization code for tokens""" data = { "grant_type": "authorization_code", "code": code, "redirect_uri": self.config.redirect_uri, "client_id": self.config.client_id, "client_secret": self.config.client_secret, } try: response = await self.http_client.post( self.config.token_endpoint, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"} ) response.raise_for_status() return response.json() except httpx.HTTPError as e: logger.error(f"Failed to exchange code for tokens: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to exchange authorization code" ) async def get_userinfo(self, access_token: str) -> Dict[str, Any]: """Get user information from OIDC provider""" headers = {"Authorization": f"Bearer {access_token}"} try: # Try standard OIDC userinfo endpoint response = await self.http_client.get( self.config.userinfo_endpoint, headers=headers ) if response.status_code == 200: return response.json() # Fallback to Nextcloud OCS API ocs_headers = { **headers, "OCS-APIRequest": "true", "Accept": "application/json" } response = await self.http_client.get( f"{self.config.issuer}/ocs/v2.php/cloud/user?format=json", headers=ocs_headers ) response.raise_for_status() ocs_data = response.json() if "ocs" in ocs_data and "data" in ocs_data["ocs"]: user_data = ocs_data["ocs"]["data"] # Map Nextcloud data to OIDC claims return { "sub": user_data.get("id"), "email": user_data.get("email"), "email_verified": bool(user_data.get("email")), "name": user_data.get("display-name", user_data.get("displayname")), "preferred_username": user_data.get("id"), "groups": user_data.get("groups", []), "quota": user_data.get("quota"), "language": user_data.get("language"), "locale": user_data.get("locale"), } return response.json() except httpx.HTTPError as e: logger.error(f"Failed to get user info: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to get user information" ) async def authenticate_user( self, code: str, state: str, stored_state: str, nonce: Optional[str] = None ) -> Tuple[User, Dict[str, Any]]: """Authenticate user with OIDC""" # Verify state if state != stored_state: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state parameter" ) # Exchange code for tokens token_response = await self.exchange_code_for_tokens(code) access_token = token_response.get("access_token") refresh_token = token_response.get("refresh_token") id_token = token_response.get("id_token") expires_in = token_response.get("expires_in", 3600) # Get user info userinfo = await self.get_userinfo(access_token) # Validate nonce if ID token is present if id_token and nonce: try: # Decode without verification first to get claims # In production, should verify against JWKS claims = jose_jwt.decode(id_token, options={"verify_signature": False}) if claims.get("nonce") != nonce: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid nonce" ) except JWTError: logger.warning("Failed to decode ID token") # Create or update user user = await self.create_or_update_user(userinfo, access_token, refresh_token, expires_in) # Create session session_token = self.create_session_token(user) return user, { "access_token": session_token, "token_type": "Bearer", "expires_in": self.settings.security.access_token_expire_minutes * 60, "user": user.to_dict() } async def create_or_update_user( self, userinfo: Dict[str, Any], access_token: str, refresh_token: Optional[str], expires_in: int ) -> User: """Create or update user from OIDC claims""" sub = userinfo.get("sub") email = userinfo.get("email") if not sub or not email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Missing required user information" ) # Try to find existing user user = self.user_repo.get_by_oidc_sub(sub, self.config.issuer) if not user: # Try to find by email user = self.user_repo.get_by_email(email) if user and user.auth_provider != AuthProvider.OIDC: # User exists with different auth provider raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists with different authentication method" ) # Parse name fields name = userinfo.get("name", "") given_name = userinfo.get("given_name", "") family_name = userinfo.get("family_name", "") if name and not (given_name or family_name): # Try to split name name_parts = name.split(" ", 1) given_name = name_parts[0] family_name = name_parts[1] if len(name_parts) > 1 else "" # Calculate token expiration token_expires_at = datetime.utcnow() + timedelta(seconds=expires_in) if user: # Update existing user user.oidc_sub = sub user.oidc_issuer = self.config.issuer user.given_name = given_name or user.given_name user.family_name = family_name or user.family_name user.preferred_username = userinfo.get("preferred_username", user.preferred_username) user.display_name = name or user.display_name user.picture_url = userinfo.get("picture", user.picture_url) user.email_verified = userinfo.get("email_verified", False) user.oidc_access_token = encrypt_token(access_token, self.settings.security.encryption_key) user.oidc_refresh_token = encrypt_token(refresh_token, self.settings.security.encryption_key) if refresh_token else None user.oidc_token_expires_at = token_expires_at user.oidc_claims = userinfo user.last_login_at = datetime.utcnow() if user.email_verified: user.verification_status = VerificationStatus.OIDC_VERIFIED else: # Create new user user = User( email=email, auth_provider=AuthProvider.OIDC, oidc_sub=sub, oidc_issuer=self.config.issuer, given_name=given_name, family_name=family_name, preferred_username=userinfo.get("preferred_username"), display_name=name, picture_url=userinfo.get("picture"), email_verified=userinfo.get("email_verified", False), verification_status=VerificationStatus.OIDC_VERIFIED if userinfo.get("email_verified") else VerificationStatus.UNVERIFIED, oidc_access_token=encrypt_token(access_token, self.settings.security.encryption_key), oidc_refresh_token=encrypt_token(refresh_token, self.settings.security.encryption_key) if refresh_token else None, oidc_token_expires_at=token_expires_at, oidc_claims=userinfo, last_login_at=datetime.utcnow() ) self.db.add(user) # Map OIDC roles to application roles await self.sync_user_roles(user, userinfo.get("groups", [])) self.db.commit() self.db.refresh(user) return user async def sync_user_roles(self, user: User, oidc_groups: List[str]): """Sync user roles based on OIDC groups""" # Get role mappings role_mappings = self.role_repo.get_oidc_role_mappings() # Clear existing non-system roles user.roles = [role for role in user.roles if role.is_system] # Add roles based on OIDC groups for group in oidc_groups: if group in role_mappings: role = role_mappings[group] if role not in user.roles: user.roles.append(role) # Special handling for admin groups admin_groups = self.settings.oidc.admin_groups or [] if any(group in admin_groups for group in oidc_groups): admin_role = self.role_repo.get_by_name("admin") if admin_role and admin_role not in user.roles: user.roles.append(admin_role) # Special handling for specific roles role_group_mapping = { "haushaltsbeauftragte": self.settings.oidc.budget_reviewer_groups or [], "finanzreferent": self.settings.oidc.finance_reviewer_groups or [], "asta": self.settings.oidc.asta_groups or [], } for role_name, groups in role_group_mapping.items(): if any(group in groups for group in oidc_groups): role = self.role_repo.get_by_name(role_name) if role and role not in user.roles: user.roles.append(role) # Ensure every verified user has at least the user role if user.verification_status != VerificationStatus.UNVERIFIED: user_role = self.role_repo.get_by_name("user") if user_role and user_role not in user.roles: user.roles.append(user_role) def create_session_token(self, user: User) -> str: """Create JWT session token""" expire = datetime.utcnow() + timedelta(minutes=self.settings.security.access_token_expire_minutes) payload = { "sub": str(user.id), "email": user.email, "roles": [role.name for role in user.roles], "exp": expire, "iat": datetime.utcnow(), "type": "access" } return jose_jwt.encode( payload, self.settings.security.jwt_secret_key, algorithm=self.settings.security.jwt_algorithm ) def create_refresh_token(self, user: User) -> str: """Create JWT refresh token""" expire = datetime.utcnow() + timedelta(days=self.settings.security.refresh_token_expire_days) payload = { "sub": str(user.id), "exp": expire, "iat": datetime.utcnow(), "type": "refresh" } return jose_jwt.encode( payload, self.settings.security.jwt_secret_key, algorithm=self.settings.security.jwt_algorithm ) async def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]: """Refresh access token using refresh token""" try: payload = jose_jwt.decode( refresh_token, self.settings.security.jwt_secret_key, algorithms=[self.settings.security.jwt_algorithm] ) if payload.get("type") != "refresh": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type" ) user_id = payload.get("sub") user = self.user_repo.get_by_id(int(user_id)) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) # Create new access token access_token = self.create_session_token(user) return { "access_token": access_token, "token_type": "Bearer", "expires_in": self.settings.security.access_token_expire_minutes * 60 } except JWTError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" ) async def logout(self, user: User, session_token: Optional[str] = None): """Logout user and invalidate session""" if session_token: # Invalidate specific session session = self.db.query(UserSession).filter( UserSession.session_token == session_token, UserSession.user_id == user.id ).first() if session: self.db.delete(session) else: # Invalidate all user sessions self.db.query(UserSession).filter( UserSession.user_id == user.id ).delete() self.db.commit()