diff --git a/infrastructure/factory.py b/infrastructure/factory.py index 9b87bdf..ba652cb 100644 --- a/infrastructure/factory.py +++ b/infrastructure/factory.py @@ -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(): diff --git a/infrastructure/oauth.py b/infrastructure/oauth.py index fc0817a..06a4bc3 100644 --- a/infrastructure/oauth.py +++ b/infrastructure/oauth.py @@ -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/"))