Skip to content

Commit ba697e8

Browse files
Address review comments on mTLS PoP cert/key handling
Four small, low-risk fixes from the code review: - application.py `_private_key_to_unencrypted_pem`: pass an explicit `backend=default_backend()` to `load_pem_private_key` (required on the supported `cryptography` floor, 2.5) and wrap the call so a bad or encrypted-without-passphrase key surfaces a clear, actionable ValueError instead of a cryptic low-level error. Mirrors the sibling `_load_private_key_from_pem_str`. - token_cache.py: coerce `key_id` to a str via a new `_key_id_to_str` helper before building the AccessToken cache key, so a non-str key_id never raises TypeError at the `"-" + key_id` concatenation. The normal ASCII-str path is byte-identical, so existing mtls_pop cache entries are unaffected. Defensive only: current callers already pass an ASCII str. - test_application.py: hoist `import os` to the top of the module and drop the mid-file `import os as _os` alias. Adds regression tests: bytes/other key_id coercion (test_token_cache.py) and the clearer private-key load errors (test_mtls_transport.py). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 51adcec commit ba697e8

5 files changed

Lines changed: 77 additions & 9 deletions

File tree

msal/application.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,18 @@ def _private_key_to_unencrypted_pem(private_key, passphrase_bytes=None):
120120
The result is suitable for ``ssl.SSLContext.load_cert_chain`` via a temp file.
121121
"""
122122
from cryptography.hazmat.primitives import serialization
123+
from cryptography.hazmat.backends import default_backend
123124
if isinstance(private_key, (str, bytes)):
124-
key_obj = serialization.load_pem_private_key(
125-
_str2bytes(private_key) if isinstance(private_key, str) else private_key,
126-
passphrase_bytes)
125+
try:
126+
key_obj = serialization.load_pem_private_key(
127+
_str2bytes(private_key) if isinstance(private_key, str) else private_key,
128+
passphrase_bytes,
129+
backend=default_backend(), # Required param until cryptography 3.1
130+
)
131+
except (TypeError, ValueError) as exc:
132+
raise ValueError(
133+
"Could not load the private key for mTLS Proof-of-Possession. "
134+
"If the key is encrypted, provide its 'passphrase'.") from exc
127135
else: # Already a cryptography private-key object
128136
key_obj = private_key
129137
return key_obj.private_bytes(

msal/token_cache.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@
1414
logger = logging.getLogger(__name__)
1515
_GRANT_TYPE_BROKER = "broker"
1616

17+
18+
def _key_id_to_str(key_id):
19+
"""Coerce a key_id (base64url x5t#S256) into a str for the AT cache key.
20+
21+
It is normally an ASCII str, but tolerate bytes (decoded as ASCII) or any
22+
other type so that building the cache key never raises ``TypeError``.
23+
"""
24+
if isinstance(key_id, bytes):
25+
return key_id.decode("ascii")
26+
return key_id if isinstance(key_id, str) else str(key_id)
27+
1728
# Fields in the request data dict that should NOT be included in the extended
1829
# cache key hash. Everything else in data IS included, because those are extra
1930
# body parameters going on the wire and must differentiate cached tokens.
@@ -170,8 +181,9 @@ def __init__(self):
170181
).lower()
171182
# key_id is a base64url x5t#S256 and is case-sensitive,
172183
# so it is appended AFTER lower-casing the rest, to keep
173-
# ATs bound to different keys/certs isolated.
174-
+ ("-" + key_id if key_id else ""),
184+
# ATs bound to different keys/certs isolated. Coerce it to
185+
# a str first so a non-str key_id never breaks caching.
186+
+ ("-" + _key_id_to_str(key_id) if key_id else ""),
175187
self.CredentialType.ID_TOKEN:
176188
lambda home_account_id=None, environment=None, client_id=None,
177189
realm=None, **ignored_payload_from_a_real_token:

tests/test_application.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import base64
44
import json
55
import logging
6+
import os
67
import sys
78
import time
89
import warnings
@@ -1589,8 +1590,7 @@ def mock_post(url, headers=None, data=None, *args, **kwargs):
15891590
"not receive a context dict")
15901591

15911592

1592-
import os as _os
1593-
_MTLS_PFX = _os.path.join(_os.path.dirname(__file__), "certificate-with-password.pfx")
1593+
_MTLS_PFX = os.path.join(os.path.dirname(__file__), "certificate-with-password.pfx")
15941594
_MTLS_CERT_CRED = {
15951595
"private_key_pfx_path": _MTLS_PFX,
15961596
"passphrase": "password",

tests/test_mtls_transport.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import unittest
44

55
from msal import mtls
6-
from msal.application import _load_mtls_cert_material
6+
from msal.application import (
7+
_load_mtls_cert_material, _private_key_to_unencrypted_pem)
78

89

910
class TestMtlsEndpointTransform(unittest.TestCase):
@@ -142,5 +143,38 @@ def test_http_client_builds_session_lazily(self):
142143
client.close()
143144

144145

146+
class TestUnencryptedPemLoading(unittest.TestCase):
147+
"""_private_key_to_unencrypted_pem pins the cryptography backend and turns
148+
low-level load failures into a clear, actionable ValueError."""
149+
150+
@staticmethod
151+
def _encrypted_pem(passphrase=b"secret"):
152+
from cryptography.hazmat.primitives import serialization
153+
from cryptography.hazmat.primitives.asymmetric import rsa
154+
from cryptography.hazmat.backends import default_backend
155+
key = rsa.generate_private_key(
156+
public_exponent=65537, key_size=2048, backend=default_backend())
157+
return key.private_bytes(
158+
serialization.Encoding.PEM,
159+
serialization.PrivateFormat.PKCS8,
160+
serialization.BestAvailableEncryption(passphrase))
161+
162+
def test_encrypted_key_without_passphrase_raises_clear_error(self):
163+
with self.assertRaises(ValueError) as cm:
164+
_private_key_to_unencrypted_pem(self._encrypted_pem(), None)
165+
self.assertIn("passphrase", str(cm.exception))
166+
167+
def test_garbage_key_raises_clear_error(self):
168+
with self.assertRaises(ValueError) as cm:
169+
_private_key_to_unencrypted_pem(b"not a real pem", None)
170+
self.assertIn("private key for mTLS", str(cm.exception))
171+
172+
def test_encrypted_key_with_passphrase_round_trips_to_unencrypted_pem(self):
173+
pem = _private_key_to_unencrypted_pem(
174+
self._encrypted_pem(b"secret"), b"secret")
175+
self.assertIn(b"PRIVATE KEY", pem)
176+
self.assertNotIn(b"ENCRYPTED", pem)
177+
178+
145179
if __name__ == "__main__":
146180
unittest.main()

tests/test_token_cache.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import time
55
import warnings
66

7-
from msal.token_cache import TokenCache, SerializableTokenCache, _compute_ext_cache_key
7+
from msal.token_cache import (
8+
TokenCache, SerializableTokenCache, _compute_ext_cache_key, _key_id_to_str)
89
from tests import unittest
910

1011

@@ -260,6 +261,19 @@ def test_access_tokens_with_different_key_id(self):
260261
home_account_id="uid.utid",
261262
))
262263

264+
def test_key_id_is_coerced_to_str_for_cache_key(self):
265+
# A bytes (or otherwise non-str) key_id must not raise a TypeError while
266+
# building the AT cache key (the "-" + key_id concatenation once assumed
267+
# str). This is defensive: current callers already pass an ASCII str.
268+
self.assertEqual("THUMB", _key_id_to_str(b"THUMB"))
269+
self.assertEqual("THUMB", _key_id_to_str("THUMB"))
270+
self.assertEqual("123", _key_id_to_str(123)) # Any other type -> str()
271+
# Adding + searching an AT with a bytes key_id no longer raises; the
272+
# helper below stores and then finds it by that same key_id.
273+
self._test_data_should_be_saved_and_searchable_in_access_token(
274+
{"key_id": b"THUMB"})
275+
self.assertEqual(1, len(self.cache._cache["AccessToken"]))
276+
263277
def test_bearer_and_mtls_pop_tokens_coexist_and_isolate(self):
264278
# The crux of mTLS PoP cache isolation (plan C6): a Bearer token and an
265279
# mtls_pop token for the SAME app/scope/tenant must coexist, and an

0 commit comments

Comments
 (0)