"""Auth orchestration shared by the JSON API and the HTMX UI. Covers credential verification (LDAP + local) and token issuance / rotation. """ import jwt from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.security import ( REFRESH_TOKEN_TYPE, create_access_token, create_refresh_token, decode_token, ) from app.crud import token as token_crud from app.crud import user as user_crud from app.models.user import User from app.services import ldap as ldap_service async def authenticate(db: AsyncSession, username: str, password: str) -> User | None: """Verify credentials and return the matching active user, or ``None``. When LDAP is enabled the directory is tried first; on a successful bind the local user row is provisioned/refreshed. Local database authentication is always attempted as a fallback, so pre-existing accounts (e.g. the seeded superuser) keep working alongside LDAP. """ if settings.ldap_enabled: ldap_user = await ldap_service.authenticate_ldap(username, password) if ldap_user is not None: user = await user_crud.upsert_ldap_user( db, email=ldap_user.email, full_name=ldap_user.full_name, default_role_names=settings.ldap_default_roles, ) return user if user.is_active else None # Local password auth (the only path when LDAP is disabled). user = await user_crud.authenticate(db, username, password) if user is not None and not user.is_active: return None return user async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]: """Create an access + refresh token and persist the refresh token for revocation.""" access = create_access_token(user.id) refresh, payload = create_refresh_token(user.id) await token_crud.store( db, user_id=user.id, jti=payload["jti"], raw_token=refresh, expires_at=payload["exp"], # timezone-aware UTC datetime ) return access, refresh class RefreshError(Exception): pass async def rotate_refresh_token( db: AsyncSession, raw_refresh: str ) -> tuple[str, str, User]: """Validate a refresh token, revoke it, and issue a fresh pair. Raises ``RefreshError`` if the token is invalid, expired, or already revoked. """ try: payload = decode_token(raw_refresh) except jwt.PyJWTError as exc: raise RefreshError("Invalid refresh token") from exc if payload.get("type") != REFRESH_TOKEN_TYPE: raise RefreshError("Not a refresh token") jti = payload.get("jti") sub = payload.get("sub") if not jti or sub is None: raise RefreshError("Malformed refresh token") record = await token_crud.get_active(db, jti=jti, raw_token=raw_refresh) if record is None: raise RefreshError("Refresh token is revoked or expired") # Local import avoids a circular import at module load time. from app.crud import user as user_crud user = await user_crud.get(db, int(sub)) if user is None or not user.is_active: raise RefreshError("User is inactive or missing") # Rotate: revoke the presented token, then mint a new pair. await token_crud.revoke(db, record) access, refresh = await issue_token_pair(db, user) return access, refresh, user