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