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