diff --git a/alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py b/alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py new file mode 100644 index 0000000..642e445 --- /dev/null +++ b/alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py @@ -0,0 +1,39 @@ +"""Add oauth_grants table + +Revision ID: q7o5l1m3n4 +Revises: p6n4k0l2m3 +""" +from alembic import op +import sqlalchemy as sa + +revision = "q7o5l1m3n4" +down_revision = "p6n4k0l2m3" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "oauth_grants", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("token", sa.String(128), unique=True, nullable=False), + 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("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"]) + + # 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)) + + +def downgrade(): + op.drop_column("oauth_codes", "grant_token") + op.drop_index("ix_oauth_grant_user", table_name="oauth_grants") + op.drop_index("ix_oauth_grant_issuer", table_name="oauth_grants") + op.drop_index("ix_oauth_grant_token", table_name="oauth_grants") + op.drop_table("oauth_grants") diff --git a/infrastructure/factory.py b/infrastructure/factory.py index ffac1e7..e586a0b 100644 --- a/infrastructure/factory.py +++ b/infrastructure/factory.py @@ -123,6 +123,55 @@ 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 + if name != "account": + @app.before_request + async def _check_grant(): + 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/"): + return + + # Check Redis cache first (avoid hammering account on every request) + cache_key = f"grant:{grant_token}" + 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": + 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) + + if not valid: + qs.pop("uid", None) + qs.pop("grant_token", None) + qs.pop("cart_sid", None) + @app.before_request async def _csrf_protect(): await protect() diff --git a/infrastructure/oauth.py b/infrastructure/oauth.py index cd20d0b..6c00ba6 100644 --- a/infrastructure/oauth.py +++ b/infrastructure/oauth.py @@ -5,6 +5,7 @@ Account is the OAuth authorization server. """ from __future__ import annotations +import os import secrets from datetime import datetime, timezone @@ -26,6 +27,12 @@ from shared.infrastructure.cart_identity import current_cart_identity from shared.events import emit_activity SESSION_USER_KEY = "uid" +GRANT_TOKEN_KEY = "grant_token" + + +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: @@ -94,9 +101,12 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: oauth_code.used_at = now user_id = oauth_code.user_id + grant_token = oauth_code.grant_token - # Set local session + # Set local session with grant token for revocation checking qsession[SESSION_USER_KEY] = user_id + if grant_token: + qsession[GRANT_TOKEN_KEY] = grant_token # Emit login activity for cart adoption ident = current_cart_identity() @@ -129,20 +139,13 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/") return resp - @bp.get("/sso-clear") - @bp.get("/sso-clear/") - async def sso_clear(): - """Clear local session. Called via hidden iframe from account logout.""" - qsession.pop(SESSION_USER_KEY, None) - qsession.pop("cart_sid", None) - return "", 204 - @bp.post("/logout") @bp.post("/logout/") async def logout(): qsession.pop(SESSION_USER_KEY, None) + qsession.pop(GRANT_TOKEN_KEY, None) qsession.pop("cart_sid", None) - # Redirect through account to clear all app sessions + # Redirect through account to revoke grants + clear account session return redirect(account_url("/auth/sso-logout/")) return bp diff --git a/models/__init__.py b/models/__init__.py index d76422b..c7303ee 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -2,6 +2,7 @@ from .user import User from .kv import KV from .magic_link import MagicLink from .oauth_code import OAuthCode +from .oauth_grant import OAuthGrant from .menu_item import MenuItem from .ghost_membership_entities import ( diff --git a/models/oauth_code.py b/models/oauth_code.py index 9aac2bf..3973dcc 100644 --- a/models/oauth_code.py +++ b/models/oauth_code.py @@ -15,6 +15,7 @@ class OAuthCode(Base): redirect_uri: Mapped[str] = mapped_column(String(512), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + grant_token: Mapped[str | None] = mapped_column(String(128), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) user = relationship("User", backref="oauth_codes") diff --git a/models/oauth_grant.py b/models/oauth_grant.py new file mode 100644 index 0000000..425bf07 --- /dev/null +++ b/models/oauth_grant.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy import String, Integer, DateTime, ForeignKey, func, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from shared.db.base import Base + + +class OAuthGrant(Base): + """Long-lived grant tracking each client-app session authorization. + + Created when the OAuth authorize endpoint issues a code. Tied to the + account session that issued it (``issuer_session``) so that logging out + on one device revokes only that device's grants. + """ + __tablename__ = "oauth_grants" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + 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) + 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) + + user = relationship("User", backref="oauth_grants") + + __table_args__ = ( + Index("ix_oauth_grant_token", "token", unique=True), + Index("ix_oauth_grant_issuer", "issuer_session"), + )