Files
boc/quixzoom-sso-auth/main.py
T
Bernt 1a12fb870b dev-api: add developer portal, OpenAPI spec, and Flatenbadet case study
- New /developers/ page with API docs, SDKs, pricing, use cases
- OpenAPI 3.0 spec for Orders, Missions, Photos, Analytics
- Case study: Glasskiosken i Flatenbadet — complete ROI analysis
- Updated /order/ with recurring missions and frequency dropdown
2026-07-14 15:27:33 +00:00

429 lines
12 KiB
Python

"""
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)