Skip to content

Commit b8ea6b5

Browse files
fix: deliver the device-login token after approval
The poll wrote the session with a low-level DynamoDB transaction whose keys were plain strings. The live client rejects those before the request is sent, so the browser can show Device authorized while the CLI never receives a token. Retry with AttributeValues, and include status=approved so an older CLI that waits for that field accepts the session. A new login replaces the stored account, and logout clears a browser session too.
1 parent 296c7d6 commit b8ea6b5

3 files changed

Lines changed: 135 additions & 1 deletion

File tree

‎src/dapier/auth/device_sessions.py‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
import time
4242
from datetime import datetime, timezone
4343

44+
from boto3.dynamodb.types import TypeSerializer
45+
from botocore.exceptions import ParamValidationError
46+
4447
PREFIX = "dapd_"
4548
# No vowels and no confusable characters (no L/V, no 0/1): codes are read
4649
# aloud and retyped by hand, and must never spell a word. This is dakit's
@@ -56,6 +59,43 @@
5659
# Grace window for the rotated-out token after a refresh, so a CLI that
5760
# races itself (or two processes sharing a session file) is not locked out.
5861
ROTATION_GRACE_SECONDS = 120
62+
_SERIALIZER = TypeSerializer()
63+
64+
65+
def _typed_map(mapping):
66+
"""Low-level AttributeValues. The DynamoDB client rejects the plain
67+
values the resource API accepts."""
68+
return {key: _SERIALIZER.serialize(value) for key, value in mapping.items()}
69+
70+
71+
def _low_level_transact(items):
72+
"""The same TransactItems in the shape ``transact_write_items`` validates."""
73+
converted = []
74+
for entry in items:
75+
(op, spec), = entry.items()
76+
spec = dict(spec)
77+
if "Key" in spec:
78+
spec["Key"] = _typed_map(spec["Key"])
79+
if "Item" in spec:
80+
spec["Item"] = _typed_map(spec["Item"])
81+
if spec.get("ExpressionAttributeValues"):
82+
spec["ExpressionAttributeValues"] = _typed_map(spec["ExpressionAttributeValues"])
83+
converted.append({op: spec})
84+
return converted
85+
86+
87+
def _commit_delivery(store, items):
88+
"""Commit the delivery transaction.
89+
90+
Moto accepts the resource-shaped items the rest of this module writes.
91+
The real client rejects them during parameter validation, before
92+
DynamoDB runs, which is why an approved device never produced a token.
93+
On that rejection, send the low-level attribute values.
94+
"""
95+
try:
96+
store.meta.client.transact_write_items(TransactItems=items)
97+
except ParamValidationError:
98+
store.meta.client.transact_write_items(TransactItems=_low_level_transact(items))
5999

60100

61101
def table():
@@ -367,7 +407,7 @@ def poll(device_code, table_ref=None):
367407
expires_at = int(time.time()) + SESSION_TTL_SECONDS
368408
now = int(time.time())
369409
try:
370-
store.meta.client.transact_write_items(TransactItems=[
410+
_commit_delivery(store, [
371411
{"Delete": {
372412
"TableName": store.name,
373413
"Key": key,
@@ -399,6 +439,10 @@ def poll(device_code, table_ref=None):
399439
# removed the user-code pointer.)
400440
return {
401441
"token": token,
442+
# ``status`` is what CLIs that predate the shared poll loop look
443+
# for on a 200. Without it they ignore the token and wait out the
444+
# code. The shared loop treats any 200 without ``error`` as success.
445+
"status": "approved",
402446
"subject": pairing.get("subject", ""),
403447
"email": pairing.get("email", ""),
404448
"expires_at": expires_at,

‎tests/test_cli.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,45 @@ def poster(url, body, bearer=None, timeout=20):
9191
return poster, calls
9292

9393

94+
def test_login_device_replaces_the_stored_account(isolated_home, monkeypatch):
95+
"""A new device login is the stored identity. The previous account
96+
does not stay behind just because its session file already exists."""
97+
config.save_session({
98+
"id_token": "alexey-token", "subject": "Google_alexey",
99+
"email": "alexey@datatalks.club", "expires_at": 4102444800, "obtained_at": 1,
100+
})
101+
poster, _ = device_poster({
102+
"/device/start": (200, {"device_code": "dapd_secret", "user_code": "TQ62-SD46",
103+
"expires_in": 900, "interval": 5}),
104+
"/device/token": (200, {"token": "dapd_orin", "status": "approved",
105+
"subject": "Google_orin", "email": "orin@datatalks.club",
106+
"expires_at": 4102444800}),
107+
})
108+
monkeypatch.setattr(auth, "_post_json", poster)
109+
110+
session = auth.login_device("https://api.example.test/", sleeper=lambda seconds: None)
111+
112+
stored = config.load_session()
113+
assert stored["email"] == "orin@datatalks.club"
114+
assert stored["token"] == "dapd_orin"
115+
assert stored is not None and "alexey-token" not in json.dumps(stored)
116+
assert session["email"] == "orin@datatalks.club"
117+
118+
119+
def test_logout_forgets_a_browser_session(isolated_home, monkeypatch):
120+
config.save_session({
121+
"id_token": "alexey-token", "subject": "Google_alexey",
122+
"email": "alexey@datatalks.club", "expires_at": 4102444800, "obtained_at": 1,
123+
})
124+
monkeypatch.setattr(auth, "revoke_session", lambda api_url, session: None)
125+
126+
import argparse
127+
code = cli_auth.run(argparse.Namespace(command="logout"), "https://api.example.test")
128+
129+
assert code == 0
130+
assert config.load_session() is None
131+
132+
94133
def test_login_device_happy_path(isolated_home, monkeypatch, capsys):
95134
poster, calls = device_poster({
96135
"/device/start": (200, {"device_code": "dapd_secret", "user_code": "ABCD-EFGH",

‎tests/test_device_delivery.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,57 @@ def test_no_plaintext_code_or_bearer_is_ever_stored(store):
106106

107107
# --- M7: one delivery, losers write nothing ------------------------------------
108108

109+
def test_winning_poll_retries_with_low_level_attribute_values(store):
110+
"""The real DynamoDB client rejects resource-shaped transaction items.
111+
112+
That rejection happens before the request is sent, so the approval is
113+
already stored, the browser says the device is authorized, and the CLI
114+
never receives a token. The retry must be the low-level shape the
115+
client validates.
116+
"""
117+
from boto3.dynamodb.types import TypeDeserializer
118+
from botocore.exceptions import ParamValidationError
119+
from botocore.validate import ParamValidator
120+
121+
device_code, _ = approved(store)
122+
client = store.meta.client
123+
seen = []
124+
original = client.transact_write_items
125+
decoder = TypeDeserializer()
126+
127+
def spy(*args, **kwargs):
128+
seen.append(kwargs)
129+
key = kwargs["TransactItems"][0]["Delete"]["Key"]["pk"]
130+
if isinstance(key, str):
131+
raise ParamValidationError(report="plain values are not AttributeValues")
132+
# Moto's transaction backend cannot use the low-level key as a dict
133+
# key. Apply the committed effect the way DynamoDB would, after the
134+
# payload has passed the same validator the client uses.
135+
shape = client.meta.service_model.operation_model("TransactWriteItems").input_shape
136+
report = ParamValidator().validate(kwargs, shape)
137+
assert not report.has_errors(), report.generate_report()
138+
pairing_key = decoder.deserialize(key)
139+
item = {name: decoder.deserialize(value)
140+
for name, value in kwargs["TransactItems"][1]["Put"]["Item"].items()}
141+
store.delete_item(Key={"pk": pairing_key})
142+
store.put_item(Item=item)
143+
return {}
144+
145+
client.transact_write_items = spy
146+
result = d.poll(device_code, table_ref=store)
147+
148+
assert result["status"] == "approved"
149+
assert result["email"]
150+
assert d.resolve(result["token"], table_ref=store)["subject"] == result["subject"]
151+
assert len(seen) == 2
152+
assert isinstance(seen[0]["TransactItems"][0]["Delete"]["Key"]["pk"], str)
153+
delete = seen[1]["TransactItems"][0]["Delete"]
154+
assert delete["Key"]["pk"]["S"].startswith("pairing#")
155+
assert delete["ExpressionAttributeValues"][":approved"] == {"S": "approved"}
156+
assert "N" in delete["ExpressionAttributeValues"][":now"]
157+
assert original # the resource client remains what moto tests call first
158+
159+
109160
def test_sequential_polls_deliver_once(store):
110161
device_code, _ = approved(store)
111162
first = d.poll(device_code, table_ref=store)

0 commit comments

Comments
 (0)