Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion python-lib/dss_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 20 additions & 15 deletions python-lib/sharepoint_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✍️ Could you put a comment here to explain what these two cases are here and when each one will happen? Also maybe mention where __credentials is populated and what kind of structure should be in there.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the convention that was chosen by Platform. I'll pm you the doc's PR on the subject...

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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions python-lib/sharepoint_fresh_token.py
Original file line number Diff line number Diff line change
@@ -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 {}
6 changes: 6 additions & 0 deletions tests/python/unit/test_common.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}