Skip to content

Commit 4e4dc0d

Browse files
fix(shield-swap): cookie session outranks bearer; onboarding idempotent under cookie auth
- _headers: prefer the live session (csrf + cookies) over Authorization; ss_ tokens don't cover the /access tier and the server reads the header first - 401 while both credentials are loaded drops the expired session and retries as bearer (15-min sessions) - _auth_done recognizes cookie sessions; CSRF never persisted as jwt; from_profile prefers the durable ss_ token over stale session creds
1 parent ef126b2 commit 4e4dc0d

4 files changed

Lines changed: 85 additions & 16 deletions

File tree

shield-swap-sdk/python/aleo_shield_swap/api.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,40 @@ def is_authenticated(self) -> bool:
110110

111111
def _headers(self) -> dict[str, str]:
112112
headers = {"accept": "application/json"}
113-
if self._token:
114-
headers["authorization"] = f"Bearer {self._token}"
115113
if self._csrf:
116-
# Cookie sessions: the access token rides as an httpOnly cookie
117-
# on self._session; state-changing calls must echo the CSRF.
114+
# A live cookie session outranks a bearer credential: it covers
115+
# every tier (ss_ tokens are data/trading-only) and the server
116+
# honors the Authorization header over cookies when both are
117+
# sent. The access token rides as an httpOnly cookie on
118+
# self._session; requests echo the CSRF.
118119
headers["x-csrf-token"] = self._csrf
120+
elif self._token:
121+
headers["authorization"] = f"Bearer {self._token}"
119122
return headers
120123

124+
def _expired_session(self, resp: Any) -> bool:
125+
"""A 401 while riding a cookie session with a bearer in reserve:
126+
the (15-min) session expired — drop it and retry as bearer."""
127+
if resp.status_code == 401 and self._csrf and self._token:
128+
self._csrf = None
129+
return True
130+
return False
131+
121132
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
122133
resp = self._session.get(f"{self.base_url}{path}", params=params,
123134
headers=self._headers(), timeout=_TIMEOUT)
135+
if self._expired_session(resp):
136+
resp = self._session.get(f"{self.base_url}{path}", params=params,
137+
headers=self._headers(), timeout=_TIMEOUT)
124138
_check(resp)
125139
return resp.json()
126140

127141
def _post(self, path: str, body: dict[str, Any]) -> Any:
128142
resp = self._session.post(f"{self.base_url}{path}", json=body,
129143
headers=self._headers(), timeout=_TIMEOUT)
144+
if self._expired_session(resp):
145+
resp = self._session.post(f"{self.base_url}{path}", json=body,
146+
headers=self._headers(), timeout=_TIMEOUT)
130147
_check(resp)
131148
return resp.json()
132149

@@ -314,21 +331,34 @@ def is_authenticated(self) -> bool:
314331

315332
def _headers(self) -> dict[str, str]:
316333
headers = {"accept": "application/json"}
317-
if self._token:
318-
headers["authorization"] = f"Bearer {self._token}"
319334
if self._csrf:
335+
# Cookie session outranks bearer — see ApiClient._headers.
320336
headers["x-csrf-token"] = self._csrf
337+
elif self._token:
338+
headers["authorization"] = f"Bearer {self._token}"
321339
return headers
322340

341+
def _expired_session(self, resp: Any) -> bool:
342+
if resp.status_code == 401 and self._csrf and self._token:
343+
self._csrf = None
344+
return True
345+
return False
346+
323347
async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
324348
resp = await self._client.get(f"{self.base_url}{path}", params=params,
325349
headers=self._headers())
350+
if self._expired_session(resp):
351+
resp = await self._client.get(f"{self.base_url}{path}", params=params,
352+
headers=self._headers())
326353
_check(resp)
327354
return resp.json()
328355

329356
async def _post(self, path: str, body: dict[str, Any]) -> Any:
330357
resp = await self._client.post(f"{self.base_url}{path}", json=body,
331358
headers=self._headers())
359+
if self._expired_session(resp):
360+
resp = await self._client.post(f"{self.base_url}{path}", json=body,
361+
headers=self._headers())
332362
_check(resp)
333363
return resp.json()
334364

shield-swap-sdk/python/aleo_shield_swap/client.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,12 @@ def from_profile(cls, home: Any = None) -> "ShieldSwap":
126126
dex = cls(aleo)
127127
dex.profile = profile
128128
dex.journal = Journal(profile.journal_path)
129-
if creds.get("jwt"):
130-
dex.api.set_token(creds["jwt"]) # session tier (24h)
131-
elif creds.get("dex_api_token"):
129+
# Durable ss_ token first — session JWTs are short-lived and stale
130+
# ones would shadow a perfectly good API token.
131+
if creds.get("dex_api_token"):
132132
dex.api.set_token(creds["dex_api_token"]) # durable data tier
133+
elif creds.get("jwt"):
134+
dex.api.set_token(creds["jwt"]) # session tier
133135
return dex
134136

135137
def _refresh_credentials(self) -> None:

shield-swap-sdk/python/aleo_shield_swap/lifecycle.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,12 @@ class Stage:
6060
# ── Stages ────────────────────────────────────────────────────────────────────
6161

6262
def _auth_done(ctx: _Ctx) -> bool:
63-
if getattr(ctx.dex.api, "_token", None) is None:
63+
# A credential may be a bearer token OR a cookie session (staging).
64+
authed = getattr(ctx.dex.api, "is_authenticated",
65+
getattr(ctx.dex.api, "_token", None) is not None)
66+
if not authed:
6467
return False
65-
try: # a stored-but-expired JWT is not auth
68+
try: # a stored-but-expired credential is not auth
6669
ctx.dex.api.access_status()
6770
return True
6871
except NotAuthenticatedError:
@@ -73,10 +76,16 @@ def _auth_run(ctx: _Ctx) -> str:
7376
import aleo
7477
net = getattr(aleo, ctx.profile.network)
7578
pk = net.PrivateKey.from_string(ctx.profile.private_key)
76-
jwt = ctx.dex.api.authenticate(ctx.profile.address,
77-
lambda msg: str(pk.sign(msg.encode())))
78-
ctx.profile.save_credentials(jwt=jwt)
79-
return "authenticated (24h JWT)"
79+
ctx.dex.api.authenticate(ctx.profile.address,
80+
lambda msg: str(pk.sign(msg.encode())))
81+
# Only a body-JWT (legacy deployments) is worth persisting — a cookie
82+
# session's CSRF token is useless in a new process and would shadow the
83+
# durable ss_ token if saved as "jwt".
84+
jwt = getattr(ctx.dex.api, "_token", None)
85+
if jwt:
86+
ctx.profile.save_credentials(jwt=jwt)
87+
return "authenticated (JWT)"
88+
return "authenticated (cookie session)"
8089

8190

8291
def _redeem_done(ctx: _Ctx) -> bool:
@@ -87,7 +96,9 @@ def _redeem_run(ctx: _Ctx) -> str:
8796
if not ctx.invite_code:
8897
raise NotRedeemedError()
8998
out = ctx.dex.api.redeem_code(ctx.invite_code)
90-
ctx.profile.save_credentials(jwt=getattr(out, "token", None))
99+
token = getattr(out, "token", None)
100+
if token: # legacy deployments only
101+
ctx.profile.save_credentials(jwt=token)
91102
return f"invite redeemed ({out.status})"
92103

93104

shield-swap-sdk/tests/test_api_client.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,29 @@ def test_access_code_self_registration_flow():
220220
assert out.status == "redeemed"
221221
assert [c[1] for c in s.calls] == ["https://x/access/generate",
222222
"https://x/access/redeem"]
223+
224+
225+
def test_cookie_session_outranks_bearer():
226+
# With both credentials loaded, requests ride the cookie session (CSRF
227+
# header, no Authorization) — the server honors Authorization first and
228+
# ss_ tokens don't cover the /access tier.
229+
s = _Session([_Resp(200, {"data": []})])
230+
api = ApiClient(base_url="https://x", session=s, token="ss_durable")
231+
api._csrf = "csrf-1"
232+
api._get("/access/status")
233+
headers = s.calls[0][3]
234+
assert headers["x-csrf-token"] == "csrf-1"
235+
assert "authorization" not in headers
236+
237+
238+
def test_expired_cookie_session_falls_back_to_bearer():
239+
s = _Session([
240+
_Resp(401, {"error": "session expired"}),
241+
_Resp(200, {"data": []}),
242+
])
243+
api = ApiClient(base_url="https://x", session=s, token="ss_durable")
244+
api._csrf = "csrf-1"
245+
api._get("/route")
246+
assert api._csrf is None # session dropped
247+
assert s.calls[0][3]["x-csrf-token"] == "csrf-1"
248+
assert s.calls[1][3]["authorization"] == "Bearer ss_durable"

0 commit comments

Comments
 (0)