diff --git a/infrastructure/factory.py b/infrastructure/factory.py index ba652cb..e12b352 100644 --- a/infrastructure/factory.py +++ b/infrastructure/factory.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import os +import secrets from pathlib import Path from typing import Callable, Awaitable, Sequence @@ -107,6 +108,29 @@ def create_base_app( from shared.infrastructure.oauth import create_oauth_blueprint app.register_blueprint(create_oauth_blueprint(name)) + # --- device id (all apps, including account) --- + _did_cookie = f"{name}_did" + + @app.before_request + async def _init_device_id(): + did = request.cookies.get(_did_cookie) + if did: + g.device_id = did + g._new_device_id = False + else: + g.device_id = secrets.token_urlsafe(32) + g._new_device_id = True + + @app.after_request + async def _set_device_cookie(response): + if getattr(g, "_new_device_id", False): + response.set_cookie( + _did_cookie, g.device_id, + max_age=30 * 24 * 3600, + secure=True, samesite="Lax", httponly=True, + ) + return response + # --- before-request hooks --- @app.before_request async def _route_log(): @@ -176,10 +200,25 @@ def create_base_app( if request.headers.get("HX-Request"): return import time as _time + now = _time.time() pnone_at = qs.get("_pnone_at") - if pnone_at and (_time.time() - pnone_at) < 300: + + # Check if account signalled a login after we cached "not logged in" + account_did = qs.get("_account_did") + if account_did and redis and pnone_at: + auth_ts = await redis.get(f"did_auth:{account_did}") + if auth_ts: + try: + if float(auth_ts) > pnone_at: + # Login on account after our cache — re-check now + qs.pop("_pnone_at", None) + return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}") + except (ValueError, TypeError): + pass + + if pnone_at and (now - pnone_at) < 300: return - device_id = request.cookies.get(f"{name}_did") + device_id = g.device_id if device_id and redis: cached = await redis.get(f"prompt:{name}:{device_id}") if cached == b"none": diff --git a/infrastructure/oauth.py b/infrastructure/oauth.py index 06a4bc3..919c39e 100644 --- a/infrastructure/oauth.py +++ b/infrastructure/oauth.py @@ -8,7 +8,6 @@ client apps can detect login/logout without cross-domain cookies. """ from __future__ import annotations -import os import secrets from datetime import datetime, timezone @@ -24,7 +23,6 @@ from quart import ( from sqlalchemy import select from shared.db.session import get_session -from shared.models import User from shared.models.oauth_code import OAuthCode from shared.infrastructure.urls import account_url, app_url from shared.infrastructure.cart_identity import current_cart_identity @@ -34,30 +32,9 @@ SESSION_USER_KEY = "uid" GRANT_TOKEN_KEY = "grant_token" -def _device_cookie_name(app_name: str) -> str: - return f"{app_name}_did" - - -def _internal_account_url() -> str: - return (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/") - - def create_oauth_blueprint(app_name: str) -> Blueprint: """Return an OAuth client blueprint for *app_name*.""" bp = Blueprint("oauth_auth", __name__, url_prefix="/auth") - cookie_name = _device_cookie_name(app_name) - - # Ensure device cookie exists on every response - @bp.after_app_request - async def _ensure_device_cookie(response): - if not request.cookies.get(cookie_name): - did = secrets.token_urlsafe(32) - response.set_cookie( - cookie_name, did, - max_age=30 * 24 * 3600, - secure=True, samesite="Lax", httponly=True, - ) - return response @bp.get("/login") @bp.get("/login/") @@ -68,7 +45,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: qsession["oauth_state"] = state qsession["oauth_next"] = next_url - device_id = request.cookies.get(cookie_name, "") + device_id = g.device_id redirect_uri = app_url(app_name, "/auth/callback") params = ( f"?client_id={app_name}" @@ -84,6 +61,11 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: @bp.get("/callback") @bp.get("/callback/") async def callback(): + # Always store account_did when account passes it back + account_did = request.args.get("account_did", "") + if account_did: + qsession["_account_did"] = account_did + # Handle prompt=none error (user not logged in on account) error = request.args.get("error") if error == "login_required": @@ -91,7 +73,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: qsession.pop("oauth_state", None) import time as _time qsession["_pnone_at"] = _time.time() - device_id = request.cookies.get(cookie_name, "") + device_id = g.device_id if device_id: from shared.browser.app.redis_cacher import get_redis _redis = get_redis() @@ -181,7 +163,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: qsession.clear() resp = await make_response(redirect("/")) resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/") - resp.delete_cookie(cookie_name, path="/") + resp.delete_cookie(f"{app_name}_did", path="/") return resp @bp.post("/logout") @@ -191,6 +173,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: qsession.pop(GRANT_TOKEN_KEY, None) qsession.pop("cart_sid", None) qsession.pop("_pnone_at", None) + qsession.pop("_account_did", None) # Redirect through account to revoke grants + clear account session return redirect(account_url("/auth/sso-logout/"))