Multi-Provider IDP — Operator Guide¶
Version: v1.6 Last Updated: 2026-06-01 (PLAN-1657: merged into main for v1.6; OIDC challenge exception handling added) Audience: Operators deploying Cesivi with multiple identity providers
See also:
- PEOPLE-PICKER-IDP.md — People-picker fan-out across providers
- UPGRADING-MULTI-PROVIDER.md — Migrating from single-slot config
- IDENTITY_PROVIDERS.md — All provider types reference
- AUTHENTICATION.md — Auth overview
Overview¶
Cesivi supports multiple OIDC providers running simultaneously. A deployment can use Keycloak for employees, Entra ID for contractors, Okta for partners, and Authentik for internal services — all at the same time. Each provider has its own callback URL, cookie scope, and token-validation keys.
Windows authentication remains single-realm by protocol design (NTLM/Kerberos is server-negotiated; multiple realms on the same HTTP port is not defined). LDAP and ADFS also support the list form as of this version.
Section 1: Config Schema Reference¶
1.1 Single-provider form (backwards-compatible)¶
Existing appsettings.json files with a single Oidc object continue to work unchanged.
Cesivi wraps the object into a synthetic 1-element list with Name="oidc" internally.
{
"Cesivi": {
"Authentication": {
"Oidc": {
"Enabled": true,
"Authority": "https://sso.example.com/realms/corp",
"ClientId": "cesivi-webui",
"ClientSecret": "...",
"Audience": "cesivi"
}
}
}
}
1.2 Multi-provider list form¶
Switch the Oidc value from an object to an array. Each element is a provider entry:
{
"Cesivi": {
"Authentication": {
"Oidc": [
{
"Name": "keycloak-corp",
"Enabled": true,
"Authority": "https://sso.example.com/realms/corp",
"ClientId": "cesivi-webui",
"ClientSecret": "...",
"Audience": "cesivi",
"Priority": 10
},
{
"Name": "entra-contractors",
"Enabled": true,
"Authority": "https://login.microsoftonline.com/{tenantId}/v2.0",
"ClientId": "...",
"ClientSecret": "...",
"Audience": "api://cesivi",
"Priority": 20
}
]
}
}
}
1.3 Provider entry fields¶
| Field | Required | Default | Description |
|---|---|---|---|
Name |
Yes (list form) | — | Unique identifier across ALL providers. Used in login-name encoding (i:0e.t|<Name>|<subject>), claim issuer headers, and the people-picker badge label. Must be URL-safe (no spaces, lowercase recommended). |
Enabled |
No | true |
Set false to disable without removing the entry. |
Authority |
Yes | — | OIDC discovery base URL ({Authority}/.well-known/openid-configuration is fetched at startup). |
ClientId |
Yes | — | OAuth2 client ID for the authorization-code flow (WebUI login). |
ClientSecret |
Yes | — | OAuth2 client secret. |
Audience |
No | "cesivi" |
Expected aud claim in Bearer JWTs. |
Priority |
No | 100 |
Lower number = tried first for JWT routing when iss matches multiple candidates. Rarely needed. |
AllowJwtWithoutIssuer |
No | false |
When true, JWT tokens missing the iss claim are matched to this provider by priority order. Should be true on at most one provider. |
CallbackPath |
No | auto | Authorization-code callback path. Auto-generated as /_auth/{Name}/callback when unset. Do not override unless you have a custom reverse-proxy rule. |
1.4 Name uniqueness constraint¶
Name must be globally unique across all providers of all types (Oidc, Ldap, Adfs). Cesivi
validates this at startup and refuses to start if there is a conflict. Pick names that won't
collide — e.g. keycloak-corp, entra-contractors, okta-partners.
Section 2: Enabling a Keycloak Provider (Worked Example)¶
Step 1 — Create a Keycloak client¶
- Open Keycloak Admin Console → your realm → Clients → Create client.
- Client type:
OpenID Connect - Client ID:
cesivi-webui - Client authentication: ON (confidential client)
- Standard flow enabled: ON
- Valid redirect URIs:
https://cesivi.example.com/_auth/keycloak-corp/callback(Replace with your actual Cesivi WebUI URL and provider name.) - Go to Credentials tab → copy the Client secret.
Step 2 — Add to appsettings.json¶
{
"Cesivi": {
"Authentication": {
"Oidc": [
{
"Name": "keycloak-corp",
"Enabled": true,
"Authority": "https://sso.example.com/realms/corp",
"ClientId": "cesivi-webui",
"ClientSecret": "PASTE_SECRET_HERE",
"Audience": "cesivi",
"Priority": 10
}
]
}
}
}
Step 3 — Verify login¶
Navigate to https://cesivi.example.com. The login screen should show a keycloak-corp button.
Clicking it redirects to Keycloak; after authentication, the user lands on the Cesivi home page.
Section 3: Enabling Entra ID (Worked Example)¶
Step 1 — App registration in Azure Portal¶
- Azure Portal → Entra ID → App registrations → New registration
- Name:
Cesivi WebUI - Supported account types: Accounts in this organizational directory only (single tenant)
- Redirect URI: Web →
https://cesivi.example.com/_auth/entra-corp/callback - Click Register. Copy the Application (client) ID and Directory (tenant) ID.
Step 2 — Create client secret¶
- Certificates & secrets → New client secret.
- Set an expiry (12 or 24 months recommended).
- Copy the secret value immediately (shown only once).
Step 3 — Admin consent for Graph (directory browse only)¶
Only required if you also enable
DirectoryBrowse(people-picker fan-out).
- API permissions → Add a permission → Microsoft Graph → Application permissions
- Add:
User.Read.All,GroupMember.Read.All - Click Grant admin consent for {your tenant} (requires Global Admin role).
Without admin consent, the directory-browse token acquisition succeeds but Graph returns 403 Forbidden on user queries.
Step 4 — Add to appsettings.json¶
{
"Cesivi": {
"Authentication": {
"Oidc": [
{
"Name": "entra-corp",
"Enabled": true,
"Authority": "https://login.microsoftonline.com/{tenantId}/v2.0",
"ClientId": "{applicationId}",
"ClientSecret": "PASTE_SECRET_HERE",
"Audience": "api://cesivi",
"Priority": 20
}
]
}
}
}
Replace {tenantId} and {applicationId} with values from the app registration.
Step 5 — Verify¶
Login page should now show an entra-corp button. First login creates the Cesivi user and stores
an ExternalLogin row linking the Entra OID to the Cesivi account.
Section 4: Federated Identity¶
What is federated identity?¶
With multi-provider support, the same real person may authenticate through different providers on different devices or use cases. Federated identity lets one Cesivi user account own multiple external logins — one per provider.
The storage layer uses an ExternalLogin entity (separate from CesiviUser), modeled after
ASP.NET Core Identity's UserLogin table pattern:
CesiviUser (id, username, email, ...)
└── ExternalLogin (providerName, providerSubject, userId, linkedAt, autoLinked)
└── ExternalLogin (providerName, providerSubject, userId, linkedAt, autoLinked)
Auto-link policy¶
When a user from Provider B logs in for the first time, Cesivi looks for an existing
CesiviUser with the same verified email. The AutoLinkPolicy controls what happens:
| Policy value | Behavior |
|---|---|
Off |
No auto-linking. Each login creates a new Cesivi user. (Default) |
SameVerifiedEmail |
If an existing user has the same email_verified=true email, the new ExternalLogin is added to their account. Requires email verification on every provider. |
AlwaysLink |
Links any email match regardless of verification flag. Not recommended for production. |
Set in appsettings.json:
{
"Cesivi": {
"Authentication": {
"FederatedIdentity": {
"AutoLinkPolicy": "SameVerifiedEmail",
"TrustedEmailProviders": ["entra-corp", "keycloak-corp"]
}
}
}
}
TrustedEmailProviders is required when AutoLinkPolicy is SameVerifiedEmail. Only
providers listed here are allowed to trigger an auto-link. This prevents a rogue provider
from hijacking accounts via email claim spoofing.
Default is Off — you must explicitly opt in.
Audit trail¶
Every link and unlink event is written to the Cesivi audit log:
[AuditLog] ExternalLogin.Linked user=alice@example.com provider=entra-corp subject=... autoLinked=true
[AuditLog] ExternalLogin.Unlinked user=alice@example.com provider=old-keycloak by=SHAREPOINT\admin
Check the audit log at /_admin/AuditLog or query via GET /_api/admin/auditlog?type=ExternalLogin.
REST API for federated identity¶
| Endpoint | Method | Description |
|---|---|---|
/_api/web/siteusers/getbyid({id})/externallogins |
GET | List all external logins for a user |
/_api/web/siteusers/getbyid({id})/externallogins |
POST | Manually link a provider login |
/_api/web/siteusers/getbyid({id})/externallogins('{provider}') |
DELETE | Unlink a specific provider |
The POST body:
{ "ProviderName": "entra-corp", "ProviderSubject": "oid-from-entra" }
Section 5: People-Picker Directory Browse¶
When a user types a name in a User or Person field, Cesivi can fan-out the search to all configured OIDC providers in parallel. This requires a separate admin client credential per provider — the login client is not used (it has only user-delegated permissions).
See PEOPLE-PICKER-IDP.md for the complete configuration guide,
provider-detection table, and troubleshooting.
Quick summary of how to enable for a provider already in the list:
{
"Name": "keycloak-corp",
"Authority": "...",
...
"DirectoryBrowse": {
"Enabled": true,
"ClientId": "cesivi-admin",
"ClientSecret": "...",
"TimeoutSeconds": 5,
"PageSize": 20
}
}
Section 6: Troubleshooting¶
JWT validation errors¶
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized on API calls after multi-provider migration |
iss claim in existing tokens doesn't match any configured provider |
Check the iss value in your JWT (decode at jwt.io) and confirm it matches one provider's Authority/.well-known/openid-configuration issuer |
SecurityTokenSignatureKeyNotFoundException |
Provider's JWKS endpoint changed or is unreachable | Restart Cesivi to force key refresh; confirm {Authority}/.well-known/openid-configuration is reachable |
| Tokens from a dev IDP accepted in prod | Dev provider left Enabled: true |
Set Enabled: false or remove from the list |
iss claim handling¶
Cesivi uses the iss claim for routing to the correct provider's signing keys. The routing
table is:
- Look up
issin theOidcIssuerRegistry— a dictionary built from allEnabledproviders' discovery documents at startup. - If found: validate signature using that provider's JWKS.
- If not found and
AllowJwtWithoutIssuer: true: try providers inPriorityorder. - If not found:
401 UnauthorizedwithWWW-Authenticate: Bearer error="invalid_token".
To diagnose routing, enable debug logging:
{
"Logging": {
"LogLevel": {
"Cesivi.Authentication.MultiIssuerJwtHandler": "Debug"
}
}
}
People-picker degraded providers¶
When a directory-browse provider times out or returns an error, Cesivi soft-fails it and continues with the remaining providers. The response includes:
X-Cesivi-Picker-DegradedProviders: entra-corp
This header lists comma-separated provider names that did not contribute results in the
last search. Check the server log for the underlying error (e.g. HttpRequestException,
TaskCanceledException).
Common causes and fixes:
| Header value | Log entry | Fix |
|---|---|---|
| Provider name | DirectoryBrowseClient: timeout after 5000ms |
Increase TimeoutSeconds; check network path to IDP |
| Provider name | 403 Forbidden from Graph |
Admin consent not granted (see Section 3, Step 3) |
| Provider name | 401 from Keycloak admin REST |
Admin client secret expired — rotate in Keycloak |
Section 7: Limitations¶
The following are explicitly out of scope for v1.2 and deferred to later versions:
| Limitation | Version | Notes |
|---|---|---|
| No admin UI for runtime provider management | v1.4+ | Adding/removing providers requires editing appsettings.json and restarting. A future admin UI would add providers without restart. |
| Config is file-based; no DB-backed config | v1.4+ | Client secrets in config files require filesystem secret management. Future: secrets in a vault / DB with audit trail. |
| LDAP / Active Directory directory browse in people picker | v1.4+ | OIDC providers are browsable today. LDAP integration reuses IADStorageService but the picker fan-out is a separate plan. Tracked in BACKLOG #32. |
| Write-back (provisioning) to IDPs | Not planned | Cesivi is read-only against IDPs. Creating users in Keycloak/Entra from Cesivi is out of scope. |
| Federation chains | Not planned | Cesivi queries the Authority directly; broker-side cross-realm linking is the IDP admin's responsibility. |
This document covers PLAN-1613 (config shape + BC shim), PLAN-1615 (multi-issuer JWT), PLAN-1616 (federated identity), PLAN-1617 (login UI), and PLAN-1618 (people-picker browse). Filed under arc PLAN-1619. Consolidated into main for v1.6 by PLAN-1657.
v1.6 Addition: OIDC Challenge Exception Handling¶
When an OIDC provider (Keycloak, Entra ID, etc.) is unreachable at login time, the login page now shows a localized error message instead of a generic HTTP 500 error page. The Challenge() call is wrapped in a try/catch; if the OIDC discovery document fetch fails (e.g., HttpRequestException when the IdP is down), the user sees:
Sign-in with '{provider}' failed. Please try again or use a different sign-in method.
This allows users to fall back to credential-based login when an OIDC provider is temporarily unavailable. Applies to all OIDC providers configured in the providers list.