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.
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user