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:
@@ -123,7 +123,7 @@ def create_base_app(
|
|||||||
for fn in before_request_fns:
|
for fn in before_request_fns:
|
||||||
app.before_request(fn)
|
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":
|
if name != "account":
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def _check_auth_state():
|
async def _check_auth_state():
|
||||||
@@ -134,14 +134,11 @@ def create_base_app(
|
|||||||
|
|
||||||
uid = qs.get("uid")
|
uid = qs.get("uid")
|
||||||
grant_token = qs.get("grant_token")
|
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
|
from shared.browser.app.redis_cacher import get_redis
|
||||||
redis = 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:
|
if uid and grant_token:
|
||||||
cache_key = f"grant:{grant_token}"
|
cache_key = f"grant:{grant_token}"
|
||||||
if redis:
|
if redis:
|
||||||
@@ -154,17 +151,17 @@ def create_base_app(
|
|||||||
qs.pop("cart_sid", None)
|
qs.pop("cart_sid", None)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from shared.db.session import get_session
|
||||||
|
from shared.models.oauth_grant import OAuthGrant
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as http:
|
async with get_session() as s:
|
||||||
async with http.get(
|
grant = await s.scalar(
|
||||||
f"{account_internal}/auth/internal/verify-grant",
|
select(OAuthGrant).where(OAuthGrant.token == grant_token)
|
||||||
params={"token": grant_token},
|
)
|
||||||
timeout=aiohttp.ClientTimeout(total=3),
|
valid = grant is not None and grant.revoked_at is None
|
||||||
) as resp:
|
|
||||||
data = await resp.json()
|
|
||||||
valid = data.get("valid", False)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return # account unreachable — don't log user out
|
return # DB error — don't log user out
|
||||||
|
|
||||||
if redis:
|
if redis:
|
||||||
await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
|
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)
|
qs.pop("cart_sid", None)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Case 2: not logged in but device cookie exists — check for auth change
|
# Case 2: not logged in — prompt=none OAuth (GET, non-HTMX only)
|
||||||
if not uid and device_id:
|
if not uid and request.method == "GET":
|
||||||
cache_key = f"device:{name}:{device_id}"
|
if request.headers.get("HX-Request"):
|
||||||
if redis:
|
return
|
||||||
cached = await redis.get(cache_key)
|
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":
|
if cached == b"none":
|
||||||
return # recently checked, no auth
|
return
|
||||||
if cached == b"active":
|
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
|
||||||
# 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='')}")
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def _csrf_protect():
|
async def _csrf_protect():
|
||||||
|
|||||||
@@ -63,23 +63,44 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
|||||||
@bp.get("/login/")
|
@bp.get("/login/")
|
||||||
async def login():
|
async def login():
|
||||||
next_url = request.args.get("next", "/")
|
next_url = request.args.get("next", "/")
|
||||||
|
prompt = request.args.get("prompt", "")
|
||||||
state = secrets.token_urlsafe(32)
|
state = secrets.token_urlsafe(32)
|
||||||
qsession["oauth_state"] = state
|
qsession["oauth_state"] = state
|
||||||
qsession["oauth_next"] = next_url
|
qsession["oauth_next"] = next_url
|
||||||
|
|
||||||
device_id = request.cookies.get(cookie_name, "")
|
device_id = request.cookies.get(cookie_name, "")
|
||||||
redirect_uri = app_url(app_name, "/auth/callback")
|
redirect_uri = app_url(app_name, "/auth/callback")
|
||||||
authorize_url = account_url(
|
params = (
|
||||||
f"/auth/oauth/authorize?client_id={app_name}"
|
f"?client_id={app_name}"
|
||||||
f"&redirect_uri={redirect_uri}"
|
f"&redirect_uri={redirect_uri}"
|
||||||
f"&device_id={device_id}"
|
f"&device_id={device_id}"
|
||||||
f"&state={state}"
|
f"&state={state}"
|
||||||
)
|
)
|
||||||
|
if prompt:
|
||||||
|
params += f"&prompt={prompt}"
|
||||||
|
authorize_url = account_url(f"/auth/oauth/authorize{params}")
|
||||||
return redirect(authorize_url)
|
return redirect(authorize_url)
|
||||||
|
|
||||||
@bp.get("/callback")
|
@bp.get("/callback")
|
||||||
@bp.get("/callback/")
|
@bp.get("/callback/")
|
||||||
async def 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")
|
code = request.args.get("code")
|
||||||
state = request.args.get("state")
|
state = request.args.get("state")
|
||||||
expected_state = qsession.pop("oauth_state", None)
|
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
|
qsession[SESSION_USER_KEY] = user_id
|
||||||
if grant_token:
|
if grant_token:
|
||||||
qsession[GRANT_TOKEN_KEY] = grant_token
|
qsession[GRANT_TOKEN_KEY] = grant_token
|
||||||
|
qsession.pop("_pnone_at", None)
|
||||||
|
|
||||||
# Emit login activity for cart adoption
|
# Emit login activity for cart adoption
|
||||||
ident = current_cart_identity()
|
ident = current_cart_identity()
|
||||||
@@ -168,6 +190,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
|||||||
qsession.pop(SESSION_USER_KEY, None)
|
qsession.pop(SESSION_USER_KEY, None)
|
||||||
qsession.pop(GRANT_TOKEN_KEY, None)
|
qsession.pop(GRANT_TOKEN_KEY, None)
|
||||||
qsession.pop("cart_sid", None)
|
qsession.pop("cart_sid", None)
|
||||||
|
qsession.pop("_pnone_at", None)
|
||||||
# Redirect through account to revoke grants + clear account session
|
# Redirect through account to revoke grants + clear account session
|
||||||
return redirect(account_url("/auth/sso-logout/"))
|
return redirect(account_url("/auth/sso-logout/"))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user