"""Authentication: Google OAuth flow, JWT issuance, dual-auth dependency. Two ways to authenticate: - JWT in `Authorization: Bearer ` (browser users, issued by /api/auth/callback) - X-API-Key header matching IMPACTFLOW_API_KEY (machine-to-machine; MCP server) The API key resolves to a synthetic admin user (`API_KEY_ADMIN_ID`) seeded into the users table on first use, so foreign keys from data tables stay valid. """ import hashlib import hmac import os import secrets import uuid from datetime import datetime, timedelta, timezone from typing import Optional from authlib.integrations.starlette_client import OAuth from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import RefreshToken, User API_KEY_ADMIN_ID = "api-key-admin" JWT_ALGORITHM = "HS256" oauth = OAuth() oauth.register( name="google", client_id=os.getenv("GOOGLE_CLIENT_ID"), client_secret=os.getenv("GOOGLE_CLIENT_SECRET"), server_metadata_url=( "https://accounts.google.com/.well-known/openid-configuration" ), client_kwargs={"scope": "openid email profile"}, ) def _jwt_secret() -> str: secret = os.getenv("JWT_SECRET") if not secret: raise RuntimeError("JWT_SECRET is not configured") return secret def _access_minutes() -> int: return int(os.getenv("JWT_ACCESS_MINUTES", "15")) def _refresh_days() -> int: return int(os.getenv("JWT_REFRESH_DAYS", "7")) def create_access_token(user_id: str, email: str) -> str: now = datetime.now(timezone.utc) payload = { "sub": user_id, "email": email, "iat": int(now.timestamp()), "exp": int((now + timedelta(minutes=_access_minutes())).timestamp()), "type": "access", } return jwt.encode(payload, _jwt_secret(), algorithm=JWT_ALGORITHM) def decode_access_token(token: str) -> dict: try: payload = jwt.decode(token, _jwt_secret(), algorithms=[JWT_ALGORITHM]) except JWTError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {exc}", ) from exc if payload.get("type") != "access": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong token type", ) return payload def hash_refresh_token(raw: str) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() async def issue_refresh_token( db: AsyncSession, user: User, device: Optional[str] ) -> str: raw = secrets.token_urlsafe(48) now = datetime.now(timezone.utc) row = RefreshToken( id=str(uuid.uuid4()), user_id=user.id, token_hash=hash_refresh_token(raw), device=(device or "")[:255] or None, created_at=now, expires_at=now + timedelta(days=_refresh_days()), ) db.add(row) await db.commit() return raw async def ensure_api_key_admin(db: AsyncSession) -> User: """Idempotently return the synthetic admin user backing the API key.""" existing = await db.get(User, API_KEY_ADMIN_ID) if existing is not None: return existing now = datetime.now(timezone.utc) admin = User( id=API_KEY_ADMIN_ID, email="api-key@impactflow.local", display_name="API Key (MCP)", role="admin", created_at=now, ) db.add(admin) await db.commit() await db.refresh(admin) return admin _bearer_scheme = HTTPBearer(auto_error=False) async def get_current_user( request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends( _bearer_scheme ), db: AsyncSession = Depends(get_db), ) -> User: """Dual-auth dependency. Tries X-API-Key first (cheap, no JWT decode), then falls back to the Authorization bearer JWT.""" expected_api_key = os.getenv("IMPACTFLOW_API_KEY") presented_api_key = request.headers.get("x-api-key") if ( expected_api_key and presented_api_key and hmac.compare_digest(presented_api_key, expected_api_key) ): admin = await ensure_api_key_admin(db) request.state.user = admin request.state.auth_source = "api_key" return admin if credentials is not None and credentials.scheme.lower() == "bearer": payload = decode_access_token(credentials.credentials) user = await db.get(User, payload["sub"]) if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists", ) request.state.user = user request.state.auth_source = "jwt" return user raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) async def require_admin(user: User = Depends(get_current_user)) -> User: if user.role != "admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required", ) return user def email_domain_allowed(email: str) -> bool: raw = os.getenv("ALLOWED_EMAIL_DOMAINS", "").strip() if not raw: return True allowed = {d.strip().lower() for d in raw.split(",") if d.strip()} domain = email.rsplit("@", 1)[-1].lower() return domain in allowed async def find_or_create_google_user( db: AsyncSession, user_info: dict ) -> User: """Idempotent: looks up by google_id, falls back to email, otherwise creates. First created human user is auto-promoted to admin.""" google_id = user_info["sub"] email = user_info["email"] now = datetime.now(timezone.utc) stmt = select(User).where(User.google_id == google_id) user = (await db.execute(stmt)).scalar_one_or_none() if user is None: stmt = select(User).where(User.email == email) user = (await db.execute(stmt)).scalar_one_or_none() if user is not None and user.google_id is None: user.google_id = google_id if user is None: # Auto-promote the first real human user. The synthetic # API_KEY_ADMIN_ID record is excluded from the count. stmt = select(User).where(User.id != API_KEY_ADMIN_ID) is_first = (await db.execute(stmt)).first() is None user = User( id=str(uuid.uuid4()), email=email, display_name=user_info.get("name") or email, avatar_url=user_info.get("picture"), google_id=google_id, role="admin" if is_first else "user", created_at=now, last_login_at=now, ) db.add(user) else: user.last_login_at = now if user_info.get("name"): user.display_name = user_info["name"] if user_info.get("picture"): user.avatar_url = user_info["picture"] await db.commit() await db.refresh(user) return user