"""Auth service: register, login, token generation.""" from __future__ import annotations from typing import Optional from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.core.security import create_access_token, hash_password, verify_password from app.models.org import Org from app.models.user import User, UserRole from app.schemas.auth import UserRegisterRequest class AuthError(Exception): """Base for auth service errors with an HTTP-friendly status code.""" status_code: int = 400 class EmailAlreadyExists(AuthError): status_code = 409 class BootstrapAlreadyCompleted(AuthError): status_code = 403 class InvalidCredentials(AuthError): status_code = 401 async def count_users(db: AsyncSession) -> int: """Count active (non-soft-deleted) users. Used to gate bootstrap registration.""" result = await db.execute( select(User).where(User.deleted_at.is_(None)) ) return len(result.scalars().all()) async def register_user( db: AsyncSession, payload: UserRegisterRequest ) -> tuple[User, str]: """Bootstrap registration. Creates a new Org and the first user (or a new user in the existing org if the org is passed in — Phase 4a only supports bootstrap here). Raises: BootstrapAlreadyCompleted: if users already exist (403). EmailAlreadyExists: if the email is already taken (409). Returns: (user, jwt_token) tuple. """ existing = await count_users(db) if existing > 0: raise BootstrapAlreadyCompleted( "Bootstrap registration is disabled: users already exist. " "Use POST /api/v1/users (admin) to invite new users." ) # Check email uniqueness within the (new) org context org = Org(name=f"{payload.name}'s Org") db.add(org) await db.flush() # assigns org.id user = User( org_id=org.id, email=payload.email.lower(), password_hash=hash_password(payload.password), name=payload.name, # First registered user is implicitly admin for bootstrap convenience. role=UserRole.admin, ) db.add(user) try: await db.commit() except IntegrityError as e: await db.rollback() raise EmailAlreadyExists( f"A user with email {payload.email!r} already exists." ) from e await db.refresh(user) role_str = user.role.value if hasattr(user.role, "value") else str(user.role) token = create_access_token(user.id, user.org_id, role_str) return user, token async def authenticate_user( db: AsyncSession, email: str, password: str ) -> Optional[tuple[User, str]]: """Verify credentials and return (user, token) on success, None on failure. The endpoint wraps this and returns 401 on None — keeping the service-level function free of HTTPException for testability. """ result = await db.execute( select(User).where( User.email == email.lower(), User.deleted_at.is_(None), ) ) user = result.scalar_one_or_none() if user is None: return None if not verify_password(password, user.password_hash): return None role_str = user.role.value if hasattr(user.role, "value") else str(user.role) token = create_access_token(user.id, user.org_id, role_str) return user, token def build_token_response(user: User) -> str: """Build a fresh access token for a user.""" settings = get_settings() _ = settings # touch to ensure config is loaded return create_access_token(user.id, user.org_id, user.role.value)