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:
Executable
+45
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user