import os import uuid from datetime import datetime, timedelta, timezone from typing import Optional from jose import jwt, JWTError from sqlmodel import select from models.sql.user import UserModel from models.sql.team import TeamModel from models.sql.team_membership import TeamMembershipModel class AuthService: def __init__(self): self.tenant_id = os.getenv("AZURE_AD_TENANT_ID") self.client_id = os.getenv("AZURE_AD_CLIENT_ID") self.client_secret = os.getenv("AZURE_AD_CLIENT_SECRET") self.redirect_uri = os.getenv("AZURE_AD_REDIRECT_URI", "http://localhost/api/v1/auth/callback") self.jwt_secret = os.getenv("JWT_SECRET_KEY", "dev-secret-change-me") self.jwt_algorithm = "HS256" self.jwt_expiry_hours = 24 self.dev_password = os.getenv("DEV_AUTH_PASSWORD") # Require explicit dev password if using dev mode if self.is_dev_mode and not self.dev_password: raise ValueError( "DEV_AUTH_PASSWORD must be set in .env file when using dev mode. " "Set AZURE_AD_TENANT_ID to enable Azure AD SSO instead." ) self._msal_app = None @property def is_dev_mode(self) -> bool: return not self.tenant_id @property def msal_app(self): if self._msal_app is None and not self.is_dev_mode: import msal self._msal_app = msal.ConfidentialClientApplication( self.client_id, authority=f"https://login.microsoftonline.com/{self.tenant_id}", client_credential=self.client_secret, ) return self._msal_app def get_authorization_url(self) -> str: if self.is_dev_mode: raise ValueError("Azure AD not configured — use dev login") result = self.msal_app.get_authorization_request_url( scopes=["User.Read"], redirect_uri=self.redirect_uri, ) return result async def exchange_code_for_token(self, code: str) -> dict: if self.is_dev_mode: raise ValueError("Azure AD not configured — use dev login") result = self.msal_app.acquire_token_by_authorization_code( code, scopes=["User.Read"], redirect_uri=self.redirect_uri, ) if "error" in result: raise ValueError(f"Token exchange failed: {result.get('error_description', result.get('error'))}") return result def create_session_jwt(self, user: UserModel) -> str: payload = { "sub": str(user.id), "email": user.email, "role": user.role, "exp": datetime.now(timezone.utc) + timedelta(hours=self.jwt_expiry_hours), "iat": datetime.now(timezone.utc), } return jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm) def validate_token(self, token: str) -> Optional[dict]: try: payload = jwt.decode(token, self.jwt_secret, algorithms=[self.jwt_algorithm]) return payload except JWTError: return None async def get_or_create_user(self, claims: dict, session) -> UserModel: """Find user by azure_oid or create new one. Auto-adds to Oliver Team.""" azure_oid = claims.get("oid") email = claims.get("preferred_username") or claims.get("email") display_name = claims.get("name", email) # Try find by azure_oid if azure_oid: result = await session.execute( select(UserModel).where(UserModel.azure_oid == azure_oid) ) user = result.scalar_one_or_none() if user: user.last_login_at = datetime.now(timezone.utc) user.display_name = display_name session.add(user) await session.commit() return user # Try find by email result = await session.execute( select(UserModel).where(UserModel.email == email) ) user = result.scalar_one_or_none() if user: if azure_oid and not user.azure_oid: user.azure_oid = azure_oid user.last_login_at = datetime.now(timezone.utc) user.display_name = display_name session.add(user) await session.commit() return user # Create new user user = UserModel( azure_oid=azure_oid, email=email, display_name=display_name, role="user", is_active=True, last_login_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) session.add(user) await session.flush() # Auto-add to Oliver Team oliver_result = await session.execute( select(TeamModel).where(TeamModel.is_default == True) # noqa: E712 ) oliver_team = oliver_result.scalar_one_or_none() if oliver_team: membership = TeamMembershipModel( user_id=user.id, team_id=oliver_team.id, assigned_at=datetime.now(timezone.utc), ) session.add(membership) await session.commit() return user async def dev_login(self, email: str, password: str, session) -> Optional[UserModel]: """Dev-mode login: validate password, get or create user.""" if not self.is_dev_mode: return None if password != self.dev_password: return None claims = { "email": email, "name": email.split("@")[0].replace(".", " ").title(), } return await self.get_or_create_user(claims, session)