Device cookie + internal endpoint for auth state detection

Each client app sets a persistent first-party device cookie ({app}_did).
On each request:
- Logged in: verify grant via account internal endpoint (cached 60s)
- Not logged in + device cookie: check-device endpoint detects if user
  logged in since last grant revocation → triggers OAuth automatically
No cross-domain cookies. No propagation chain. Each app checks independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 12:50:43 +00:00
parent 6bb26522a1
commit de93dfdc73
4 changed files with 94 additions and 37 deletions

View File

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

View File

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