Skip to content

Commit d801968

Browse files
wphillipmoorewphillipmoore-claude
andauthored
fix: LTPA cookie extraction uses prefix matching for suffixed cookie names (#418)
Liberty's MQ REST API uses LtpaToken2_<suffix> cookie names by default. Changed from exact match to prefix match on LtpaToken2 and store the full cookie name for use in subsequent requests. Enabled the LTPA integration test. Co-authored-by: wphillipmoore-claude <255925739+wphillipmoore-claude@users.noreply.github.com>
1 parent 4eb21ff commit d801968

5 files changed

Lines changed: 94 additions & 21 deletions

File tree

src/pymqrest/auth.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ def _perform_ltpa_login(
8080
csrf_token: str | None,
8181
timeout_seconds: float | None,
8282
verify_tls: bool,
83-
) -> str:
84-
"""Perform an LTPA login and return the ``LtpaToken2`` token value.
83+
) -> tuple[str, str]:
84+
"""Perform an LTPA login and return the cookie name and token value.
8585
8686
Args:
8787
transport: The transport to use for the login POST request.
@@ -92,7 +92,8 @@ def _perform_ltpa_login(
9292
verify_tls: Whether to verify the server's TLS certificate.
9393
9494
Returns:
95-
The ``LtpaToken2`` cookie value string.
95+
A ``(cookie_name, token_value)`` tuple. The cookie name may be
96+
``"LtpaToken2"`` or a suffixed variant like ``"LtpaToken2_xyz"``.
9697
9798
Raises:
9899
MQRESTAuthError: If the login request fails or the response
@@ -120,34 +121,36 @@ def _perform_ltpa_login(
120121
url=login_url,
121122
status_code=response.status_code,
122123
)
123-
token = _extract_ltpa_token(response.headers)
124-
if token is None:
124+
result = _extract_ltpa_token(response.headers)
125+
if result is None:
125126
raise MQRESTAuthError(
126127
ERROR_LTPA_TOKEN_MISSING,
127128
url=login_url,
128129
status_code=response.status_code,
129130
)
130-
return token
131+
return result
131132

132133

133-
def _extract_ltpa_token(headers: Mapping[str, str]) -> str | None:
134-
"""Extract the ``LtpaToken2`` value from response ``Set-Cookie`` headers.
134+
def _extract_ltpa_token(headers: Mapping[str, str]) -> tuple[str, str] | None:
135+
"""Extract an ``LtpaToken2`` cookie from response ``Set-Cookie`` headers.
135136
136-
Uses :class:`http.cookies.SimpleCookie` for robust cookie parsing.
137+
Matches any cookie whose name equals ``"LtpaToken2"`` or starts with
138+
``"LtpaToken2"`` (e.g. ``"LtpaToken2_abcdef"``), using prefix matching
139+
to support Liberty's suffixed cookie names.
137140
138141
Args:
139142
headers: Response headers mapping.
140143
141144
Returns:
142-
The token string, or ``None`` if not found.
145+
A ``(cookie_name, token_value)`` tuple, or ``None`` if not found.
143146
144147
"""
145148
set_cookie = headers.get("Set-Cookie") or headers.get("set-cookie")
146149
if not set_cookie:
147150
return None
148151
cookie = http.cookies.SimpleCookie()
149152
cookie.load(set_cookie)
150-
morsel = cookie.get(LTPA_COOKIE_NAME)
151-
if morsel is not None:
152-
return morsel.value
153+
for name, morsel in cookie.items():
154+
if name.startswith(LTPA_COOKIE_NAME):
155+
return (name, morsel.value)
153156
return None

src/pymqrest/session.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
validate_mapping_overrides,
1919
validate_mapping_overrides_complete,
2020
)
21-
from .auth import LTPA_COOKIE_NAME, BasicAuth, CertificateAuth, Credentials, LTPAAuth, _perform_ltpa_login
21+
from .auth import BasicAuth, CertificateAuth, Credentials, LTPAAuth, _perform_ltpa_login
2222
from .commands import MQRESTCommandMixin
2323
from .ensure import MQRESTEnsureMixin
2424
from .exceptions import (
@@ -282,9 +282,10 @@ def __init__( # noqa: PLR0913
282282
else:
283283
self._transport = transport or RequestsTransport()
284284

285+
self._ltpa_cookie_name: str | None = None
285286
self._ltpa_token: str | None = None
286287
if isinstance(credentials, LTPAAuth):
287-
self._ltpa_token = _perform_ltpa_login(
288+
self._ltpa_cookie_name, self._ltpa_token = _perform_ltpa_login(
288289
self._transport,
289290
self._rest_base_url,
290291
credentials,
@@ -407,7 +408,7 @@ def _build_headers(self) -> dict[str, str]:
407408
self._credentials.password,
408409
)
409410
elif isinstance(self._credentials, LTPAAuth) and self._ltpa_token is not None:
410-
headers["Cookie"] = f"{LTPA_COOKIE_NAME}={self._ltpa_token}"
411+
headers["Cookie"] = f"{self._ltpa_cookie_name}={self._ltpa_token}"
411412
if self._csrf_token is not None:
412413
headers["ibm-mq-rest-csrf-token"] = self._csrf_token
413414
if self._gateway_qmgr is not None:

tests/integration/test_mq_integration.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,6 @@ def test_ensure_channel_lifecycle() -> None:
506506
session.delete_channel(name=TEST_ENSURE_CHANNEL)
507507

508508

509-
@pytest.mark.xfail(reason="MQ developer container does not return LtpaToken2 cookies", strict=False)
510509
def test_ltpa_auth_display_qmgr() -> None:
511510
config = load_integration_config()
512511
session = MQRESTSession(

tests/pymqrest/test_auth.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def test_perform_ltpa_login_success() -> None:
106106
),
107107
)
108108

109-
token = _perform_ltpa_login(
109+
cookie_name, token = _perform_ltpa_login(
110110
transport,
111111
"https://example.invalid/ibmmq/rest/v2",
112112
LTPAAuth("user", TEST_PASSWORD),
@@ -115,13 +115,36 @@ def test_perform_ltpa_login_success() -> None:
115115
verify_tls=False,
116116
)
117117

118+
assert cookie_name == "LtpaToken2"
118119
assert token == "abc123"
119120
assert transport.recorded_url == "https://example.invalid/ibmmq/rest/v2/login"
120121
assert transport.recorded_payload == {"username": "user", "password": TEST_PASSWORD}
121122
assert transport.recorded_headers is not None
122123
assert transport.recorded_headers["ibm-mq-rest-csrf-token"] == "local"
123124

124125

126+
def test_perform_ltpa_login_success_with_suffixed_cookie() -> None:
127+
transport = FakeLoginTransport(
128+
TransportResponse(
129+
status_code=STATUS_OK,
130+
text="",
131+
headers={"Set-Cookie": "LtpaToken2_abcdef=suffixed_tok; Path=/; HttpOnly"},
132+
),
133+
)
134+
135+
cookie_name, token = _perform_ltpa_login(
136+
transport,
137+
"https://example.invalid/ibmmq/rest/v2",
138+
LTPAAuth("user", TEST_PASSWORD),
139+
csrf_token="local",
140+
timeout_seconds=30.0,
141+
verify_tls=False,
142+
)
143+
144+
assert cookie_name == "LtpaToken2_abcdef"
145+
assert token == "suffixed_tok"
146+
147+
125148
def test_perform_ltpa_login_without_csrf_token() -> None:
126149
transport = FakeLoginTransport(
127150
TransportResponse(
@@ -131,7 +154,7 @@ def test_perform_ltpa_login_without_csrf_token() -> None:
131154
),
132155
)
133156

134-
token = _perform_ltpa_login(
157+
cookie_name, token = _perform_ltpa_login(
135158
transport,
136159
"https://example.invalid/ibmmq/rest/v2",
137160
LTPAAuth("user", TEST_PASSWORD),
@@ -140,6 +163,7 @@ def test_perform_ltpa_login_without_csrf_token() -> None:
140163
verify_tls=False,
141164
)
142165

166+
assert cookie_name == "LtpaToken2"
143167
assert token == "token_value"
144168
assert transport.recorded_headers is not None
145169
assert "ibm-mq-rest-csrf-token" not in transport.recorded_headers
@@ -219,7 +243,16 @@ def test_perform_ltpa_login_no_set_cookie_raises() -> None:
219243

220244
def test_extract_ltpa_token_with_multiple_cookies() -> None:
221245
headers = {"Set-Cookie": "Other=x; Path=/, LtpaToken2=multi_tok; Path=/; Secure"}
222-
assert _extract_ltpa_token(headers) == "multi_tok"
246+
result = _extract_ltpa_token(headers)
247+
assert result is not None
248+
assert result == ("LtpaToken2", "multi_tok")
249+
250+
251+
def test_extract_ltpa_token_with_suffixed_cookie_name() -> None:
252+
headers = {"Set-Cookie": "LtpaToken2_xyz123=suffixed_tok; Path=/; Secure"}
253+
result = _extract_ltpa_token(headers)
254+
assert result is not None
255+
assert result == ("LtpaToken2_xyz123", "suffixed_tok")
223256

224257

225258
def test_extract_ltpa_token_no_match() -> None:
@@ -238,4 +271,6 @@ def test_extract_ltpa_token_no_headers() -> None:
238271

239272
def test_extract_ltpa_token_lowercase_header() -> None:
240273
headers = {"set-cookie": "LtpaToken2=lower_tok; Path=/"}
241-
assert _extract_ltpa_token(headers) == "lower_tok"
274+
result = _extract_ltpa_token(headers)
275+
assert result is not None
276+
assert result == ("LtpaToken2", "lower_tok")

tests/pymqrest/test_session.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,6 +1560,41 @@ def test_credentials_ltpa_auth_sends_cookie_header() -> None:
15601560
assert command_request.headers["Cookie"] == "LtpaToken2=ltpa_test_token"
15611561

15621562

1563+
def test_credentials_ltpa_auth_with_suffixed_cookie_name() -> None:
1564+
login_response = TransportResponse(
1565+
status_code=200,
1566+
text="",
1567+
headers={"Set-Cookie": "LtpaToken2_abc123=suffixed_tok; Path=/; HttpOnly"},
1568+
)
1569+
command_response = TransportResponse(
1570+
status_code=200,
1571+
text=json.dumps(
1572+
{
1573+
"commandResponse": [
1574+
{"completionCode": 0, "reasonCode": 0, "parameters": {"QMNAME": "QM1"}},
1575+
],
1576+
"overallCompletionCode": 0,
1577+
"overallReasonCode": 0,
1578+
},
1579+
),
1580+
headers={},
1581+
)
1582+
transport = MultiResponseTransport([login_response, command_response])
1583+
1584+
session = MQRESTSession(
1585+
"https://example.invalid/ibmmq/rest/v2",
1586+
"QM1",
1587+
credentials=LTPAAuth("user", TEST_PASSWORD),
1588+
transport=transport,
1589+
)
1590+
1591+
result = session.display_qmgr()
1592+
1593+
assert result == {"queue_manager_name": "QM1"}
1594+
command_request = transport.recorded_requests[1]
1595+
assert command_request.headers["Cookie"] == "LtpaToken2_abc123=suffixed_tok"
1596+
1597+
15631598
def test_credentials_certificate_auth_no_auth_header() -> None:
15641599
response_payload = {
15651600
"commandResponse": [

0 commit comments

Comments
 (0)