Add silent auto-login via prompt=none OAuth check
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:
giles
2026-02-23 23:43:08 +00:00
parent c7466a2fe8
commit ab3b6b672d
2 changed files with 72 additions and 10 deletions

View File

@@ -4,15 +4,23 @@ Art-DAG L1 Server Application Factory.
Creates and configures the FastAPI application with all routers and middleware. Creates and configures the FastAPI application with all routers and middleware.
""" """
import time
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from artdag_common import create_jinja_env from artdag_common import create_jinja_env
from artdag_common.middleware.auth import get_user_from_cookie
from .config import settings 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: def create_app() -> FastAPI:
""" """
@@ -38,6 +46,37 @@ def create_app() -> FastAPI:
async def shutdown(): async def shutdown():
await close_db() 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 # Initialize Jinja2 templates
template_dir = Path(__file__).parent / "templates" template_dir = Path(__file__).parent / "templates"
app.state.templates = create_jinja_env(template_dir) app.state.templates = create_jinja_env(template_dir)

View File

@@ -7,6 +7,7 @@ GET /auth/logout — clear cookie, redirect through account SSO logout
""" """
import secrets import secrets
import time
import httpx import httpx
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
@@ -33,10 +34,11 @@ def _get_signer() -> URLSafeSerializer:
async def login(request: Request): async def login(request: Request):
"""Store state + next in signed cookie, redirect to account OAuth authorize.""" """Store state + next in signed cookie, redirect to account OAuth authorize."""
next_url = request.query_params.get("next", "/") next_url = request.query_params.get("next", "/")
prompt = request.query_params.get("prompt", "")
state = secrets.token_urlsafe(32) state = secrets.token_urlsafe(32)
signer = _get_signer() 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 = ( authorize_url = (
f"{settings.oauth_authorize_url}" f"{settings.oauth_authorize_url}"
@@ -44,6 +46,8 @@ async def login(request: Request):
f"&redirect_uri={settings.oauth_redirect_uri}" f"&redirect_uri={settings.oauth_redirect_uri}"
f"&state={state}" f"&state={state}"
) )
if prompt:
authorize_url += f"&prompt={prompt}"
response = RedirectResponse(url=authorize_url, status_code=302) response = RedirectResponse(url=authorize_url, status_code=302)
response.set_cookie( response.set_cookie(
@@ -62,23 +66,41 @@ async def callback(request: Request):
"""Validate state, exchange code via token endpoint, set session cookie.""" """Validate state, exchange code via token endpoint, set session cookie."""
code = request.query_params.get("code", "") code = request.query_params.get("code", "")
state = request.query_params.get("state", "") 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", "") 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() signer = _get_signer()
try: try:
payload = signer.loads(state_cookie) payload = signer.loads(state_cookie) if state_cookie else {}
except Exception: 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) return RedirectResponse(url="/", status_code=302)
if payload.get("state") != state: if payload.get("state") != state:
return RedirectResponse(url="/", status_code=302) return RedirectResponse(url="/", status_code=302)
next_url = payload.get("next", "/")
# Exchange code for user info via account's token endpoint # Exchange code for user info via account's token endpoint
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
try: try:
@@ -109,8 +131,8 @@ async def callback(request: Request):
response = RedirectResponse(url=next_url, status_code=302) response = RedirectResponse(url=next_url, status_code=302)
set_auth_cookie(response, user) set_auth_cookie(response, user)
# Clear the temporary state cookie
response.delete_cookie("oauth_state") response.delete_cookie("oauth_state")
response.delete_cookie("pnone_at")
return response return response
@@ -120,4 +142,5 @@ async def logout():
response = RedirectResponse(url=settings.oauth_logout_url, status_code=302) response = RedirectResponse(url=settings.oauth_logout_url, status_code=302)
clear_auth_cookie(response) clear_auth_cookie(response)
response.delete_cookie("oauth_state") response.delete_cookie("oauth_state")
response.delete_cookie("pnone_at")
return response return response