From 43d4711af4f71e2dc210160d363979d4a469c34e Mon Sep 17 00:00:00 2001 From: Lochii Connectivity Date: Sun, 1 Nov 2020 21:21:08 +0000 Subject: [PATCH 1/7] Add support for acquiring a token with a client provided, pre-signed JWT. Useful for where the signing takes place externally for example using Azure Key Vault (AKV). AKV sample included. --- msal/application.py | 53 +++++++++------ sample/vault_jwt_sample.py | 134 +++++++++++++++++++++++++++++++++++++ tests/test_client.py | 10 ++- tests/test_e2e.py | 10 +++ 4 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 sample/vault_jwt_sample.py diff --git a/msal/application.py b/msal/application.py index cae9013d..eef48931 100644 --- a/msal/application.py +++ b/msal/application.py @@ -151,6 +151,14 @@ def __init__( "The provided signature value did not match the expected signature value", you may try use only the leaf cert (in PEM/str format) instead. + **NEW** + it can also be a completly pre-signed JWT that you've assembled yourself + simply pass a container containing only the key "jwt", like this: + + { + "jwt": "..." + } + :param dict client_claims: *Added in version 0.5.0*: It is a dictionary of extra claims that would be signed by @@ -256,28 +264,33 @@ def _build_client(self, client_credential, authority): default_headers['x-app-ver'] = self.app_version default_body = {"client_info": 1} if isinstance(client_credential, dict): - assert ("private_key" in client_credential - and "thumbprint" in client_credential) + assert (("private_key" in client_credential + and "thumbprint" in client_credential) or + "jwt" in client_credential) headers = {} - if 'public_certificate' in client_credential: - headers["x5c"] = extract_certs(client_credential['public_certificate']) - if not client_credential.get("passphrase"): - unencrypted_private_key = client_credential['private_key'] + if 'jwt' in client_credential: + client_assertion = client_credential['jwt'] + client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT else: - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.backends import default_backend - unencrypted_private_key = serialization.load_pem_private_key( - _str2bytes(client_credential["private_key"]), - _str2bytes(client_credential["passphrase"]), - backend=default_backend(), # It was a required param until 2020 - ) - assertion = JwtAssertionCreator( - unencrypted_private_key, algorithm="RS256", - sha1_thumbprint=client_credential.get("thumbprint"), headers=headers) - client_assertion = assertion.create_regenerative_assertion( - audience=authority.token_endpoint, issuer=self.client_id, - additional_claims=self.client_claims or {}) - client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT + if 'public_certificate' in client_credential: + headers["x5c"] = extract_certs(client_credential['public_certificate']) + if not client_credential.get("passphrase"): + unencrypted_private_key = client_credential['private_key'] + else: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.backends import default_backend + unencrypted_private_key = serialization.load_pem_private_key( + _str2bytes(client_credential["private_key"]), + _str2bytes(client_credential["passphrase"]), + backend=default_backend(), # It was a required param until 2020 + ) + assertion = JwtAssertionCreator( + unencrypted_private_key, algorithm="RS256", + sha1_thumbprint=client_credential.get("thumbprint"), headers=headers) + client_assertion = assertion.create_regenerative_assertion( + audience=authority.token_endpoint, issuer=self.client_id, + additional_claims=self.client_claims or {}) + client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT else: default_body['client_secret'] = client_credential server_configuration = { diff --git a/sample/vault_jwt_sample.py b/sample/vault_jwt_sample.py new file mode 100644 index 00000000..bae0e597 --- /dev/null +++ b/sample/vault_jwt_sample.py @@ -0,0 +1,134 @@ +""" +The configuration file would look like this (sans those // comments): +{ + "tenant": "your_tenant_name", + // Your target tenant, DNS name + "client_id": "your_client_id", + // Target app ID in Azure AD + "scope": ["https://graph.microsoft.com/.default"], + // Specific to Client Credentials Grant i.e. acquire_token_for_client(), + // you don't specify, in the code, the individual scopes you want to access. + // Instead, you statically declared them when registering your application. + // Therefore the only possible scope is "resource/.default" + // (here "https://graph.microsoft.com/.default") + // which means "the static permissions defined in the application". + "vault_tenant": "your_vault_tenant_name", + // Your Vault tenant may be different to your target tenant + // If that's not the case, you can set this to the same + // as "tenant" + "vault_clientid": "your_vault_client_id", + // Client ID of your vault app in your vault tenant + "vault_clientsecret": "your_vault_client_secret", + // Secret for your vault app + "vault_url": "your_vault_url", + // URL of your vault app + "cert": "your_cert_name", + // Name of your certificate in your vault + "cert_thumb": "your_cert_thumbprint", + // Thumbprint of your certificate + "endpoint": "https://graph.microsoft.com/v1.0/users" + // For this resource to work, you need to visit Application Permissions + // page in portal, declare scope User.Read.All, which needs admin consent + // https://github.com/Azure-Samples/ms-identity-python-daemon/blob/master/2-Call-MsGraph-WithCertificate/README.md +} +You can then run this sample with a JSON configuration file: + python sample.py parameters.json +""" + +import base64 +import json +import logging +import requests +import sys +import time +import uuid +import msal + +# Optional logging +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs + +from azure.keyvault import KeyVaultClient, KeyVaultAuthentication +from azure.common.credentials import ServicePrincipalCredentials +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes + +config = json.load(open(sys.argv[1])) + +def auth_vault_callback(server, resource, scope): + credentials = ServicePrincipalCredentials( + client_id=config['vault_clientid'], + secret=config['vault_clientsecret'], + tenant=config['vault_tenant'], + resource='https://vault.azure.net' + ) + token = credentials.token + return token['token_type'], token['access_token'] + + +def make_vault_jwt(): + + header = { + 'alg': 'RS256', + 'typ': 'JWT', + 'x5t': base64.b64encode( + config['cert_thumb'].decode('hex')) + } + header_b64 = base64.b64encode(json.dumps(header).encode('utf-8')) + + body = { + 'aud': "https://login.microsoftonline.com/%s/oauth2/token" % + config['tenant'], + 'exp': (int(time.time()) + 600), + 'iss': config['client_id'], + 'jti': str(uuid.uuid4()), + 'nbf': int(time.time()), + 'sub': config['client_id'] + } + body_b64 = base64.b64encode(json.dumps(body).encode('utf-8')) + + full_b64 = b'.'.join([header_b64, body_b64]) + + client = KeyVaultClient(KeyVaultAuthentication(auth_vault_callback)) + chosen_hash = hashes.SHA256() + hasher = hashes.Hash(chosen_hash, default_backend()) + hasher.update(full_b64) + digest = hasher.finalize() + signed_digest = client.sign(config['vault_url'], + config['cert'], '', 'RS256', + digest).result + + full_token = b'.'.join([full_b64, base64.b64encode(signed_digest)]) + + return full_token + + +authority = "https://login.microsoftonline.com/%s" % config['tenant'] + +app = msal.ConfidentialClientApplication( + config['client_id'], authority=authority, client_credential={"jwt": make_vault_jwt()} + ) + +# The pattern to acquire a token looks like this. +result = None + +# Firstly, looks up a token from cache +# Since we are looking for token for the current app, NOT for an end user, +# notice we give account parameter as None. +result = app.acquire_token_silent(config["scope"], account=None) + +if not result: + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") + result = app.acquire_token_for_client(scopes=config["scope"]) + +if "access_token" in result: + # 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(result.get("error")) + print(result.get("error_description")) + print(result.get("correlation_id")) # You may need this when reporting a bug + diff --git a/tests/test_client.py b/tests/test_client.py index ebce8e55..39350dea 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -85,7 +85,15 @@ class TestClient(Oauth2TestCase): @classmethod def setUpClass(cls): http_client = MinimalHttpClient() - if "client_certificate" in CONFIG: + if "jwt" in CONFIG: + cls.client = Client( + CONFIG["openid_configuration"], + CONFIG['client_id'], + http_client=http_client, + client_assertion=CONFIG["jwt"], + client_assertion_type=Client.CLIENT_ASSERTION_TYPE_JWT, + ) + elif "client_certificate" in CONFIG: private_key_path = CONFIG["client_certificate"]["private_key_path"] with open(os.path.join(THIS_FOLDER, private_key_path)) as f: private_key = f.read() # Expecting PEM format diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 957d01a4..78a1e1b7 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -277,6 +277,16 @@ def test_subject_name_issuer_authentication(self): self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) + def test_pre_signed_jwt_authentication(self): + self.skipUnlessWithConfig(["client_id", "jwt"]) + self.app = msal.ConfidentialClientApplication( + self.config['client_id'], authority=self.config["authority"], + client_credential={"jwt": self.config["jwt"]}, + http_client=MinimalHttpClient()) + scope = self.config.get("scope", []) + result = self.app.acquire_token_for_client(scope) + self.assertIn('access_token', result) + self.assertCacheWorksForApp(result, scope) @unittest.skipUnless(os.path.exists(CONFIG), "Optional %s not found" % CONFIG) class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once From 8aa8b96fffb02b7da838c2c4cc4c8be3884e509d Mon Sep 17 00:00:00 2001 From: David Freedman Date: Fri, 4 Jun 2021 19:53:41 +0100 Subject: [PATCH 2/7] Changes to parameter name for #271 --- msal/application.py | 10 +++++----- sample/vault_jwt_sample.py | 2 +- tests/test_client.py | 4 ++-- tests/test_e2e.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/msal/application.py b/msal/application.py index eef48931..415fe5b2 100644 --- a/msal/application.py +++ b/msal/application.py @@ -153,10 +153,10 @@ def __init__( **NEW** it can also be a completly pre-signed JWT that you've assembled yourself - simply pass a container containing only the key "jwt", like this: + simply pass a container containing only the key "client_assertion", like this: { - "jwt": "..." + "client_assertion": "..." } :param dict client_claims: @@ -266,10 +266,10 @@ def _build_client(self, client_credential, authority): if isinstance(client_credential, dict): assert (("private_key" in client_credential and "thumbprint" in client_credential) or - "jwt" in client_credential) + "client_assertion" in client_credential) headers = {} - if 'jwt' in client_credential: - client_assertion = client_credential['jwt'] + if 'client_assertion' in client_credential: + client_assertion = client_credential['client_assertion'] client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT else: if 'public_certificate' in client_credential: diff --git a/sample/vault_jwt_sample.py b/sample/vault_jwt_sample.py index bae0e597..131732e1 100644 --- a/sample/vault_jwt_sample.py +++ b/sample/vault_jwt_sample.py @@ -106,7 +106,7 @@ def make_vault_jwt(): authority = "https://login.microsoftonline.com/%s" % config['tenant'] app = msal.ConfidentialClientApplication( - config['client_id'], authority=authority, client_credential={"jwt": make_vault_jwt()} + config['client_id'], authority=authority, client_credential={"client_assertion": make_vault_jwt()} ) # The pattern to acquire a token looks like this. diff --git a/tests/test_client.py b/tests/test_client.py index 39350dea..4590d401 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -85,12 +85,12 @@ class TestClient(Oauth2TestCase): @classmethod def setUpClass(cls): http_client = MinimalHttpClient() - if "jwt" in CONFIG: + if "client_assertion" in CONFIG: cls.client = Client( CONFIG["openid_configuration"], CONFIG['client_id'], http_client=http_client, - client_assertion=CONFIG["jwt"], + client_assertion=CONFIG["client_assertion"], client_assertion_type=Client.CLIENT_ASSERTION_TYPE_JWT, ) elif "client_certificate" in CONFIG: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 78a1e1b7..45246e11 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -278,10 +278,10 @@ def test_subject_name_issuer_authentication(self): self.assertCacheWorksForApp(result, scope) def test_pre_signed_jwt_authentication(self): - self.skipUnlessWithConfig(["client_id", "jwt"]) + self.skipUnlessWithConfig(["client_id", "client_assertion"]) self.app = msal.ConfidentialClientApplication( self.config['client_id'], authority=self.config["authority"], - client_credential={"jwt": self.config["jwt"]}, + client_credential={"client_assertion": self.config["client_assertion"]}, http_client=MinimalHttpClient()) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) From 8c0774a2fc80f8b6810ba5472fbfc30c2a217ee8 Mon Sep 17 00:00:00 2001 From: David Freedman Date: Mon, 7 Jun 2021 09:58:37 +0100 Subject: [PATCH 3/7] Address comment in #271 "No need to repeat this statement twice in both if and else" --- msal/application.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 415fe5b2..c38cd900 100644 --- a/msal/application.py +++ b/msal/application.py @@ -268,9 +268,9 @@ def _build_client(self, client_credential, authority): and "thumbprint" in client_credential) or "client_assertion" in client_credential) headers = {} + client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT if 'client_assertion' in client_credential: client_assertion = client_credential['client_assertion'] - client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT else: if 'public_certificate' in client_credential: headers["x5c"] = extract_certs(client_credential['public_certificate']) @@ -290,7 +290,6 @@ def _build_client(self, client_credential, authority): client_assertion = assertion.create_regenerative_assertion( audience=authority.token_endpoint, issuer=self.client_id, additional_claims=self.client_claims or {}) - client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT else: default_body['client_secret'] = client_credential server_configuration = { From 9b98f80e7f62f2efed2fa494ea4da16f6206d099 Mon Sep 17 00:00:00 2001 From: David Freedman Date: Mon, 7 Jun 2021 18:46:54 +0100 Subject: [PATCH 4/7] merge rayluo / microsoft-authentication-library-for-python:patch1 --- msal/application.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/msal/application.py b/msal/application.py index c38cd900..5eabd94a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -151,12 +151,12 @@ def __init__( "The provided signature value did not match the expected signature value", you may try use only the leaf cert (in PEM/str format) instead. - **NEW** - it can also be a completly pre-signed JWT that you've assembled yourself - simply pass a container containing only the key "client_assertion", like this: + *Added in version 1.13.0*: + It can also be a completly pre-signed assertion that you've assembled yourself. + Simply pass a container containing only the key "client_assertion", like this: { - "client_assertion": "..." + "client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..." } :param dict client_claims: @@ -267,11 +267,11 @@ def _build_client(self, client_credential, authority): assert (("private_key" in client_credential and "thumbprint" in client_credential) or "client_assertion" in client_credential) - headers = {} client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT if 'client_assertion' in client_credential: client_assertion = client_credential['client_assertion'] else: + headers = {} if 'public_certificate' in client_credential: headers["x5c"] = extract_certs(client_credential['public_certificate']) if not client_credential.get("passphrase"): From 4fb5ceef6fa12b717db015b8ebf753fd59f7e3aa Mon Sep 17 00:00:00 2001 From: David Freedman Date: Mon, 7 Jun 2021 19:09:45 +0100 Subject: [PATCH 5/7] Update msal/application.py Co-authored-by: Ray Luo --- msal/application.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 5eabd94a..0ed61ba2 100644 --- a/msal/application.py +++ b/msal/application.py @@ -153,7 +153,7 @@ def __init__( *Added in version 1.13.0*: It can also be a completly pre-signed assertion that you've assembled yourself. - Simply pass a container containing only the key "client_assertion", like this: + Simply pass a container containing only the key "client_assertion", like this:: { "client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..." @@ -1093,4 +1093,3 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID), }, **kwargs) - From a621c868092dfa16cd106cabb9bc5f9dfff0639d Mon Sep 17 00:00:00 2001 From: David Freedman Date: Mon, 7 Jun 2021 19:09:55 +0100 Subject: [PATCH 6/7] Update tests/test_e2e.py Co-authored-by: Ray Luo --- tests/test_e2e.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 45246e11..b0c2dd61 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -277,7 +277,7 @@ def test_subject_name_issuer_authentication(self): self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) - def test_pre_signed_jwt_authentication(self): + def test_client_assertion(self): self.skipUnlessWithConfig(["client_id", "client_assertion"]) self.app = msal.ConfidentialClientApplication( self.config['client_id'], authority=self.config["authority"], @@ -614,4 +614,3 @@ def test_acquire_token_silent_with_an_empty_cache_should_return_none(self): if __name__ == "__main__": unittest.main() - From 52729012438e7ac83c768a0889187100d617399c Mon Sep 17 00:00:00 2001 From: David Freedman Date: Mon, 7 Jun 2021 19:42:54 +0100 Subject: [PATCH 7/7] Resolve merge conflict --- msal/application.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/msal/application.py b/msal/application.py index 0ed61ba2..e478fb24 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1087,9 +1087,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No requested_token_use="on_behalf_of", claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)), - headers={ - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID), - }, - **kwargs) + headers=telemetry_context.generate_headers(), + **kwargs)) + telemetry_context.update_telemetry(response) + return response