SSO & SCIM Provisioning
Single sign-on (OIDC / SAML) and automated user provisioning via SCIM 2.0.
Cloud Feature
SSO and SCIM require the Cloud tier. API key authentication (kn_...) is available on all tiers.
SSO Integration
Kanoniv supports two SSO protocols for enterprise identity federation:
| Protocol | Use Case |
|---|---|
| OIDC | Modern IdPs (Okta, Azure AD, Google Workspace, Auth0). Recommended for new setups. |
| SAML 2.0 | Legacy or compliance-mandated environments (ADFS, Ping Identity, OneLogin). |
Both protocols follow the same pattern: the frontend initiates login via the API, the user authenticates at the IdP, and the callback endpoint exchanges credentials for a Kanoniv JWT. SSO-provisioned users are created with provisioning_source: "sso" and the viewer role.
How It Works
- Frontend calls
POST /v1/auth/sso/initiatewith the tenant slug and provider type - API generates a CSRF state token (stored in Redis with 10-minute TTL) and returns a redirect URL to the IdP
- User authenticates at the IdP (Okta, Azure AD, etc.)
- IdP redirects back with an authorization code (OIDC) or SAML response
- Frontend sends the callback data to
POST /v1/auth/sso/callback - API validates the state token, exchanges the code/response for user identity, upserts the user, and issues a JWT
- Frontend uses the JWT for all subsequent API calls
Configuration
SSO configuration is managed through the settings API. Admin role required.
Create or Update an SSO Config
import httpx
client = httpx.Client(
base_url="https://api.kanoniv.com",
headers={"X-API-Key": "kn_live_..."}
)
# OIDC configuration (Okta example)
resp = client.post("/v1/settings/sso-config", json={
"provider_type": "oidc",
"display_name": "Okta SSO",
"issuer_url": "https://api.kanoniv.com",
"client_id": "0oa1b2c3d4e5f6g7h8",
"client_secret_enc": "your-oidc-client-secret",
"auth_url": "https://acme-corp.okta.com/oauth2/default/v1/authorize",
"token_url": "https://acme-corp.okta.com/oauth2/default/v1/token",
"jwks_url": "https://acme-corp.okta.com/oauth2/default/v1/keys",
"is_active": True
})
sso_config = resp.json()
print(f"SSO config ID: {sso_config['id']}")# OIDC configuration (Okta example)
curl -X POST https://api.kanoniv.com/v1/settings/sso-config \
-H "X-API-Key: kn_live_..." \
-H "Content-Type: application/json" \
-d '{
"provider_type": "oidc",
"display_name": "Okta SSO",
"issuer_url": "https://api.kanoniv.com",
"client_id": "0oa1b2c3d4e5f6g7h8",
"client_secret_enc": "your-oidc-client-secret",
"auth_url": "https://acme-corp.okta.com/oauth2/default/v1/authorize",
"token_url": "https://acme-corp.okta.com/oauth2/default/v1/token",
"jwks_url": "https://acme-corp.okta.com/oauth2/default/v1/keys",
"is_active": true
}'SSO Config Fields
| Field | Required | Description |
|---|---|---|
provider_type | Yes | "oidc" or "saml" |
display_name | Yes | Human-readable name shown in the login UI |
issuer_url | Yes | Your Kanoniv platform URL (used to build callback/ACS URLs) |
client_id | OIDC | OAuth client ID from your IdP |
client_secret_enc | OIDC | OAuth client secret from your IdP |
auth_url | Yes | IdP authorization endpoint (OIDC) or SSO URL (SAML) |
token_url | OIDC | IdP token endpoint |
jwks_url | OIDC | IdP JSON Web Key Set URL |
saml_metadata_url | SAML | IdP SAML metadata URL (alternative to auth_url) |
is_active | Yes | Enable or disable this SSO config |
SAML Configuration
For SAML setups, provide your IdP with Kanoniv's SP metadata:
resp = client.get("/v1/auth/sso/metadata/acme-corp")
print(resp.text) # SAML SP metadata XML# Download SP metadata XML for your IdP
curl https://api.kanoniv.com/v1/auth/sso/metadata/acme-corpThe metadata endpoint returns XML with:
- Entity ID:
urn:cannon:sp:{tenant_slug} - ACS URL:
{issuer_url}/v1/auth/sso/callback - NameID format:
emailAddress
Upload this XML to your IdP (Okta, Azure AD, ADFS) when configuring the SAML application.
List and Delete SSO Configs
# List all SSO configs for your tenant
configs = client.get("/v1/settings/sso-config").json()
# Delete a config by ID
client.delete(f"/v1/settings/sso-config/{configs[0]['id']}")# List all SSO configs
curl https://api.kanoniv.com/v1/settings/sso-config \
-H "X-API-Key: kn_live_..."
# Delete a config
curl -X DELETE https://api.kanoniv.com/v1/settings/sso-config/{config_id} \
-H "X-API-Key: kn_live_..."SSO Login Flow
These endpoints are unauthenticated (they are the login mechanism). Rate limiting is applied.
Step 1: Initiate SSO
import httpx
client = httpx.Client(base_url="https://api.kanoniv.com")
resp = client.post("/v1/auth/sso/initiate", json={
"tenant_slug": "acme-corp",
"provider": "oidc"
})
data = resp.json()
# Redirect the user to data["redirect_url"]
print(f"Redirect to: {data['redirect_url']}")curl -X POST https://api.kanoniv.com/v1/auth/sso/initiate \
-H "Content-Type: application/json" \
-d '{"tenant_slug": "acme-corp", "provider": "oidc"}'
# Response:
# { "redirect_url": "https://acme-corp.okta.com/oauth2/default/v1/authorize?..." }Step 2: User Authenticates at IdP
The user is redirected to the IdP login page. After authenticating, the IdP redirects back to your application with an authorization code (OIDC) or SAML response.
Step 3: Complete SSO Callback
# OIDC callback (after IdP redirect)
resp = client.post("/v1/auth/sso/callback", json={
"provider": "oidc",
"state": "state-from-redirect",
"code": "authorization-code-from-idp"
})
auth = resp.json()
access_token = auth["token"]["access_token"]
refresh_token = auth["token"]["refresh_token"]
user = auth["user"]
print(f"Logged in as {user['email']} (role: {user['role']})")
# Use the JWT for subsequent API calls
authed = httpx.Client(
base_url="https://api.kanoniv.com",
headers={"Authorization": f"Bearer {access_token}"}
)# OIDC callback
curl -X POST https://api.kanoniv.com/v1/auth/sso/callback \
-H "Content-Type: application/json" \
-d '{
"provider": "oidc",
"state": "state-from-redirect",
"code": "authorization-code-from-idp"
}'
# Response:
# {
# "token": {
# "access_token": "eyJ...",
# "refresh_token": "rt_...",
# "token_type": "Bearer"
# },
# "user": {
# "id": "...",
# "email": "[email protected]",
# "name": "Jane Smith",
# "role": "viewer"
# }
# }For SAML, send saml_response instead of code:
resp = client.post("/v1/auth/sso/callback", json={
"provider": "saml",
"state": "state-from-relay-state",
"saml_response": "PHNhbWxwOlJlc3BvbnNlIC4uLj4..." # base64-encoded
})SCIM Provisioning
SCIM 2.0 (RFC 7644) enables your IdP to automatically create, update, and deactivate Kanoniv users. When someone joins or leaves your organization in Okta/Azure AD, the change propagates to Kanoniv automatically.
SCIM-provisioned users are created with:
provisioning_source: "scim"role: "viewer"(default)- No local password (authentication is via SSO)
Key Behaviors
- Create: IdP pushes a new user via
POST /scim/v2/Users. TheuserNamefield maps to email,externalIdis stored for correlation. - Update: IdP sends
PUT(full replace) orPATCH(partial update) to modify user attributes. - Deactivate: IdP sends
DELETEor patchesactive: false. Kanoniv performs a soft deactivate (setsis_verified = false) to preserve the user record for audit. - Conflict: If a user with the same email already exists in the tenant,
POSTreturns409 Conflict.
SCIM Setup
Step 1: Generate a SCIM Token
SCIM uses a separate bearer token (not your API key or JWT). The token hash is stored in tenants.settings.scim_token_hash.
Generate a token and store its SHA-256 hash in your tenant settings:
# Generate a random SCIM token
SCIM_TOKEN=$(openssl rand -hex 32)
echo "Save this token for your IdP: $SCIM_TOKEN"
# Hash it and store in tenant settings
HASH=$(echo -n "$SCIM_TOKEN" | sha256sum | cut -d' ' -f1)
# Store the hash via your tenant settings (admin API or database)Step 2: Configure Your IdP
Okta
- In Okta Admin, go to Applications > your SAML/OIDC app > Provisioning
- Enable SCIM connector
- Set the SCIM connector base URL:
https://api.kanoniv.com/scim/v2 - Set authentication mode: HTTP Header
- Paste your SCIM token as the bearer token
- Enable: Create Users, Update User Attributes, Deactivate Users
- Map attributes:
userName-> user emailgivenName/familyName-> user nameexternalId-> Okta user ID
Azure AD (Entra ID)
- In Azure Portal, go to Enterprise Applications > your app > Provisioning
- Set provisioning mode to Automatic
- Set tenant URL:
https://api.kanoniv.com/scim/v2 - Set secret token: your SCIM bearer token
- Test connection, then enable provisioning
- Map attributes under Attribute Mapping
Step 3: Test the Connection
import httpx
scim = httpx.Client(
base_url="https://api.kanoniv.com/scim/v2",
headers={"Authorization": "Bearer your-scim-token-here"}
)
# List users
resp = scim.get("/Users")
print(resp.json()["totalResults"])
# Create a test user
resp = scim.post("/Users", json={
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "[email protected]",
"name": {"givenName": "Test", "familyName": "User"},
"externalId": "ext-12345",
"active": True
})
user = resp.json()
print(f"Created: {user['id']}")# List SCIM users
curl https://api.kanoniv.com/scim/v2/Users \
-H "Authorization: Bearer your-scim-token-here"
# Create a user
curl -X POST https://api.kanoniv.com/scim/v2/Users \
-H "Authorization: Bearer your-scim-token-here" \
-H "Content-Type: application/scim+json" \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "[email protected]",
"name": {"givenName": "Test", "familyName": "User"},
"externalId": "ext-12345",
"active": true
}'SCIM Endpoints
All SCIM endpoints are under /scim/v2 and authenticate via Authorization: Bearer <scim_token>.
| Method | Path | Description |
|---|---|---|
GET | /scim/v2/Users | List users. Supports filter, startIndex, count params. |
GET | /scim/v2/Users/:id | Get a single user by ID. |
POST | /scim/v2/Users | Create a new user. Returns 201 or 409 on conflict. |
PUT | /scim/v2/Users/:id | Full replace of user attributes. |
PATCH | /scim/v2/Users/:id | Partial update via SCIM PatchOp. |
DELETE | /scim/v2/Users/:id | Soft deactivate user. Returns 204. |
Filtering
The list endpoint supports the SCIM filter userName eq "email":
GET /scim/v2/Users?filter=userName eq "[email protected]"Pagination
SCIM uses 1-based indexing:
GET /scim/v2/Users?startIndex=1&count=50PATCH Operations
The PATCH endpoint accepts SCIM PatchOp with replace, add, and remove operations:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{"op": "replace", "path": "active", "value": false},
{"op": "replace", "value": {"userName": "[email protected]"}},
{"op": "replace", "path": "name.givenName", "value": "Janet"},
{"op": "remove", "path": "externalId"}
]
}Supported paths: active, userName, externalId, name.givenName, name.familyName. Unsupported paths are silently ignored; unsupported operations return 400.
End-to-End Cloud Workflow
This section shows how SSO, SCIM, and the Python SDK work together in a typical Cloud deployment. The IT team configures SSO and SCIM, users authenticate via their IdP, and analysts use the Python SDK to run reconciliation jobs.
1. IT Admin: Configure SSO + SCIM
import httpx
admin = httpx.Client(
base_url="https://api.kanoniv.com",
headers={"X-API-Key": "kn_live_admin_key"}
)
# Set up OIDC SSO
admin.post("/v1/settings/sso-config", json={
"provider_type": "oidc",
"display_name": "Okta SSO",
"issuer_url": "https://api.kanoniv.com",
"client_id": "0oa1b2c3d4e5f6g7h8",
"client_secret_enc": "secret-from-okta",
"auth_url": "https://acme-corp.okta.com/oauth2/default/v1/authorize",
"token_url": "https://acme-corp.okta.com/oauth2/default/v1/token",
"is_active": True
})
# SCIM is configured in the IdP pointing to:
# Base URL: https://api.kanoniv.com/scim/v2
# Auth: Bearer <scim-token>
# Users are auto-provisioned when assigned to the app in Okta.2. IdP Auto-Provisions Team Members via SCIM
When the IT admin assigns users to the Kanoniv app in Okta, the IdP automatically calls the SCIM endpoints:
# This happens automatically via IdP -- shown here for illustration
scim = httpx.Client(
base_url="https://api.kanoniv.com/scim/v2",
headers={"Authorization": "Bearer scim-token"}
)
# Okta provisions three team members
for member in [
{"userName": "[email protected]", "name": {"givenName": "Jane", "familyName": "Smith"}},
{"userName": "[email protected]", "name": {"givenName": "Bob", "familyName": "Chen"}},
{"userName": "[email protected]", "name": {"givenName": "Sara", "familyName": "Patel"}},
]:
scim.post("/Users", json={
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
**member,
"active": True
})3. Analyst Logs In via SSO
import httpx
client = httpx.Client(base_url="https://api.kanoniv.com")
# Initiate SSO login
resp = client.post("/v1/auth/sso/initiate", json={
"tenant_slug": "acme-corp",
"provider": "oidc"
})
redirect_url = resp.json()["redirect_url"]
# User authenticates at Okta, gets redirected back with code + state
# Complete callback (in practice, the frontend handles this)
resp = client.post("/v1/auth/sso/callback", json={
"provider": "oidc",
"state": "state-param",
"code": "auth-code-from-okta"
})
jwt = resp.json()["token"]["access_token"]4. Analyst Runs Reconciliation with Python SDK
Once authenticated, the analyst uses the Kanoniv Python SDK with Source adapters to run identity resolution locally. The SDK operates independently of the SSO flow; it uses the local PyO3 reconciliation engine.
from kanoniv import Source, Spec, reconcile
# Load data from multiple sources using Source adapters
crm = Source.from_csv("crm", "crm_export.csv", primary_key="customer_id")
import pandas as pd
billing_df = pd.read_csv("stripe_customers.csv")
billing = Source.from_pandas("stripe", billing_df, primary_key="stripe_id")
warehouse = Source.from_warehouse(
"postgres_customers",
table="analytics.dim_customers",
connection_string="postgresql://user:[email protected]/analytics",
primary_key="customer_key"
)
dbt_orders = Source.from_dbt(
"dbt_orders",
model="ref('fct_orders')",
manifest_path="target/manifest.json",
connection_string="snowflake://user:[email protected]/PROD/ANALYTICS",
primary_key="order_id"
)
# Load the reconciliation spec
spec = Spec.from_file("spec.yml")
# Run reconciliation with Cloud tier features
# (exclusions, scoring, compliance, freshness, hierarchy)
result = reconcile(
sources=[crm, billing, warehouse, dbt_orders],
spec=spec,
tier="cloud"
)
print(f"Clusters: {result.cluster_count}")
print(f"Merge rate: {result.merge_rate:.2%}")
print(f"Sources reconciled: 4")
# Export the golden records
golden_df = result.to_pandas()
golden_df.to_csv("golden_records.csv", index=False)5. Offboarding: SCIM Deprovisioning
When a team member leaves the organization and is removed from the Okta app, the IdP automatically deactivates them:
# IdP sends DELETE (automatic -- shown for illustration)
scim.delete(f"/Users/{bob_user_id}") # Returns 204
# Bob's account is soft-deactivated (is_verified=false)
# His reconciliation history and audit trail are preservedSSO vs API Keys
SSO and SCIM manage user identities, specifically who can log in to the dashboard and what role they have. The Python SDK authenticates using API keys (kn_...), which are separate from SSO sessions. An enterprise team typically uses SSO for dashboard access and API keys for programmatic SDK usage.
