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:
giles
2026-02-23 12:30:08 +00:00
parent 9a637c6227
commit 6bb26522a1
6 changed files with 133 additions and 10 deletions

View File

@@ -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()