diff --git a/alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py b/alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py index 642e445..b973872 100644 --- a/alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py +++ b/alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py @@ -20,12 +20,14 @@ def upgrade(): sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("client_id", sa.String(64), nullable=False), sa.Column("issuer_session", sa.String(128), nullable=False), + sa.Column("device_id", sa.String(128), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), ) op.create_index("ix_oauth_grant_token", "oauth_grants", ["token"], unique=True) op.create_index("ix_oauth_grant_issuer", "oauth_grants", ["issuer_session"]) op.create_index("ix_oauth_grant_user", "oauth_grants", ["user_id"]) + op.create_index("ix_oauth_grant_device", "oauth_grants", ["device_id", "client_id"]) # Add grant_token column to oauth_codes to link code → grant op.add_column("oauth_codes", sa.Column("grant_token", sa.String(128), nullable=True)) diff --git a/infrastructure/factory.py b/infrastructure/factory.py index e586a0b..9b87bdf 100644 --- a/infrastructure/factory.py +++ b/infrastructure/factory.py @@ -123,54 +123,84 @@ def create_base_app( for fn in before_request_fns: app.before_request(fn) - # Grant revocation check: client apps verify their grant is still valid + # Auth state check via device cookie + account internal endpoint if name != "account": @app.before_request - async def _check_grant(): + async def _check_auth_state(): from quart import session as qs - grant_token = qs.get("grant_token") - if not grant_token or not qs.get("uid"): - return - if request.path.startswith("/auth/"): + from urllib.parse import quote as _quote + if request.path.startswith("/auth/") or request.path.startswith("/static/"): return - # Check Redis cache first (avoid hammering account on every request) - cache_key = f"grant:{grant_token}" + 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() - if redis: - cached = await redis.get(cache_key) - if cached == b"ok": - return - if cached == b"revoked": + account_internal = (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/") + + # Case 1: logged in locally — verify grant still valid + if uid and grant_token: + cache_key = f"grant:{grant_token}" + if redis: + cached = await redis.get(cache_key) + if cached == b"ok": + return + if cached == b"revoked": + qs.pop("uid", None) + qs.pop("grant_token", None) + qs.pop("cart_sid", None) + return + + 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) + except Exception: + return # account unreachable — don't log user out + + if redis: + await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60) + if not valid: qs.pop("uid", None) qs.pop("grant_token", None) qs.pop("cart_sid", None) - return - - # Call account's internal endpoint - import os, aiohttp - account_internal = (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/") - 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) - except Exception: - # If account is unreachable, don't log user out return - if redis: - await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60) + # 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) + 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='')}") - if not valid: - qs.pop("uid", None) - qs.pop("grant_token", None) - qs.pop("cart_sid", None) + 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 async def _csrf_protect(): diff --git a/infrastructure/oauth.py b/infrastructure/oauth.py index 6c00ba6..fc0817a 100644 --- a/infrastructure/oauth.py +++ b/infrastructure/oauth.py @@ -2,6 +2,9 @@ Each client app gets /auth/login, /auth/callback, /auth/logout. Account is the OAuth authorization server. + +Device cookie ({app}_did) ties the browser to its auth state so +client apps can detect login/logout without cross-domain cookies. """ from __future__ import annotations @@ -16,6 +19,7 @@ from quart import ( session as qsession, g, current_app, + make_response, ) from sqlalchemy import select @@ -30,14 +34,30 @@ 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: - """Internal URL for account service (Docker network).""" 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/") @@ -47,10 +67,12 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: 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}" f"&redirect_uri={redirect_uri}" + f"&device_id={device_id}" f"&state={state}" ) return redirect(authorize_url) @@ -135,8 +157,9 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: async def clear(): """One-time migration helper: clear all session cookies.""" qsession.clear() - resp = redirect("/") + resp = await make_response(redirect("/")) resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/") + resp.delete_cookie(cookie_name, path="/") return resp @bp.post("/logout") diff --git a/models/oauth_grant.py b/models/oauth_grant.py index 425bf07..01a0718 100644 --- a/models/oauth_grant.py +++ b/models/oauth_grant.py @@ -19,6 +19,7 @@ class OAuthGrant(Base): user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) client_id: Mapped[str] = mapped_column(String(64), nullable=False) issuer_session: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + device_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) @@ -27,4 +28,5 @@ class OAuthGrant(Base): __table_args__ = ( Index("ix_oauth_grant_token", "token", unique=True), Index("ix_oauth_grant_issuer", "issuer_session"), + Index("ix_oauth_grant_device", "device_id", "client_id"), )