diff --git a/CHANGELOG.md b/CHANGELOG.md index f41864a..4022fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog -## [Version 1.3.1](https://github.com/dataiku/dss-plugin-sharepoint-online/releases/tag/v1.3.1) - Security release - 2026-04-08 +## [Version 1.4.0](https://github.com/dataiku/dss-plugin-sharepoint-online/releases/tag/v1.4.0) - Security and feature release - 2026-04-08 - Increase the version of the package cryptography to 46.0.7 in response to CVE-2026-34073 and CVE-2026-39892 +- Add access token refreshing for *Certificates* and *App username password* +- Add access token refreshing for *Azure Single Sign On* preset on DSS 14.5+ ## [Version 1.3.0](https://github.com/dataiku/dss-plugin-sharepoint-online/releases/tag/v1.3.0) - Security release - 2026-02-26 diff --git a/plugin.json b/plugin.json index f41d293..c39a192 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "id": "sharepoint-online", - "version": "1.3.1", + "version": "1.4.0", "meta": { "label": "SharePoint Online", "description": "Read and write data from/to your SharePoint Online account", diff --git a/python-lib/dss_constants.py b/python-lib/dss_constants.py index 0b60de9..f981fd0 100644 --- a/python-lib/dss_constants.py +++ b/python-lib/dss_constants.py @@ -38,7 +38,7 @@ class DSSConstants(object): "sharepoint_oauth": "The access token is missing" } PATH = 'path' - PLUGIN_VERSION = "1.3.1" + PLUGIN_VERSION = "1.4.0" 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 898dbf3..2b797b0 100644 --- a/python-lib/sharepoint_client.py +++ b/python-lib/sharepoint_client.py @@ -21,6 +21,7 @@ format_private_key, format_certificate_thumbprint, url_encode ) from safe_logger import SafeLogger +from sharepoint_fresh_token import FreshToken logger = SafeLogger("sharepoint-online plugin", DSSConstants.SECRET_PARAMETERS_KEYS) @@ -58,13 +59,20 @@ def __init__(self, config, root_name_overwrite_legacy_mode=False): self.setup_login_details(login_details) self.apply_paths_overwrite(config) self.setup_sharepoint_online_url(login_details) - self.sharepoint_access_token = login_details['sharepoint_oauth'] + sharepoint_access_token = login_details['sharepoint_oauth'] + if "__credentials" in login_details: + logger.info("Refreshable access token") + from dataiku.core import plugin + access_token_getter = plugin.OAuthCredentials(login_details.get("__credentials", {}).get("sharepoint_oauth")) + else: + logger.info("One time access token") + access_token_getter = FreshToken(access_token=sharepoint_access_token) self.session.update_settings(session=SharePointSession( None, None, self.sharepoint_url, self.sharepoint_site, - sharepoint_access_token=self.sharepoint_access_token + access_token_getter=access_token_getter ), max_retries=SharePointConstants.MAX_RETRIES, base_retry_timer_sec=SharePointConstants.WAIT_TIME_BEFORE_RETRY_SEC @@ -103,13 +111,12 @@ def __init__(self, config, root_name_overwrite_legacy_mode=False): self.tenant_id = login_details.get("tenant_id") self.client_secret = login_details.get("client_secret") self.client_id = login_details.get("client_id") - self.sharepoint_access_token = self.get_site_app_access_token() self.session.update_settings(session=SharePointSession( None, None, self.sharepoint_url, self.sharepoint_site, - sharepoint_access_token=self.sharepoint_access_token + access_token_getter=FreshToken(access_token=self.get_site_app_access_token()) ), max_retries=SharePointConstants.MAX_RETRIES, base_retry_timer_sec=SharePointConstants.WAIT_TIME_BEFORE_RETRY_SEC @@ -126,13 +133,12 @@ def __init__(self, config, root_name_overwrite_legacy_mode=False): self.client_certificate_thumbprint = format_certificate_thumbprint(login_details.get("client_certificate_thumbprint")) 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.session.update_settings(session=SharePointSession( None, None, self.sharepoint_url, self.sharepoint_site, - sharepoint_access_token=self.sharepoint_access_token + access_token_getter=FreshToken(token_refresh_method=self.get_certificate_app_access_token) ), max_retries=SharePointConstants.MAX_RETRIES, base_retry_timer_sec=SharePointConstants.WAIT_TIME_BEFORE_RETRY_SEC @@ -148,13 +154,12 @@ def __init__(self, config, root_name_overwrite_legacy_mode=False): self.sharepoint_tenant = login_details.get("sharepoint_tenant") username = login_details.get("username") password = login_details.get("password") - self.sharepoint_access_token = self.get_username_password_access_token(username, password) self.session.update_settings(session=SharePointSession( None, None, self.sharepoint_url, self.sharepoint_site, - sharepoint_access_token=self.sharepoint_access_token + access_token_getter=FreshToken(access_token=self.get_username_password_access_token(username, password)) ), max_retries=SharePointConstants.MAX_RETRIES, base_retry_timer_sec=SharePointConstants.WAIT_TIME_BEFORE_RETRY_SEC @@ -1098,12 +1103,12 @@ def is_column_displayable(self, column, display_metadata=False, metadata_to_retr class SharePointSession(): - def __init__(self, sharepoint_user_name, sharepoint_password, sharepoint_url, sharepoint_site, sharepoint_access_token=None, max_retry=10): + def __init__(self, sharepoint_user_name, sharepoint_password, sharepoint_url, sharepoint_site, access_token_getter=None, max_retry=10): self.sharepoint_url = sharepoint_url self.sharepoint_site = sharepoint_site - self.sharepoint_access_token = sharepoint_access_token + self.access_token_getter = access_token_getter requests.adapters.DEFAULT_RETRIES = max_retry - self.form_digest_value = get_form_digest_value(sharepoint_url, sharepoint_site, sharepoint_access_token=self.sharepoint_access_token) + self.form_digest_value = get_form_digest_value(sharepoint_url, sharepoint_site, access_token_getter=self.access_token_getter) def get(self, url, headers=None, params=None): retries_limit = ItemsLimit(SharePointConstants.MAX_RETRIES) @@ -1152,10 +1157,10 @@ def close(): logger.info("Closing SharePointSession.") def get_authorization_bearer(self): - return "Bearer {}".format(self.sharepoint_access_token) + return "Bearer {}".format(self.access_token_getter.access_token) -def get_form_digest_value(sharepoint_url, sharepoint_site, session=None, sharepoint_access_token=None): +def get_form_digest_value(sharepoint_url, sharepoint_site, session=None, access_token_getter=None): def get_contextinfo_url(): return "https://{}/{}/_api/contextinfo".format( sharepoint_url, sharepoint_site @@ -1170,8 +1175,8 @@ def get_contextinfo_url(): ) form_digest_value = None try: - if sharepoint_access_token: - headers = {**DSSConstants.JSON_HEADERS, **{"Authorization": "Bearer {}".format(sharepoint_access_token)}} + if access_token_getter: + headers = {**DSSConstants.JSON_HEADERS, **{"Authorization": "Bearer {}".format(access_token_getter.access_token)}} response = session.post( url=get_contextinfo_url(), headers=headers, diff --git a/python-lib/sharepoint_fresh_token.py b/python-lib/sharepoint_fresh_token.py new file mode 100644 index 0000000..1905049 --- /dev/null +++ b/python-lib/sharepoint_fresh_token.py @@ -0,0 +1,62 @@ +from safe_logger import SafeLogger +from dss_constants import DSSConstants +import time + +logger = SafeLogger("sharepoint-online plugin FreshToken", DSSConstants.SECRET_PARAMETERS_KEYS) +TOKEN_VALIDITY_SAFETY_MARGIN_SECONDS = 60 + + +class FreshToken(): + def __init__(self, token_refresh_method=None, access_token=None): + logger.info("FreshToken init") + if access_token: + logger.info("Permanent access token provided") + self.current_token = access_token + self.token_refresh_method = self._default_refresh_method + self.token_renewal_time = None + if token_refresh_method is not None: + logger.info("Using refresh method") + self.token_refresh_method = token_refresh_method + self.refresh_token() + + def _default_refresh_method(self): + return self.current_token + + def token_needs_renewal(self): + if self.token_renewal_time is None: + return True + epoch_time_now = int(time.time()) + return self.token_renewal_time <= epoch_time_now + + def refresh_token(self): + self.current_token = self.token_refresh_method() + decoded_jwt = decode_jwt(self.current_token) + self.token_renewal_time = decoded_jwt.get("exp", None) + if isinstance(self.token_renewal_time, int): + self.token_renewal_time = self.token_renewal_time - TOKEN_VALIDITY_SAFETY_MARGIN_SECONDS + logger.info("The token is valid until {}".format(self.token_renewal_time)) + + @property + def access_token(self): + if not self.token_needs_renewal(): + logger.info("Token reaching its time limit, refreshing it...") + self.refresh_token() + return self.current_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_payload = sub_tokens[1] + padded_token = token_payload + "=" * (-len(token_payload) % 4) + 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 {} diff --git a/tests/python/unit/test_common.py b/tests/python/unit/test_common.py index aa6d87a..2a1c091 100644 --- a/tests/python/unit/test_common.py +++ b/tests/python/unit/test_common.py @@ -1,4 +1,5 @@ from common import get_value_from_path, is_request_performed, decode_retry_after_header +from sharepoint_fresh_token import decode_jwt from sharepoint_constants import SharePointConstants import pytest @@ -32,6 +33,7 @@ def setup_class(self): self.mock_response_http_429_date_in_past = MockResponse(429, {"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"}) self.mock_response_http_429_date_in_future = MockResponse(429, {"Retry-After": "Wed, 21 Oct 9999 07:28:00 GMT"}) self.mock_response_http_429_garbage = MockResponse(429, {"Retry-After": "blablablabla"}) + self.mock_jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" def test_get_value_from_path_long_path(self): key = get_value_from_path(self.dictionary_to_search, self.ok_path_1) @@ -85,3 +87,7 @@ def test_decode_retry_after_header_garbage(self): def test_decode_retry_after_header_no_header(self): seconds_before_retry = decode_retry_after_header(self.mock_response_http_429_no_header) assert seconds_before_retry == SharePointConstants.DEFAULT_WAIT_BEFORE_RETRY + + def test_decode_jwt(self): + decoded_jwt_token = decode_jwt(self.mock_jwt_token) + assert decoded_jwt_token == {'sub': '1234567890', 'name': 'John Doe', 'admin': True, 'iat': 1516239022}