diff --git a/msal/managed_identity.py b/msal/managed_identity.py index b045c971..8caf4717 100644 --- a/msal/managed_identity.py +++ b/msal/managed_identity.py @@ -2,16 +2,23 @@ # All rights reserved. # # This code is licensed under the MIT License. +import copy import hashlib +import hmac import json import logging import os +import ssl import sys import time import uuid from urllib.parse import urlparse # Python 3+ from collections import UserDict # Python 3+ from typing import List, Optional, Union # Needed in Python 3.7 & 3.8 +import requests +from requests.adapters import HTTPAdapter +from urllib3.connection import HTTPSConnection +from urllib3.connectionpool import HTTPSConnectionPool from .token_cache import TokenCache from .individual_cache import _IndividualCache as IndividualCache from .throttled_http_client import ThrottledHttpClientBase, RetryAfterParser @@ -190,6 +197,11 @@ def __init__( managed_identity = ... client = msal.ManagedIdentityClient(managed_identity, http_client=s) + For Service Fabric managed identity, ``http_client`` must be a + ``requests.Session`` using the standard ``requests.adapters.HTTPAdapter``. + MSAL derives a separate session for the Service Fabric endpoint so that + its certificate thumbprint can be validated before the Secret header is sent. + :param token_cache: Optional. It accepts a :class:`msal.TokenCache` instance to store tokens. It will use an in-memory token cache by default. @@ -594,7 +606,13 @@ def _obtain_token_on_service_fabric( # See also https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/identity/azure-identity/tests/managed-identity-live/service-fabric/service_fabric.md # Protocol https://learn.microsoft.com/en-us/azure/service-fabric/how-to-managed-identity-service-fabric-app-code#acquiring-an-access-token-using-rest-api logger.debug("Obtaining token via managed identity on Azure Service Fabric") - resp = http_client.get( + parsed_endpoint = urlparse(endpoint) + if parsed_endpoint.scheme.lower() != "https" or not parsed_endpoint.hostname: + raise ManagedIdentityError( + "Service Fabric managed identity endpoint must use HTTPS.") + service_fabric_http_client = _create_service_fabric_http_client( + http_client, endpoint, _normalize_service_fabric_thumbprint(server_thumbprint)) + resp = service_fabric_http_client.get( endpoint, params={k: v for k, v in { "api-version": "2019-07-01-preview", @@ -630,6 +648,124 @@ def _obtain_token_on_service_fabric( raise +def _normalize_service_fabric_thumbprint(server_thumbprint): + normalized = "".join( + character for character in str(server_thumbprint) + if character not in " \t\r\n:") + if len(normalized) != 40 or any( + character not in "0123456789abcdefABCDEF" + for character in normalized): + raise ManagedIdentityError( + "IDENTITY_SERVER_THUMBPRINT must be a SHA-1 certificate thumbprint.") + return normalized.lower() + + +class _ServiceFabricHTTPSConnection(HTTPSConnection): + """An HTTPS connection that authenticates the Service Fabric endpoint certificate.""" + _server_thumbprint = None + + def connect(self): + super(_ServiceFabricHTTPSConnection, self).connect() + if getattr(self, "proxy_is_forwarding", False): + self.close() + raise ssl.SSLCertVerificationError( + "Cannot validate the Service Fabric endpoint certificate through " + "a forwarding proxy.") + certificate = self.sock.getpeercert(binary_form=True) + actual_thumbprint = hashlib.sha1(certificate).hexdigest() + if not hmac.compare_digest(actual_thumbprint, self._server_thumbprint): + self.close() + raise ssl.SSLCertVerificationError( + "Service Fabric endpoint certificate thumbprint does not match " + "IDENTITY_SERVER_THUMBPRINT.") + self.is_verified = True + + +class _ServiceFabricHTTPSConnectionPool(HTTPSConnectionPool): + ConnectionCls = _ServiceFabricHTTPSConnection + + +class _ServiceFabricHTTPAdapter(HTTPAdapter): + """Use certificate-thumbprint authentication for the Service Fabric endpoint.""" + + def __init__(self, server_thumbprint, *args, **kwargs): + connection_class = type( + "_PinnedServiceFabricHTTPSConnection", + (_ServiceFabricHTTPSConnection,), + {"_server_thumbprint": server_thumbprint}, + ) + self._connection_pool_class = type( + "_PinnedServiceFabricHTTPSConnectionPool", + (_ServiceFabricHTTPSConnectionPool,), + {"ConnectionCls": connection_class}, + ) + super(_ServiceFabricHTTPAdapter, self).__init__(*args, **kwargs) + + def _configure_pool_manager(self, pool_manager): + # PoolManager's mapping is module-global by default, so copy it before + # replacing HTTPS only for this derived Service Fabric session. + pool_manager.pool_classes_by_scheme = pool_manager.pool_classes_by_scheme.copy() + pool_manager.pool_classes_by_scheme["https"] = self._connection_pool_class + + def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): + super(_ServiceFabricHTTPAdapter, self).init_poolmanager( + connections, maxsize, block=block, **pool_kwargs) + self._configure_pool_manager(self.poolmanager) + + def proxy_manager_for(self, proxy, **proxy_kwargs): + pool_manager = super(_ServiceFabricHTTPAdapter, self).proxy_manager_for( + proxy, **proxy_kwargs) + self._configure_pool_manager(pool_manager) + return pool_manager + + def cert_verify(self, conn, url, verify, cert): + # The exact Service Fabric certificate thumbprint is the trust anchor. + # Do not inherit caller-provided verify=False or a custom CA configuration. + super(_ServiceFabricHTTPAdapter, self).cert_verify( + conn, url, verify=False, cert=cert) + + +def _create_service_fabric_http_client(http_client, endpoint, server_thumbprint): + """Clone a standard Requests session and attach a pinning-only HTTPS transport. + + Custom HTTP clients and adapters are rejected because MSAL cannot prove that + they will validate the certificate before transmitting the Secret header. + """ + if isinstance(http_client, ThrottledHttpClientBase): + http_client = http_client.http_client + if not isinstance(http_client, requests.Session): + raise ManagedIdentityError( + "Service Fabric managed identity requires a requests.Session " + "with the standard HTTPAdapter.") + source_adapter = http_client.get_adapter(endpoint) + if type(source_adapter) is not HTTPAdapter: + raise ManagedIdentityError( + "Service Fabric managed identity does not support custom HTTP adapters.") + + service_fabric_client = requests.Session() + service_fabric_client.headers = http_client.headers.copy() + service_fabric_client.cookies = http_client.cookies.copy() + service_fabric_client.auth = http_client.auth + service_fabric_client.params = copy.copy(http_client.params) + service_fabric_client.hooks = { + event: handlers[:] for event, handlers in http_client.hooks.items()} + service_fabric_client.proxies = http_client.proxies.copy() + service_fabric_client.stream = http_client.stream + service_fabric_client.trust_env = http_client.trust_env + service_fabric_client.max_redirects = http_client.max_redirects + service_fabric_client.cert = http_client.cert + service_fabric_client.verify = True + service_fabric_client.adapters.clear() + service_fabric_client.mount("https://", _ServiceFabricHTTPAdapter( + server_thumbprint, + max_retries=copy.deepcopy(source_adapter.max_retries), + pool_connections=source_adapter._pool_connections, + pool_maxsize=source_adapter._pool_maxsize, + pool_block=source_adapter._pool_block, + )) + return service_fabric_client + + _supported_arc_platforms_and_their_prefixes = { "linux": "/var/opt/azcmagent/tokens", "win32": os.path.expandvars(r"%ProgramData%\AzureConnectedMachineAgent\Tokens"), diff --git a/setup.cfg b/setup.cfg index 0e2a29c7..b3727e8b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,7 +5,7 @@ universal=0 [metadata] name = msal -version = attr: msal.__version__ +version = attr: msal.sku.__version__ description = The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect. long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/test_mi.py b/tests/test_mi.py index 81dedeba..92c2f33d 100644 --- a/tests/test_mi.py +++ b/tests/test_mi.py @@ -1,9 +1,14 @@ import hashlib import json import os +import ssl import sys +import tempfile +import threading import time import uuid +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import List, Optional import unittest try: @@ -11,6 +16,13 @@ except: from mock import patch, ANY, mock_open, Mock import requests +from requests.adapters import HTTPAdapter +from requests.exceptions import SSLError +from urllib3.util.retry import Retry +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID from tests.test_throttled_http_client import ( MinimalResponse, ThrottledHttpClientBaseTestCase, DummyHttpClient) @@ -30,6 +42,8 @@ MACHINE_LEARNING, SERVICE_FABRIC, DEFAULT_TO_VM, + _create_service_fabric_http_client, + _obtain_token_on_service_fabric, ) from msal.token_cache import is_subdict_of @@ -355,9 +369,9 @@ def test_machine_learning_error_should_be_normalized(self): @patch.dict(os.environ, { - "IDENTITY_ENDPOINT": "http://localhost", + "IDENTITY_ENDPOINT": "https://localhost", "IDENTITY_HEADER": "foo", - "IDENTITY_SERVER_THUMBPRINT": "bar", + "IDENTITY_SERVER_THUMBPRINT": "ab" * 20, }) class ServiceFabricTestCase(ClientTestCase): access_token = "AT" @@ -365,18 +379,21 @@ class ServiceFabricTestCase(ClientTestCase): def _test_happy_path(self, app, *, claims_challenge=None) -> callable: expires_in = 1234 - with patch.object(app._http_client, "get", return_value=MinimalResponse( - status_code=200, - text='{"access_token": "%s", "expires_on": %s, "resource": "R", "token_type": "Bearer"}' % ( - self.access_token, int(time.time()) + expires_in), - )) as mocked_method: - super(ServiceFabricTestCase, self)._test_happy_path( - app, mocked_method, expires_in, claims_challenge=claims_challenge) - return mocked_method + with patch( + "msal.managed_identity._create_service_fabric_http_client", + return_value=app._http_client): + with patch.object(app._http_client, "get", return_value=MinimalResponse( + status_code=200, + text='{"access_token": "%s", "expires_on": %s, "resource": "R", "token_type": "Bearer"}' % ( + self.access_token, int(time.time()) + expires_in), + )) as mocked_method: + super(ServiceFabricTestCase, self)._test_happy_path( + app, mocked_method, expires_in, claims_challenge=claims_challenge) + return mocked_method def test_happy_path_with_client_capabilities_should_relay_capabilities(self): self._test_happy_path(self._build_app(client_capabilities=["foo", "bar"])).assert_called_with( - 'http://localhost', + 'https://localhost', params={ 'api-version': '2019-07-01-preview', 'resource': 'R', @@ -391,7 +408,7 @@ def test_happy_path_with_claim_challenge_should_send_sha256_to_provider(self): self._build_app(client_capabilities=[]), # Test empty client_capabilities claims_challenge='{"access_token": {"nbf": {"essential": true, "value": "1563308371"}}}', ).assert_called_with( - 'http://localhost', + 'https://localhost', params={ 'api-version': '2019-07-01-preview', 'resource': 'R', @@ -414,15 +431,193 @@ def test_sf_error_should_be_normalized(self): "code": "SecretHeaderNotFound", "message": "Secret is not found in the request headers." }}''' # https://learn.microsoft.com/en-us/azure/service-fabric/how-to-managed-identity-service-fabric-app-code#error-handling - with patch.object(self.app._http_client, "get", return_value=MinimalResponse( - status_code=404, - text=raw_error, - )) as mocked_method: - self.assertEqual({ - "error": "unauthorized_client", - "error_description": raw_error, - }, self.app.acquire_token_for_client(resource="R")) - self.assertEqual({}, self.app._token_cache._cache) + with patch( + "msal.managed_identity._create_service_fabric_http_client", + return_value=self.app._http_client): + with patch.object(self.app._http_client, "get", return_value=MinimalResponse( + status_code=404, + text=raw_error, + )) as mocked_method: + self.assertEqual({ + "error": "unauthorized_client", + "error_description": raw_error, + }, self.app.acquire_token_for_client(resource="R")) + self.assertEqual({}, self.app._token_cache._cache) + + +class _ServiceFabricTlsRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.server.requests.append({ + "path": self.path, + "headers": dict(self.headers), + }) + body = json.dumps({ + "access_token": "AT", + "expires_on": str(int(time.time()) + 3600), + "resource": "R", + "token_type": "Bearer", + }).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + +class ServiceFabricTlsValidationTestCase(unittest.TestCase): + def setUp(self): + self._temporary_directory = tempfile.TemporaryDirectory() + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + certificate = ( + x509.CertificateBuilder() + .subject_name(x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "localhost"), + ])) + .issuer_name(x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "localhost"), + ])) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc)) + .not_valid_after( + datetime.now(timezone.utc) + timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost")]), + critical=False, + ) + .sign(private_key, hashes.SHA256()) + ) + self.thumbprint = certificate.fingerprint(hashes.SHA1()).hex() + certificate_path = os.path.join(self._temporary_directory.name, "server.pem") + private_key_path = os.path.join(self._temporary_directory.name, "server.key") + with open(certificate_path, "wb") as certificate_file: + certificate_file.write(certificate.public_bytes(serialization.Encoding.PEM)) + with open(private_key_path, "wb") as private_key_file: + private_key_file.write(private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + self.server = ThreadingHTTPServer( + ("localhost", 0), _ServiceFabricTlsRequestHandler) + self.server.requests = [] + tls_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + tls_context.minimum_version = ssl.TLSVersion.TLSv1_2 + tls_context.load_cert_chain(certificate_path, private_key_path) + self.server.socket = tls_context.wrap_socket(self.server.socket, server_side=True) + self.server_thread = threading.Thread(target=self.server.serve_forever) + self.server_thread.start() + self.endpoint = "https://localhost:{}/token".format(self.server.server_port) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.server_thread.join() + self._temporary_directory.cleanup() + + def _new_session(self): + session = requests.Session() + session.trust_env = False + return session + + def test_matching_thumbprint_sends_secret_after_validating_certificate(self): + result = _obtain_token_on_service_fabric( + self._new_session(), + self.endpoint, + "service-fabric-secret", + ":".join( + self.thumbprint[index:index + 2].upper() + for index in range(0, len(self.thumbprint), 2)), + "R", + ) + + self.assertEqual("AT", result["access_token"]) + self.assertEqual(1, len(self.server.requests)) + self.assertEqual( + "service-fabric-secret", + self.server.requests[0]["headers"]["Secret"]) + + def test_mismatching_thumbprint_prevents_the_secret_from_being_sent(self): + with self.assertRaises(SSLError): + _obtain_token_on_service_fabric( + self._new_session(), + self.endpoint, + "service-fabric-secret", + "00" * 20, + "R", + ) + + self.assertEqual([], self.server.requests) + + def test_non_https_endpoint_is_rejected_before_a_request_can_send_the_secret(self): + with self.assertRaises(ManagedIdentityError): + _obtain_token_on_service_fabric( + self._new_session(), + self.endpoint.replace("https://", "http://", 1), + "service-fabric-secret", + self.thumbprint, + "R", + ) + + self.assertEqual([], self.server.requests) + + def test_derived_client_preserves_standard_session_settings_without_mutation(self): + source = self._new_session() + source.verify = False + source.headers["X-Caller-Header"] = "caller-header" + source.cookies.set("caller-cookie", "cookie-value") + source.auth = ("caller", "password") + source.params = {"caller-param": "caller-value"} + source.proxies = {} + source.max_redirects = 7 + source.mount("https://", HTTPAdapter(max_retries=Retry(total=2))) + + derived = _create_service_fabric_http_client( + source, self.endpoint, self.thumbprint) + + self.assertFalse(source.verify) + self.assertTrue(derived.verify) + self.assertEqual(source.headers, derived.headers) + self.assertEqual(source.cookies, derived.cookies) + self.assertEqual(source.auth, derived.auth) + self.assertEqual(source.params, derived.params) + self.assertEqual(source.proxies, derived.proxies) + self.assertEqual(source.max_redirects, derived.max_redirects) + self.assertEqual(2, derived.get_adapter(self.endpoint).max_retries.total) + self.assertIsNot(source.get_adapter(self.endpoint), derived.get_adapter(self.endpoint)) + response = derived.get( + self.endpoint, + params={"request-param": "request-value"}, + headers={"Secret": "service-fabric-secret"}, + ) + self.assertEqual(200, response.status_code) + request = self.server.requests[0] + self.assertIn("caller-param=caller-value", request["path"]) + self.assertIn("request-param=request-value", request["path"]) + self.assertEqual("caller-header", request["headers"]["X-Caller-Header"]) + self.assertIn("caller-cookie=cookie-value", request["headers"]["Cookie"]) + self.assertTrue(request["headers"]["Authorization"].startswith("Basic ")) + + def test_custom_adapter_is_rejected_before_a_request_can_send_the_secret(self): + source = self._new_session() + + class CustomAdapter(HTTPAdapter): + pass + + source.mount("https://", CustomAdapter()) + with self.assertRaises(ManagedIdentityError): + _obtain_token_on_service_fabric( + source, + self.endpoint, + "service-fabric-secret", + self.thumbprint, + "R", + ) + + self.assertEqual([], self.server.requests) @patch.dict(os.environ, {