Device cookie auth + check-device endpoint, remove propagation chain
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s

OAuth authorize stores device_id on grants. New /internal/check-device
endpoint lets client apps detect login/logout by checking device's
grant state + user.last_login_at. Propagation chain removed — each
app detects auth changes independently via its device cookie.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 12:57:44 +00:00
parent 1cd11b9a2d
commit b847e10949
2 changed files with 43 additions and 50 deletions

View File

@@ -57,6 +57,7 @@ def register(url_prefix="/auth"):
client_id = request.args.get("client_id", "") client_id = request.args.get("client_id", "")
redirect_uri = request.args.get("redirect_uri", "") redirect_uri = request.args.get("redirect_uri", "")
state = request.args.get("state", "") state = request.args.get("state", "")
device_id = request.args.get("device_id", "")
if client_id not in ALLOWED_CLIENTS: if client_id not in ALLOWED_CLIENTS:
return "Invalid client_id", 400 return "Invalid client_id", 400
@@ -89,6 +90,7 @@ def register(url_prefix="/auth"):
user_id=g.user.id, user_id=g.user.id,
client_id=client_id, client_id=client_id,
issuer_session=account_sid, issuer_session=account_sid,
device_id=device_id or None,
) )
s.add(grant) s.add(grant)
@@ -122,6 +124,45 @@ def register(url_prefix="/auth"):
return jsonify({"valid": False}), 200 return jsonify({"valid": False}), 200
return jsonify({"valid": True}), 200 return jsonify({"valid": True}), 200
@auth_bp.get("/internal/check-device")
async def check_device():
"""Called by client apps to check if a device has an active auth.
Looks up the most recent grant for (device_id, client_id).
If the grant is active → {active: true}.
If revoked but user has logged in since → {active: true} (re-auth needed).
Otherwise → {active: false}.
"""
device_id = request.args.get("device_id", "")
app_name = request.args.get("app", "")
if not device_id or not app_name:
return jsonify({"active": False}), 200
async with get_session() as s:
# Find the most recent grant for this device + app
result = await s.execute(
select(OAuthGrant)
.where(OAuthGrant.device_id == device_id)
.where(OAuthGrant.client_id == app_name)
.order_by(OAuthGrant.created_at.desc())
.limit(1)
)
grant = result.scalar_one_or_none()
if not grant:
return jsonify({"active": False}), 200
# Grant still active
if grant.revoked_at is None:
return jsonify({"active": True}), 200
# Grant revoked — check if user logged in since
user = await s.get(User, grant.user_id)
if user and user.last_login_at and user.last_login_at > grant.revoked_at:
return jsonify({"active": True}), 200
return jsonify({"active": False}), 200
# --- Magic link login flow ----------------------------------------------- # --- Magic link login flow -----------------------------------------------
@auth_bp.get("/login/") @auth_bp.get("/login/")
@@ -230,56 +271,8 @@ def register(url_prefix="/auth"):
# Fresh account session ID for grant tracking # Fresh account session ID for grant tracking
qsession[ACCOUNT_SESSION_KEY] = secrets.token_urlsafe(32) qsession[ACCOUNT_SESSION_KEY] = secrets.token_urlsafe(32)
# Propagate login to all client apps via OAuth chain
redirect_url = pop_login_redirect_target() redirect_url = pop_login_redirect_target()
qsession["sso_final"] = redirect_url return redirect(redirect_url, 303)
qsession["sso_chain"] = list(ALLOWED_CLIENTS)
return redirect(url_for("auth.propagate"), 303)
@auth_bp.get("/propagate")
@auth_bp.get("/propagate/")
async def propagate():
"""Chain through each client app's OAuth login to propagate the
account session. Each app does its OAuth flow (instant since
account is already logged in) then redirects back here. When
the chain is empty, redirect to the original target.
Dead apps are skipped via internal health check."""
import os, aiohttp
from urllib.parse import quote
chain = qsession.get("sso_chain", [])
final = qsession.get("sso_final", account_url("/"))
if not g.get("user"):
qsession.pop("sso_chain", None)
qsession.pop("sso_final", None)
return redirect(final)
comeback = account_url("/auth/propagate")
while chain:
next_app = chain.pop(0)
qsession["sso_chain"] = chain
# Health check via internal URL before redirecting
internal = (os.getenv(f"INTERNAL_URL_{next_app.upper()}") or f"http://{next_app}:8000").rstrip("/")
try:
async with aiohttp.ClientSession() as http:
async with http.head(
internal,
timeout=aiohttp.ClientTimeout(total=2),
allow_redirects=True,
) as resp:
if resp.status < 500:
login = app_url(next_app, f"/auth/login?next={quote(comeback, safe='')}")
return redirect(login)
except Exception:
current_app.logger.warning("[propagate] skipping dead app: %s", next_app)
continue
qsession.pop("sso_chain", None)
qsession.pop("sso_final", None)
return redirect(final)
@auth_bp.post("/logout/") @auth_bp.post("/logout/")
async def logout(): async def logout():

2
shared

Submodule shared updated: 6bb26522a1...de93dfdc73