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", "")
|
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
2
shared
Submodule shared updated: 6bb26522a1...de93dfdc73
Reference in New Issue
Block a user