From b847e109491ad003799ca7bf21f558d7afa4ff6f Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 23 Feb 2026 12:57:44 +0000 Subject: [PATCH] Device cookie auth + check-device endpoint, remove propagation chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bp/auth/routes.py | 91 ++++++++++++++++++++++------------------------- shared | 2 +- 2 files changed, 43 insertions(+), 50 deletions(-) diff --git a/bp/auth/routes.py b/bp/auth/routes.py index a497bb2..443e13e 100644 --- a/bp/auth/routes.py +++ b/bp/auth/routes.py @@ -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(): diff --git a/shared b/shared index 6bb2652..de93dfd 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit 6bb26522a127e171045b8b2cca6e4710046ccec5 +Subproject commit de93dfdc7392f43393bc57e456a2620988513dba