diff --git a/CHANGELOG.md b/CHANGELOG.md index a3cbddb..8691781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [Version 1.1.5](https://github.com/dataiku/dss-plugin-sharepoint-online/releases/tag/v1.1.5) - Feature release - 2024-10-15 + +- Add JWT token diag in case of authorization error + ## [Version 1.1.4](https://github.com/dataiku/dss-plugin-sharepoint-online/releases/tag/v1.1.4) - Feature release - 2024-07-16 - Fix writing when using presets with no root folder defined diff --git a/plugin.json b/plugin.json index c357c1b..cdb516f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "id": "sharepoint-online", - "version": "1.1.4", + "version": "1.1.5", "meta": { "label": "SharePoint Online", "description": "Read and write data from/to your SharePoint Online account", diff --git a/python-lib/common.py b/python-lib/common.py index 0b1a7a0..d826eb3 100644 --- a/python-lib/common.py +++ b/python-lib/common.py @@ -153,6 +153,67 @@ def update_dict_in_kwargs(kwargs, key_to_update, update): return kwargs +def run_oauth_diagnostic(jwt_token): + censored_token = diagnose_jwt(jwt_token) + ip_in_jwt = censored_token.get("ipaddr", "") + if not ip_in_jwt: + logger.info("No IP address in the JWT token") + else: + # Retrieve the plugin's external IP to check it matches the one + # stored in the JWT token + kernel_external_ip = get_kernel_external_ip() + if not kernel_external_ip: + return + if ip_in_jwt != kernel_external_ip: + logger.error("The plugin external IP address does not match the IP allowed in the JWT token") + else: + logger.info("IP addresses in the OAuth token and the plugin kernel match") + + +def diagnose_jwt(jwt_token): + # Display ebough details about the JWT token to allow debugging + # without making it possible to reuse it + keys_to_report = ["aud", "exp", "app_displayname", "appid", "ipaddr", "name", "scp", "unique_name", "upn", "roles"] + decoded_token = decode_jwt(jwt_token) + censored_token = {} + for key_to_report in keys_to_report: + censored_token[key_to_report] = decoded_token.get(key_to_report) + logger.info("Decoded token: {}".format(censored_token)) + return censored_token + + +def decode_jwt(jwt_token): + try: + import base64 + import json + sub_tokens = jwt_token.split('.') + if len(sub_tokens) < 2: + logger.error("JWT format is wrong") + return {} + token_of_interest = sub_tokens[1] + padded_token = token_of_interest + "="*divmod(len(token_of_interest), 4)[1] + decoded_token = base64.urlsafe_b64decode(padded_token.encode('utf-8')) + json_token = json.loads(decoded_token) + return json_token + except Exception as error: + logger.error("Could not decode JWT token ({})".format(error)) + return {} + + +def get_kernel_external_ip(): + try: + import requests + response = requests.get("https://api64.ipify.org?format=json") + if response.status_code >= 400: + logger.error("Error {} trying to check kernel's external ip:{}".format(response.status_code, response.content)) + json_response = response.json() + kernel_external_ip = json_response.get("ip", "") + return kernel_external_ip + except Exception as error: + logger.error("Could not fetch kernel's remote ip ({})".format(error)) + return "" + + def url_encode(string_to_encode): return urlparse.quote(string_to_encode.encode("utf-8")) diff --git a/python-lib/dss_constants.py b/python-lib/dss_constants.py index 89fedcc..f6254da 100644 --- a/python-lib/dss_constants.py +++ b/python-lib/dss_constants.py @@ -37,7 +37,7 @@ class DSSConstants(object): "sharepoint_oauth": "The access token is missing" } PATH = 'path' - PLUGIN_VERSION = "1.1.4" + PLUGIN_VERSION = "1.1.5" SECRET_PARAMETERS_KEYS = ["Authorization", "sharepoint_username", "sharepoint_password", "client_secret", "client_certificate", "passphrase"] SITE_APP_DETAILS = { "sharepoint_tenant": "The tenant name is missing", diff --git a/python-lib/sharepoint_client.py b/python-lib/sharepoint_client.py index 656b3e0..7a262e8 100644 --- a/python-lib/sharepoint_client.py +++ b/python-lib/sharepoint_client.py @@ -17,7 +17,7 @@ from common import ( is_email_address, get_value_from_path, parse_url, get_value_from_paths, is_request_performed, ItemsLimit, - is_empty_path, merge_paths, get_lnt_path, + is_empty_path, merge_paths, run_oauth_diagnostic, get_lnt_path, format_private_key, format_certificate_thumbprint, url_encode ) from safe_logger import SafeLogger @@ -41,6 +41,7 @@ def __init__(self, config): self.session = RobustSession(status_codes_to_retry=[429, 503], attempt_session_reset_on_403=attempt_session_reset_on_403) self.number_dumped_logs = 0 self.username_for_namespace_diag = None + self.jwt_diag_done = False self.dss_column_name = {} self.column_ids = {} @@ -57,6 +58,7 @@ def __init__(self, config): self.apply_paths_overwrite(config) self.setup_sharepoint_online_url(login_details) self.sharepoint_access_token = login_details['sharepoint_oauth'] + self.auth_token_for_diag =self.sharepoint_access_token self.session.update_settings(session=SharePointSession( None, None, @@ -125,6 +127,7 @@ def __init__(self, config): self.passphrase = login_details.get("passphrase") self.client_id = login_details.get("client_id") self.sharepoint_access_token = self.get_certificate_app_access_token() + self.auth_token_for_diag =self.sharepoint_access_token self.session.update_settings(session=SharePointSession( None, None, @@ -786,9 +789,8 @@ def assert_response_ok(self, response, no_json=False, calling_method=""): if status_code == 404: raise SharePointClientError("Not found. Please check tenant, site type or site name. ({})".format(calling_method)) if status_code == 403: - logger.error("403 error. Checking for federated namespace.") self.assert_non_federated_namespace() - logger.error("User does not belong to federated namespace.") + self.run_jwt_validity_test() raise SharePointClientError("403 Forbidden. Please check your account credentials. ({})".format(calling_method)) raise SharePointClientError("Error {} ({})".format(status_code, calling_method)) if not no_json: @@ -797,6 +799,7 @@ def assert_response_ok(self, response, no_json=False, calling_method=""): def assert_non_federated_namespace(self): # Called following 403 error if self.username_for_namespace_diag: + logger.error("403 error. Checking for federated namespace.") # username / password login was used to login # we check if the email used as username belongs to a federated namespace json_response = "" @@ -817,6 +820,14 @@ def assert_non_federated_namespace(self): + "Dataiku might not be able to use it to access SharePoint-Online. " + "Please contact your administrator to configure a Single Sign On or an App token access." ) + logger.error("User does not belong to federated namespace.") + + def run_jwt_validity_test(self): + # Called following 403 error + if self.auth_token_for_diag and not self.jwt_diag_done: + self.jwt_diag_done = True + logger.info("403 Error. Running diag on auth token") + run_oauth_diagnostic(self.auth_token_for_diag) @staticmethod def get_enriched_error_message(response):