diff --git a/app/__init__.py b/app/__init__.py index bc0358b..2e3caf4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,15 +4,23 @@ Art-DAG L1 Server Application Factory. Creates and configures the FastAPI application with all routers and middleware. """ +import time from pathlib import Path +from urllib.parse import quote + from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from artdag_common import create_jinja_env +from artdag_common.middleware.auth import get_user_from_cookie from .config import settings +# Paths that should never trigger a silent auth check +_SKIP_PREFIXES = ("/auth/", "/static/", "/api/", "/ipfs/", "/download/") +_SILENT_CHECK_COOLDOWN = 300 # 5 minutes + def create_app() -> FastAPI: """ @@ -38,6 +46,37 @@ def create_app() -> FastAPI: async def shutdown(): await close_db() + # Silent auth check — auto-login via prompt=none OAuth + @app.middleware("http") + async def silent_auth_check(request: Request, call_next): + path = request.url.path + if ( + request.method != "GET" + or any(path.startswith(p) for p in _SKIP_PREFIXES) + or request.headers.get("hx-request") # skip HTMX + ): + return await call_next(request) + + # Already logged in + if get_user_from_cookie(request): + return await call_next(request) + + # Check cooldown — don't re-check within 5 minutes + pnone_at = request.cookies.get("pnone_at") + if pnone_at: + try: + if (time.time() - float(pnone_at)) < _SILENT_CHECK_COOLDOWN: + return await call_next(request) + except (ValueError, TypeError): + pass + + # Redirect to silent OAuth check + current_url = str(request.url) + return RedirectResponse( + url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}", + status_code=302, + ) + # Initialize Jinja2 templates template_dir = Path(__file__).parent / "templates" app.state.templates = create_jinja_env(template_dir) diff --git a/app/routers/auth.py b/app/routers/auth.py index 84a1258..4d78d47 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -7,6 +7,7 @@ GET /auth/logout — clear cookie, redirect through account SSO logout """ import secrets +import time import httpx from fastapi import APIRouter, Request @@ -33,10 +34,11 @@ def _get_signer() -> URLSafeSerializer: async def login(request: Request): """Store state + next in signed cookie, redirect to account OAuth authorize.""" next_url = request.query_params.get("next", "/") + prompt = request.query_params.get("prompt", "") state = secrets.token_urlsafe(32) signer = _get_signer() - state_payload = signer.dumps({"state": state, "next": next_url}) + state_payload = signer.dumps({"state": state, "next": next_url, "prompt": prompt}) authorize_url = ( f"{settings.oauth_authorize_url}" @@ -44,6 +46,8 @@ async def login(request: Request): f"&redirect_uri={settings.oauth_redirect_uri}" f"&state={state}" ) + if prompt: + authorize_url += f"&prompt={prompt}" response = RedirectResponse(url=authorize_url, status_code=302) response.set_cookie( @@ -62,23 +66,41 @@ async def callback(request: Request): """Validate state, exchange code via token endpoint, set session cookie.""" code = request.query_params.get("code", "") state = request.query_params.get("state", "") + error = request.query_params.get("error", "") - # Recover and validate state from signed cookie + # Recover state from signed cookie state_cookie = request.cookies.get("oauth_state", "") - if not state_cookie or not code or not state: - return RedirectResponse(url="/", status_code=302) - signer = _get_signer() try: - payload = signer.loads(state_cookie) + payload = signer.loads(state_cookie) if state_cookie else {} except Exception: + payload = {} + + next_url = payload.get("next", "/") + was_silent = payload.get("prompt") == "none" + + # Handle prompt=none rejection (user not logged in on account) + if error == "login_required": + response = RedirectResponse(url=next_url, status_code=302) + response.delete_cookie("oauth_state") + # Set cooldown cookie — don't re-check for 5 minutes + response.set_cookie( + key="pnone_at", + value=str(time.time()), + max_age=300, + httponly=True, + samesite="lax", + secure=True, + ) + return response + + # Normal callback — validate state + code + if not state_cookie or not code or not state: return RedirectResponse(url="/", status_code=302) if payload.get("state") != state: return RedirectResponse(url="/", status_code=302) - next_url = payload.get("next", "/") - # Exchange code for user info via account's token endpoint async with httpx.AsyncClient(timeout=10) as client: try: @@ -109,8 +131,8 @@ async def callback(request: Request): response = RedirectResponse(url=next_url, status_code=302) set_auth_cookie(response, user) - # Clear the temporary state cookie response.delete_cookie("oauth_state") + response.delete_cookie("pnone_at") return response @@ -120,4 +142,5 @@ async def logout(): response = RedirectResponse(url=settings.oauth_logout_url, status_code=302) clear_auth_cookie(response) response.delete_cookie("oauth_state") + response.delete_cookie("pnone_at") return response