scouting / auth.py
matias-cataife
fix: cross-site cookie + auth-status open path for HF iframe
9687f99
Raw
History Blame Contribute Delete
3.34 kB
"""
Shared-password auth via signed cookie.
Set APP_PASSWORD in env. SESSION_SECRET signs the cookie; falls back to a derived value.
"""
import hashlib
import os
import secrets
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from itsdangerous import BadSignature, URLSafeTimedSerializer
from starlette.middleware.base import BaseHTTPMiddleware
APP_PASSWORD = os.environ.get('APP_PASSWORD', '')
SESSION_SECRET = os.environ.get(
'SESSION_SECRET',
hashlib.sha256(('racing-' + APP_PASSWORD).encode()).hexdigest() if APP_PASSWORD else 'dev-only-secret',
)
COOKIE_NAME = 'auth'
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
# paths that bypass auth
OPEN_PATHS = {'/api/login', '/api/logout', '/api/auth-status',
'/healthz', '/api/docs', '/openapi.json'}
OPEN_PREFIXES = ('/static/',)
_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt='auth-cookie')
def _sign():
return _serializer.dumps('ok')
def _verify(token):
try:
_serializer.loads(token, max_age=COOKIE_MAX_AGE)
return True
except (BadSignature, Exception):
return False
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# always allow open paths and the index page (frontend self-renders login modal on 401)
if path == '/' or path in OPEN_PATHS or any(path.startswith(p) for p in OPEN_PREFIXES):
return await call_next(request)
# allow if no password configured (dev mode)
if not APP_PASSWORD:
return await call_next(request)
token = request.cookies.get(COOKIE_NAME)
if token and _verify(token):
return await call_next(request)
return JSONResponse(status_code=401, content={'detail': 'unauthorized'})
def register_auth_routes(app: FastAPI):
@app.post('/api/login')
async def login(request: Request):
body = await request.json()
password = body.get('password', '')
if not APP_PASSWORD:
# dev mode — accept any password and set cookie so the frontend doesn't loop
response = JSONResponse({'ok': True, 'mode': 'dev'})
response.set_cookie(COOKIE_NAME, _sign(), max_age=COOKIE_MAX_AGE,
httponly=True, samesite='lax', secure=False)
return response
if not secrets.compare_digest(password, APP_PASSWORD):
return JSONResponse(status_code=401, content={'detail': 'wrong password'})
response = JSONResponse({'ok': True})
# SameSite=None + Secure required so the cookie survives the HF iframe (cross-site).
response.set_cookie(COOKIE_NAME, _sign(), max_age=COOKIE_MAX_AGE,
httponly=True, samesite='none', secure=True)
return response
@app.post('/api/logout')
async def logout(response: Response):
response = JSONResponse({'ok': True})
response.delete_cookie(COOKIE_NAME)
return response
@app.get('/api/auth-status')
async def auth_status(request: Request):
if not APP_PASSWORD:
return {'auth_required': False}
token = request.cookies.get(COOKIE_NAME)
return {'auth_required': True, 'authenticated': bool(token and _verify(token))}