Device cookie auth + check-device endpoint, remove propagation chain
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
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:
@@ -57,6 +57,7 @@ def register(url_prefix="/auth"):
|
||||
client_id = request.args.get("client_id", "")
|
||||
redirect_uri = request.args.get("redirect_uri", "")
|
||||
state = request.args.get("state", "")
|
||||
device_id = request.args.get("device_id", "")
|
||||
|
||||
if client_id not in ALLOWED_CLIENTS:
|
||||
return "Invalid client_id", 400
|
||||
@@ -89,6 +90,7 @@ def register(url_prefix="/auth"):
|
||||
user_id=g.user.id,
|
||||
client_id=client_id,
|
||||
issuer_session=account_sid,
|
||||
device_id=device_id or None,
|
||||
)
|
||||
s.add(grant)
|
||||
|
||||
@@ -122,6 +124,45 @@ def register(url_prefix="/auth"):
|
||||
return jsonify({"valid": False}), 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 -----------------------------------------------
|
||||
|
||||
@auth_bp.get("/login/")
|
||||
@@ -230,56 +271,8 @@ def register(url_prefix="/auth"):
|
||||
# Fresh account session ID for grant tracking
|
||||
qsession[ACCOUNT_SESSION_KEY] = secrets.token_urlsafe(32)
|
||||
|
||||
# Propagate login to all client apps via OAuth chain
|
||||
redirect_url = pop_login_redirect_target()
|
||||
qsession["sso_final"] = redirect_url
|
||||
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)
|
||||
return redirect(redirect_url, 303)
|
||||
|
||||
@auth_bp.post("/logout/")
|
||||
async def logout():
|
||||
|
||||
Reference in New Issue
Block a user