From c3ba28ea0368cb29b3709749d5335bf242d2b394 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 25 Feb 2026 19:41:09 +0000 Subject: [PATCH] Add device authorization flow (RFC 8628) for CLI login Implements the device code grant flow so artdag CLI can authenticate via browser approval. Includes device/authorize, device/token endpoints, user code verification page, and approval confirmation template. Co-Authored-By: Claude Opus 4.6 --- account/bp/auth/routes.py | 232 +++++++++++++++++++- account/templates/auth/device.html | 41 ++++ account/templates/auth/device_approved.html | 9 + 3 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 account/templates/auth/device.html create mode 100644 account/templates/auth/device_approved.html diff --git a/account/bp/auth/routes.py b/account/bp/auth/routes.py index 4cbee59..c226e25 100644 --- a/account/bp/auth/routes.py +++ b/account/bp/auth/routes.py @@ -5,6 +5,7 @@ OAuth2 authorize endpoint, grant verification, and SSO logout. """ from __future__ import annotations +import json import secrets from datetime import datetime, timezone, timedelta @@ -202,7 +203,13 @@ def register(url_prefix="/auth"): ) if not grant or grant.revoked_at is not None: return jsonify({"valid": False}), 200 - return jsonify({"valid": True}), 200 + user = await s.get(User, grant.user_id) + return jsonify({ + "valid": True, + "user_id": grant.user_id, + "username": user.email if user else "", + "display_name": user.name if user else "", + }), 200 @auth_bp.get("/internal/check-device") async def check_device(): @@ -480,4 +487,227 @@ def register(url_prefix="/auth"): resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/") return resp + # --- Device Authorization Flow (RFC 8628) --------------------------------- + + _DEVICE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ" + _DEVICE_CODE_TTL = 900 # 15 minutes + _DEVICE_POLL_INTERVAL = 5 + + def _generate_user_code() -> str: + """Generate an unambiguous 8-char user code like KBMN-TWRP.""" + chars = [secrets.choice(_DEVICE_ALPHABET) for _ in range(8)] + return "".join(chars[:4]) + "-" + "".join(chars[4:]) + + async def _approve_device(device_code: str, user) -> bool: + """Approve a pending device flow and create an OAuthGrant.""" + from shared.infrastructure.auth_redis import get_auth_redis + + r = await get_auth_redis() + raw = await r.get(f"devflow:{device_code}") + if not raw: + return False + + blob = json.loads(raw) + if blob.get("status") != "pending": + return False + + account_sid = qsession.get(ACCOUNT_SESSION_KEY) + if not account_sid: + account_sid = secrets.token_urlsafe(32) + qsession[ACCOUNT_SESSION_KEY] = account_sid + + grant_token = secrets.token_urlsafe(48) + + async with get_session() as s: + async with s.begin(): + grant = OAuthGrant( + token=grant_token, + user_id=user.id, + client_id=blob["client_id"], + issuer_session=account_sid, + ) + s.add(grant) + + # Update Redis blob + blob["status"] = "approved" + blob["user_id"] = user.id + blob["grant_token"] = grant_token + user_code = blob["user_code"] + + ttl = await r.ttl(f"devflow:{device_code}") + if ttl and ttl > 0: + await r.set(f"devflow:{device_code}", json.dumps(blob).encode(), ex=ttl) + else: + await r.set(f"devflow:{device_code}", json.dumps(blob).encode(), ex=_DEVICE_CODE_TTL) + + # Remove reverse lookup (code already used) + normalized_uc = user_code.replace("-", "").upper() + await r.delete(f"devflow_uc:{normalized_uc}") + + return True + + @csrf_exempt + @auth_bp.post("/device/authorize") + @auth_bp.post("/device/authorize/") + async def device_authorize(): + """RFC 8628 — CLI requests a device code.""" + data = await request.get_json(silent=True) or {} + client_id = data.get("client_id", "") + + if client_id not in ALLOWED_CLIENTS: + return jsonify({"error": "invalid_client"}), 400 + + device_code = secrets.token_urlsafe(32) + user_code = _generate_user_code() + + from shared.infrastructure.auth_redis import get_auth_redis + + r = await get_auth_redis() + + blob = json.dumps({ + "client_id": client_id, + "user_code": user_code, + "status": "pending", + "user_id": None, + "grant_token": None, + }).encode() + + normalized_uc = user_code.replace("-", "").upper() + pipe = r.pipeline() + pipe.set(f"devflow:{device_code}", blob, ex=_DEVICE_CODE_TTL) + pipe.set(f"devflow_uc:{normalized_uc}", device_code.encode(), ex=_DEVICE_CODE_TTL) + await pipe.execute() + + verification_uri = account_url("/auth/device") + + return jsonify({ + "device_code": device_code, + "user_code": user_code, + "verification_uri": verification_uri, + "expires_in": _DEVICE_CODE_TTL, + "interval": _DEVICE_POLL_INTERVAL, + }) + + @csrf_exempt + @auth_bp.post("/device/token") + @auth_bp.post("/device/token/") + async def device_token(): + """RFC 8628 — CLI polls for the grant token.""" + data = await request.get_json(silent=True) or {} + device_code = data.get("device_code", "") + client_id = data.get("client_id", "") + + if not device_code or client_id not in ALLOWED_CLIENTS: + return jsonify({"error": "invalid_request"}), 400 + + from shared.infrastructure.auth_redis import get_auth_redis + + r = await get_auth_redis() + raw = await r.get(f"devflow:{device_code}") + if not raw: + return jsonify({"error": "expired_token"}), 400 + + blob = json.loads(raw) + + if blob.get("client_id") != client_id: + return jsonify({"error": "invalid_request"}), 400 + + if blob["status"] == "pending": + return jsonify({"error": "authorization_pending"}), 428 + + if blob["status"] == "denied": + return jsonify({"error": "access_denied"}), 400 + + if blob["status"] == "approved": + async with get_session() as s: + user = await s.get(User, blob["user_id"]) + if not user: + return jsonify({"error": "access_denied"}), 400 + + # Clean up Redis + await r.delete(f"devflow:{device_code}") + + return jsonify({ + "access_token": blob["grant_token"], + "token_type": "bearer", + "user_id": blob["user_id"], + "username": user.email or "", + "display_name": user.name or "", + }) + + return jsonify({"error": "invalid_request"}), 400 + + @auth_bp.get("/device") + @auth_bp.get("/device/") + async def device_form(): + """Browser form where user enters the code displayed in terminal.""" + code = request.args.get("code", "") + return await render_template("auth/device.html", code=code) + + @auth_bp.post("/device") + @auth_bp.post("/device/") + async def device_submit(): + """Browser submit — validates code, approves if logged in.""" + form = await request.form + user_code = (form.get("code") or "").strip().replace("-", "").upper() + + if not user_code or len(user_code) != 8: + return await render_template( + "auth/device.html", + error="Please enter a valid 8-character code.", + code=form.get("code", ""), + ), 400 + + from shared.infrastructure.auth_redis import get_auth_redis + + r = await get_auth_redis() + device_code = await r.get(f"devflow_uc:{user_code}") + if not device_code: + return await render_template( + "auth/device.html", + error="Code not found or expired. Please try again.", + code=form.get("code", ""), + ), 400 + + if isinstance(device_code, bytes): + device_code = device_code.decode() + + # Not logged in — redirect to login, then come back to complete + if not g.get("user"): + complete_url = url_for("auth.device_complete", code=device_code) + store_login_redirect_target() + return redirect(url_for("auth.login_form", next=complete_url)) + + # Logged in — approve immediately + ok = await _approve_device(device_code, g.user) + if not ok: + return await render_template( + "auth/device.html", + error="Code expired or already used.", + ), 400 + + return await render_template("auth/device_approved.html") + + @auth_bp.get("/device/complete") + @auth_bp.get("/device/complete/") + async def device_complete(): + """Post-login redirect — completes approval after magic link auth.""" + device_code = request.args.get("code", "") + + if not device_code: + return redirect(url_for("auth.device_form")) + + if not g.get("user"): + store_login_redirect_target() + return redirect(url_for("auth.login_form")) + + ok = await _approve_device(device_code, g.user) + if not ok: + return await render_template( + "auth/device.html", + error="Code expired or already used. Please start the login process again in your terminal.", + ), 400 + + return await render_template("auth/device_approved.html") + return auth_bp diff --git a/account/templates/auth/device.html b/account/templates/auth/device.html new file mode 100644 index 0000000..9ed704e --- /dev/null +++ b/account/templates/auth/device.html @@ -0,0 +1,41 @@ +{% extends "_types/root/_index.html" %} +{% block meta %}{% endblock %} +{% block title %}Authorize Device — Rose Ash{% endblock %} +{% block content %} +
+

Authorize device

+

Enter the code shown in your terminal to sign in.

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ +
+ + +
+ +
+
+{% endblock %} diff --git a/account/templates/auth/device_approved.html b/account/templates/auth/device_approved.html new file mode 100644 index 0000000..ee052a9 --- /dev/null +++ b/account/templates/auth/device_approved.html @@ -0,0 +1,9 @@ +{% extends "_types/root/_index.html" %} +{% block meta %}{% endblock %} +{% block title %}Device Authorized — Rose Ash{% endblock %} +{% block content %} +
+

Device authorized

+

You can close this window and return to your terminal.

+
+{% endblock %}