diff --git a/msal/application.py b/msal/application.py index 260d80e0..712bd0da 100644 --- a/msal/application.py +++ b/msal/application.py @@ -11,7 +11,7 @@ from .oauth2cli import Client, JwtAssertionCreator from .oauth2cli.oidc import decode_part from .authority import Authority, WORLD_WIDE -from .mex import send_request as mex_send_request +from .mex import send_request as mex_send_request, send_request_iwa as mex_send_request_iwa from .wstrust_request import send_request as wst_send_request from .wstrust_response import * from .token_cache import TokenCache, _get_username, _GRANT_TYPE_BROKER @@ -222,6 +222,7 @@ class ClientApplication(object): ACQUIRE_TOKEN_FOR_CLIENT_ID = "730" ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID = "832" ACQUIRE_TOKEN_INTERACTIVE = "169" + ACQUIRE_TOKEN_INTEGRATED_WINDOWS_AUTH_ID = "870" GET_ACCOUNTS_ID = "902" REMOVE_ACCOUNT_ID = "903" @@ -238,6 +239,7 @@ class ClientApplication(object): "You can enable broker by following these instructions. " "https://msal-python.readthedocs.io/en/latest/#publicclientapplication") + def __init__( self, client_id, client_credential=None, authority=None, validate_authority=True, @@ -1888,11 +1890,10 @@ def _acquire_token_by_username_password_federated( wstrust_endpoint.get("action"), self.http_client) if not ("token" in wstrust_result and "type" in wstrust_result): raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result) - GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer' grant_type = { - SAML_TOKEN_TYPE_V1: GRANT_TYPE_SAML1_1, + SAML_TOKEN_TYPE_V1: self.client.GRANT_TYPE_SAML1_1, SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2, - WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1, + WSS_SAML_TOKEN_PROFILE_V1_1: self.client.GRANT_TYPE_SAML1_1, WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2 }.get(wstrust_result.get("type")) if not grant_type: @@ -2334,6 +2335,78 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs): telemetry_context.update_telemetry(response) return response + def acquire_token_integrated_windows_auth(self, username, scopes="openid", **kwargs): + """Gets a token for a given resource via Integrated Windows Authentication (IWA). + + :param str username: Typically a UPN in the form of an email address. + :param str scopes: Scopes requested to access a protected API (a resource). + + :return: A dict representing the json response from AAD: + + - A successful response would contain "access_token" key, + - an error response would contain "error" and usually "error_description". + """ + telemetry_context = self._build_telemetry_context( + self.ACQUIRE_TOKEN_INTEGRATED_WINDOWS_AUTH_ID) + headers = telemetry_context.generate_headers() + user_realm_result = self.authority.user_realm_discovery( + username, + correlation_id=headers[msal.telemetry.CLIENT_REQUEST_ID] + ) + if user_realm_result.get("account_type") != "Federated": + raise ValueError("Server returned an unknown account type: %s" % user_realm_result.get("account_type")) + response = _clean_up(self._acquire_token_by_iwa_federated(user_realm_result, username, scopes, **kwargs)) + if response is None: # Either ADFS or not federated + raise ValueError("Integrated Windows Authentication failed for this user: %s", username) + if "access_token" in response: + response[self._TOKEN_SOURCE] = self._TOKEN_SOURCE_IDP + telemetry_context.update_telemetry(response) + return response + + def _acquire_token_by_iwa_federated( + self, user_realm_result, username, scopes="openid", **kwargs): + wstrust_endpoint = {} + if user_realm_result.get("federation_metadata_url"): + mex_endpoint = user_realm_result.get("federation_metadata_url") + logger.debug( + "Attempting mex at: %(mex_endpoint)s", + {"mex_endpoint": mex_endpoint}) + wstrust_endpoint = mex_send_request_iwa(mex_endpoint, self.http_client) + if wstrust_endpoint is None: + raise ValueError("Unable to find wstrust endpoint from MEX. " + "This typically happens when attempting MSA accounts. " + "More details available here. " + "https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication") + logger.debug("wstrust_endpoint = %s", wstrust_endpoint) + wstrust_result = wst_send_request( + None, None, + user_realm_result.get("cloud_audience_urn", "urn:federation:MicrosoftOnline"), + wstrust_endpoint.get("address", + # Fallback to an AAD supplied endpoint + user_realm_result.get("federation_active_auth_url")), + wstrust_endpoint.get("action"), self.http_client) + if not ("token" in wstrust_result and "type" in wstrust_result): + raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result) + grant_type = { + SAML_TOKEN_TYPE_V1: self.client.GRANT_TYPE_SAML1_1, + SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2, + WSS_SAML_TOKEN_PROFILE_V1_1: self.client.GRANT_TYPE_SAML1_1, + WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2 + }.get(wstrust_result.get("type")) + if not grant_type: + raise RuntimeError( + "RSTR returned unknown token type: %s", wstrust_result.get("type")) + self.client.grant_assertion_encoders.setdefault( # Register a non-standard type + grant_type, self.client.encode_saml_assertion) + return self.client.obtain_token_by_assertion( + wstrust_result["token"], grant_type, scope=scopes, + on_obtaining_tokens=lambda event: self.token_cache.add(dict( + event, + environment=self.authority.instance, + username=username, # Useful in case IDT contains no such info + iwa=True + )), + **kwargs) class ConfidentialClientApplication(ClientApplication): # server-side web app """Same as :func:`ClientApplication.__init__`, diff --git a/msal/mex.py b/msal/mex.py index e6f3ed07..6fbec130 100644 --- a/msal/mex.py +++ b/msal/mex.py @@ -53,6 +53,16 @@ def send_request(mex_endpoint, http_client, **kwargs): "Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text) raise +def send_request_iwa(mex_endpoint, http_client, **kwargs): + mex_resp = http_client.get(mex_endpoint, **kwargs) + mex_resp.raise_for_status() + try: + return Mex(mex_resp.text).get_wstrust_iwa_endpoint() + except ET.ParseError: + logger.exception( + "Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text) + raise + class Mex(object): @@ -126,6 +136,14 @@ def _get_endpoints(self, bindings, policy_ids): {"address": address.text, "action": binding["action"]}) return endpoints + def get_wstrust_iwa_endpoint(self): + """Returns {"address": "https://...", "action": "the soapAction value"}""" + endpoints = self._get_endpoints( + self._get_bindings(), self._get_iwa_policy_ids()) + for e in endpoints: + if e["action"] == self.ACTION_13: + return e # Historically, we prefer ACTION_13 a.k.a. WsTrust13 + return endpoints[0] if endpoints else None def get_wstrust_username_password_endpoint(self): """Returns {"address": "https://...", "action": "the soapAction value"}""" endpoints = self._get_endpoints( diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index 01b7fc34..0666b12f 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -300,6 +300,7 @@ class Client(BaseClient): # We choose to implement all 4 grants in 1 class "DEVICE_CODE": "device_code", } DEVICE_FLOW_RETRIABLE_ERRORS = ("authorization_pending", "slow_down") + GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer' GRANT_TYPE_SAML2 = "urn:ietf:params:oauth:grant-type:saml2-bearer" # RFC7522 GRANT_TYPE_JWT = "urn:ietf:params:oauth:grant-type:jwt-bearer" # RFC7523 grant_assertion_encoders = {GRANT_TYPE_SAML2: BaseClient.encode_saml_assertion} diff --git a/msal/token_cache.py b/msal/token_cache.py index 66be5c9f..abffb590 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -240,8 +240,8 @@ def __add(self, event, now=None): # 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(sorted(event.get("scope") or [])) # Schema should have required sorting + iwa = event.get("iwa", False) # Integrated Windows Authentication with self._lock: now = int(time.time() if now is None else now) @@ -277,7 +277,7 @@ def __add(self, event, now=None): at["refresh_on"] = str(now + refresh_in) # Schema wants a string self.modify(self.CredentialType.ACCESS_TOKEN, at, at) - if client_info and not event.get("skip_account_creation"): + if (client_info or iwa) and not event.get("skip_account_creation"): account = { "home_account_id": home_account_id, "environment": environment, diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index 43a2804f..05b6ac5c 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -37,6 +37,7 @@ def send_request( username, password, cloud_audience_urn, endpoint_address, soap_action, http_client, **kwargs): + iwa = username is None and password is None if not endpoint_address: raise ValueError("WsTrust endpoint address can not be empty") if soap_action is None: @@ -49,10 +50,18 @@ def send_request( "Contact your administrator to check your ADFS's MEX settings." % soap_action) data = _build_rst( username, password, cloud_audience_urn, endpoint_address, soap_action) - resp = http_client.post(endpoint_address, data=data, headers={ + if iwa: + # Make request kerberized + from requests_kerberos import HTTPKerberosAuth, DISABLED + resp = http_client.post(endpoint_address, data=data, headers={ 'Content-type':'application/soap+xml; charset=utf-8', 'SOAPAction': soap_action, - }, **kwargs) + }, auth=HTTPKerberosAuth(mutual_authentication=DISABLED), allow_redirects=True) + else: + resp = http_client.post(endpoint_address, data=data, headers={ + 'Content-type':'application/soap+xml; charset=utf-8', + 'SOAPAction': soap_action, + }, **kwargs) if resp.status_code >= 400: logger.debug("Unsuccessful WsTrust request receives: %s", resp.text) # It turns out ADFS uses 5xx status code even with client-side incorrect password error @@ -76,16 +85,11 @@ def wsu_time_format(datetime_obj): def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_action): + iwa = username is None and password is None now = datetime.utcnow() - return """ - - {soap_action} - urn:uuid:{message_id} - - http://www.w3.org/2005/08/addressing/anonymous - - {endpoint_address} - + _security_header = "" + if not iwa: + _security_header = """ @@ -97,7 +101,21 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac {password} - + """.format( + username = username, + password = escape_password(password), + time_now=wsu_time_format(now), + time_expire=wsu_time_format(now + timedelta(minutes=10)), + ) + return """ + + {soap_action} + urn:uuid:{message_id} + + http://www.w3.org/2005/08/addressing/anonymous + + {endpoint_address} + {security_header} @@ -114,9 +132,6 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac s=Mex.NS["s"], wsu=Mex.NS["wsu"], wsa=Mex.NS["wsa10"], soap_action=soap_action, message_id=str(uuid.uuid4()), endpoint_address=endpoint_address, - time_now=wsu_time_format(now), - time_expire=wsu_time_format(now + timedelta(minutes=10)), - username=username, password=escape_password(password), wst=Mex.NS["wst"] if soap_action == Mex.ACTION_13 else Mex.NS["wst2005"], applies_to=cloud_audience_urn, key_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer' @@ -125,5 +140,6 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac request_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue' if soap_action == Mex.ACTION_13 else 'http://schemas.xmlsoap.org/ws/2005/02/trust/Issue', + security_header=_security_header ) diff --git a/requirements.txt b/requirements.txt index 7252b96e..6cd5905d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ python-dotenv>=0.21,<2 pytest-benchmark>=4,<5 perf_baseline>=0.1,<0.2 +requests-kerberos==0.14.0 diff --git a/sample/integrated_windows_authentication_sample.py b/sample/integrated_windows_authentication_sample.py new file mode 100644 index 00000000..cddd3927 --- /dev/null +++ b/sample/integrated_windows_authentication_sample.py @@ -0,0 +1,88 @@ +""" +The configuration file would look like this: + +{ + "authority": "https://login.microsoftonline.com/organizations", + "client_id": "your_client_id", + "username": "your_username@your_tenant.com", + "scope": ["User.ReadBasic.All"], + // You can find the other permission names from this document + // https://docs.microsoft.com/en-us/graph/permissions-reference + "endpoint": "https://graph.microsoft.com/v1.0/users" + // You can find more Microsoft Graph API endpoints from Graph Explorer + // https://developer.microsoft.com/en-us/graph/graph-explorer +} + +You can then run this sample with a JSON configuration file: + + python sample.py parameters.json +""" + +import sys # For simplicity, we'll read config file from 1st CLI param sys.argv[1] +import json +import logging +import time + +import requests +import msal +from msal.token_cache import TokenCache + + +# Optional logging +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs + +config = json.load(open(sys.argv[1])) + +# If for whatever reason you plan to recreate same ClientApplication periodically, +# you shall create one global token cache and reuse it by each ClientApplication +global_token_cache = msal.TokenCache() # The TokenCache() is in-memory. +# See more options in https://msal-python.readthedocs.io/en/latest/#tokencache + +# Create a preferably long-lived app instance, to avoid the overhead of app creation +global_app = msal.PublicClientApplication( + config["client_id"], authority=config["authority"], + client_credential=config.get("client_secret"), + token_cache=global_token_cache, # Let this app (re)use an existing token cache. + # If absent, ClientApplication will create its own empty token cache +) + + +def acquire_and_use_token(): + # The pattern to acquire a token looks like this. + result = None + + # Firstly, check the cache to see if this end user has signed in before + accounts = global_app.get_accounts(username=config["username"]) + if accounts: + print("Account(s) exists in cache, probably with token too. Let's try.") + result = global_app.acquire_token_silent(config["scope"], account=accounts[0]) + + if not result: + print("No suitable token exists in cache. Let's get a new one from AAD.") + # See this page for constraints of Username Password Flow. + # https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication + result = global_app.acquire_token_integrated_windows_auth( + config["username"], scopes=config["scope"]) + + if "access_token" in result: + print("Token was obtained from:", result["token_source"]) # Since MSAL 1.25 + # Calling graph using the access token + graph_data = requests.get( # Use token to call downstream service + config["endpoint"], + headers={'Authorization': 'Bearer ' + result['access_token']},).json() + print("Graph API call result: %s" % json.dumps(graph_data, indent=2)) + else: + print("Token acquisition failed") # Examine result["error_description"] etc. to diagnose error + print(result) + if 65001 in result.get("error_codes", []): # Not mean to be coded programatically, but... + raise RuntimeError( + "AAD requires user consent for U/P flow to succeed. " + "Run acquire_token_interactive() instead.") + + +while True: # Here we mimic a long-lived daemon + acquire_and_use_token() + print("Press Ctrl-C to stop.") + time.sleep(5) # Let's say your app would run a workload every X minutes. + diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a0796547..739c1d2e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -230,6 +230,29 @@ def _test_username_password(self, ) return result + def _test_iwa(self, + authority=None, client_id=None, username=None, scope=None, + client_secret=None, + azure_region=None, + http_client=None, + **ignored): + assert authority and client_id and username and scope + self.app = self._build_app( + client_id, authority=authority, + http_client=requests, + azure_region=azure_region, client_credential=client_secret) + self.assertEqual( + self.app.get_accounts(username=username), [], "Cache starts empty") + result = self.app.acquire_token_integrated_windows_auth( + username, scopes=scope) + self.assertLoosely(result) + self.assertCacheWorksForUser( + result, scope, + username=username + ) + return result + + @unittest.skipIf( os.getenv("TRAVIS"), # It is set when running on TravisCI or Github Actions "Although it is doable, we still choose to skip device flow to save time") @@ -348,6 +371,10 @@ def test_username_password(self): self.skipUnlessWithConfig(["client_id", "username", "password", "scope"]) self._test_username_password(**self.config) + def test_iwa(self): + self.skipUnlessWithConfig(["client_id","username","scope"]) + return self._test_iwa(**self.config) + def _get_app_and_auth_code(self, scopes=None, **kwargs): return _get_app_and_auth_code( self.config["client_id"],