From 56bdab93938e54199a10d3507d587c01f3a44c51 Mon Sep 17 00:00:00 2001 From: micwoj92 <45581170+micwoj92@users.noreply.github.com> Date: Sat, 18 Nov 2023 01:43:47 +0100 Subject: [PATCH 1/4] Remove newlines from description. --- setup.cfg | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/setup.cfg b/setup.cfg index adf13aba..75df4f9d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,12 +6,7 @@ universal=1 [metadata] name = msal version = attr: msal.__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. +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 license = MIT From 460dc66acd6074ff805a2134f046ff784c7e4b74 Mon Sep 17 00:00:00 2001 From: Bogdan Gavril Date: Fri, 1 Dec 2023 08:26:41 +0000 Subject: [PATCH 2/4] #629 - skip region discory when region=None (#630) * #629 - skip region discory when region=None * Tidy up --------- Co-authored-by: Ray Luo --- msal/application.py | 53 +++++++++++---------------------------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/msal/application.py b/msal/application.py index 2f33e965..52bd6835 100644 --- a/msal/application.py +++ b/msal/application.py @@ -336,51 +336,22 @@ def __init__( `claims parameter `_ which you will later provide via one of the acquire-token request. - :param str azure_region: - AAD provides regional endpoints for apps to opt in - to keep their traffic remain inside that region. + :param str azure_region: (optional) + Instructs MSAL to use the Entra regional token service. This legacy feature is only available to + first-party applications. Only ``acquire_token_for_client()`` is supported. - As of 2021 May, regional service is only available for - ``acquire_token_for_client()`` sent by any of the following scenarios: + Supports 3 values: - 1. An app powered by a capable MSAL - (MSAL Python 1.12+ will be provisioned) - - 2. An app with managed identity, which is formerly known as MSI. - (However MSAL Python does not support managed identity, - so this one does not apply.) - - 3. An app authenticated by - `Subject Name/Issuer (SNI) `_. - - 4. An app which already onboard to the region's allow-list. - - This parameter defaults to None, which means region behavior remains off. - - App developer can opt in to a regional endpoint, - by provide its region name, such as "westus", "eastus2". - You can find a full list of regions by running - ``az account list-locations -o table``, or referencing to - `this doc `_. - - An app running inside Azure Functions and Azure VM can use a special keyword - ``ClientApplication.ATTEMPT_REGION_DISCOVERY`` to auto-detect region. + ``azure_region=None`` - meaning no region is used. This is the default value. + ``azure_region="some_region"`` - meaning the specified region is used. + ``azure_region=True`` - meaning MSAL will try to auto-detect the region. This is not recommended. .. note:: + Region auto-discovery has been tested on VMs and on Azure Functions. It is unreliable. + Applications using this option should configure a short timeout. - Setting ``azure_region`` to non-``None`` for an app running - outside of Azure Function/VM could hang indefinitely. - - You should consider opting in/out region behavior on-demand, - by loading ``azure_region=None`` or ``azure_region="westus"`` - or ``azure_region=True`` (which means opt-in and auto-detect) - from your per-deployment configuration, and then do - ``app = ConfidentialClientApplication(..., azure_region=azure_region)``. - - Alternatively, you can configure a short timeout, - or provide a custom http_client which has a short timeout. - That way, the latency would be under your control, - but still less performant than opting out of region feature. + For more details and for the values of the region string + see https://learn.microsoft.com/entra/msal/dotnet/resources/region-discovery-troubleshooting New in version 1.12.0. @@ -612,6 +583,8 @@ def _build_telemetry_context( correlation_id=correlation_id, refresh_reason=refresh_reason) def _get_regional_authority(self, central_authority): + if not self._region_configured: # User did not opt-in to ESTS-R + return None # Short circuit to completely bypass region detection self._region_detected = self._region_detected or _detect_region( self.http_client if self._region_configured is not None else None) if (self._region_configured != self.ATTEMPT_REGION_DISCOVERY From 607e702632ae94a7659e4c466868262d527ba995 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 5 Dec 2023 00:31:04 -0800 Subject: [PATCH 3/4] AT POP for Public Client based on broker (#511) * AT POP for Public Client based on broker Pop test case * Use token source during e2e tests * WIP: unsuccessful e2e test for POP SHR --- msal/__init__.py | 1 + msal/__main__.py | 11 +++ msal/application.py | 60 +++++++++++++- msal/auth_scheme.py | 34 ++++++++ msal/broker.py | 23 +++++- tests/test_e2e.py | 192 ++++++++++++++++++++++++++++++++++++-------- 6 files changed, 283 insertions(+), 38 deletions(-) create mode 100644 msal/auth_scheme.py diff --git a/msal/__init__.py b/msal/__init__.py index 4e2faaed..09b7a504 100644 --- a/msal/__init__.py +++ b/msal/__init__.py @@ -33,4 +33,5 @@ ) from .oauth2cli.oidc import Prompt from .token_cache import TokenCache, SerializableTokenCache +from .auth_scheme import PopAuthScheme diff --git a/msal/__main__.py b/msal/__main__.py index 8bd19d33..aeb123b0 100644 --- a/msal/__main__.py +++ b/msal/__main__.py @@ -22,6 +22,11 @@ _AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" _VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd" +placeholder_auth_scheme = msal.PopAuthScheme( + http_method=msal.PopAuthScheme.HTTP_GET, + url="https://example.com/endpoint", + nonce="placeholder", + ) def print_json(blob): print(json.dumps(blob, indent=2, sort_keys=True)) @@ -88,6 +93,9 @@ def _acquire_token_silent(app): _input_scopes(), account=account, force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), + auth_scheme=placeholder_auth_scheme + if app.is_pop_supported() and _input_boolean("Acquire AT POP via Broker?") + else None, )) def _acquire_token_interactive(app, scopes=None, data=None): @@ -117,6 +125,9 @@ def _acquire_token_interactive(app, scopes=None, data=None): ], # Here this test app mimics the setting for some known MSA-PT apps port=1234, # Hard coded for testing. Real app typically uses default value. prompt=prompt, login_hint=login_hint, data=data or {}, + auth_scheme=placeholder_auth_scheme + if app.is_pop_supported() and _input_boolean("Acquire AT POP via Broker?") + else None, ) if login_hint and "id_token_claims" in result: signed_in_user = result.get("id_token_claims", {}).get("preferred_username") diff --git a/msal/application.py b/msal/application.py index 52bd6835..a7ae7bc2 100644 --- a/msal/application.py +++ b/msal/application.py @@ -182,6 +182,10 @@ class ClientApplication(object): _TOKEN_SOURCE_BROKER = "broker" _enable_broker = False + _AUTH_SCHEME_UNSUPPORTED = ( + "auth_scheme is currently only available from broker. " + "You can enable broker by following these instructions. " + "https://msal-python.readthedocs.io/en/latest/#publicclientapplication") def __init__( self, client_id, @@ -557,6 +561,10 @@ def _decide_broker(self, allow_broker, enable_pii_log): "We will fallback to non-broker.") logger.debug("Broker enabled? %s", self._enable_broker) + def is_pop_supported(self): + """Returns True if this client supports Proof-of-Possession Access Token.""" + return self._enable_broker + def _decorate_scope( self, scopes, reserved_scope=frozenset(['openid', 'profile', 'offline_access'])): @@ -1185,6 +1193,7 @@ def acquire_token_silent( authority=None, # See get_authorization_request_url() force_refresh=False, # type: Optional[boolean] claims_challenge=None, + auth_scheme=None, **kwargs): """Acquire an access token for given account, without user interaction. @@ -1205,7 +1214,7 @@ def acquire_token_silent( return None # A backward-compatible NO-OP to drop the account=None usage result = _clean_up(self._acquire_token_silent_with_error( scopes, account, authority=authority, force_refresh=force_refresh, - claims_challenge=claims_challenge, **kwargs)) + claims_challenge=claims_challenge, auth_scheme=auth_scheme, **kwargs)) return result if result and "error" not in result else None def acquire_token_silent_with_error( @@ -1215,6 +1224,7 @@ def acquire_token_silent_with_error( authority=None, # See get_authorization_request_url() force_refresh=False, # type: Optional[boolean] claims_challenge=None, + auth_scheme=None, **kwargs): """Acquire an access token for given account, without user interaction. @@ -1241,6 +1251,12 @@ def acquire_token_silent_with_error( in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. + :param object auth_scheme: + You can provide an ``msal.auth_scheme.PopAuthScheme`` object + so that MSAL will get a Proof-of-Possession (POP) token for you. + + New in version 1.26.0. + :return: - A dict containing no "error" key, and typically contains an "access_token" key, @@ -1252,7 +1268,7 @@ def acquire_token_silent_with_error( return None # A backward-compatible NO-OP to drop the account=None usage return _clean_up(self._acquire_token_silent_with_error( scopes, account, authority=authority, force_refresh=force_refresh, - claims_challenge=claims_challenge, **kwargs)) + claims_challenge=claims_challenge, auth_scheme=auth_scheme, **kwargs)) def _acquire_token_silent_with_error( self, @@ -1261,6 +1277,7 @@ def _acquire_token_silent_with_error( authority=None, # See get_authorization_request_url() force_refresh=False, # type: Optional[boolean] claims_challenge=None, + auth_scheme=None, **kwargs): assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) @@ -1276,6 +1293,7 @@ def _acquire_token_silent_with_error( scopes, account, self.authority, force_refresh=force_refresh, claims_challenge=claims_challenge, correlation_id=correlation_id, + auth_scheme=auth_scheme, **kwargs) if result and "error" not in result: return result @@ -1298,6 +1316,7 @@ def _acquire_token_silent_with_error( scopes, account, the_authority, force_refresh=force_refresh, claims_challenge=claims_challenge, correlation_id=correlation_id, + auth_scheme=auth_scheme, **kwargs) if result: if "error" not in result: @@ -1322,12 +1341,13 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( claims_challenge=None, correlation_id=None, http_exceptions=None, + auth_scheme=None, **kwargs): # This internal method has two calling patterns: # it accepts a non-empty account to find token for a user, # and accepts account=None to find a token for the current app. access_token_from_cache = None - if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims + if not (force_refresh or claims_challenge or auth_scheme): # Then attempt AT cache query={ "client_id": self.client_id, "environment": authority.instance, @@ -1370,6 +1390,8 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( try: data = kwargs.get("data", {}) if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL: + if auth_scheme: + raise ValueError("auth_scheme is not supported in Cloud Shell") return self._acquire_token_by_cloud_shell(scopes, data=data) if self._enable_broker and account and account.get("account_source") in ( @@ -1385,6 +1407,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge), correlation_id=correlation_id, + auth_scheme=auth_scheme, **data) if response: # Broker provides a decisive outcome account_was_established_by_broker = account.get( @@ -1393,6 +1416,8 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( if account_was_established_by_broker or broker_attempt_succeeded_just_now: return self._process_broker_response(response, scopes, data) + if auth_scheme: + raise ValueError(self._AUTH_SCHEME_UNSUPPORTED) if account: result = self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, self._decorate_scope(scopes), account, @@ -1588,7 +1613,11 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): return response def acquire_token_by_username_password( - self, username, password, scopes, claims_challenge=None, **kwargs): + self, username, password, scopes, claims_challenge=None, + # Note: We shouldn't need to surface enable_msa_passthrough, + # because this ROPC won't work with MSA account anyway. + auth_scheme=None, + **kwargs): """Gets a token for a given resource via user credentials. See this page for constraints of Username Password Flow. @@ -1604,6 +1633,12 @@ def acquire_token_by_username_password( returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. + :param object auth_scheme: + You can provide an ``msal.auth_scheme.PopAuthScheme`` object + so that MSAL will get a Proof-of-Possession (POP) token for you. + + New in version 1.26.0. + :return: A dict representing the json response from AAD: - A successful response would contain "access_token" key, @@ -1623,9 +1658,12 @@ def acquire_token_by_username_password( self.authority._is_known_to_developer or self._instance_discovery is False) else None, claims=claims, + auth_scheme=auth_scheme, ) return self._process_broker_response(response, scopes, kwargs.get("data", {})) + if auth_scheme: + raise ValueError(self._AUTH_SCHEME_UNSUPPORTED) scopes = self._decorate_scope(scopes) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID) @@ -1768,6 +1806,7 @@ def acquire_token_interactive( max_age=None, parent_window_handle=None, on_before_launching_ui=None, + auth_scheme=None, **kwargs): """Acquire token interactively i.e. via a local browser. @@ -1843,6 +1882,12 @@ def acquire_token_interactive( New in version 1.20.0. + :param object auth_scheme: + You can provide an ``msal.auth_scheme.PopAuthScheme`` object + so that MSAL will get a Proof-of-Possession (POP) token for you. + + New in version 1.26.0. + :return: - A dict containing no "error" key, and typically contains an "access_token" key. @@ -1887,12 +1932,15 @@ def acquire_token_interactive( claims, data, on_before_launching_ui, + auth_scheme, prompt=prompt, login_hint=login_hint, max_age=max_age, ) return self._process_broker_response(response, scopes, data) + if auth_scheme: + raise ValueError("auth_scheme is currently only available from broker") on_before_launching_ui(ui="browser") telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_INTERACTIVE) @@ -1927,6 +1975,7 @@ def _acquire_token_interactive_via_broker( claims, # type: str data, # type: dict on_before_launching_ui, # type: callable + auth_scheme, # type: object prompt=None, login_hint=None, # type: Optional[str] max_age=None, @@ -1950,6 +1999,7 @@ def _acquire_token_interactive_via_broker( accounts[0]["local_account_id"], scopes, claims=claims, + auth_scheme=auth_scheme, **data) if response and "error" not in response: return response @@ -1962,6 +2012,7 @@ def _acquire_token_interactive_via_broker( claims=claims, max_age=max_age, enable_msa_pt=enable_msa_passthrough, + auth_scheme=auth_scheme, **data) is_wrong_account = bool( # _signin_silently() only gets tokens for default account, @@ -2002,6 +2053,7 @@ def _acquire_token_interactive_via_broker( claims=claims, max_age=max_age, enable_msa_pt=enable_msa_passthrough, + auth_scheme=auth_scheme, **data) def initiate_device_flow(self, scopes=None, **kwargs): diff --git a/msal/auth_scheme.py b/msal/auth_scheme.py new file mode 100644 index 00000000..841adab3 --- /dev/null +++ b/msal/auth_scheme.py @@ -0,0 +1,34 @@ +try: + from urllib.parse import urlparse +except ImportError: # Fall back to Python 2 + from urlparse import urlparse + +# We may support more auth schemes in the future +class PopAuthScheme(object): + HTTP_GET = "GET" + HTTP_POST = "POST" + HTTP_PUT = "PUT" + HTTP_DELETE = "DELETE" + HTTP_PATCH = "PATCH" + _HTTP_METHODS = (HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_DELETE, HTTP_PATCH) + # Internal design: https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PoPTokensProtocol/PopTokensProtocol.md + def __init__(self, http_method=None, url=None, nonce=None): + """Create an auth scheme which is needed to obtain a Proof-of-Possession token. + + :param str http_method: + Its value is an uppercase http verb, such as "GET" and "POST". + :param str url: + The url to be signed. + :param str nonce: + The nonce came from resource's challenge. + """ + if not (http_method and url and nonce): + # In the future, we may also support accepting an http_response as input + raise ValueError("All http_method, url and nonce are required parameters") + if http_method not in self._HTTP_METHODS: + raise ValueError("http_method must be uppercase, according to " + "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-signed-http-request-03#section-3") + self._http_method = http_method + self._url = urlparse(url) + self._nonce = nonce + diff --git a/msal/broker.py b/msal/broker.py index ea0366b3..a0904199 100644 --- a/msal/broker.py +++ b/msal/broker.py @@ -99,13 +99,17 @@ def _convert_result(result, client_id, expected_token_type=None): # Mimic an on assert account, "Account is expected to be always available" # Note: There are more account attribute getters available in pymsalruntime 0.13+ return_value = {k: v for k, v in { - "access_token": result.get_access_token(), + "access_token": + result.get_authorization_header() # It returns "pop SignedHttpRequest" + .split()[1] + if result.is_pop_authorization() else result.get_access_token(), "expires_in": result.get_access_token_expiry_time() - int(time.time()), # Convert epoch to count-down "id_token": result.get_raw_id_token(), # New in pymsalruntime 0.8.1 "id_token_claims": id_token_claims, "client_info": account.get_client_info(), "_account_id": account.get_account_id(), - "token_type": expected_token_type or "Bearer", # Workaround its absence from broker + "token_type": "pop" if result.is_pop_authorization() else ( + expected_token_type or "bearer"), # Workaround "ssh-cert"'s absence from broker }.items() if v} likely_a_cert = return_value["access_token"].startswith("AAAA") # Empirical observation if return_value["token_type"].lower() == "ssh-cert" and not likely_a_cert: @@ -128,11 +132,16 @@ def _enable_msa_pt(params): def _signin_silently( authority, client_id, scopes, correlation_id=None, claims=None, enable_msa_pt=False, + auth_scheme=None, **kwargs): params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority) params.set_requested_scopes(scopes) if claims: params.set_decoded_claims(claims) + if auth_scheme: + params.set_pop_params( + auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path, + auth_scheme._nonce) callback_data = _CallbackData() for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc. if v is not None: @@ -156,6 +165,7 @@ def _signin_interactively( claims=None, correlation_id=None, enable_msa_pt=False, + auth_scheme=None, **kwargs): params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority) params.set_requested_scopes(scopes) @@ -178,6 +188,10 @@ def _signin_interactively( params.set_additional_parameter("msal_gui_thread", "true") # Since pymsalruntime 0.8.1 if enable_msa_pt: _enable_msa_pt(params) + if auth_scheme: + params.set_pop_params( + auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path, + auth_scheme._nonce) for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc. if v is not None: params.set_additional_parameter(k, str(v)) @@ -197,6 +211,7 @@ def _signin_interactively( def _acquire_token_silently( authority, client_id, account_id, scopes, claims=None, correlation_id=None, + auth_scheme=None, **kwargs): # For MSA PT scenario where you use the /organizations, yes, # acquireTokenSilently is expected to fail. - Sam Wilson @@ -208,6 +223,10 @@ def _acquire_token_silently( params.set_requested_scopes(scopes) if claims: params.set_decoded_claims(claims) + if auth_scheme: + params.set_pop_params( + auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path, + auth_scheme._nonce) for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc. if v is not None: params.set_additional_parameter(k, str(v)) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 36e7a445..ace95a53 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,4 +1,4 @@ -"""If the following ENV VAR are available, many end-to-end test cases would run. +"""If the following ENV VAR were available, many end-to-end test cases would run. LAB_APP_CLIENT_SECRET=... LAB_OBO_CLIENT_SECRET=... LAB_APP_CLIENT_ID=... @@ -27,10 +27,23 @@ import msal from tests.http_client import MinimalHttpClient, MinimalResponse from msal.oauth2cli import AuthCodeReceiver +from msal.oauth2cli.oidc import decode_part +try: + import pymsalruntime + broker_available = True +except ImportError: + broker_available = False logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG if "-v" in sys.argv else logging.INFO) +try: + from dotenv import load_dotenv # Use this only in local dev machine + load_dotenv() # take environment variables from .env. +except ImportError: + logger.warn("Run pip install -r requirements.txt for optional dependency") + +_AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" def _get_app_and_auth_code( client_id, @@ -93,7 +106,7 @@ def assertLoosely(self, response, assertion=None, assertion() def assertCacheWorksForUser( - self, result_from_wire, scope, username=None, data=None): + self, result_from_wire, scope, username=None, data=None, auth_scheme=None): logger.debug( "%s: cache = %s, id_token_claims = %s", self.id(), @@ -109,35 +122,34 @@ def assertCacheWorksForUser( set(scope) <= set(result_from_wire["scope"].split(" ")) ): # Going to test acquire_token_silent(...) to locate an AT from cache - result_from_cache = self.app.acquire_token_silent( - scope, account=account, data=data or {}) - self.assertIsNotNone(result_from_cache) + silent_result = self.app.acquire_token_silent( + scope, account=account, data=data or {}, auth_scheme=auth_scheme) + self.assertIsNotNone(silent_result) self.assertIsNone( - result_from_cache.get("refresh_token"), "A cache hit returns no RT") - self.assertEqual( - result_from_wire['access_token'], result_from_cache['access_token'], - "We should get a cached AT") + silent_result.get("refresh_token"), "acquire_token_silent() should return no RT") + if auth_scheme: + self.assertNotEqual( + self.app._TOKEN_SOURCE_CACHE, silent_result[self.app._TOKEN_SOURCE]) + else: + self.assertEqual( + self.app._TOKEN_SOURCE_CACHE, silent_result[self.app._TOKEN_SOURCE]) if "refresh_token" in result_from_wire: + assert auth_scheme is None # Going to test acquire_token_silent(...) to obtain an AT by a RT from cache self.app.token_cache._cache["AccessToken"] = {} # A hacky way to clear ATs - result_from_cache = self.app.acquire_token_silent( - scope, account=account, data=data or {}) - if "refresh_token" not in result_from_wire: - self.assertEqual( - result_from_cache["access_token"], result_from_wire["access_token"], - "The previously cached AT should be returned") - self.assertIsNotNone(result_from_cache, + silent_result = self.app.acquire_token_silent( + scope, account=account, data=data or {}) + self.assertIsNotNone(silent_result, "We should get a result from acquire_token_silent(...) call") - self.assertIsNotNone( - # We used to assert it this way: - # result_from_wire['access_token'] != result_from_cache['access_token'] - # but ROPC in B2C tends to return the same AT we obtained seconds ago. - # Now looking back, "refresh_token grant would return a brand new AT" - # was just an empirical observation but never a commitment in specs, - # so we adjust our way to assert here. - (result_from_cache or {}).get("access_token"), - "We should get an AT from acquire_token_silent(...) call") + self.assertEqual( + # We used to assert it this way: + # result_from_wire['access_token'] != silent_result['access_token'] + # but ROPC in B2C tends to return the same AT we obtained seconds ago. + # Now looking back, "refresh_token grant would return a brand new AT" + # was just an empirical observation but never a commitment in specs, + # so we adjust our way to assert here. + self.app._TOKEN_SOURCE_IDP, silent_result[self.app._TOKEN_SOURCE]) def assertCacheWorksForApp(self, result_from_wire, scope): logger.debug( @@ -150,11 +162,9 @@ def assertCacheWorksForApp(self, result_from_wire, scope): self.app.acquire_token_silent(scope, account=None), "acquire_token_silent(..., account=None) shall always return None") # Going to test acquire_token_for_client(...) to locate an AT from cache - result_from_cache = self.app.acquire_token_for_client(scope) - self.assertIsNotNone(result_from_cache) - self.assertEqual( - result_from_wire['access_token'], result_from_cache['access_token'], - "We should get a cached AT") + silent_result = self.app.acquire_token_for_client(scope) + self.assertIsNotNone(silent_result) + self.assertEqual(self.app._TOKEN_SOURCE_CACHE, silent_result[self.app._TOKEN_SOURCE]) @classmethod def _build_app(cls, @@ -192,6 +202,7 @@ def _test_username_password(self, client_secret=None, # Since MSAL 1.11, confidential client has ROPC too azure_region=None, http_client=None, + auth_scheme=None, **ignored): assert authority and client_id and username and password and scope self.app = self._build_app( @@ -203,12 +214,14 @@ def _test_username_password(self, self.assertEqual( self.app.get_accounts(username=username), [], "Cache starts empty") result = self.app.acquire_token_by_username_password( - username, password, scopes=scope) + username, password, scopes=scope, auth_scheme=auth_scheme) self.assertLoosely(result) self.assertCacheWorksForUser( result, scope, username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C + auth_scheme=auth_scheme, ) + return result @unittest.skipIf( os.getenv("TRAVIS"), # It is set when running on TravisCI or Github Actions @@ -246,6 +259,7 @@ def _test_acquire_token_interactive( data=None, # Needed by ssh-cert feature prompt=None, enable_msa_passthrough=None, + auth_scheme=None, **ignored): assert client_id and authority and scope self.app = self._build_app(client_id, authority=authority) @@ -266,6 +280,7 @@ def _test_acquire_token_interactive( """.format(id=self.id(), hint=_get_hint( html_mode=True, username=username, lab_name=lab_name, username_uri=username_uri)), + auth_scheme=auth_scheme, data=data or {}, ) self.assertIn( @@ -279,7 +294,8 @@ def _test_acquire_token_interactive( username, result["id_token_claims"]["preferred_username"], "You are expected to sign in as account {}, but tokens returned is for {}".format( username, result["id_token_claims"]["preferred_username"])) - self.assertCacheWorksForUser(result, scope, username=None, data=data or {}) + self.assertCacheWorksForUser( + result, scope, username=None, data=data or {}, auth_scheme=auth_scheme) return result # For further testing @@ -1147,5 +1163,117 @@ def test_acquire_token_silent_with_an_empty_cache_should_return_none(self): # If this test case passes without exception, # it means MSAL Python is not affected by that. + +@unittest.skipUnless(broker_available, "AT POP feature is only supported by using broker") +class PopTestCase(LabBasedTestCase): + def test_at_pop_should_contain_pop_scheme_content(self): + auth_scheme = msal.PopAuthScheme( + http_method=msal.PopAuthScheme.HTTP_GET, + url="https://www.Contoso.com/Path1/Path2?queryParam1=a&queryParam2=b", + nonce="placeholder", + ) + result = self._test_acquire_token_interactive( + # Lab test users tend to get kicked out from WAM, we use local user to test + client_id=_AZURE_CLI, + authority="https://login.microsoftonline.com/organizations", + scope=["https://management.azure.com/.default"], + auth_scheme=auth_scheme, + ) # It also tests assertCacheWorksForUser() + self.assertEqual(result["token_source"], "broker", "POP is only supported by broker") + self.assertEqual(result["token_type"], "pop") + payload = json.loads(decode_part(result["access_token"].split(".")[1])) + logger.debug("AT POP payload = %s", json.dumps(payload, indent=2)) + self.assertEqual(payload["m"], auth_scheme._http_method) + self.assertEqual(payload["u"], auth_scheme._url.netloc) + self.assertEqual(payload["p"], auth_scheme._url.path) + self.assertEqual(payload["nonce"], auth_scheme._nonce) + + # TODO: Remove this, as ROPC support is removed by Broker-on-Win + def test_at_pop_via_testingsts_service(self): + """Based on https://testingsts.azurewebsites.net/ServerNonce""" + self.skipTest("ROPC support is removed by Broker-on-Win") + auth_scheme = msal.PopAuthScheme( + http_method="POST", + url="https://www.Contoso.com/Path1/Path2?queryParam1=a&queryParam2=b", + nonce=requests.get( + # TODO: Could use ".../missing" and then parse its WWW-Authenticate header + "https://testingsts.azurewebsites.net/servernonce/get").text, + ) + config = self.get_lab_user(usertype="cloud") + config["password"] = self.get_lab_user_secret(config["lab_name"]) + result = self._test_username_password(auth_scheme=auth_scheme, **config) + self.assertEqual(result["token_type"], "pop") + shr = result["access_token"] + payload = json.loads(decode_part(result["access_token"].split(".")[1])) + logger.debug("AT POP payload = %s", json.dumps(payload, indent=2)) + self.assertEqual(payload["m"], auth_scheme._http_method) + self.assertEqual(payload["u"], auth_scheme._url.netloc) + self.assertEqual(payload["p"], auth_scheme._url.path) + self.assertEqual(payload["nonce"], auth_scheme._nonce) + + validation = requests.post( + # TODO: This endpoint does not seem to validate the url + "https://testingsts.azurewebsites.net/servernonce/validateshr", + data={"SHR": shr}, + ) + self.assertEqual(validation.status_code, 200) + + def test_at_pop_calling_pattern(self): + # The calling pattern was described here: + # https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PoPTokensProtocol/PoP_API_In_MSAL.md&_a=preview&anchor=proposal-2---optional-isproofofposessionsupportedbyclient-helper-(accepted) + + # It is supposed to call app.is_pop_supported() first, + # and then fallback to bearer token code path. + # We skip it here because this test case has not yet initialize self.app + # assert self.app.is_pop_supported() + api_endpoint = "https://20.190.132.47/beta/me" + resp = requests.get(api_endpoint, verify=False) + self.assertEqual(resp.status_code, 401, "Initial call should end with an http 401 error") + result = self._get_shr_pop(**dict( + self.get_lab_user(usertype="cloud"), # This is generally not the current laptop's default AAD account + scope=["https://graph.microsoft.com/.default"], + auth_scheme=msal.PopAuthScheme( + http_method=msal.PopAuthScheme.HTTP_GET, + url=api_endpoint, + nonce=self._extract_pop_nonce(resp.headers.get("WWW-Authenticate")), + ), + )) + resp = requests.get(api_endpoint, verify=False, headers={ + "Authorization": "pop {}".format(result["access_token"]), + }) + if resp.status_code != 200: + # TODO https://teams.microsoft.com/l/message/19:b1697a70b1de43ddaea281d98ff2e985@thread.v2/1700184847801?context=%7B%22contextType%22%3A%22chat%22%7D + self.skipTest("We haven't got this end-to-end test case working") + self.assertEqual(resp.status_code, 200, "POP resource should be accessible") + + def _extract_pop_nonce(self, www_authenticate): + # This is a hack for testing purpose only. Do not use this in prod. + # FYI: There is a www-authenticate package but it falters when encountering realm="" + import re + found = re.search(r'nonce="(.+?)"', www_authenticate) + if found: + return found.group(1) + + def _get_shr_pop( + self, client_id=None, authority=None, scope=None, auth_scheme=None, + **kwargs): + result = self._test_acquire_token_interactive( + # Lab test users tend to get kicked out from WAM, we use local user to test + client_id=client_id, + authority=authority, + scope=scope, + auth_scheme=auth_scheme, + **kwargs) # It also tests assertCacheWorksForUser() + self.assertEqual(result["token_source"], "broker", "POP is only supported by broker") + self.assertEqual(result["token_type"], "pop") + payload = json.loads(decode_part(result["access_token"].split(".")[1])) + logger.debug("AT POP payload = %s", json.dumps(payload, indent=2)) + self.assertEqual(payload["m"], auth_scheme._http_method) + self.assertEqual(payload["u"], auth_scheme._url.netloc) + self.assertEqual(payload["p"], auth_scheme._url.path) + self.assertEqual(payload["nonce"], auth_scheme._nonce) + return result + + if __name__ == "__main__": unittest.main() From 35310b5953b4524461ddb38b82bec93ee535cad0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 29 Nov 2023 00:46:38 -0800 Subject: [PATCH 4/4] Prepare 1.26 release --- msal/application.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index a7ae7bc2..49de7cda 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.25.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.26.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" @@ -2201,8 +2201,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No """ telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID) - # The implementation is NOT based on Token Exchange - # https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16 + # The implementation is NOT based on Token Exchange (RFC 8693) response = _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521 user_assertion, self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs