Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-msal for openSUSE:Factory checked in at 2022-10-14 15:42:04 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-msal (Old) and /work/SRC/openSUSE:Factory/.python-msal.new.2275 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-msal" Fri Oct 14 15:42:04 2022 rev:12 rq:1010483 version:1.20.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-msal/python-msal.changes 2022-05-24 20:32:28.718978935 +0200 +++ /work/SRC/openSUSE:Factory/.python-msal.new.2275/python-msal.changes 2022-10-14 15:43:11.255983353 +0200 @@ -1,0 +2,23 @@ +Thu Oct 13 08:02:41 UTC 2022 - John Paul Adrian Glaubitz <[email protected]> + +- Update to version 1.20.0 + + New feature: If your app uses MSAL's acquire_token_interactive(), you can + now opt in to use broker on Windows platform to achieve Single-Sign-On (SSO) + and also obtain more secure tokens, all without switching the log-in experience + to a browser. See details in this online doc, and try it out from this sample. + (#451, #415) +- from version 1.19.0 + + New feature: A new ClientApplication(..., instance_discovery=False) parameter + to turn off MSAL's Instance Discovery behavior. See more details in its full + documentation. Also, ADFS authority will no longer trigger Instance Discovery. (#496) + + Enhancement: Use provided authority port when building the tenant discovery endpoint (#484) + + Bugfix: Fix a regression in regional endpoint which affects MSAL Python 1.14+ (#485) + + Enhancement: Tolerate home_account_id to be None +- from version 1.18.0 + + New feature: Optional initiate_auth_code_flow(..., response_mode="form_post") + to allow the auth code being delivered to your app by form post, which is + considered even more secure. (#396, #469) + + New feature: acquire_token_interactive(..., prompt="none") can obtain some + tokens from within Cloud Shell, without any prompt. (#420) + +------------------------------------------------------------------- Old: ---- msal-1.18.0b1.tar.gz New: ---- msal-1.20.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-msal.spec ++++++ --- /var/tmp/diff_new_pack.ZpXAMv/_old 2022-10-14 15:43:11.851984348 +0200 +++ /var/tmp/diff_new_pack.ZpXAMv/_new 2022-10-14 15:43:11.859984362 +0200 @@ -21,7 +21,7 @@ %define skip_python2 1 %endif Name: python-msal -Version: 1.18.0b1 +Version: 1.20.0 Release: 0 Summary: Microsoft Authentication Library (MSAL) for Python License: MIT ++++++ msal-1.18.0b1.tar.gz -> msal-1.20.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/PKG-INFO new/msal-1.20.0/PKG-INFO --- old/msal-1.18.0b1/PKG-INFO 2022-05-19 10:33:55.600890400 +0200 +++ new/msal-1.20.0/PKG-INFO 2022-10-07 07:22:32.308485300 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: msal -Version: 1.18.0b1 +Version: 1.20.0 Summary: 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. Home-page: https://github.com/AzureAD/microsoft-authentication-library-for-python Author: Microsoft Corporation @@ -21,6 +21,7 @@ Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Description-Content-Type: text/markdown +Provides-Extra: broker License-File: LICENSE # Microsoft Authentication Library (MSAL) for Python diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal/application.py new/msal-1.20.0/msal/application.py --- old/msal-1.18.0b1/msal/application.py 2022-05-19 10:33:40.000000000 +0200 +++ new/msal-1.20.0/msal/application.py 2022-10-07 07:22:21.000000000 +0200 @@ -13,11 +13,11 @@ from .oauth2cli import Client, JwtAssertionCreator from .oauth2cli.oidc import decode_part -from .authority import Authority +from .authority import Authority, WORLD_WIDE from .mex import send_request as mex_send_request from .wstrust_request import send_request as wst_send_request from .wstrust_response import * -from .token_cache import TokenCache +from .token_cache import TokenCache, _get_username import msal.telemetry from .region import _detect_region from .throttled_http_client import ThrottledHttpClient @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.18.0b1" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.20.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" @@ -67,8 +67,12 @@ def _clean_up(result): if isinstance(result, dict): - result.pop("refresh_in", None) # MSAL handled refresh_in, customers need not - return result + return { + k: result[k] for k in result + if k != "refresh_in" # MSAL handled refresh_in, customers need not + and not k.startswith('_') # Skim internal properties + } + return result # It could be None def _preferred_browser(): @@ -146,7 +150,6 @@ class ClientApplication(object): - ACQUIRE_TOKEN_SILENT_ID = "84" ACQUIRE_TOKEN_BY_REFRESH_TOKEN = "85" ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID = "301" @@ -174,6 +177,8 @@ # when we would eventually want to add this feature to PCA in future. exclude_scopes=None, http_cache=None, + instance_discovery=None, + allow_broker=None, ): """Create an instance of application. @@ -300,7 +305,7 @@ Client capability is implemented using "claims" parameter on the wire, for now. MSAL will combine them into - `claims parameter <https://openid.net/specs/openid-connect-core-1_0-final.html#ClaimsParameter`_ + `claims parameter <https://openid.net/specs/openid-connect-core-1_0-final.html#ClaimsParameter>`_ which you will later provide via one of the acquire-token request. :param str azure_region: @@ -409,11 +414,82 @@ Personally Identifiable Information (PII). Encryption is unnecessary. New in version 1.16.0. + + :param boolean instance_discovery: + Historically, MSAL would connect to a central endpoint located at + ``https://login.microsoftonline.com`` to acquire some metadata, + especially when using an unfamiliar authority. + This behavior is known as Instance Discovery. + + This parameter defaults to None, which enables the Instance Discovery. + + If you know some authorities which you allow MSAL to operate with as-is, + without involving any Instance Discovery, the recommended pattern is:: + + known_authorities = frozenset([ # Treat your known authorities as const + "https://contoso.com/adfs", "https://login.azs/foo"]) + ... + authority = "https://contoso.com/adfs" # Assuming your app will use this + app1 = PublicClientApplication( + "client_id", + authority=authority, + # Conditionally disable Instance Discovery for known authorities + instance_discovery=authority not in known_authorities, + ) + + If you do not know some authorities beforehand, + yet still want MSAL to accept any authority that you will provide, + you can use a ``False`` to unconditionally disable Instance Discovery. + + New in version 1.19.0. + + :param boolean allow_broker: + A broker is a component installed on your device. + Broker implicitly gives your device an identity. By using a broker, + your device becomes a factor that can satisfy MFA (Multi-factor authentication). + This factor would become mandatory + if a tenant's admin enables a corresponding Conditional Access (CA) policy. + The broker's presence allows Microsoft identity platform + to have higher confidence that the tokens are being issued to your device, + and that is more secure. + + An additional benefit of broker is, + it runs as a long-lived process with your device's OS, + and maintains its own cache, + so that your broker-enabled apps (even a CLI) + could automatically SSO from a previously established signed-in session. + + This parameter defaults to None, which means MSAL will not utilize a broker. + If this parameter is set to True, + MSAL will use the broker whenever possible, + and automatically fall back to non-broker behavior. + That also means your app does not need to enable broker conditionally, + you can always set allow_broker to True, + as long as your app meets the following prerequisite: + + * Installed optional dependency, e.g. ``pip install msal[broker]>=1.20,<2``. + (Note that broker is currently only available on Windows 10+) + + * Register a new redirect_uri for your desktop app as: + ``ms-appx-web://Microsoft.AAD.BrokerPlugin/your_client_id`` + + * Tested your app in following scenarios: + + * Windows 10+ + + * PublicClientApplication's following methods:: + acquire_token_interactive(), acquire_token_by_username_password(), + acquire_token_silent() (or acquire_token_silent_with_error()). + + * AAD and MSA accounts (i.e. Non-ADFS, non-B2C) + + New in version 1.20.0. """ self.client_id = client_id self.client_credential = client_credential self.client_claims = client_claims self._client_capabilities = client_capabilities + self._instance_discovery = instance_discovery if exclude_scopes and not isinstance(exclude_scopes, list): raise ValueError( @@ -453,9 +529,13 @@ # Here the self.authority will not be the same type as authority in input try: + authority_to_use = authority or "https://{}/common/".format(WORLD_WIDE) self.authority = Authority( - authority or "https://login.microsoftonline.com/common/", - self.http_client, validate_authority=validate_authority) + authority_to_use, + self.http_client, + validate_authority=validate_authority, + instance_discovery=self._instance_discovery, + ) except ValueError: # Those are explicit authority validation errors raise except Exception: # The rest are typically connection errors @@ -463,10 +543,28 @@ # Since caller opts in to use region, here we tolerate connection # errors happened during authority validation at non-region endpoint self.authority = Authority( - authority or "https://login.microsoftonline.com/common/", - self.http_client, validate_authority=False) + authority_to_use, + self.http_client, + instance_discovery=False, + ) else: raise + is_confidential_app = bool( + isinstance(self, ConfidentialClientApplication) or self.client_credential) + if is_confidential_app and allow_broker: + raise ValueError("allow_broker=True is only supported in PublicClientApplication") + self._enable_broker = False + if (allow_broker and not is_confidential_app + and sys.platform == "win32" + and not self.authority.is_adfs and not self.authority._is_b2c): + try: + from . import broker # Trigger Broker's initialization + self._enable_broker = True + except RuntimeError: + logger.exception( + "Broker is unavailable on this platform. " + "We will fallback to non-broker.") + logger.debug("Broker enabled? %s", self._enable_broker) self.token_cache = token_cache or TokenCache() self._region_configured = azure_region @@ -526,16 +624,19 @@ if region_to_use: regional_host = ("{}.r.login.microsoftonline.com".format(region_to_use) if central_authority.instance in ( - # The list came from https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629400328 + # The list came from point 3 of the algorithm section in this internal doc + # https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PinAuthToRegion/AAD%20SDK%20Proposal%20to%20Pin%20Auth%20to%20region.md&anchor=algorithm&_a=preview "login.microsoftonline.com", + "login.microsoft.com", "login.windows.net", "sts.windows.net", ) else "{}.{}".format(region_to_use, central_authority.instance)) - return Authority( + return Authority( # The central_authority has already been validated "https://{}/{}".format(regional_host, central_authority.tenant), self.http_client, - validate_authority=False) # The central_authority has already been validated + instance_discovery=False, + ) return None def _build_client(self, client_credential, authority, skip_regional_client=False): @@ -787,7 +888,8 @@ # Multi-tenant app can use new authority on demand the_authority = Authority( authority, - self.http_client + self.http_client, + instance_discovery=self._instance_discovery, ) if authority else self.authority client = _ClientWithCcsRoutingInfo( @@ -1010,14 +1112,23 @@ } return list(grouped_accounts.values()) + def _get_instance_metadata(self): # This exists so it can be mocked in unit test + resp = self.http_client.get( + "https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize", # TBD: We may extend this to use self._instance_discovery endpoint + headers={'Accept': 'application/json'}) + resp.raise_for_status() + return json.loads(resp.text)['metadata'] + def _get_authority_aliases(self, instance): + if self._instance_discovery is False: + return [] + if self.authority._is_known_to_developer: + # Then it is an ADFS/B2C/known_authority_hosts situation + # which may not reach the central endpoint, so we skip it. + return [] if not self.authority_groups: - resp = self.http_client.get( - "https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize", - headers={'Accept': 'application/json'}) - resp.raise_for_status() self.authority_groups = [ - set(group['aliases']) for group in json.loads(resp.text)['metadata']] + set(group['aliases']) for group in self._get_instance_metadata()] for group in self.authority_groups: if instance in group: return [alias for alias in group if alias != instance] @@ -1026,6 +1137,11 @@ def remove_account(self, account): """Sign me out and forget me from token cache""" self._forget_me(account) + if self._enable_broker: + from .broker import _signout_silently + error = _signout_silently(self.client_id, account["local_account_id"]) + if error: + logger.debug("_signout_silently() returns error: %s", error) def _sign_out(self, home_account): # Remove all relevant RTs and ATs from token cache @@ -1166,6 +1282,7 @@ # the_authority = Authority( # authority, # self.http_client, + # instance_discovery=self._instance_discovery, # ) if authority else self.authority result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, self.authority, force_refresh=force_refresh, @@ -1187,7 +1304,8 @@ the_authority = Authority( "https://" + alias + "/" + self.authority.tenant, self.http_client, - validate_authority=False) + instance_discovery=False, + ) result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, the_authority, force_refresh=force_refresh, claims_challenge=claims_challenge, @@ -1253,9 +1371,24 @@ refresh_reason = msal.telemetry.FORCE_REFRESH # TODO: It could also mean claims_challenge assert refresh_reason, "It should have been established at this point" try: + data = kwargs.get("data", {}) if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL: - return self._acquire_token_by_cloud_shell( - scopes, data=kwargs.get("data")) + return self._acquire_token_by_cloud_shell(scopes, data=data) + + if self._enable_broker and account is not None and data.get("token_type") != "ssh-cert": + from .broker import _acquire_token_silently + response = _acquire_token_silently( + "https://{}/{}".format(self.authority.instance, self.authority.tenant), + self.client_id, + account["local_account_id"], + scopes, + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge), + correlation_id=correlation_id, + **data) + if response: # The broker provided a decisive outcome, so we use it + return self._process_broker_response(response, scopes, data) + result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, self._decorate_scope(scopes), account, refresh_reason=refresh_reason, claims_challenge=claims_challenge, @@ -1269,6 +1402,18 @@ raise # We choose to bubble up the exception return access_token_from_cache + def _process_broker_response(self, response, scopes, data): + if "error" not in response: + self.token_cache.add(dict( + client_id=self.client_id, + scope=response["scope"].split() if "scope" in response else scopes, + token_endpoint=self.authority.token_endpoint, + response=response.copy(), + data=data, + _account_id=response["_account_id"], + )) + return _clean_up(response) + def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self, authority, scopes, account, **kwargs): query = { @@ -1340,7 +1485,7 @@ reverse=True): logger.debug("Cache attempts an RT") headers = telemetry_context.generate_headers() - if "home_account_id" in query: # Then use it as CCS Routing info + if query.get("home_account_id"): # Then use it as CCS Routing info headers["X-AnchorMailbox"] = "Oid:{}".format( # case-insensitive value query["home_account_id"].replace(".", "@")) response = client.obtain_token_by_refresh_token( @@ -1445,14 +1590,28 @@ - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ + claims = _merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge) + if self._enable_broker: + from .broker import _signin_silently + response = _signin_silently( + "https://{}/{}".format(self.authority.instance, self.authority.tenant), + self.client_id, + scopes, # Decorated scopes won't work due to offline_access + MSALRuntime_Username=username, + MSALRuntime_Password=password, + validateAuthority="no" if ( + self.authority._is_known_to_developer + or self._instance_discovery is False) else None, + claims=claims, + ) + return self._process_broker_response(response, scopes, kwargs.get("data", {})) + scopes = self._decorate_scope(scopes) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID) headers = telemetry_context.generate_headers() - data = dict( - kwargs.pop("data", {}), - claims=_merge_claims_challenge_and_capabilities( - self._client_capabilities, claims_challenge)) + data = dict(kwargs.pop("data", {}), claims=claims) if not self.authority.is_adfs: user_realm_result = self.authority.user_realm_discovery( username, correlation_id=headers[msal.telemetry.CLIENT_REQUEST_ID]) @@ -1518,6 +1677,7 @@ class PublicClientApplication(ClientApplication): # browser app or mobile app DEVICE_FLOW_CORRELATION_ID = "_correlation_id" + CONSOLE_WINDOW_HANDLE = object() def __init__(self, client_id, client_credential=None, **kwargs): if client_credential is not None: @@ -1536,11 +1696,16 @@ port=None, extra_scopes_to_consent=None, max_age=None, + parent_window_handle=None, + on_before_launching_ui=None, **kwargs): """Acquire token interactively i.e. via a local browser. Prerequisite: In Azure Portal, configure the Redirect URI of your "Mobile and Desktop application" as ``http://localhost``. + If you opts in to use broker during ``PublicClientApplication`` creation, + your app also need this Redirect URI: + ``ms-appx-web://Microsoft.AAD.BrokerPlugin/YOUR_CLIENT_ID`` :param list scopes: It is a list of case-sensitive strings. @@ -1592,17 +1757,72 @@ New in version 1.15. + :param int parent_window_handle: + OPTIONAL. If your app is a GUI app running on modern Windows system, + and your app opts in to use broker, + you are recommended to also provide its window handle, + so that the sign in UI window will properly pop up on top of your window. + + New in version 1.20.0. + + :param function on_before_launching_ui: + A callback with the form of + ``lambda ui="xyz", **kwargs: print("A {} will be launched".format(ui))``, + where ``ui`` will be either "browser" or "broker". + You can use it to inform your end user to expect a pop-up window. + + New in version 1.20.0. + :return: - A dict containing no "error" key, and typically contains an "access_token" key. - A dict containing an "error" key, when token refresh failed. """ - self._validate_ssh_cert_input_data(kwargs.get("data", {})) + data = kwargs.pop("data", {}) + enable_msa_passthrough = kwargs.pop( # MUST remove it from kwargs + "enable_msa_passthrough", # Keep it as a hidden param, for now. + # OPTIONAL. MSA-Passthrough is a legacy configuration, + # needed by a small amount of Microsoft first-party apps, + # which would login MSA accounts via ".../organizations" authority. + # If you app belongs to this category, AND you are enabling broker, + # you would want to enable this flag. Default value is False. + # More background of MSA-PT is available from this internal docs: + # https://microsoft.sharepoint.com/:w:/t/Identity-DevEx/EatIUauX3c9Ctw1l7AQ6iM8B5CeBZxc58eoQCE0IuZ0VFw?e=tgc3jP&CID=39c853be-76ea-79d7-ee73-f1b2706ede05 + False + ) and data.get("token_type") != "ssh-cert" # Work around a known issue as of PyMsalRuntime 0.8 + self._validate_ssh_cert_input_data(data) + if not on_before_launching_ui: + on_before_launching_ui = lambda **kwargs: None if _is_running_in_cloud_shell() and prompt == "none": - return self._acquire_token_by_cloud_shell( - scopes, data=kwargs.pop("data", {})) + # Note: _acquire_token_by_cloud_shell() is always silent, + # so we would not fire on_before_launching_ui() + return self._acquire_token_by_cloud_shell(scopes, data=data) claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) + if self._enable_broker and data.get("token_type") != "ssh-cert": + if parent_window_handle is None: + raise ValueError( + "parent_window_handle is required when you opted into using broker. " + "You need to provide the window handle of your GUI application, " + "or use msal.PublicClientApplication.CONSOLE_WINDOW_HANDLE " + "when and only when your application is a console app.") + if extra_scopes_to_consent: + logger.warning( + "Ignoring parameter extra_scopes_to_consent, " + "which is not supported by broker") + return self._acquire_token_interactive_via_broker( + scopes, + parent_window_handle, + enable_msa_passthrough, + claims, + data, + on_before_launching_ui, + prompt=prompt, + login_hint=login_hint, + max_age=max_age, + ) + + on_before_launching_ui(ui="browser") telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_INTERACTIVE) response = _clean_up(self.client.obtain_token_by_browser( @@ -1619,13 +1839,101 @@ "claims": claims, "domain_hint": domain_hint, }, - data=dict(kwargs.pop("data", {}), claims=claims), + data=dict(data, claims=claims), headers=telemetry_context.generate_headers(), browser_name=_preferred_browser(), **kwargs)) telemetry_context.update_telemetry(response) return response + def _acquire_token_interactive_via_broker( + self, + scopes, # type: list[str] + parent_window_handle, # type: int + enable_msa_passthrough, # type: boolean + claims, # type: str + data, # type: dict + on_before_launching_ui, # type: callable + prompt=None, + login_hint=None, # type: Optional[str] + max_age=None, + **kwargs): + from .broker import _signin_interactively, _signin_silently, _acquire_token_silently + if "welcome_template" in kwargs: + logger.debug(kwargs["welcome_template"]) # Experimental + authority = "https://{}/{}".format( + self.authority.instance, self.authority.tenant) + validate_authority = "no" if ( + self.authority._is_known_to_developer + or self._instance_discovery is False) else None + # Calls different broker methods to mimic the OIDC behaviors + if login_hint and prompt != "select_account": # OIDC prompts when the user did not sign in + accounts = self.get_accounts(username=login_hint) + if len(accounts) == 1: # Unambiguously proceed with this account + logger.debug("Calling broker._acquire_token_silently()") + response = _acquire_token_silently( # When it works, it bypasses prompt + authority, + self.client_id, + accounts[0]["local_account_id"], + scopes, + claims=claims, + **data) + if response and "error" not in response: + return self._process_broker_response(response, scopes, data) + # login_hint undecisive or not exists + if prompt == "none" or not prompt: # Must/Can attempt _signin_silently() + logger.debug("Calling broker._signin_silently()") + response = _signin_silently( # Unlike OIDC, it doesn't honor login_hint + authority, self.client_id, scopes, + validateAuthority=validate_authority, + claims=claims, + max_age=max_age, + enable_msa_pt=enable_msa_passthrough, + **data) + is_wrong_account = bool( + # _signin_silently() only gets tokens for default account, + # but this seems to have been fixed in PyMsalRuntime 0.11.2 + "access_token" in response and login_hint + and response.get("id_token_claims", {}) != login_hint) + wrong_account_error_message = ( + 'prompt="none" will not work for login_hint="non-default-user"') + if is_wrong_account: + logger.debug(wrong_account_error_message) + if prompt == "none": + return self._process_broker_response( # It is either token or error + response, scopes, data + ) if not is_wrong_account else { + "error": "broker_error", + "error_description": wrong_account_error_message, + } + else: + assert bool(prompt) is False + from pymsalruntime import Response_Status + recoverable_errors = frozenset([ + Response_Status.Status_AccountUnusable, + Response_Status.Status_InteractionRequired, + ]) + if is_wrong_account or "error" in response and response.get( + "_broker_status") in recoverable_errors: + pass # It will fall back to the _signin_interactively() + else: + return self._process_broker_response(response, scopes, data) + + logger.debug("Falls back to broker._signin_interactively()") + on_before_launching_ui(ui="broker") + response = _signin_interactively( + authority, self.client_id, scopes, + None if parent_window_handle is self.CONSOLE_WINDOW_HANDLE + else parent_window_handle, + validateAuthority=validate_authority, + login_hint=login_hint, + prompt=prompt, + claims=claims, + max_age=max_age, + enable_msa_pt=enable_msa_passthrough, + **data) + return self._process_broker_response(response, scopes, data) + def initiate_device_flow(self, scopes=None, **kwargs): """Initiate a Device Flow instance, which will be used in :func:`~acquire_token_by_device_flow`. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal/authority.py new/msal-1.20.0/msal/authority.py --- old/msal-1.18.0b1/msal/authority.py 2022-05-19 10:33:40.000000000 +0200 +++ new/msal-1.20.0/msal/authority.py 2022-10-07 07:22:21.000000000 +0200 @@ -58,7 +58,11 @@ "authority.http_client might be removed in MSAL Python 1.21+", DeprecationWarning) return self._http_client - def __init__(self, authority_url, http_client, validate_authority=True): + def __init__( + self, authority_url, http_client, + validate_authority=True, + instance_discovery=None, + ): """Creates an authority instance, and also validates it. :param validate_authority: @@ -67,19 +71,34 @@ This parameter only controls whether an instance discovery will be performed. """ + # :param instance_discovery: + # By default, the known-to-Microsoft validation will use an + # instance discovery endpoint located at ``login.microsoftonline.com``. + # You can customize the endpoint by providing a url as a string. + # Or you can turn this behavior off by passing in a False here. self._http_client = http_client if isinstance(authority_url, AuthorityBuilder): authority_url = str(authority_url) authority, self.instance, tenant = canonicalize(authority_url) + self.is_adfs = tenant.lower() == 'adfs' parts = authority.path.split('/') - is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS) or ( - len(parts) == 3 and parts[2].lower().startswith("b2c_")) - if (tenant != "adfs" and (not is_b2c) and validate_authority - and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS): - payload = instance_discovery( + self._is_b2c = any( + self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS + ) or (len(parts) == 3 and parts[2].lower().startswith("b2c_")) + self._is_known_to_developer = self.is_adfs or self._is_b2c or not validate_authority + is_known_to_microsoft = self.instance in WELL_KNOWN_AUTHORITY_HOSTS + instance_discovery_endpoint = 'https://{}/common/discovery/instance'.format( # Note: This URL seemingly returns V1 endpoint only + WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too + # See https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadInstanceDiscovery.cs#L101-L103 + # and https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadAuthority.cs#L19-L33 + ) if instance_discovery in (None, True) else instance_discovery + if instance_discovery_endpoint and not ( + is_known_to_microsoft or self._is_known_to_developer): + payload = _instance_discovery( "https://{}{}/oauth2/v2.0/authorize".format( self.instance, authority.path), - self._http_client) + self._http_client, + instance_discovery_endpoint) if payload.get("error") == "invalid_instance": raise ValueError( "invalid_instance: " @@ -91,8 +110,9 @@ tenant_discovery_endpoint = payload['tenant_discovery_endpoint'] else: tenant_discovery_endpoint = ( - 'https://{}{}{}/.well-known/openid-configuration'.format( + 'https://{}:{}{}{}/.well-known/openid-configuration'.format( self.instance, + 443 if authority.port is None else authority.port, authority.path, # In B2C scenario, it is "/tenant/policy" "" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint )) @@ -112,7 +132,6 @@ self.token_endpoint = openid_config['token_endpoint'] self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint') _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID - self.is_adfs = self.tenant.lower() == 'adfs' def user_realm_discovery(self, username, correlation_id=None, response=None): # It will typically return a dict containing "ver", "account_type", @@ -144,13 +163,9 @@ % authority_url) return authority, authority.hostname, parts[1] -def instance_discovery(url, http_client, **kwargs): - resp = http_client.get( # Note: This URL seemingly returns V1 endpoint only - 'https://{}/common/discovery/instance'.format( - WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too - # See https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadInstanceDiscovery.cs#L101-L103 - # and https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadAuthority.cs#L19-L33 - ), +def _instance_discovery(url, http_client, instance_discovery_endpoint, **kwargs): + resp = http_client.get( + instance_discovery_endpoint, params={'authorization_endpoint': url, 'api-version': '1.0'}, **kwargs) return json.loads(resp.text) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal/broker.py new/msal-1.20.0/msal/broker.py --- old/msal-1.18.0b1/msal/broker.py 1970-01-01 01:00:00.000000000 +0100 +++ new/msal-1.20.0/msal/broker.py 2022-10-07 07:22:21.000000000 +0200 @@ -0,0 +1,237 @@ +"""This module is an adaptor to the underlying broker. +It relies on PyMsalRuntime which is the package providing broker's functionality. +""" +from threading import Event +import json +import logging +import time +import uuid + + +logger = logging.getLogger(__name__) +try: + import pymsalruntime # Its API description is available in site-packages/pymsalruntime/PyMsalRuntime.pyi + pymsalruntime.register_logging_callback(lambda message, level: { # New in pymsalruntime 0.7 + pymsalruntime.LogLevel.TRACE: logger.debug, # Python has no TRACE level + pymsalruntime.LogLevel.DEBUG: logger.debug, + # Let broker's excess info, warning and error logs map into default DEBUG, for now + #pymsalruntime.LogLevel.INFO: logger.info, + #pymsalruntime.LogLevel.WARNING: logger.warning, + #pymsalruntime.LogLevel.ERROR: logger.error, + pymsalruntime.LogLevel.FATAL: logger.critical, + }.get(level, logger.debug)(message)) +except (ImportError, AttributeError): # AttributeError happens when a prior pymsalruntime uninstallation somehow leaved an empty folder behind + # PyMsalRuntime currently supports these Windows versions, listed in this MSFT internal link + # https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/2406/files + raise ImportError( # TODO: Remove or adjust this line right before merging this PR + 'You need to install dependency by: pip install "msal[broker]>=1.20,<2"') +# It could throw RuntimeError when running on ancient versions of Windows + + +class RedirectUriError(ValueError): + pass + + +class TokenTypeError(ValueError): + pass + + +class _CallbackData: + def __init__(self): + self.signal = Event() + self.result = None + + def complete(self, result): + self.signal.set() + self.result = result + + +def _convert_error(error, client_id): + context = error.get_context() # Available since pymsalruntime 0.0.4 + if ( + "AADSTS50011" in context # In WAM, this could happen on both interactive and silent flows + or "AADSTS7000218" in context # This "request body must contain ... client_secret" is just a symptom of current app has no WAM redirect_uri + ): + raise RedirectUriError( # This would be seen by either the app developer or end user + "MsalRuntime won't work unless this one more redirect_uri is registered to current app: " + "ms-appx-web://Microsoft.AAD.BrokerPlugin/{}".format(client_id)) + # OTOH, AAD would emit other errors when other error handling branch was hit first, + # so, the AADSTS50011/RedirectUriError is not guaranteed to happen. + return { + "error": "broker_error", # Note: Broker implies your device needs to be compliant. + # You may use "dsregcmd /status" to check your device state + # https://docs.microsoft.com/en-us/azure/active-directory/devices/troubleshoot-device-dsregcmd + "error_description": "{}. Status: {}, Error code: {}, Tag: {}".format( + context, + error.get_status(), error.get_error_code(), error.get_tag()), + "_broker_status": error.get_status(), + "_broker_error_code": error.get_error_code(), + "_broker_tag": error.get_tag(), + } + + +def _read_account_by_id(account_id, correlation_id): + """Return an instance of MSALRuntimeError or MSALRuntimeAccount, or None""" + callback_data = _CallbackData() + pymsalruntime.read_account_by_id( + account_id, + correlation_id, + lambda result, callback_data=callback_data: callback_data.complete(result) + ) + callback_data.signal.wait() + return (callback_data.result.get_error() or callback_data.result.get_account() + or None) # None happens when the account was not created by broker + + +def _convert_result(result, client_id, expected_token_type=None): # Mimic an on-the-wire response from AAD + error = result.get_error() + if error: + return _convert_error(error, client_id) + id_token_claims = json.loads(result.get_id_token()) if result.get_id_token() else {} + account = result.get_account() + 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(), + "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 + }.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: + raise TokenTypeError("Broker could not get an SSH Cert: {}...".format( + return_value["access_token"][:8])) + granted_scopes = result.get_granted_scopes() # New in pymsalruntime 0.3.x + if granted_scopes: + return_value["scope"] = " ".join(granted_scopes) # Mimic the on-the-wire data format + return return_value + + +def _get_new_correlation_id(): + return str(uuid.uuid4()) + + +def _enable_msa_pt(params): + params.set_additional_parameter("msal_request_type", "consumer_passthrough") # PyMsalRuntime 0.8+ + + +def _signin_silently( + authority, client_id, scopes, correlation_id=None, claims=None, + enable_msa_pt=False, + **kwargs): + params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority) + params.set_requested_scopes(scopes) + if claims: + params.set_decoded_claims(claims) + 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: + params.set_additional_parameter(k, str(v)) + if enable_msa_pt: + _enable_msa_pt(params) + pymsalruntime.signin_silently( + params, + correlation_id or _get_new_correlation_id(), + lambda result, callback_data=callback_data: callback_data.complete(result)) + callback_data.signal.wait() + return _convert_result( + callback_data.result, client_id, expected_token_type=kwargs.get("token_type")) + + +def _signin_interactively( + authority, client_id, scopes, + parent_window_handle, # None means auto-detect for console apps + prompt=None, # Note: This function does not really use this parameter + login_hint=None, + claims=None, + correlation_id=None, + enable_msa_pt=False, + **kwargs): + params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority) + params.set_requested_scopes(scopes) + params.set_redirect_uri("placeholder") # pymsalruntime 0.1 requires non-empty str, + # the actual redirect_uri will be overridden by a value hardcoded by the broker + if prompt: + if prompt == "select_account": + if login_hint: + # FWIW, AAD's browser interactive flow would honor select_account + # and ignore login_hint in such a case. + # But pymsalruntime 0.3.x would pop up a meaningless account picker + # and then force the account_hint user to re-input password. Not what we want. + # https://identitydivision.visualstudio.com/Engineering/_workitems/edit/1744492 + login_hint = None # Mimicing the AAD behavior + logger.warning("Using both select_account and login_hint is ambiguous. Ignoring login_hint.") + else: + logger.warning("prompt=%s is not supported by this module", prompt) + if parent_window_handle is None: + # This fixes account picker hanging in IDE debug mode on some machines + params.set_additional_parameter("msal_gui_thread", "true") # Since pymsalruntime 0.8.1 + if enable_msa_pt: + _enable_msa_pt(params) + 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)) + if claims: + params.set_decoded_claims(claims) + callback_data = _CallbackData() + pymsalruntime.signin_interactively( + parent_window_handle or pymsalruntime.get_console_window() or pymsalruntime.get_desktop_window(), # Since pymsalruntime 0.2+ + params, + correlation_id or _get_new_correlation_id(), + login_hint, # None value will be accepted since pymsalruntime 0.3+ + lambda result, callback_data=callback_data: callback_data.complete(result)) + callback_data.signal.wait() + return _convert_result( + callback_data.result, client_id, expected_token_type=kwargs.get("token_type")) + + +def _acquire_token_silently( + authority, client_id, account_id, scopes, claims=None, correlation_id=None, + **kwargs): + # For MSA PT scenario where you use the /organizations, yes, + # acquireTokenSilently is expected to fail. - Sam Wilson + correlation_id = correlation_id or _get_new_correlation_id() + account = _read_account_by_id(account_id, correlation_id) + if isinstance(account, pymsalruntime.MSALRuntimeError): + return _convert_error(account, client_id) + if account is None: + return + params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority) + params.set_requested_scopes(scopes) + if claims: + params.set_decoded_claims(claims) + 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)) + callback_data = _CallbackData() + pymsalruntime.acquire_token_silently( + params, + correlation_id, + account, + lambda result, callback_data=callback_data: callback_data.complete(result)) + callback_data.signal.wait() + return _convert_result( + callback_data.result, client_id, expected_token_type=kwargs.get("token_type")) + + +def _signout_silently(client_id, account_id, correlation_id=None): + correlation_id = correlation_id or _get_new_correlation_id() + account = _read_account_by_id(account_id, correlation_id) + if isinstance(account, pymsalruntime.MSALRuntimeError): + return _convert_error(account, client_id) + if account is None: + return + callback_data = _CallbackData() + pymsalruntime.signout_silently( # New in PyMsalRuntime 0.7 + client_id, + correlation_id, + account, + lambda result, callback_data=callback_data: callback_data.complete(result)) + callback_data.signal.wait() + error = callback_data.result.get_error() + if error: + return _convert_error(error, client_id) + diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal/oauth2cli/assertion.py new/msal-1.20.0/msal/oauth2cli/assertion.py --- old/msal-1.18.0b1/msal/oauth2cli/assertion.py 2022-05-19 10:33:40.000000000 +0200 +++ new/msal-1.20.0/msal/oauth2cli/assertion.py 2022-10-07 07:22:21.000000000 +0200 @@ -115,7 +115,7 @@ payload, self.key, algorithm=self.algorithm, headers=self.headers) return _str2bytes(str_or_bytes) # We normalize them into bytes except: - if self.algorithm.startswith("RS") or self.algorithm.starswith("ES"): + if self.algorithm.startswith("RS") or self.algorithm.startswith("ES"): logger.exception( 'Some algorithms requires "pip install cryptography". ' 'See https://pyjwt.readthedocs.io/en/latest/installation.html#cryptographic-dependencies-optional') diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal/oauth2cli/oauth2.py new/msal-1.20.0/msal/oauth2cli/oauth2.py --- old/msal-1.18.0b1/msal/oauth2cli/oauth2.py 2022-05-19 10:33:40.000000000 +0200 +++ new/msal-1.20.0/msal/oauth2cli/oauth2.py 2022-10-07 07:22:21.000000000 +0200 @@ -139,8 +139,7 @@ """ if not server_configuration: raise ValueError("Missing input parameter server_configuration") - if not client_id: - raise ValueError("Missing input parameter client_id") + # Generally we should have client_id, but we tolerate its absence self.configuration = server_configuration self.client_id = client_id self.client_secret = client_secret diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal/token_cache.py new/msal-1.20.0/msal/token_cache.py --- old/msal-1.18.0b1/msal/token_cache.py 2022-05-19 10:33:40.000000000 +0200 +++ new/msal-1.20.0/msal/token_cache.py 2022-10-07 07:22:21.000000000 +0200 @@ -12,6 +12,10 @@ def is_subdict_of(small, big): return dict(big, **small) == big +def _get_username(id_token_claims): + return id_token_claims.get( + "preferred_username", # AAD + id_token_claims.get("upn")) # ADFS 2019 class TokenCache(object): """This is considered as a base class containing minimal cache behavior. @@ -149,10 +153,9 @@ access_token = response.get("access_token") refresh_token = response.get("refresh_token") id_token = response.get("id_token") - id_token_claims = ( - decode_id_token(id_token, client_id=event["client_id"]) - if id_token - else response.get("id_token_claims", {})) # Broker would provide id_token_claims + id_token_claims = response.get("id_token_claims") or ( # Prefer the claims from broker + # Only use decode_id_token() when necessary, it contains time-sensitive validation + decode_id_token(id_token, client_id=event["client_id"]) if id_token else {}) client_info, home_account_id = self.__parse_account(response, id_token_claims) target = ' '.join(event.get("scope") or []) # Per schema, we don't sort it @@ -190,10 +193,11 @@ "home_account_id": home_account_id, "environment": environment, "realm": realm, - "local_account_id": id_token_claims.get( - "oid", id_token_claims.get("sub")), - "username": id_token_claims.get("preferred_username") # AAD - or id_token_claims.get("upn") # ADFS 2019 + "local_account_id": event.get( + "_account_id", # Came from mid-tier code path. + # Emperically, it is the oid in AAD or cid in MSA. + id_token_claims.get("oid", id_token_claims.get("sub"))), + "username": _get_username(id_token_claims) or data.get("username") # Falls back to ROPC username or event.get("username") # Falls back to Federated ROPC username or "", # The schema does not like null diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal.egg-info/PKG-INFO new/msal-1.20.0/msal.egg-info/PKG-INFO --- old/msal-1.18.0b1/msal.egg-info/PKG-INFO 2022-05-19 10:33:55.000000000 +0200 +++ new/msal-1.20.0/msal.egg-info/PKG-INFO 2022-10-07 07:22:32.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: msal -Version: 1.18.0b1 +Version: 1.20.0 Summary: 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. Home-page: https://github.com/AzureAD/microsoft-authentication-library-for-python Author: Microsoft Corporation @@ -21,6 +21,7 @@ Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Description-Content-Type: text/markdown +Provides-Extra: broker License-File: LICENSE # Microsoft Authentication Library (MSAL) for Python diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal.egg-info/SOURCES.txt new/msal-1.20.0/msal.egg-info/SOURCES.txt --- old/msal-1.18.0b1/msal.egg-info/SOURCES.txt 2022-05-19 10:33:55.000000000 +0200 +++ new/msal-1.20.0/msal.egg-info/SOURCES.txt 2022-10-07 07:22:32.000000000 +0200 @@ -5,6 +5,7 @@ msal/__init__.py msal/application.py msal/authority.py +msal/broker.py msal/cloudshell.py msal/exceptions.py msal/individual_cache.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/msal.egg-info/requires.txt new/msal-1.20.0/msal.egg-info/requires.txt --- old/msal-1.18.0b1/msal.egg-info/requires.txt 2022-05-19 10:33:55.000000000 +0200 +++ new/msal-1.20.0/msal.egg-info/requires.txt 2022-10-07 07:22:32.000000000 +0200 @@ -1,6 +1,11 @@ requests<3,>=2.0.0 PyJWT[crypto]<3,>=1.0.0 -cryptography<40,>=0.6 +cryptography<41,>=0.6 [:python_version < "3.3"] mock + +[broker] + +[broker:python_version >= "3.6" and platform_system == "Windows"] +pymsalruntime<0.14,>=0.11.2 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/msal-1.18.0b1/setup.py new/msal-1.20.0/setup.py --- old/msal-1.18.0b1/setup.py 2022-05-19 10:33:40.000000000 +0200 +++ new/msal-1.20.0/setup.py 2022-10-07 07:22:21.000000000 +0200 @@ -74,9 +74,9 @@ # See https://stackoverflow.com/a/14211600/728675 for more detail install_requires=[ 'requests>=2.0.0,<3', - 'PyJWT[crypto]>=1.0.0,<3', + 'PyJWT[crypto]>=1.0.0,<3', # MSAL does not use jwt.decode(), therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+ - 'cryptography>=0.6,<40', + 'cryptography>=0.6,<41', # load_pem_private_key() is available since 0.6 # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 # @@ -85,6 +85,14 @@ # https://cryptography.io/en/latest/api-stability/#deprecation "mock;python_version<'3.3'", - ] + ], + extras_require={ # It does not seem to work if being defined inside setup.cfg + "broker": [ + # The broker is defined as optional dependency, + # so that downstream apps can opt in. The opt-in is needed, partially because + # most existing MSAL Python apps do not have the redirect_uri needed by broker. + "pymsalruntime>=0.11.2,<0.14;python_version>='3.6' and platform_system=='Windows'", + ], + }, )
