From dcf241da7f4877cd8540d020b6e8478fc10279ef Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 28 May 2026 16:24:57 -0500 Subject: [PATCH] feat: SharePoint certificate-based authentication (#3) Add SHAREPOINT_CERTIFICATE_PATH as a secure alternative to SHAREPOINT_CLIENT_SECRET for authenticating with SharePoint via Microsoft Graph API. - JWT assertion flow using cryptography + PyJWT - SHAREPOINT_CERTIFICATE_PASSWORD for encrypted PEM keys - Mutual exclusivity validation (secret vs cert) - New optional extra: pip install oikb[sharepoint-cert] Closes #3 --- CHANGELOG.md | 6 ++ docs/guide.md | 56 +++++++++++ pyproject.toml | 5 +- src/oikb/connectors/sharepoint.py | 161 +++++++++++++++++++++++++++--- 4 files changed, 212 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e18222c..2c2ca60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.3.6] - 2026-05-28 + +### Added + +- **SharePoint**: certificate-based authentication via `SHAREPOINT_CERTIFICATE_PATH` env var — more secure alternative to client secret auth. Supports encrypted PEM keys with `SHAREPOINT_CERTIFICATE_PASSWORD`. Requires `pip install oikb[sharepoint-cert]`. + ## [0.3.5] - 2025-05-21 ### Added diff --git a/docs/guide.md b/docs/guide.md index e81010e..7323dc0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -21,6 +21,7 @@ A complete guide to syncing content into Open WebUI Knowledge Bases. - [GitLab / Bitbucket](#gitlab--bitbucket) - [Confluence](#confluence) - [Cloud Storage (S3 / GCS / Azure)](#cloud-storage-s3--gcs--azure) + - [SharePoint](#sharepoint) - [All Connectors](#all-connectors) - [Filtering](#filtering) - [Include / Exclude Globs](#include--exclude-globs) @@ -258,6 +259,61 @@ oikb sync azure://container/prefix --kb-id your-kb-id Uses standard cloud SDK credentials (AWS profiles, service accounts, etc.). +### SharePoint + +Sync a SharePoint document library: + +```bash +oikb sync sharepoint:mysite.sharepoint.com --kb-id your-kb-id +oikb sync sharepoint:mysite.sharepoint.com/Documents --kb-id your-kb-id +``` + +SharePoint supports two authentication methods: + +#### Client Secret (simpler setup) + +Set these environment variables: + +```bash +export SHAREPOINT_TENANT_ID=your-tenant-id +export SHAREPOINT_CLIENT_ID=your-client-id +export SHAREPOINT_CLIENT_SECRET=your-client-secret +``` + +#### Certificate Auth (recommended for production) + +Certificate-based auth is more secure and follows Microsoft best practices for production environments. + +First install the required extras: + +```bash +pip install oikb[sharepoint-cert] +``` + +Then set: + +```bash +export SHAREPOINT_TENANT_ID=your-tenant-id +export SHAREPOINT_CLIENT_ID=your-client-id +export SHAREPOINT_CERTIFICATE_PATH=/path/to/cert.pem +# Optional — only needed if the PEM key is encrypted: +export SHAREPOINT_CERTIFICATE_PASSWORD=your-password +``` + +The PEM file must contain both the private key and the certificate. The connector extracts the thumbprint and signs a JWT assertion automatically. + +> **Note:** `SHAREPOINT_CLIENT_SECRET` and `SHAREPOINT_CERTIFICATE_PATH` are mutually exclusive — set one or the other. + +#### .oikb.yaml example + +```yaml +sources: + - name: legal-docs + source: sharepoint:contoso.sharepoint.com/Legal + kb-id: abc123 + interval: 1h +``` + ### All Connectors 44 connectors available. See the full list: diff --git a/pyproject.toml b/pyproject.toml index 5aeeb9f..7db85d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oikb" -version = "0.3.5" +version = "0.3.6" description = "Sync anything to Open WebUI Knowledge Bases" readme = "README.md" authors = [ @@ -31,6 +31,7 @@ gmail = ["google-api-python-client>=2.100", "google-auth>=2.25"] gsites = ["google-api-python-client>=2.100", "google-auth>=2.25"] web = ["beautifulsoup4>=4.12"] oracle = ["oci"] +sharepoint-cert = ["cryptography>=42.0", "PyJWT>=2.8"] all = [ "boto3>=1.34", "google-cloud-storage>=2.14", @@ -40,6 +41,8 @@ all = [ "google-auth>=2.25", "beautifulsoup4>=4.12", "oci", + "cryptography>=42.0", + "PyJWT>=2.8", ] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"] diff --git a/src/oikb/connectors/sharepoint.py b/src/oikb/connectors/sharepoint.py index a01596f..004393d 100644 --- a/src/oikb/connectors/sharepoint.py +++ b/src/oikb/connectors/sharepoint.py @@ -1,12 +1,22 @@ """SharePoint connector — sync a document library to a Knowledge Base. Uses Microsoft Graph API. Auth via SHAREPOINT_TENANT_ID, SHAREPOINT_CLIENT_ID, -SHAREPOINT_CLIENT_SECRET env vars (app-only auth). +and one of: + - SHAREPOINT_CLIENT_SECRET (client secret auth) + - SHAREPOINT_CERTIFICATE_PATH (certificate auth — more secure, recommended + for production). Optionally set SHAREPOINT_CERTIFICATE_PASSWORD for + encrypted PEM keys. + +The two auth methods are mutually exclusive. """ from __future__ import annotations +import base64 +import hashlib import os +import time +import uuid from typing import Any import httpx @@ -24,6 +34,8 @@ class SharePointConnector(BaseConnector): tenant_id: str | None = None, client_id: str | None = None, client_secret: str | None = None, + certificate_path: str | None = None, + certificate_password: str | None = None, ): self.site = site self.library = library @@ -31,25 +43,44 @@ class SharePointConnector(BaseConnector): tid = tenant_id or os.environ.get("SHAREPOINT_TENANT_ID", "") cid = client_id or os.environ.get("SHAREPOINT_CLIENT_ID", "") secret = client_secret or os.environ.get("SHAREPOINT_CLIENT_SECRET", "") + cert_path = certificate_path or os.environ.get("SHAREPOINT_CERTIFICATE_PATH", "") + cert_password = certificate_password or os.environ.get("SHAREPOINT_CERTIFICATE_PASSWORD", "") - if not all([tid, cid, secret]): + if not tid or not cid: raise ValueError( "SharePoint credentials required. Set env vars:\n" - " SHAREPOINT_TENANT_ID, SHAREPOINT_CLIENT_ID, SHAREPOINT_CLIENT_SECRET" + " SHAREPOINT_TENANT_ID, SHAREPOINT_CLIENT_ID, and either\n" + " SHAREPOINT_CLIENT_SECRET or SHAREPOINT_CERTIFICATE_PATH" ) - # Get access token. - token_resp = httpx.post( - f"https://login.microsoftonline.com/{tid}/oauth2/v2.0/token", - data={ - "grant_type": "client_credentials", - "client_id": cid, - "client_secret": secret, - "scope": "https://graph.microsoft.com/.default", - }, - ) - token_resp.raise_for_status() - access_token = token_resp.json()["access_token"] + if secret and cert_path: + raise ValueError( + "SHAREPOINT_CLIENT_SECRET and SHAREPOINT_CERTIFICATE_PATH are " + "mutually exclusive. Set one or the other, not both." + ) + + if not secret and not cert_path: + raise ValueError( + "SharePoint auth method required. Set one of:\n" + " SHAREPOINT_CLIENT_SECRET (client secret)\n" + " SHAREPOINT_CERTIFICATE_PATH (certificate)" + ) + + token_url = f"https://login.microsoftonline.com/{tid}/oauth2/v2.0/token" + + if cert_path: + access_token = _get_token_via_certificate( + token_url=token_url, + client_id=cid, + certificate_path=cert_path, + certificate_password=cert_password or None, + ) + else: + access_token = _get_token_via_secret( + token_url=token_url, + client_id=cid, + client_secret=secret, + ) self._http = httpx.Client( base_url="https://graph.microsoft.com/v1.0", @@ -109,6 +140,106 @@ class SharePointConnector(BaseConnector): self._http.close() +# ── Auth helpers ──────────────────────────────────────────────── + + +def _get_token_via_secret(token_url: str, client_id: str, client_secret: str) -> str: + """Obtain an access token using client ID + client secret.""" + token_resp = httpx.post( + token_url, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": "https://graph.microsoft.com/.default", + }, + ) + token_resp.raise_for_status() + return token_resp.json()["access_token"] + + +def _get_token_via_certificate( + token_url: str, + client_id: str, + certificate_path: str, + certificate_password: str | None = None, +) -> str: + """Obtain an access token using client ID + certificate (JWT assertion). + + Reads a PEM file that contains both the private key and the certificate. + Builds a signed JWT assertion per the Microsoft identity platform spec: + https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials + """ + try: + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + except ImportError: + raise ImportError( + "Certificate auth requires the 'cryptography' package.\n" + "Install it with: pip install oikb[sharepoint-cert]" + ) + + try: + import jwt + except ImportError: + raise ImportError( + "Certificate auth requires the 'PyJWT' package.\n" + "Install it with: pip install oikb[sharepoint-cert]" + ) + + # Load PEM file. + pem_path = os.path.expanduser(certificate_path) + if not os.path.isfile(pem_path): + raise FileNotFoundError(f"Certificate file not found: {pem_path}") + + with open(pem_path, "rb") as f: + pem_data = f.read() + + password_bytes = certificate_password.encode() if certificate_password else None + + # Load private key. + private_key = serialization.load_pem_private_key(pem_data, password=password_bytes) + + # Load certificate to extract thumbprint. + cert = x509.load_pem_x509_certificate(pem_data) + thumbprint = cert.fingerprint(cert.signature_hash_algorithm or x509.hashes.SHA256()) + x5t = base64.urlsafe_b64encode(thumbprint).rstrip(b"=").decode("ascii") + + # Build JWT assertion. + now = int(time.time()) + claims = { + "aud": token_url, + "iss": client_id, + "sub": client_id, + "jti": str(uuid.uuid4()), + "iat": now, + "nbf": now, + "exp": now + 600, # 10 minute validity + } + headers = { + "x5t": x5t, + } + + assertion = jwt.encode(claims, private_key, algorithm="RS256", headers=headers) + + # Exchange assertion for access token. + token_resp = httpx.post( + token_url, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": assertion, + "scope": "https://graph.microsoft.com/.default", + }, + ) + token_resp.raise_for_status() + return token_resp.json()["access_token"] + + +# ── Source parser ─────────────────────────────────────────────── + + def parse_sharepoint_source(source: str) -> dict[str, str | None]: """Parse sharepoint:site/library or sharepoint:site.""" source = source.removeprefix("sharepoint:")