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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user