""" Passwordless authentication routes Handles cross-device login between app and web """ import os import time import hashlib import secrets from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, HTTPException, Header, Request, Depends from fastapi.responses import JSONResponse from pydantic import BaseModel, Field import redis router = APIRouter(prefix="/auth/passwordless", tags=["passwordless"]) # Redis connection redis_client = redis.Redis( host=os.getenv('REDIS_HOST', 'localhost'), port=int(os.getenv('REDIS_PORT', 6379)), db=int(os.getenv('REDIS_DB', 0)), decode_responses=True ) # Constants SESSION_TTL_SECONDS = 900 # 15 minutes POLL_INTERVAL_MS = 2000 RATE_LIMIT_PER_IP = 10 # per minute RATE_LIMIT_PER_USER = 5 # per minute # ─── Request/Response Models ──────────────────────────────────────────────── class InitiateRequest(BaseModel): client_id: str = Field(default="web-dashboard") redirect_url: Optional[str] = Field(default="https://quixzoom.se/dashboard") device_info: Optional[dict] = Field(default=None) class InitiateResponse(BaseModel): session_id: str request_token: str qr_data: str expires_at: str poll_interval_ms: int = POLL_INTERVAL_MS class ApproveRequest(BaseModel): session_id: str request_token: str signature: str timestamp: str approving_device_id: str class ApproveResponse(BaseModel): status: str session_id: str approved_at: str class StatusResponse(BaseModel): status: str session_id: str request_token: str expires_at: str approved_at: Optional[str] = None completed_at: Optional[str] = None access_token: Optional[str] = None refresh_token: Optional[str] = None error_message: Optional[str] = None class RejectRequest(BaseModel): session_id: str request_token: str device_id: str # ─── Helper Functions ─────────────────────────────────────────────────────── def generate_session_id() -> str: """Generate unique session ID""" return f"pls_{secrets.token_urlsafe(16)}" def generate_request_token() -> str: """Generate cryptographically secure request token""" return secrets.token_urlsafe(32) def generate_nonce() -> str: """Generate nonce for challenge""" return secrets.token_hex(16) def get_client_ip(request: Request) -> str: """Extract client IP from request""" forwarded = request.headers.get('X-Forwarded-For') if forwarded: return forwarded.split(',')[0].strip() return request.client.host if request.client else "unknown" def check_rate_limit(key: str, limit: int, window: int = 60) -> bool: """Check if rate limit is exceeded""" current = redis_client.get(f"ratelimit:{key}") if not current: redis_client.setex(f"ratelimit:{key}", window, 1) return True count = int(current) if count >= limit: return False redis_client.incr(f"ratelimit:{key}") return True def create_qr_data(session_id: str, request_token: str) -> str: """Create QR code data string""" return f"quixzoom://auth?sid={session_id}&token={request_token}" def hash_fingerprint(user_agent: str, ip: str) -> str: """Create hash of device fingerprint""" data = f"{user_agent}:{ip}" return hashlib.sha256(data.encode()).hexdigest() # ─── Routes ───────────────────────────────────────────────────────────────── @router.post("/initiate", response_model=InitiateResponse) async def initiate_passwordless( request: Request, body: InitiateRequest ): """ Initiate a new passwordless authentication session. Returns session details including QR code data. """ client_ip = get_client_ip(request) user_agent = request.headers.get('User-Agent', 'unknown') # Rate limiting if not check_rate_limit(f"ip:{client_ip}", RATE_LIMIT_PER_IP): raise HTTPException( status_code=429, detail="Too many requests. Please try again later." ) # Generate session session_id = generate_session_id() request_token = generate_request_token() nonce = generate_nonce() challenge = secrets.token_urlsafe(32) expires_at = datetime.utcnow() + timedelta(seconds=SESSION_TTL_SECONDS) # Store in Redis session_data = { "session_id": session_id, "request_token": request_token, "nonce": nonce, "challenge": challenge, "status": "pending", "client_id": body.client_id, "redirect_url": body.redirect_url or "https://quixzoom.se/dashboard", "web_ip": client_ip, "web_user_agent": user_agent, "web_fingerprint": hash_fingerprint(user_agent, client_ip), "created_at": datetime.utcnow().isoformat(), "expires_at": expires_at.isoformat(), } redis_key = f"passwordless:{session_id}" redis_client.hset(redis_key, mapping=session_data) redis_client.expire(redis_key, SESSION_TTL_SECONDS) # Also store by request_token for lookup redis_client.setex( f"passwordless_token:{request_token}", SESSION_TTL_SECONDS, session_id ) return InitiateResponse( session_id=session_id, request_token=request_token, qr_data=create_qr_data(session_id, request_token), expires_at=expires_at.isoformat() + "Z", poll_interval_ms=POLL_INTERVAL_MS ) @router.post("/approve", response_model=ApproveResponse) async def approve_passwordless( request: Request, body: ApproveRequest, authorization: Optional[str] = Header(None) ): """ Approve a passwordless authentication request from the app. Requires valid app JWT in Authorization header. """ # Verify app JWT (simplified - integrate with your JWT validation) # TODO: Enable full JWT validation in production # For now, accept any Bearer token for testing app_token = None if authorization and authorization.startswith("Bearer "): app_token = authorization.replace("Bearer ", "") # In production: validate app_token against JWT service # For MVP testing: skip validation # Look up session redis_key = f"passwordless:{body.session_id}" session_data = redis_client.hgetall(redis_key) if not session_data: raise HTTPException(status_code=410, detail="Session not found or expired") # Verify request_token matches if session_data.get("request_token") != body.request_token: raise HTTPException(status_code=403, detail="Invalid request token") # Check status if session_data.get("status") != "pending": raise HTTPException( status_code=409, detail=f"Session already {session_data.get('status')}" ) # Verify timestamp is recent (within 5 minutes) try: # Parse timestamp and make it offset-aware timestamp_str = body.timestamp.replace('Z', '+00:00') timestamp = datetime.fromisoformat(timestamp_str) # Ensure timestamp is offset-aware if timestamp.tzinfo is None: timestamp = timestamp.replace(tzinfo=timezone.utc) # Get current UTC time as offset-aware now = datetime.now(timezone.utc) if now - timestamp > timedelta(minutes=5): raise HTTPException(status_code=403, detail="Timestamp too old") except ValueError as e: raise HTTPException(status_code=400, detail=f"Invalid timestamp format: {str(e)}") # TODO: Verify signature # expected_signature = hmac_sha256(session_id + request_token + timestamp, device_secret) # For MVP, we'll skip signature verification and rely on JWT + request_token # Update session approved_at = datetime.utcnow() redis_client.hset(redis_key, mapping={ "status": "approved", "approved_at": approved_at.isoformat(), "approving_device_id": body.approving_device_id, "device_ip": get_client_ip(request), "device_user_agent": request.headers.get('User-Agent', 'unknown'), }) # Keep TTL but extend slightly for completion redis_client.expire(redis_key, 300) # 5 more minutes return ApproveResponse( status="approved", session_id=body.session_id, approved_at=approved_at.isoformat() + "Z" ) @router.get("/status") async def check_status( request: Request, session_id: str, request_token: str ): """ Check the status of a passwordless authentication session. Called by web client via polling. """ redis_key = f"passwordless:{session_id}" session_data = redis_client.hgetall(redis_key) if not session_data: raise HTTPException(status_code=404, detail="Session not found") # Verify request_token if session_data.get("request_token") != request_token: raise HTTPException(status_code=403, detail="Invalid request token") status = session_data.get("status", "unknown") response = { "status": status, "session_id": session_id, "request_token": request_token, "expires_at": session_data.get("expires_at"), } if status == "approved": response["approved_at"] = session_data.get("approved_at") # Generate web session tokens # TODO: Integrate with your JWT service to create proper tokens user_id = "user_from_app_token" # Extract from app JWT # Create web session web_session_token = secrets.token_urlsafe(32) refresh_token = secrets.token_urlsafe(32) # Store web session web_session_key = f"web_session:{web_session_token}" redis_client.hset(web_session_key, mapping={ "user_id": user_id, "session_id": session_id, "created_at": datetime.utcnow().isoformat(), "ip": get_client_ip(request), "user_agent": request.headers.get('User-Agent', 'unknown'), }) redis_client.expire(web_session_key, 86400) # 24 hours # Mark session as completed redis_client.hset(redis_key, mapping={ "status": "completed", "completed_at": datetime.utcnow().isoformat(), "web_session_token": web_session_token, }) response.update({ "status": "completed", "completed_at": datetime.utcnow().isoformat() + "Z", "access_token": web_session_token, "refresh_token": refresh_token, }) elif status == "rejected": response["error_message"] = "Authentication rejected by user" elif status == "expired": response["error_message"] = "Session expired" return StatusResponse(**response) @router.post("/reject") async def reject_passwordless( request: Request, body: RejectRequest, authorization: Optional[str] = Header(None) ): """ Reject a passwordless authentication request from the app. """ # Verify app JWT if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing authorization") redis_key = f"passwordless:{body.session_id}" session_data = redis_client.hgetall(redis_key) if not session_data: raise HTTPException(status_code=404, detail="Session not found") if session_data.get("request_token") != body.request_token: raise HTTPException(status_code=403, detail="Invalid request token") redis_client.hset(redis_key, mapping={ "status": "rejected", "rejected_at": datetime.utcnow().isoformat(), "rejecting_device_id": body.device_id, }) return {"status": "rejected", "session_id": body.session_id} @router.post("/cancel") async def cancel_passwordless( session_id: str, request_token: str ): """ Cancel a pending passwordless authentication request. Called by web client. """ redis_key = f"passwordless:{session_id}" session_data = redis_client.hgetall(redis_key) if not session_data: raise HTTPException(status_code=404, detail="Session not found") if session_data.get("request_token") != request_token: raise HTTPException(status_code=403, detail="Invalid request token") if session_data.get("status") != "pending": raise HTTPException(status_code=409, detail="Session already processed") redis_client.hset(redis_key, mapping={ "status": "cancelled", "cancelled_at": datetime.utcnow().isoformat(), }) return {"status": "cancelled", "session_id": session_id}