Replace propagation chain + check-device with prompt=none OAuth handshake

Client apps now do a silent OAuth round-trip (prompt=none) to account on
first visit. If user is logged in on account, they get silently logged in.
If not, the result is cached (5 min) to avoid repeated handshakes.

Grant verification now uses direct DB query instead of aiohttp HTTP calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 13:40:58 +00:00
parent 38a2023ca3
commit c4590d1442
2 changed files with 49 additions and 42 deletions

View File

@@ -123,7 +123,7 @@ def create_base_app(
for fn in before_request_fns:
app.before_request(fn)
# Auth state check via device cookie + account internal endpoint
# Auth state check via grant verification + silent OAuth handshake
if name != "account":
@app.before_request
async def _check_auth_state():
@@ -134,14 +134,11 @@ def create_base_app(
uid = qs.get("uid")
grant_token = qs.get("grant_token")
device_id = request.cookies.get(f"{name}_did")
import os, aiohttp
from shared.browser.app.redis_cacher import get_redis
redis = get_redis()
account_internal = (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/")
# Case 1: logged in locally — verify grant still valid
# Case 1: logged in — verify grant still valid (direct DB, cached)
if uid and grant_token:
cache_key = f"grant:{grant_token}"
if redis:
@@ -154,17 +151,17 @@ def create_base_app(
qs.pop("cart_sid", None)
return
from sqlalchemy import select
from shared.db.session import get_session
from shared.models.oauth_grant import OAuthGrant
try:
async with aiohttp.ClientSession() as http:
async with http.get(
f"{account_internal}/auth/internal/verify-grant",
params={"token": grant_token},
timeout=aiohttp.ClientTimeout(total=3),
) as resp:
data = await resp.json()
valid = data.get("valid", False)
async with get_session() as s:
grant = await s.scalar(
select(OAuthGrant).where(OAuthGrant.token == grant_token)
)
valid = grant is not None and grant.revoked_at is None
except Exception:
return # account unreachable — don't log user out
return # DB error — don't log user out
if redis:
await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
@@ -174,33 +171,20 @@ def create_base_app(
qs.pop("cart_sid", None)
return
# Case 2: not logged in but device cookie exists — check for auth change
if not uid and device_id:
cache_key = f"device:{name}:{device_id}"
if redis:
cached = await redis.get(cache_key)
# Case 2: not logged in — prompt=none OAuth (GET, non-HTMX only)
if not uid and request.method == "GET":
if request.headers.get("HX-Request"):
return
import time as _time
pnone_at = qs.get("_pnone_at")
if pnone_at and (_time.time() - pnone_at) < 300:
return
device_id = request.cookies.get(f"{name}_did")
if device_id and redis:
cached = await redis.get(f"prompt:{name}:{device_id}")
if cached == b"none":
return # recently checked, no auth
if cached == b"active":
# Auth available — trigger OAuth
return redirect(f"/auth/login/?next={_quote(request.url, safe='')}")
try:
async with aiohttp.ClientSession() as http:
async with http.get(
f"{account_internal}/auth/internal/check-device",
params={"device_id": device_id, "app": name},
timeout=aiohttp.ClientTimeout(total=3),
) as resp:
data = await resp.json()
active = data.get("active", False)
except Exception:
return # account unreachable — stay anonymous
if redis:
await redis.set(cache_key, b"active" if active else b"none", ex=60)
if active:
return redirect(f"/auth/login/?next={_quote(request.url, safe='')}")
return
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
@app.before_request
async def _csrf_protect():

View File

@@ -63,23 +63,44 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
@bp.get("/login/")
async def login():
next_url = request.args.get("next", "/")
prompt = request.args.get("prompt", "")
state = secrets.token_urlsafe(32)
qsession["oauth_state"] = state
qsession["oauth_next"] = next_url
device_id = request.cookies.get(cookie_name, "")
redirect_uri = app_url(app_name, "/auth/callback")
authorize_url = account_url(
f"/auth/oauth/authorize?client_id={app_name}"
params = (
f"?client_id={app_name}"
f"&redirect_uri={redirect_uri}"
f"&device_id={device_id}"
f"&state={state}"
)
if prompt:
params += f"&prompt={prompt}"
authorize_url = account_url(f"/auth/oauth/authorize{params}")
return redirect(authorize_url)
@bp.get("/callback")
@bp.get("/callback/")
async def callback():
# Handle prompt=none error (user not logged in on account)
error = request.args.get("error")
if error == "login_required":
next_url = qsession.pop("oauth_next", "/")
qsession.pop("oauth_state", None)
import time as _time
qsession["_pnone_at"] = _time.time()
device_id = request.cookies.get(cookie_name, "")
if device_id:
from shared.browser.app.redis_cacher import get_redis
_redis = get_redis()
if _redis:
await _redis.set(
f"prompt:{app_name}:{device_id}", b"none", ex=300
)
return redirect(next_url)
code = request.args.get("code")
state = request.args.get("state")
expected_state = qsession.pop("oauth_state", None)
@@ -129,6 +150,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
qsession[SESSION_USER_KEY] = user_id
if grant_token:
qsession[GRANT_TOKEN_KEY] = grant_token
qsession.pop("_pnone_at", None)
# Emit login activity for cart adoption
ident = current_cart_identity()
@@ -168,6 +190,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
qsession.pop(SESSION_USER_KEY, None)
qsession.pop(GRANT_TOKEN_KEY, None)
qsession.pop("cart_sid", None)
qsession.pop("_pnone_at", None)
# Redirect through account to revoke grants + clear account session
return redirect(account_url("/auth/sso-logout/"))