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

@@ -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