"""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" # Cookie names for the browser session. The refresh cookie is scoped to the # auth path so it is only ever sent to /api/auth/* (refresh, logout), not to # every discovery request. ACCESS_COOKIE = "access_token" REFRESH_COOKIE = "refresh_token" REFRESH_COOKIE_PATH = "/api/auth" 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 _cookie_secure() -> bool: """Secure-by-default. Set COOKIE_SECURE=false for local http dev, where Secure cookies would never be sent over plain http://localhost.""" return os.getenv("COOKIE_SECURE", "true").strip().lower() not in ( "false", "0", "no", ) def set_auth_cookies( response, access_token: str, refresh_token: Optional[str] = None ) -> None: """Write the session as httpOnly cookies. Pass refresh_token only when it rotates (login); a plain access refresh leaves the refresh cookie intact.""" secure = _cookie_secure() response.set_cookie( ACCESS_COOKIE, access_token, max_age=_access_minutes() * 60, httponly=True, secure=secure, samesite="lax", path="/", ) if refresh_token is not None: response.set_cookie( REFRESH_COOKIE, refresh_token, max_age=_refresh_days() * 86400, httponly=True, secure=secure, samesite="lax", path=REFRESH_COOKIE_PATH, ) def clear_auth_cookies(response) -> None: response.delete_cookie(ACCESS_COOKIE, path="/") response.delete_cookie(REFRESH_COOKIE, path=REFRESH_COOKIE_PATH) 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 # Browser session: the access token rides in an httpOnly cookie. An # expired cookie decodes to 401, which the frontend recovers from by # calling /api/auth/refresh. cookie_token = request.cookies.get(ACCESS_COOKIE) if cookie_token: payload = decode_access_token(cookie_token) 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 = "cookie" 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