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
This commit is contained in:
Timothy Jaeryang Baek 2026-05-28 16:24:57 -05:00
parent 04ccb30d72
commit dcf241da7f
4 changed files with 212 additions and 16 deletions

View file

@ -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

View file

@ -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:

View file

@ -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"]

View file

@ -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:")