""" quiXzoom SSO Auth Service Cross-domain single sign-on with silent login """ from fastapi import FastAPI, HTTPException, Response, Request, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, RedirectResponse from pydantic import BaseModel, EmailStr from datetime import datetime, timedelta import jwt import redis import uuid import os from typing import Optional app = FastAPI(title="quiXzoom SSO Auth", version="2.0.0") # CORS for cross-domain app.add_middleware( CORSMiddleware, allow_origins=[ "https://quixzoom.com", "https://app.quixzoom.com", "https://www.quixzoom.com", "https://quixzoom.se", "https://quixzoom.de", "https://quixzoom.fr", "https://quixzoom.nl", "https://quixzoom.co.uk", "https://quixzoom.asia", "http://localhost:3000", "http://localhost:3003", "http://localhost:5173", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Redis for session storage redis_client = redis.Redis( host=os.getenv('REDIS_HOST', 'localhost'), port=int(os.getenv('REDIS_PORT', 6379)), db=0, decode_responses=True ) # JWT config JWT_SECRET = os.getenv('JWT_SECRET', 'quixzoom-sso-secret-key-2026') JWT_ALGORITHM = 'HS256' ACCESS_TOKEN_EXPIRE = 15 # minutes REFRESH_TOKEN_EXPIRE = 30 # days # Cookie config for cross-domain COOKIE_DOMAIN = None # Allow localhost COOKIE_SECURE = False # Allow HTTP for localhost COOKIE_SAMESITE = "lax" class UserLogin(BaseModel): email: EmailStr password: str remember_me: bool = False class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" expires_in: int user: dict class UserProfile(BaseModel): id: str email: str name: str avatar: Optional[str] = None role: str = "zoomer" # Mock users database (replace with real DB) USERS = { "erik@landvex.com": { "id": "usr_001", "email": "erik@landvex.com", "name": "Erik Svensson", "password": "***", # In production: hashed "role": "admin", "avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=Erik", }, "demo@quixzoom.com": { "id": "usr_002", "email": "demo@quixzoom.com", "name": "Demo Zoomer", "password": "demo123", "role": "zoomer", "avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=Demo", } } def create_access_token(user_id: str, email: str, role: str) -> str: """Create short-lived access token""" now = datetime.utcnow() payload = { "sub": user_id, "email": email, "role": role, "iat": now, "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE), "type": "access", "jti": str(uuid.uuid4()), } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) def create_refresh_token(user_id: str) -> str: """Create long-lived refresh token""" now = datetime.utcnow() token_id = str(uuid.uuid4()) payload = { "sub": user_id, "iat": now, "exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE), "type": "refresh", "jti": token_id, } # Store in Redis for revocation redis_client.setex( f"refresh:{token_id}", timedelta(days=REFRESH_TOKEN_EXPIRE), user_id ) return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) def verify_token(token: str, token_type: str = "access") -> Optional[dict]: """Verify JWT token""" try: payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) if payload.get("type") != token_type: return None # Check revocation for refresh tokens if token_type == "refresh": jti = payload.get("jti") if not redis_client.exists(f"refresh:{jti}"): return None return payload except jwt.ExpiredSignatureError: return None except jwt.InvalidTokenError: return None def set_auth_cookies(response: Response, access_token: str, refresh_token: str): """Set cross-domain auth cookies""" # Access token cookie (short-lived, HttpOnly) response.set_cookie( key="qz_access_token", value=access_token, max_age=ACCESS_TOKEN_EXPIRE * 60, httponly=True, secure=False, samesite="lax", domain=None, ) # Refresh token cookie (long-lived, HttpOnly) response.set_cookie( key="qz_refresh_token", value=refresh_token, max_age=REFRESH_TOKEN_EXPIRE * 24 * 60 * 60, httponly=True, secure=False, samesite="lax", domain=None, ) # Non-HttpOnly cookie for JS detection (no sensitive data) response.set_cookie( key="qz_auth", value="1", max_age=REFRESH_TOKEN_EXPIRE * 24 * 60 * 60, httponly=False, secure=False, samesite="lax", domain=None, ) def clear_auth_cookies(response: Response): """Clear all auth cookies""" for cookie_name in ["qz_access_token", "qz_refresh_token", "qz_auth"]: response.delete_cookie(key=cookie_name) @app.post("/auth/login", response_model=TokenResponse) async def login(credentials: UserLogin, response: Response): """Login and set cross-domain cookies""" user = USERS.get(credentials.email) if not user or user["password"] != credentials.password: raise HTTPException(status_code=401, detail="Invalid credentials") # Create tokens access_token = create_access_token(user["id"], user["email"], user["role"]) refresh_token = create_refresh_token(user["id"]) # Set cookies set_auth_cookies(response, access_token, refresh_token) # Store session in Redis session_id = str(uuid.uuid4()) redis_client.setex( f"session:{session_id}", timedelta(days=REFRESH_TOKEN_EXPIRE), user["id"] ) return TokenResponse( access_token=access_token, refresh_token=refresh_token, expires_in=ACCESS_TOKEN_EXPIRE * 60, user={ "id": user["id"], "email": user["email"], "name": user["name"], "role": user["role"], "avatar": user.get("avatar"), } ) @app.post("/auth/refresh") async def refresh_token(request: Request, response: Response): """Refresh access token using refresh token cookie""" refresh_token = request.cookies.get("qz_refresh_token") if not refresh_token: raise HTTPException(status_code=401, detail="No refresh token") payload = verify_token(refresh_token, "refresh") if not payload: raise HTTPException(status_code=401, detail="Invalid refresh token") user_id = payload["sub"] # Get user from DB user = next((u for u in USERS.values() if u["id"] == user_id), None) if not user: raise HTTPException(status_code=401, detail="User not found") # Create new tokens new_access = create_access_token(user["id"], user["email"], user["role"]) new_refresh = create_refresh_token(user["id"]) # Revoke old refresh token old_jti = payload.get("jti") if old_jti: redis_client.delete(f"refresh:{old_jti}") # Set new cookies set_auth_cookies(response, new_access, new_refresh) return { "access_token": new_access, "refresh_token": new_refresh, "expires_in": ACCESS_TOKEN_EXPIRE * 60, } @app.post("/auth/logout") async def logout(request: Request, response: Response): """Logout and clear all cookies""" refresh_token = request.cookies.get("qz_refresh_token") if refresh_token: payload = verify_token(refresh_token, "refresh") if payload: # Revoke refresh token jti = payload.get("jti") if jti: redis_client.delete(f"refresh:{jti}") clear_auth_cookies(response) return {"message": "Logged out successfully"} @app.get("/auth/me") async def get_current_user(request: Request): """Get current user from access token""" # Try Authorization header first auth_header = request.headers.get("Authorization") token = None if auth_header and auth_header.startswith("Bearer "): token = auth_header[7:] else: # Fall back to cookie token = request.cookies.get("qz_access_token") if not token: raise HTTPException(status_code=401, detail="Not authenticated") payload = verify_token(token, "access") if not payload: raise HTTPException(status_code=401, detail="Invalid token") user_id = payload["sub"] user = next((u for u in USERS.values() if u["id"] == user_id), None) if not user: raise HTTPException(status_code=401, detail="User not found") return { "id": user["id"], "email": user["email"], "name": user["name"], "role": user["role"], "avatar": user.get("avatar"), } @app.get("/auth/check") async def check_auth(request: Request): """Silent auth check — returns user if logged in, null if not""" token = request.cookies.get("qz_access_token") if not token: return {"authenticated": False, "user": None} payload = verify_token(token, "access") if not payload: # Token expired but refresh token might exist refresh = request.cookies.get("qz_refresh_token") if refresh and verify_token(refresh, "refresh"): return { "authenticated": True, "user": None, "needs_refresh": True, } return {"authenticated": False, "user": None} user_id = payload["sub"] user = next((u for u in USERS.values() if u["id"] == user_id), None) if not user: return {"authenticated": False, "user": None} return { "authenticated": True, "user": { "id": user["id"], "email": user["email"], "name": user["name"], "role": user["role"], "avatar": user.get("avatar"), }, "needs_refresh": False, } @app.get("/auth/silent") async def silent_login(request: Request, response: Response): """ Silent login endpoint — attempts to refresh token without user interaction Used by apps/sites to maintain session across domains """ refresh_token = request.cookies.get("qz_refresh_token") if not refresh_token: return {"authenticated": False} payload = verify_token(refresh_token, "refresh") if not payload: clear_auth_cookies(response) return {"authenticated": False} user_id = payload["sub"] user = next((u for u in USERS.values() if u["id"] == user_id), None) if not user: clear_auth_cookies(response) return {"authenticated": False} # Create new access token new_access = create_access_token(user["id"], user["email"], user["role"]) # Set new access cookie response.set_cookie( key="qz_access_token", value=new_access, max_age=ACCESS_TOKEN_EXPIRE * 60, httponly=True, secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE, domain=None, ) return { "authenticated": True, "user": { "id": user["id"], "email": user["email"], "name": user["name"], "role": user["role"], "avatar": user.get("avatar"), }, } @app.get("/auth/session-info") async def session_info(request: Request): """Debug endpoint — show session cookies""" return { "cookies_present": { "qz_access_token": bool(request.cookies.get("qz_access_token")), "qz_refresh_token": bool(request.cookies.get("qz_refresh_token")), "qz_auth": bool(request.cookies.get("qz_auth")), }, "headers": { "origin": request.headers.get("origin"), "referer": request.headers.get("referer"), } } @app.get("/health") async def health(): """Health check""" return { "status": "ok", "service": "quixzoom-sso-auth", "version": "2.0.0", "timestamp": datetime.utcnow().isoformat(), } if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8080)