Add silent auto-login via prompt=none OAuth check
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
Middleware on every GET checks if user is logged in. If not, does a silent prompt=none redirect to account. If account has an active session, login completes invisibly. Otherwise sets a 5-minute cooldown cookie to avoid redirect loops. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user