Skip to content

Commit 77dc661

Browse files
GkGk
authored andcommitted
fix(login): handle existing-user poll response without api_key
`cueapi login` crashed with KeyError on every existing-user login since backend commit adbfe77 changed POST /v1/auth/device-code/poll to omit `api_key` when the user's api_key_encrypted couldn't be decrypted. The CLI blindly did `poll_data["api_key"]` at line 71 and blew up. New flow -------- When poll approves without an inline api_key, resolve it via the session-token → JWT → reveal-key handshake: 1. Exchange the one-time session_token for a JWT via POST /v1/auth/session. 2. Call GET /v1/auth/key with the JWT as a Bearer token — the server decrypts and returns the plaintext api_key. 3. Save it as the new-user path would. Edge cases the CLI now handles with actionable messages (instead of traces): - Poll has neither api_key nor session_token → "try again or contact support" - /auth/session returns non-200 → surface status + "try again" - /auth/key returns 410 plaintext_unavailable → tell user to run `cueapi key regenerate` (the only remedy; the stored plaintext is gone and the only path forward is a fresh key) - /auth/key returns any other non-200 → surface status + "try again" Behavior unchanged for new users — the inline api_key path runs exactly as before. Subtle UX tweak --------------- For existing users we no longer reprint their full api_key to the terminal. They already have it from first signup; printing it again leaks it to their scrollback for no reason. New users still see the plaintext once because that's literally the only time they'll ever see it. Tests (tests/test_login_existing_user.py — 6 new) ------------------------------------------------- - new user → saves api_key from poll, skips session exchange - existing user with inline key → saves it, skips session exchange (session_token can be present; we just don't need to use it) - existing user without api_key → exchanges session_token, calls /auth/key with Bearer jwt, saves revealed api_key - /auth/key returns 410 → "cueapi key regenerate" guidance + no partial credential written - /auth/session fails → clear error + no partial credential - poll approves with neither api_key nor session_token → actionable error, no crash All 25 tests pass (19 pre-existing + 6 new).
1 parent e646c51 commit 77dc661

2 files changed

Lines changed: 322 additions & 3 deletions

File tree

cueapi/auth.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,70 @@ def _generate_device_code() -> str:
2828
return f"{part1}-{part2}"
2929

3030

31+
def _resolve_key_via_session(client, poll_data: dict) -> Optional[str]:
32+
"""Exchange a one-time session_token for a JWT, then call
33+
GET /v1/auth/key to reveal the user's stored api_key plaintext.
34+
35+
Used for the existing-user login path where poll_data omits
36+
``api_key`` (the server couldn't decrypt it, or the record
37+
predates encrypted storage). Returns the plaintext api_key on
38+
success, None on failure — errors are echoed to the user with
39+
actionable next steps.
40+
"""
41+
session_token = poll_data.get("session_token")
42+
if not session_token:
43+
# No key AND no session token — poll response is malformed or
44+
# this user hasn't been upgraded to the session-token flow.
45+
# Shouldn't happen for any production code path as of 2026-04-19
46+
# (commit adbfe77) but we still want an actionable message.
47+
click.echo()
48+
echo_error(
49+
"Login approved but the server didn't return an api_key or "
50+
"session_token. Try `cueapi login` again, or contact support."
51+
)
52+
return None
53+
54+
# Exchange session_token (single-use) for a JWT bearer.
55+
exchange = client.post("/auth/session", json={"token": session_token})
56+
if exchange.status_code != 200:
57+
click.echo()
58+
echo_error(
59+
f"Could not finalize login (session exchange HTTP "
60+
f"{exchange.status_code}). Run `cueapi login` to try again."
61+
)
62+
return None
63+
jwt = exchange.json().get("session_token")
64+
if not jwt:
65+
click.echo()
66+
echo_error("Login response missing session_token. Try again.")
67+
return None
68+
69+
# Use the JWT as a bearer to reveal the decrypted api_key. /auth/key
70+
# returns 410 plaintext_unavailable if the encrypted column is empty
71+
# (no reversible copy exists) — in that case the only remedy is a
72+
# key rotation, so we surface that guidance directly.
73+
reveal = client.get(
74+
"/auth/key",
75+
headers={"Authorization": f"Bearer {jwt}"},
76+
)
77+
if reveal.status_code == 200:
78+
return reveal.json().get("api_key")
79+
if reveal.status_code == 410:
80+
click.echo()
81+
echo_error(
82+
"Your API key can't be recovered on this device — the stored "
83+
"plaintext is no longer available. Run `cueapi key regenerate` "
84+
"to mint a fresh key, then `cueapi login` again."
85+
)
86+
return None
87+
click.echo()
88+
echo_error(
89+
f"Could not retrieve your api_key (HTTP {reveal.status_code}). "
90+
"Run `cueapi login` to try again or contact support."
91+
)
92+
return None
93+
94+
3195
def do_login(api_base: Optional[str] = None, profile: str = "default") -> None:
3296
"""Run the device code login flow."""
3397
base = api_base or resolve_api_base(profile=profile)
@@ -68,9 +132,28 @@ def do_login(api_base: Optional[str] = None, profile: str = "default") -> None:
68132
status = poll_data.get("status")
69133

70134
if status == "approved":
71-
api_key = poll_data["api_key"]
72135
email = poll_data["email"]
73136

137+
# Resolve the plaintext api_key from the response.
138+
# Shape varies by user type (server-side logic lives in
139+
# app/services/device_code_service.py::poll_device_code):
140+
# - New user: poll_data contains "api_key" directly.
141+
# - Existing user whose api_key_encrypted decrypts:
142+
# poll_data ALSO contains "api_key".
143+
# - Existing user whose decryption failed or whose
144+
# record predates encrypted-storage: poll_data has
145+
# NO "api_key", only "session_token" +
146+
# "existing_user": true. The CLI must exchange the
147+
# session token for a JWT and then call
148+
# GET /v1/auth/key to reveal the stored plaintext.
149+
api_key = poll_data.get("api_key")
150+
if not api_key:
151+
api_key = _resolve_key_via_session(client, poll_data)
152+
if not api_key:
153+
# _resolve_key_via_session already printed a
154+
# user-facing error + next-step guidance.
155+
return
156+
74157
# Save credentials
75158
save_credentials(
76159
profile=profile,
@@ -84,8 +167,15 @@ def do_login(api_base: Optional[str] = None, profile: str = "default") -> None:
84167
click.echo()
85168
echo_success(f"Authenticated as {email}")
86169
click.echo(f"API key stored in credentials file.\n")
87-
click.echo(f"Your API key: {api_key}")
88-
click.echo("(This is the only time your full key will be shown. Save it if you need it elsewhere.)\n")
170+
# Only show the key plaintext for new users. For existing
171+
# users it's already on their record from first signup —
172+
# reprinting it here is a pointless exfil risk (their
173+
# terminal scrollback, screen-share, etc.).
174+
if poll_data.get("existing_user"):
175+
click.echo(f"Welcome back, {email}.")
176+
else:
177+
click.echo(f"Your API key: {api_key}")
178+
click.echo("(This is the only time your full key will be shown. Save it if you need it elsewhere.)\n")
89179
click.echo('Run `cueapi quickstart` to create your first cue.')
90180
return
91181

tests/test_login_existing_user.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""Tests for the existing-user login path in cueapi.auth.do_login.
2+
3+
Backend commit adbfe77 changed POST /v1/auth/device-code/poll so the
4+
response can approve a login WITHOUT returning an api_key (for
5+
existing users whose encrypted record couldn't be decrypted). Before
6+
this fix, do_login did `poll_data["api_key"]` and crashed with
7+
KeyError on every existing-user login. These tests pin the new flow
8+
so the regression can't return silently.
9+
"""
10+
from __future__ import annotations
11+
12+
from unittest.mock import MagicMock, patch
13+
14+
import pytest
15+
16+
from cueapi import auth
17+
18+
19+
@pytest.fixture
20+
def poll_responses(monkeypatch):
21+
"""Feed a scripted series of UnauthClient.get / .post responses.
22+
23+
Returns (script, used). `script` is a list the caller mutates
24+
to specify response shape; `used` captures what the test code
25+
actually called, for assertion.
26+
"""
27+
script = {"post": [], "get": []}
28+
used = {"post": [], "get": []}
29+
30+
def _make_response(status_code: int, body: dict | None = None):
31+
r = MagicMock()
32+
r.status_code = status_code
33+
r.json.return_value = body or {}
34+
return r
35+
36+
class FakeUnauth:
37+
def __init__(self, *args, **kwargs):
38+
pass
39+
def __enter__(self):
40+
return self
41+
def __exit__(self, *a):
42+
pass
43+
def post(self, path, **kwargs):
44+
used["post"].append({"path": path, **kwargs})
45+
sc, body = script["post"].pop(0)
46+
return _make_response(sc, body)
47+
def get(self, path, **kwargs):
48+
used["get"].append({"path": path, **kwargs})
49+
sc, body = script["get"].pop(0)
50+
return _make_response(sc, body)
51+
52+
monkeypatch.setattr(auth, "UnauthClient", FakeUnauth)
53+
# Skip the browser open and sleep so tests run instantly.
54+
monkeypatch.setattr(auth, "webbrowser", MagicMock())
55+
monkeypatch.setattr(auth.time, "sleep", lambda _s: None)
56+
57+
return script, used
58+
59+
60+
@pytest.fixture
61+
def captured_saves(monkeypatch):
62+
"""Capture save_credentials calls so we can assert what got stored."""
63+
calls = []
64+
65+
def _capture(**kwargs):
66+
calls.append(kwargs)
67+
68+
monkeypatch.setattr(auth, "save_credentials", _capture)
69+
monkeypatch.setattr(auth, "resolve_api_base", lambda profile=None: "https://api.cueapi.ai/v1")
70+
return calls
71+
72+
73+
class TestNewUserLogin:
74+
"""New-user poll responses include api_key directly — unchanged
75+
behavior, pinned so the refactor didn't break the happy path."""
76+
77+
def test_new_user_saves_api_key_from_poll(
78+
self, poll_responses, captured_saves
79+
):
80+
script, used = poll_responses
81+
# Sequence: device-code create → poll approved with api_key
82+
script["post"] = [
83+
(201, {"verification_url": "https://example/verify", "expires_in": 60}),
84+
(200, {"status": "approved", "api_key": "cue_sk_new_user_123", "email": "new@example.com"}),
85+
]
86+
87+
auth.do_login(api_base="https://api.cueapi.ai/v1", profile="default")
88+
89+
assert len(captured_saves) == 1
90+
assert captured_saves[0]["data"]["api_key"] == "cue_sk_new_user_123"
91+
assert captured_saves[0]["data"]["email"] == "new@example.com"
92+
# Session exchange must NOT have been called — new user path
93+
# has the key inline.
94+
assert not any(c["path"] == "/auth/session" for c in used["post"])
95+
assert not any(c["path"] == "/auth/key" for c in used["get"])
96+
97+
98+
class TestExistingUserDecryptSucceeded:
99+
"""Server was able to decrypt the stored api_key_encrypted —
100+
poll returns api_key + existing_user=True."""
101+
102+
def test_existing_user_with_inline_key_skips_session_exchange(
103+
self, poll_responses, captured_saves
104+
):
105+
script, used = poll_responses
106+
script["post"] = [
107+
(201, {"verification_url": "https://example/verify", "expires_in": 60}),
108+
(200, {
109+
"status": "approved",
110+
"api_key": "cue_sk_existing_decrypted",
111+
"email": "old@example.com",
112+
"existing_user": True,
113+
"session_token": "stk_abc",
114+
}),
115+
]
116+
117+
auth.do_login(api_base="https://api.cueapi.ai/v1", profile="default")
118+
119+
assert captured_saves[0]["data"]["api_key"] == "cue_sk_existing_decrypted"
120+
# session_token is present in the poll response but we have the
121+
# key already — no need to hit /auth/session or /auth/key.
122+
assert not any(c["path"] == "/auth/session" for c in used["post"])
123+
assert not any(c["path"] == "/auth/key" for c in used["get"])
124+
125+
126+
class TestExistingUserNeedsSessionExchange:
127+
"""Server couldn't decrypt — poll omits api_key. CLI must exchange
128+
session_token for JWT, then call GET /v1/auth/key."""
129+
130+
def test_falls_back_to_session_exchange_then_reveal(
131+
self, poll_responses, captured_saves
132+
):
133+
script, used = poll_responses
134+
script["post"] = [
135+
# create device code
136+
(201, {"verification_url": "https://example/verify", "expires_in": 60}),
137+
# poll approved — no api_key, but session_token present
138+
(200, {
139+
"status": "approved",
140+
"email": "existing@example.com",
141+
"session_token": "stk_one_time",
142+
"existing_user": True,
143+
}),
144+
# /auth/session → JWT
145+
(200, {"session_token": "jwt_payload_here", "email": "existing@example.com"}),
146+
]
147+
script["get"] = [
148+
# /auth/key with Bearer jwt → api_key
149+
(200, {"api_key": "cue_sk_revealed_via_jwt"}),
150+
]
151+
152+
auth.do_login(api_base="https://api.cueapi.ai/v1", profile="default")
153+
154+
# Session exchange happened with the single-use token.
155+
session_calls = [c for c in used["post"] if c["path"] == "/auth/session"]
156+
assert len(session_calls) == 1
157+
assert session_calls[0]["json"]["token"] == "stk_one_time"
158+
159+
# /auth/key was called with the JWT as Bearer.
160+
reveal_calls = [c for c in used["get"] if c["path"] == "/auth/key"]
161+
assert len(reveal_calls) == 1
162+
assert reveal_calls[0]["headers"]["Authorization"] == "Bearer jwt_payload_here"
163+
164+
# Saved the revealed key, not the session_token.
165+
assert captured_saves[0]["data"]["api_key"] == "cue_sk_revealed_via_jwt"
166+
167+
168+
class TestFailureModes:
169+
"""echo_error in cueapi.formatting raises SystemExit(1), so these
170+
failure-path tests wrap do_login in pytest.raises(SystemExit) and
171+
read the actionable message from stderr (not stdout)."""
172+
173+
def test_reveal_returns_410_shows_regenerate_hint(
174+
self, poll_responses, captured_saves, capsys
175+
):
176+
"""plaintext_unavailable (410) must be translated into a clear
177+
"run cueapi key regenerate" message, not a cryptic stacktrace."""
178+
script, used = poll_responses
179+
script["post"] = [
180+
(201, {"verification_url": "https://example/verify", "expires_in": 60}),
181+
(200, {"status": "approved", "email": "gone@example.com",
182+
"session_token": "stk_x", "existing_user": True}),
183+
(200, {"session_token": "jwt_x", "email": "gone@example.com"}),
184+
]
185+
script["get"] = [(410, {"error": {"code": "plaintext_unavailable"}})]
186+
187+
with pytest.raises(SystemExit):
188+
auth.do_login(api_base="https://api.cueapi.ai/v1", profile="default")
189+
190+
assert captured_saves == []
191+
err = capsys.readouterr().err
192+
assert "cueapi key regenerate" in err
193+
194+
def test_session_exchange_failure_surfaces_error(
195+
self, poll_responses, captured_saves, capsys
196+
):
197+
script, used = poll_responses
198+
script["post"] = [
199+
(201, {"verification_url": "https://example/verify", "expires_in": 60}),
200+
(200, {"status": "approved", "email": "x@example.com",
201+
"session_token": "stk_x", "existing_user": True}),
202+
# /auth/session fails
203+
(500, {"error": {"code": "session_unavailable"}}),
204+
]
205+
206+
with pytest.raises(SystemExit):
207+
auth.do_login(api_base="https://api.cueapi.ai/v1", profile="default")
208+
209+
assert captured_saves == []
210+
err = capsys.readouterr().err
211+
assert "500" in err or "try again" in err.lower()
212+
213+
def test_approved_without_any_key_or_token_shows_actionable_error(
214+
self, poll_responses, captured_saves, capsys
215+
):
216+
"""Defensive: if the server returns neither api_key nor
217+
session_token we don't crash — we tell the user what to do."""
218+
script, used = poll_responses
219+
script["post"] = [
220+
(201, {"verification_url": "https://example/verify", "expires_in": 60}),
221+
(200, {"status": "approved", "email": "x@example.com"}),
222+
]
223+
224+
with pytest.raises(SystemExit):
225+
auth.do_login(api_base="https://api.cueapi.ai/v1", profile="default")
226+
227+
assert captured_saves == []
228+
err = capsys.readouterr().err
229+
assert "api_key" in err or "session_token" in err or "support" in err

0 commit comments

Comments
 (0)