Add OAuth grants for per-device session revocation
- OAuthGrant model tracks each client authorization, tied to the account session (issuer_session) that issued it - OAuth authorize creates grant + code together - Client apps store grant_token in session, verify via account's internal /auth/internal/verify-grant endpoint (Redis-cached 60s) - Account logout revokes only grants from that device's session - Replaces iframe-based logout with server-side grant revocation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
39
alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py
Normal file
39
alembic/versions/q7o5l1m3n4_add_oauth_grants_table.py
Normal file
@@ -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")
|
||||||
@@ -123,6 +123,55 @@ def create_base_app(
|
|||||||
for fn in before_request_fns:
|
for fn in before_request_fns:
|
||||||
app.before_request(fn)
|
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
|
@app.before_request
|
||||||
async def _csrf_protect():
|
async def _csrf_protect():
|
||||||
await protect()
|
await protect()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Account is the OAuth authorization server.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -26,6 +27,12 @@ from shared.infrastructure.cart_identity import current_cart_identity
|
|||||||
from shared.events import emit_activity
|
from shared.events import emit_activity
|
||||||
|
|
||||||
SESSION_USER_KEY = "uid"
|
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:
|
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
|
oauth_code.used_at = now
|
||||||
user_id = oauth_code.user_id
|
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
|
qsession[SESSION_USER_KEY] = user_id
|
||||||
|
if grant_token:
|
||||||
|
qsession[GRANT_TOKEN_KEY] = grant_token
|
||||||
|
|
||||||
# Emit login activity for cart adoption
|
# Emit login activity for cart adoption
|
||||||
ident = current_cart_identity()
|
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="/")
|
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
|
||||||
return resp
|
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")
|
||||||
@bp.post("/logout/")
|
@bp.post("/logout/")
|
||||||
async def logout():
|
async def logout():
|
||||||
qsession.pop(SESSION_USER_KEY, None)
|
qsession.pop(SESSION_USER_KEY, None)
|
||||||
|
qsession.pop(GRANT_TOKEN_KEY, None)
|
||||||
qsession.pop("cart_sid", 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 redirect(account_url("/auth/sso-logout/"))
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from .user import User
|
|||||||
from .kv import KV
|
from .kv import KV
|
||||||
from .magic_link import MagicLink
|
from .magic_link import MagicLink
|
||||||
from .oauth_code import OAuthCode
|
from .oauth_code import OAuthCode
|
||||||
|
from .oauth_grant import OAuthGrant
|
||||||
from .menu_item import MenuItem
|
from .menu_item import MenuItem
|
||||||
|
|
||||||
from .ghost_membership_entities import (
|
from .ghost_membership_entities import (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class OAuthCode(Base):
|
|||||||
redirect_uri: Mapped[str] = mapped_column(String(512), nullable=False)
|
redirect_uri: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), 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)
|
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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
user = relationship("User", backref="oauth_codes")
|
user = relationship("User", backref="oauth_codes")
|
||||||
|
|||||||
30
models/oauth_grant.py
Normal file
30
models/oauth_grant.py
Normal file
@@ -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"),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user