Device-id SSO: account sets did, signals login via Redis

- Factory: set {name}_did cookie for all apps (including account)
  via before_request/after_request hooks (g.device_id always available)
- Factory: _check_auth_state checks did_auth:{account_did} in Redis
  to override stale "not logged in" cache when account login detected
- OAuth: removed _ensure_device_cookie (moved to factory), callback
  stores account_did from authorize redirect in session
- OAuth: login uses g.device_id, logout clears _account_did

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 14:57:40 +00:00
parent c4590d1442
commit cad528d732
2 changed files with 50 additions and 28 deletions

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import secrets
from pathlib import Path from pathlib import Path
from typing import Callable, Awaitable, Sequence from typing import Callable, Awaitable, Sequence
@@ -107,6 +108,29 @@ def create_base_app(
from shared.infrastructure.oauth import create_oauth_blueprint from shared.infrastructure.oauth import create_oauth_blueprint
app.register_blueprint(create_oauth_blueprint(name)) app.register_blueprint(create_oauth_blueprint(name))
# --- device id (all apps, including account) ---
_did_cookie = f"{name}_did"
@app.before_request
async def _init_device_id():
did = request.cookies.get(_did_cookie)
if did:
g.device_id = did
g._new_device_id = False
else:
g.device_id = secrets.token_urlsafe(32)
g._new_device_id = True
@app.after_request
async def _set_device_cookie(response):
if getattr(g, "_new_device_id", False):
response.set_cookie(
_did_cookie, g.device_id,
max_age=30 * 24 * 3600,
secure=True, samesite="Lax", httponly=True,
)
return response
# --- before-request hooks --- # --- before-request hooks ---
@app.before_request @app.before_request
async def _route_log(): async def _route_log():
@@ -176,10 +200,25 @@ def create_base_app(
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
return return
import time as _time import time as _time
now = _time.time()
pnone_at = qs.get("_pnone_at") pnone_at = qs.get("_pnone_at")
if pnone_at and (_time.time() - pnone_at) < 300:
# Check if account signalled a login after we cached "not logged in"
account_did = qs.get("_account_did")
if account_did and redis and pnone_at:
auth_ts = await redis.get(f"did_auth:{account_did}")
if auth_ts:
try:
if float(auth_ts) > pnone_at:
# Login on account after our cache — re-check now
qs.pop("_pnone_at", None)
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
except (ValueError, TypeError):
pass
if pnone_at and (now - pnone_at) < 300:
return return
device_id = request.cookies.get(f"{name}_did") device_id = g.device_id
if device_id and redis: if device_id and redis:
cached = await redis.get(f"prompt:{name}:{device_id}") cached = await redis.get(f"prompt:{name}:{device_id}")
if cached == b"none": if cached == b"none":

View File

@@ -8,7 +8,6 @@ client apps can detect login/logout without cross-domain cookies.
""" """
from __future__ import annotations from __future__ import annotations
import os
import secrets import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -24,7 +23,6 @@ from quart import (
from sqlalchemy import select from sqlalchemy import select
from shared.db.session import get_session from shared.db.session import get_session
from shared.models import User
from shared.models.oauth_code import OAuthCode from shared.models.oauth_code import OAuthCode
from shared.infrastructure.urls import account_url, app_url from shared.infrastructure.urls import account_url, app_url
from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.cart_identity import current_cart_identity
@@ -34,30 +32,9 @@ 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:
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/")
@@ -68,7 +45,7 @@ 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, "") device_id = g.device_id
redirect_uri = app_url(app_name, "/auth/callback") redirect_uri = app_url(app_name, "/auth/callback")
params = ( params = (
f"?client_id={app_name}" f"?client_id={app_name}"
@@ -84,6 +61,11 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
@bp.get("/callback") @bp.get("/callback")
@bp.get("/callback/") @bp.get("/callback/")
async def callback(): async def callback():
# Always store account_did when account passes it back
account_did = request.args.get("account_did", "")
if account_did:
qsession["_account_did"] = account_did
# Handle prompt=none error (user not logged in on account) # Handle prompt=none error (user not logged in on account)
error = request.args.get("error") error = request.args.get("error")
if error == "login_required": if error == "login_required":
@@ -91,7 +73,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
qsession.pop("oauth_state", None) qsession.pop("oauth_state", None)
import time as _time import time as _time
qsession["_pnone_at"] = _time.time() qsession["_pnone_at"] = _time.time()
device_id = request.cookies.get(cookie_name, "") device_id = g.device_id
if device_id: if device_id:
from shared.browser.app.redis_cacher import get_redis from shared.browser.app.redis_cacher import get_redis
_redis = get_redis() _redis = get_redis()
@@ -181,7 +163,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
qsession.clear() qsession.clear()
resp = await make_response(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="/") resp.delete_cookie(f"{app_name}_did", path="/")
return resp return resp
@bp.post("/logout") @bp.post("/logout")
@@ -191,6 +173,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
qsession.pop(GRANT_TOKEN_KEY, None) qsession.pop(GRANT_TOKEN_KEY, None)
qsession.pop("cart_sid", None) qsession.pop("cart_sid", None)
qsession.pop("_pnone_at", None) qsession.pop("_pnone_at", None)
qsession.pop("_account_did", None)
# Redirect through account to revoke grants + clear account session # Redirect through account to revoke grants + clear account session
return redirect(account_url("/auth/sso-logout/")) return redirect(account_url("/auth/sso-logout/"))