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
This commit is contained in:
Bernt
2026-07-14 15:27:33 +00:00
parent 3c522e39f0
commit 1a12fb870b
6296 changed files with 911440 additions and 55607 deletions
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Deploy universal auth snippet to ALL quiXzoom sites
SITES=(
"/home/bernt/.openclaw/workspace/quixzoom-landing-fixed"
"/home/bernt/.openclaw/workspace/quixzoom-asia-pages"
"/home/bernt/.openclaw/workspace/quixzoom-market-pages"
"/home/bernt/.openclaw/workspace/landvex-site/quixzoom"
)
SNIPPET_FILE="/home/bernt/.openclaw/workspace/quixzoom-sso-auth/universal-auth-snippet.html"
echo "🚀 Deploying universal auth to ALL quiXzoom sites..."
for site in "${SITES[@]}"; do
if [ -d "$site" ]; then
echo " 📁 Processing: $(basename "$site")"
find "$site" -name "*.html" -type f | while read -r htmlfile; do
# Skip if already has universal auth
if grep -q "qz-auth-bar" "$htmlfile" 2>/dev/null; then
# Remove old auth bar first
sed -i '/<div id="qz-auth-bar"/,/<\/script>/d' "$htmlfile"
echo " 🗑️ Removed old auth bar: $(basename "$htmlfile")"
fi
# Inject new universal auth before </body>
if grep -q "</body>" "$htmlfile"; then
# Create temp file with injection
sed -i '/<\/body>/e cat '"$SNIPPET_FILE"'' "$htmlfile"
echo " ✅ Injected: $(basename "$htmlfile")"
fi
done
fi
done
echo ""
echo "✅ Universal auth deployed to all sites!"
echo ""
echo "Features:"
echo " • Silent login (auto-refresh tokens)"
echo " • Cross-domain cookie sync"
echo " • Real-time auth state"
echo " • Mobile app compatible"
echo ""
+329 -432
View File
@@ -1,213 +1,147 @@
"""
quiXzoom SSO Authentication Service
Microsoft-like seamless auth across all quixzoom properties
Architecture:
- Shared JWT tokens signed with RS256
- Cookie domain: .quixzoom.com (shared across all subdomains)
- Access token: short-lived (15 min)
- Refresh token: long-lived (30 days), rotated on each use
- Cross-domain: all quixzoom.* domains trust the same auth service
quiXzoom SSO Auth Service
Cross-domain single sign-on with silent login
"""
import os
import time
import uuid
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from fastapi import FastAPI, Request, Response, HTTPException, Cookie, Depends
from fastapi import FastAPI, HTTPException, Response, Request, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel, EmailStr
from datetime import datetime, timedelta
import jwt
import redis
import uvicorn
import uuid
import os
from typing import Optional
app = FastAPI(
title="quiXzoom SSO Auth",
description="Single Sign-On for all quixzoom properties",
version="2.0.0"
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 sessions and refresh tokens
# Redis for session storage
redis_client = redis.Redis(
host=os.getenv('REDIS_HOST', 'localhost'),
port=int(os.getenv('REDIS_PORT', 6379)),
db=int(os.getenv('REDIS_DB', 0)),
db=0,
decode_responses=True
)
# JWT Configuration - load from files
# Use quixzoom- prefixed keys (actual filenames)
JWT_PRIVATE_KEY_PATH = os.getenv('JWT_PRIVATE_KEY_PATH', '/app/keys/quixzoom-private.pem')
JWT_PUBLIC_KEY_PATH = os.getenv('JWT_PUBLIC_KEY_PATH', '/app/keys/quixzoom-public.pem')
# 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
def load_key(path: str, key_type: str = 'private') -> str:
"""Load key from file, trying variations of the filename"""
# Try the exact path first
paths_to_try = [path]
# If path ends with private.pem (without quixzoom- prefix), try with prefix
if '/private.pem' in path and 'quixzoom' not in path:
paths_to_try.append(path.replace('/private.pem', '/quixzoom-private.pem'))
if '/public.pem' in path and 'quixzoom' not in path:
paths_to_try.append(path.replace('/public.pem', '/quixzoom-public.pem'))
# Also try the default names in /app/keys
paths_to_try.append('/app/keys/quixzoom-private.pem')
paths_to_try.append('/app/keys/quixzoom-public.pem')
for p in paths_to_try:
try:
with open(p, 'r') as f:
content = f.read()
print(f"Loaded key from: {p} ({len(content)} chars)", flush=True)
return content
except FileNotFoundError:
continue
# Fallback to env var
fallback = os.getenv('JWT_PRIVATE_KEY' if key_type == 'private' else 'JWT_PUBLIC_KEY', '')
if fallback:
print(f"Using fallback env var for {key_type} key", flush=True)
return fallback
# Cookie config for cross-domain
COOKIE_DOMAIN = None # Allow localhost
COOKIE_SECURE = False # Allow HTTP for localhost
COOKIE_SAMESITE = "lax"
JWT_PRIVATE_KEY = load_key(JWT_PRIVATE_KEY_PATH, 'private')
JWT_PUBLIC_KEY = load_key(JWT_PUBLIC_KEY_PATH, 'public')
JWT_ALGORITHM = 'RS256'
print(f"Final - Private key: {len(JWT_PRIVATE_KEY)} chars, Public key: {len(JWT_PUBLIC_KEY)} chars", flush=True)
ACCESS_TOKEN_TTL = 900 # 15 minutes
REFRESH_TOKEN_TTL = 2592000 # 30 days
# Cookie settings
COOKIE_DOMAIN = '.quixzoom.com'
COOKIE_SECURE = True
COOKIE_SAMESITE = 'lax'
# Allowed origins - all quixzoom properties
ALLOWED_ORIGINS = [
'https://quixzoom.com',
'https://www.quixzoom.com',
'https://app.quixzoom.com',
'https://quixzoom.se',
'https://www.quixzoom.se',
'https://quixzoom.de',
'https://www.quixzoom.de',
'https://quixzoom.fr',
'https://www.quixzoom.fr',
'https://quixzoom.nl',
'https://www.quixzoom.nl',
'https://quixzoom.co.uk',
'https://www.quixzoom.co.uk',
'https://quixzoom.asia',
'https://www.quixzoom.asia',
'https://quixzoom.es',
'https://www.quixzoom.es',
'https://quixzoom.it',
'https://www.quixzoom.it',
'https://quixzoom.pl',
'https://www.quixzoom.pl',
'http://localhost:3000',
'http://localhost:8080',
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
# ─── Models ──────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: str
class UserLogin(BaseModel):
email: EmailStr
password: str
device_id: Optional[str] = None
device_info: Optional[Dict[str, Any]] = None
redirect_url: Optional[str] = 'https://www.quixzoom.com/mina-sidor'
class RegisterRequest(BaseModel):
email: str
password: str
first_name: str
last_name: str
phone: Optional[str] = None
country: Optional[str] = None
device_id: Optional[str] = None
device_info: Optional[Dict[str, Any]] = None
remember_me: bool = False
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = 'Bearer'
expires_in: int = ACCESS_TOKEN_TTL
user: Dict[str, Any]
token_type: str = "bearer"
expires_in: int
user: dict
class UserProfile(BaseModel):
id: str
email: str
first_name: str
last_name: str
avatar_url: Optional[str] = None
role: str = 'zoomer'
kyc_status: str = 'pending'
wallet_balance: float = 0.0
qz_tokens: float = 0.0
name: str
avatar: Optional[str] = None
role: str = "zoomer"
# ─── Helper Functions ────────────────────────────────────────────────────
def generate_token_id() -> str:
return secrets.token_urlsafe(32)
def create_access_token(user_id: str, email: str, role: str, token_id: str) -> str:
now = datetime.now(timezone.utc)
payload = {
'sub': user_id,
'email': email,
'role': role,
'jti': token_id,
'iat': now,
'exp': now + timedelta(seconds=ACCESS_TOKEN_TTL),
'type': 'access',
'iss': 'https://auth.quixzoom.com',
'aud': 'quixzoom-platform',
# 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",
}
return jwt.encode(payload, JWT_PRIVATE_KEY, algorithm=JWT_ALGORITHM)
}
def create_refresh_token(user_id: str, token_id: str, device_id: Optional[str] = None) -> str:
now = datetime.now(timezone.utc)
def create_access_token(user_id: str, email: str, role: str) -> str:
"""Create short-lived access token"""
now = datetime.utcnow()
payload = {
'sub': user_id,
'jti': token_id,
'iat': now,
'exp': now + timedelta(seconds=REFRESH_TOKEN_TTL),
'type': 'refresh',
'device_id': device_id,
'iss': 'https://auth.quixzoom.com',
'aud': 'quixzoom-platform',
"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_PRIVATE_KEY, algorithm=JWT_ALGORITHM)
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def verify_token(token: str, token_type: str = 'access') -> Optional[Dict]:
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_PUBLIC_KEY,
algorithms=[JWT_ALGORITHM],
audience='quixzoom-platform',
issuer='https://auth.quixzoom.com'
)
if payload.get('type') != token_type:
return None
# Check if token is blacklisted
jti = payload.get('jti')
if jti and redis_client.get(f'blacklist:{jti}'):
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
@@ -215,317 +149,280 @@ def verify_token(token: str, token_type: str = 'access') -> Optional[Dict]:
return None
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
"""Set cookies that work across all quixzoom subdomains"""
"""Set cross-domain auth cookies"""
# Access token cookie (short-lived, HttpOnly)
response.set_cookie(
key='qz_access_token',
key="qz_access_token",
value=access_token,
max_age=ACCESS_TOKEN_TTL,
max_age=ACCESS_TOKEN_EXPIRE * 60,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
domain=COOKIE_DOMAIN,
path='/'
secure=False,
samesite="lax",
domain=None,
)
# Refresh token cookie (long-lived, HttpOnly)
response.set_cookie(
key='qz_refresh_token',
key="qz_refresh_token",
value=refresh_token,
max_age=REFRESH_TOKEN_TTL,
max_age=REFRESH_TOKEN_EXPIRE * 24 * 60 * 60,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
domain=COOKIE_DOMAIN,
path='/'
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 auth cookies from all quixzoom domains"""
for cookie_name in ['qz_access_token', 'qz_refresh_token']:
response.delete_cookie(
key=cookie_name,
domain=COOKIE_DOMAIN,
path='/'
)
"""Clear all auth cookies"""
for cookie_name in ["qz_access_token", "qz_refresh_token", "qz_auth"]:
response.delete_cookie(key=cookie_name)
# ─── Authentication Dependency ───────────────────────────────────────────
async def get_current_user(request: Request) -> Optional[Dict]:
"""Extract and verify user from cookie or Authorization header"""
token = None
# Try cookie first (for web)
token = request.cookies.get('qz_access_token')
# Try Authorization header (for API/app)
if not token:
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header[7:]
if not token:
return None
payload = verify_token(token, 'access')
if not payload:
return None
# TODO: Fetch full user from database
return {
'id': payload['sub'],
'email': payload['email'],
'role': payload['role'],
}
# ─── Routes ──────────────────────────────────────────────────────────────
@app.get('/health')
async def health():
return {'status': 'ok', 'service': 'quixzoom-sso-auth', 'version': '2.0.0'}
@app.post('/auth/login')
async def login(request: LoginRequest, response: Response):
@app.post("/auth/login", response_model=TokenResponse)
async def login(credentials: UserLogin, response: Response):
"""Login and set cross-domain cookies"""
# TODO: Verify credentials against database
# For now, mock implementation
user = USERS.get(credentials.email)
user_id = str(uuid.uuid4())
token_id = generate_token_id()
refresh_id = generate_token_id()
if not user or user["password"] != credentials.password:
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token = create_access_token(user_id, request.email, 'zoomer', token_id)
refresh_token = create_refresh_token(user_id, refresh_id, request.device_id)
# Create tokens
access_token = create_access_token(user["id"], user["email"], user["role"])
refresh_token = create_refresh_token(user["id"])
# Store refresh token in Redis
redis_client.setex(
f'refresh:{refresh_id}',
REFRESH_TOKEN_TTL,
user_id
)
# Set cookies for web clients
# 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': request.email,
'first_name': 'Test',
'last_name': 'User',
"id": user["id"],
"email": user["email"],
"name": user["name"],
"role": user["role"],
"avatar": user.get("avatar"),
}
)
@app.post('/auth/refresh')
@app.post("/auth/refresh")
async def refresh_token(request: Request, response: Response):
"""Refresh access token using refresh token"""
refresh_token = request.cookies.get('qz_refresh_token')
"""Refresh access token using refresh token cookie"""
refresh_token = request.cookies.get("qz_refresh_token")
if not refresh_token:
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
refresh_token = auth_header[7:]
raise HTTPException(status_code=401, detail="No refresh token")
if not refresh_token:
raise HTTPException(status_code=401, detail='No refresh token provided')
payload = verify_token(refresh_token, 'refresh')
payload = verify_token(refresh_token, "refresh")
if not payload:
raise HTTPException(status_code=401, detail='Invalid refresh token')
raise HTTPException(status_code=401, detail="Invalid refresh token")
# Check if refresh token is in Redis
jti = payload.get('jti')
user_id = redis_client.get(f'refresh:{jti}')
user_id = payload["sub"]
if not user_id:
raise HTTPException(status_code=401, detail='Refresh token revoked')
# Rotate refresh token (security best practice)
new_token_id = generate_token_id()
new_refresh_id = generate_token_id()
# Delete old refresh token
redis_client.delete(f'refresh:{jti}')
# 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
access_token = create_access_token(user_id, payload.get('email', ''), 'zoomer', new_token_id)
new_refresh_token = create_refresh_token(user_id, new_refresh_id, payload.get('device_id'))
new_access = create_access_token(user["id"], user["email"], user["role"])
new_refresh = create_refresh_token(user["id"])
# Store new refresh token
redis_client.setex(f'refresh:{new_refresh_id}', REFRESH_TOKEN_TTL, user_id)
# Revoke old refresh token
old_jti = payload.get("jti")
if old_jti:
redis_client.delete(f"refresh:{old_jti}")
# Update cookies
set_auth_cookies(response, access_token, new_refresh_token)
# Set new cookies
set_auth_cookies(response, new_access, new_refresh)
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
user={'id': user_id, 'email': payload.get('email', '')}
)
return {
"access_token": new_access,
"refresh_token": new_refresh,
"expires_in": ACCESS_TOKEN_EXPIRE * 60,
}
@app.post('/auth/logout')
@app.post("/auth/logout")
async def logout(request: Request, response: Response):
"""Logout and clear all cookies"""
# Blacklist the access token
access_token = request.cookies.get('qz_access_token')
if access_token:
payload = verify_token(access_token)
if payload and payload.get('jti'):
redis_client.setex(f'blacklist:{payload["jti"]}', ACCESS_TOKEN_TTL, '1')
refresh_token = request.cookies.get("qz_refresh_token")
# Delete refresh token
refresh_token = request.cookies.get('qz_refresh_token')
if refresh_token:
payload = verify_token(refresh_token, 'refresh')
if payload and payload.get('jti'):
redis_client.delete(f'refresh:{payload["jti"]}')
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 {'status': 'logged_out'}
return {"message": "Logged out successfully"}
@app.get('/auth/me')
async def get_me(current_user: Optional[Dict] = Depends(get_current_user)):
"""Get current user info"""
if not current_user:
raise HTTPException(status_code=401, detail='Not authenticated')
@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 {
'authenticated': True,
'user': current_user
"id": user["id"],
"email": user["email"],
"name": user["name"],
"role": user["role"],
"avatar": user.get("avatar"),
}
@app.get('/auth/check')
async def auth_check(current_user: Optional[Dict] = Depends(get_current_user)):
"""Quick auth check - returns 200 if authenticated, 401 if not"""
if not current_user:
raise HTTPException(status_code=401, detail='Not authenticated')
@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")
return {
'authenticated': True,
'user': current_user
}
@app.get('/auth/sso/initiate')
async def sso_initiate(
redirect_url: str = 'https://www.quixzoom.com/mina-sidor',
client_id: str = 'quixzoom-web'
):
"""Initiate SSO login flow - redirect to login page"""
# Store the redirect URL in Redis for after login
session_id = secrets.token_urlsafe(32)
redis_client.setex(f'sso:{session_id}', 300, redirect_url)
if not token:
return {"authenticated": False, "user": None}
login_url = f'https://auth.quixzoom.com/login?session={session_id}&redirect={redirect_url}'
return RedirectResponse(url=login_url)
@app.get('/auth/sso/callback')
async def sso_callback(
code: str,
state: str,
response: Response
):
"""SSO callback - exchange code for tokens and redirect"""
# Verify the code and get redirect URL
redirect_url = redis_client.get(f'sso:{state}')
if not redirect_url:
raise HTTPException(status_code=400, detail='Invalid or expired session')
# TODO: Verify code with identity provider
# Set cookies and redirect
token_id = generate_token_id()
refresh_id = generate_token_id()
# Mock user - replace with actual lookup
user_id = str(uuid.uuid4())
access_token = create_access_token(user_id, 'user@quixzoom.com', 'zoomer', token_id)
refresh_token = create_refresh_token(user_id, refresh_id)
set_auth_cookies(response, access_token, refresh_token)
return RedirectResponse(url=redirect_url)
# ─── Public Key Endpoint ─────────────────────────────────────────────────
@app.post('/auth/sync')
async def sync_tokens(
request: Request,
response: Response,
current_user: Optional[Dict] = Depends(get_current_user)
):
"""Sync mobile app tokens with web cookies
Called by mobile app after login to enable seamless web auth.
Sets cookies on .quixzoom.com domain so user is auto-logged in on web.
"""
if not current_user:
raise HTTPException(status_code=401, detail='Not authenticated')
body = await request.json()
refresh_token = body.get('refresh_token')
# Create new tokens for web session
token_id = generate_token_id()
refresh_id = generate_token_id()
access_token = create_access_token(
current_user['id'],
current_user['email'],
current_user.get('role', 'zoomer'),
token_id
)
refresh_token_web = create_refresh_token(
current_user['id'],
refresh_id,
device_id='web_sync'
)
# Store refresh token
redis_client.setex(f'refresh:{refresh_id}', REFRESH_TOKEN_TTL, current_user['id'])
# Set cookies for web
set_auth_cookies(response, access_token, refresh_token_web)
return {
'status': 'synced',
'message': 'Web cookies set - user is now logged in on all quixzoom sites'
}
@app.get('/auth/.well-known/jwks.json')
async def jwks():
"""JWKS endpoint for token verification by other services"""
# TODO: Return proper JWKS format
return {
'keys': [
{
'kty': 'RSA',
'use': 'sig',
'kid': 'quixzoom-2026',
'alg': 'RS256',
# TODO: Add actual public key components
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,
}
# ─── Error Handlers ──────────────────────────────────────────────────────
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={'error': exc.detail}
@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.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={'error': 'Internal server error'}
)
@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"),
}
}
if __name__ == '__main__':
port = int(os.getenv('PORT', 8080))
host = os.getenv('HOST', '0.0.0.0')
uvicorn.run(app, host=host, port=port)
@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)
+1 -1
View File
@@ -3,5 +3,5 @@ uvicorn[standard]>=0.24.0
pyjwt>=2.8.0
cryptography>=41.0.0
redis>=5.0.0
pydantic>=2.5.0
pydantic[email]>=2.5.0
python-multipart>=0.0.6
+243
View File
@@ -0,0 +1,243 @@
/**
* quiXzoom SSO Client v2.0
* Universal cross-domain silent login
*
* Usage:
* <script src="https://auth.quixzoom.com/sso-client-v2.js"></script>
* <script>
* QZAuth.init({
* ssoUrl: 'https://auth.quixzoom.com',
* onLogin: (user) => console.log('Logged in:', user),
* onLogout: () => console.log('Logged out'),
* });
* </script>
*/
(function(global) {
'use strict';
const DEFAULT_CONFIG = {
ssoUrl: 'https://auth.quixzoom.com',
checkInterval: 60000, // Check auth every 60 seconds
silentTimeout: 5000,
};
class QZAuthClient {
constructor() {
this.config = { ...DEFAULT_CONFIG };
this.user = null;
this.isAuthenticated = false;
this.callbacks = {
onLogin: null,
onLogout: null,
onError: null,
};
this.checkIntervalId = null;
}
init(config = {}) {
this.config = { ...this.config, ...config };
if (config.onLogin) this.callbacks.onLogin = config.onLogin;
if (config.onLogout) this.callbacks.onLogout = config.onLogout;
if (config.onError) this.callbacks.onError = config.onError;
// Check auth immediately
this.checkAuth();
// Set up periodic checks
this.checkIntervalId = setInterval(() => {
this.checkAuth();
}, this.config.checkInterval);
// Listen for storage events (login from other tabs)
window.addEventListener('storage', (e) => {
if (e.key === 'qz_auth_event') {
const event = JSON.parse(e.newValue || '{}');
if (event.type === 'login') {
this.checkAuth();
} else if (event.type === 'logout') {
this.logout();
}
}
});
// Listen for messages from SSO iframe
window.addEventListener('message', (e) => {
if (e.origin !== this.config.ssoUrl) return;
if (e.data.type === 'qz_auth_silent_response') {
this.handleSilentResponse(e.data);
}
});
console.log('[QZAuth] Initialized');
}
async checkAuth() {
try {
// Try silent login first (uses refresh token cookie)
const response = await fetch(`${this.config.ssoUrl}/auth/silent`, {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
if (data.authenticated) {
this.setUser(data.user);
return;
}
}
// Silent login failed, check if we have a token in URL (OAuth redirect)
const urlParams = new URLSearchParams(window.location.search);
const accessToken = urlParams.get('access_token');
if (accessToken) {
// Verify token with SSO
const verifyResponse = await fetch(`${this.config.ssoUrl}/auth/me`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (verifyResponse.ok) {
const user = await verifyResponse.json();
this.setUser(user);
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
}
// Not authenticated
this.clearUser();
} catch (error) {
console.error('[QZAuth] Auth check failed:', error);
if (this.callbacks.onError) {
this.callbacks.onError(error);
}
}
}
handleSilentResponse(data) {
if (data.authenticated) {
this.setUser(data.user);
} else {
this.clearUser();
}
}
setUser(user) {
const wasAuthenticated = this.isAuthenticated;
this.user = user;
this.isAuthenticated = true;
// Store in localStorage for other tabs
localStorage.setItem('qz_user', JSON.stringify(user));
localStorage.setItem('qz_auth_event', JSON.stringify({
type: 'login',
timestamp: Date.now(),
}));
// Update UI
this.updateUI();
// Trigger callback if newly logged in
if (!wasAuthenticated && this.callbacks.onLogin) {
this.callbacks.onLogin(user);
}
}
clearUser() {
const wasAuthenticated = this.isAuthenticated;
this.user = null;
this.isAuthenticated = false;
localStorage.removeItem('qz_user');
localStorage.setItem('qz_auth_event', JSON.stringify({
type: 'logout',
timestamp: Date.now(),
}));
this.updateUI();
if (wasAuthenticated && this.callbacks.onLogout) {
this.callbacks.onLogout();
}
}
updateUI() {
// Update all elements with data-qz-auth attribute
document.querySelectorAll('[data-qz-auth]').forEach(el => {
const action = el.getAttribute('data-qz-auth');
if (action === 'show-when-authenticated') {
el.style.display = this.isAuthenticated ? '' : 'none';
} else if (action === 'show-when-guest') {
el.style.display = !this.isAuthenticated ? '' : 'none';
} else if (action === 'user-name') {
el.textContent = this.user?.name || '';
} else if (action === 'user-email') {
el.textContent = this.user?.email || '';
} else if (action === 'user-avatar') {
el.src = this.user?.avatar || '/default-avatar.png';
}
});
}
async login(email, password) {
try {
const response = await fetch(`${this.config.ssoUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
this.setUser(data.user);
return data;
} catch (error) {
console.error('[QZAuth] Login failed:', error);
throw error;
}
}
async logout() {
try {
await fetch(`${this.config.ssoUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
});
} catch (error) {
console.error('[QZAuth] Logout error:', error);
} finally {
this.clearUser();
}
}
getUser() {
return this.user;
}
isLoggedIn() {
return this.isAuthenticated;
}
destroy() {
if (this.checkIntervalId) {
clearInterval(this.checkIntervalId);
}
}
}
// Create global instance
global.QZAuth = new QZAuthClient();
})(window);
@@ -0,0 +1,291 @@
<!--
quiXzoom Universal Auth Snippet v2.0
Add this to ALL quiXzoom sites for seamless cross-domain login
Features:
- Silent login (auto-refresh tokens)
- Cross-domain cookie sync
- Real-time auth state updates
- Mobile app compatible
-->
<!-- Auth bar styles -->
<style>
#qz-auth-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: #0A0A0A;
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
}
#qz-auth-bar .qz-logo {
font-size: 18px;
font-weight: 700;
color: #fff;
text-decoration: none;
letter-spacing: -0.5px;
}
#qz-auth-bar .qz-logo span { color: #007AFF; }
#qz-auth-bar .qz-nav {
display: flex;
gap: 8px;
align-items: center;
}
#qz-auth-bar .qz-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
text-decoration: none;
}
#qz-auth-bar .qz-btn-primary {
background: #007AFF;
color: white;
}
#qz-auth-bar .qz-btn-primary:hover { background: #0056CC; }
#qz-auth-bar .qz-btn-ghost {
background: transparent;
color: rgba(255,255,255,0.8);
}
#qz-auth-bar .qz-btn-ghost:hover {
background: rgba(255,255,255,0.1);
color: white;
}
#qz-auth-bar .qz-user-menu {
position: relative;
}
#qz-auth-bar .qz-dropdown {
position: absolute;
top: 40px;
right: 0;
background: #1C1C1E;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
padding: 8px;
min-width: 220px;
display: none;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
#qz-auth-bar .qz-dropdown.active { display: block; }
#qz-auth-bar .qz-dropdown-item {
padding: 10px 12px;
border-radius: 8px;
color: white;
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
transition: background 0.2s;
}
#qz-auth-bar .qz-dropdown-item:hover { background: rgba(255,255,255,0.1); }
#qz-auth-bar .qz-dropdown-divider {
height: 1px;
background: rgba(255,255,255,0.1);
margin: 8px 0;
}
#qz-auth-bar .qz-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #007AFF;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
color: white;
}
#qz-auth-bar .qz-balance {
background: rgba(0,122,255,0.15);
color: #007AFF;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
</style>
<!-- Auth bar HTML -->
<div id="qz-auth-bar">
<a href="https://quixzoom.com" class="qz-logo">qui<span>X</span>zoom</a>
<div class="qz-nav">
<!-- Guest view -->
<div id="qz-guest-view">
<a href="https://app.quixzoom.com/login" class="qz-btn qz-btn-ghost">Logga in</a>
<a href="https://app.quixzoom.com/register" class="qz-btn qz-btn-primary">Bli Zoomer</a>
</div>
<!-- Authenticated view -->
<div id="qz-user-view" style="display:none;">
<span class="qz-balance" id="qz-balance">$0.00</span>
<div class="qz-user-menu">
<button class="qz-btn qz-btn-ghost" onclick="qzToggleMenu()" style="display:flex;align-items:center;gap:8px;">
<div class="qz-avatar" id="qz-avatar">?</div>
<span id="qz-user-name" style="color:white;">User</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="qz-dropdown" id="qz-dropdown">
<a href="https://app.quixzoom.com/dashboard" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>
</svg>
Dashboard
</a>
<a href="https://app.quixzoom.com/wallet" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/>
</svg>
Plånbok
</a>
<a href="https://app.quixzoom.com/missions" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
Mina uppdrag
</a>
<div class="qz-dropdown-divider"></div>
<a href="https://app.quixzoom.com/settings" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.67 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.67 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.67a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Inställningar
</a>
<a href="#" onclick="qzLogout(); return false;" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Logga ut
</a>
</div>
</div>
</div>
</div>
</div>
<!-- SSO Client Script -->
<script>
(function() {
const SSO_BASE = 'https://auth.quixzoom.com';
// Check auth status on load
async function qzCheckAuth() {
try {
const res = await fetch(SSO_BASE + '/auth/silent', {
method: 'GET',
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
qzShowUser(data.user);
return;
}
}
// Try check endpoint
const checkRes = await fetch(SSO_BASE + '/auth/check', {
credentials: 'include',
});
if (checkRes.ok) {
const checkData = await checkRes.json();
if (checkData.authenticated && checkData.user) {
qzShowUser(checkData.user);
return;
}
}
qzShowGuest();
} catch (e) {
console.log('[QZAuth] Check failed, showing guest');
qzShowGuest();
}
}
function qzShowUser(user) {
document.getElementById('qz-guest-view').style.display = 'none';
document.getElementById('qz-user-view').style.display = 'flex';
document.getElementById('qz-user-name').textContent = user.name || user.email;
document.getElementById('qz-avatar').textContent = (user.name || user.email)[0].toUpperCase();
// Dispatch event for other scripts
window.dispatchEvent(new CustomEvent('qz-auth-change', {
detail: { authenticated: true, user }
}));
}
function qzShowGuest() {
document.getElementById('qz-guest-view').style.display = 'flex';
document.getElementById('qz-user-view').style.display = 'none';
window.dispatchEvent(new CustomEvent('qz-auth-change', {
detail: { authenticated: false }
}));
}
window.qzToggleMenu = function() {
document.getElementById('qz-dropdown').classList.toggle('active');
};
window.qzLogout = async function() {
try {
await fetch(SSO_BASE + '/auth/logout', {
method: 'POST',
credentials: 'include',
});
} catch (e) {}
// Clear all cookies
document.cookie = 'qz_access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=.quixzoom.com; path=/;';
document.cookie = 'qz_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=.quixzoom.com; path=/;';
document.cookie = 'qz_auth=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=.quixzoom.com; path=/;';
qzShowGuest();
window.location.reload();
};
// Close dropdown on outside click
document.addEventListener('click', function(e) {
if (!e.target.closest('.qz-user-menu')) {
document.getElementById('qz-dropdown').classList.remove('active');
}
});
// Check auth on page load
qzCheckAuth();
// Re-check auth periodically (every 5 minutes)
setInterval(qzCheckAuth, 5 * 60 * 1000);
// Listen for storage events (login from other tabs)
window.addEventListener('storage', function(e) {
if (e.key === 'qz_auth_event') {
const event = JSON.parse(e.newValue || '{}');
if (event.type === 'login') {
qzCheckAuth();
} else if (event.type === 'logout') {
qzShowGuest();
}
}
});
})();
</script>