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:
@@ -20,12 +20,14 @@ def upgrade():
|
|||||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), 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("client_id", sa.String(64), nullable=False),
|
||||||
sa.Column("issuer_session", sa.String(128), nullable=False),
|
sa.Column("issuer_session", sa.String(128), nullable=False),
|
||||||
|
sa.Column("device_id", sa.String(128), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
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),
|
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_token", "oauth_grants", ["token"], unique=True)
|
||||||
op.create_index("ix_oauth_grant_issuer", "oauth_grants", ["issuer_session"])
|
op.create_index("ix_oauth_grant_issuer", "oauth_grants", ["issuer_session"])
|
||||||
op.create_index("ix_oauth_grant_user", "oauth_grants", ["user_id"])
|
op.create_index("ix_oauth_grant_user", "oauth_grants", ["user_id"])
|
||||||
|
op.create_index("ix_oauth_grant_device", "oauth_grants", ["device_id", "client_id"])
|
||||||
|
|
||||||
# Add grant_token column to oauth_codes to link code → grant
|
# 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))
|
op.add_column("oauth_codes", sa.Column("grant_token", sa.String(128), nullable=True))
|
||||||
|
|||||||
@@ -123,21 +123,27 @@ 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
|
# Auth state check via device cookie + account internal endpoint
|
||||||
if name != "account":
|
if name != "account":
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def _check_grant():
|
async def _check_auth_state():
|
||||||
from quart import session as qs
|
from quart import session as qs
|
||||||
grant_token = qs.get("grant_token")
|
from urllib.parse import quote as _quote
|
||||||
if not grant_token or not qs.get("uid"):
|
if request.path.startswith("/auth/") or request.path.startswith("/static/"):
|
||||||
return
|
|
||||||
if request.path.startswith("/auth/"):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check Redis cache first (avoid hammering account on every request)
|
uid = qs.get("uid")
|
||||||
cache_key = f"grant:{grant_token}"
|
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
|
from shared.browser.app.redis_cacher import get_redis
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
|
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:
|
if redis:
|
||||||
cached = await redis.get(cache_key)
|
cached = await redis.get(cache_key)
|
||||||
if cached == b"ok":
|
if cached == b"ok":
|
||||||
@@ -148,9 +154,6 @@ def create_base_app(
|
|||||||
qs.pop("cart_sid", None)
|
qs.pop("cart_sid", None)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Call account's internal endpoint
|
|
||||||
import os, aiohttp
|
|
||||||
account_internal = (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/")
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as http:
|
async with aiohttp.ClientSession() as http:
|
||||||
async with http.get(
|
async with http.get(
|
||||||
@@ -161,16 +164,43 @@ def create_base_app(
|
|||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
valid = data.get("valid", False)
|
valid = data.get("valid", False)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If account is unreachable, don't log user out
|
return # account unreachable — don't log user out
|
||||||
return
|
|
||||||
|
|
||||||
if redis:
|
if redis:
|
||||||
await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
|
await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
|
||||||
|
|
||||||
if not valid:
|
if not valid:
|
||||||
qs.pop("uid", None)
|
qs.pop("uid", None)
|
||||||
qs.pop("grant_token", None)
|
qs.pop("grant_token", None)
|
||||||
qs.pop("cart_sid", None)
|
qs.pop("cart_sid", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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='')}")
|
||||||
|
|
||||||
|
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
|
@app.before_request
|
||||||
async def _csrf_protect():
|
async def _csrf_protect():
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Each client app gets /auth/login, /auth/callback, /auth/logout.
|
Each client app gets /auth/login, /auth/callback, /auth/logout.
|
||||||
Account is the OAuth authorization server.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,6 +19,7 @@ from quart import (
|
|||||||
session as qsession,
|
session as qsession,
|
||||||
g,
|
g,
|
||||||
current_app,
|
current_app,
|
||||||
|
make_response,
|
||||||
)
|
)
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -30,14 +34,30 @@ SESSION_USER_KEY = "uid"
|
|||||||
GRANT_TOKEN_KEY = "grant_token"
|
GRANT_TOKEN_KEY = "grant_token"
|
||||||
|
|
||||||
|
|
||||||
|
def _device_cookie_name(app_name: str) -> str:
|
||||||
|
return f"{app_name}_did"
|
||||||
|
|
||||||
|
|
||||||
def _internal_account_url() -> str:
|
def _internal_account_url() -> str:
|
||||||
"""Internal URL for account service (Docker network)."""
|
|
||||||
return (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/")
|
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:
|
||||||
"""Return an OAuth client blueprint for *app_name*."""
|
"""Return an OAuth client blueprint for *app_name*."""
|
||||||
bp = Blueprint("oauth_auth", __name__, url_prefix="/auth")
|
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")
|
||||||
@bp.get("/login/")
|
@bp.get("/login/")
|
||||||
@@ -47,10 +67,12 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
|||||||
qsession["oauth_state"] = state
|
qsession["oauth_state"] = state
|
||||||
qsession["oauth_next"] = next_url
|
qsession["oauth_next"] = next_url
|
||||||
|
|
||||||
|
device_id = request.cookies.get(cookie_name, "")
|
||||||
redirect_uri = app_url(app_name, "/auth/callback")
|
redirect_uri = app_url(app_name, "/auth/callback")
|
||||||
authorize_url = account_url(
|
authorize_url = account_url(
|
||||||
f"/auth/oauth/authorize?client_id={app_name}"
|
f"/auth/oauth/authorize?client_id={app_name}"
|
||||||
f"&redirect_uri={redirect_uri}"
|
f"&redirect_uri={redirect_uri}"
|
||||||
|
f"&device_id={device_id}"
|
||||||
f"&state={state}"
|
f"&state={state}"
|
||||||
)
|
)
|
||||||
return redirect(authorize_url)
|
return redirect(authorize_url)
|
||||||
@@ -135,8 +157,9 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
|||||||
async def clear():
|
async def clear():
|
||||||
"""One-time migration helper: clear all session cookies."""
|
"""One-time migration helper: clear all session cookies."""
|
||||||
qsession.clear()
|
qsession.clear()
|
||||||
resp = redirect("/")
|
resp = await make_response(redirect("/"))
|
||||||
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
|
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
|
||||||
|
resp.delete_cookie(cookie_name, path="/")
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@bp.post("/logout")
|
@bp.post("/logout")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class OAuthGrant(Base):
|
|||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
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)
|
client_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
issuer_session: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
issuer_session: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=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())
|
||||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
@@ -27,4 +28,5 @@ class OAuthGrant(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_oauth_grant_token", "token", unique=True),
|
Index("ix_oauth_grant_token", "token", unique=True),
|
||||||
Index("ix_oauth_grant_issuer", "issuer_session"),
|
Index("ix_oauth_grant_issuer", "issuer_session"),
|
||||||
|
Index("ix_oauth_grant_device", "device_id", "client_id"),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user