Skip to content

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

  1. Open Keycloak Admin Console → your realm → ClientsCreate client.
  2. Client type: OpenID Connect
  3. Client ID: cesivi-webui
  4. Client authentication: ON (confidential client)
  5. Standard flow enabled: ON
  6. Valid redirect URIs: https://cesivi.example.com/_auth/keycloak-corp/callback (Replace with your actual Cesivi WebUI URL and provider name.)
  7. 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

  1. Azure PortalEntra IDApp registrationsNew registration
  2. Name: Cesivi WebUI
  3. Supported account types: Accounts in this organizational directory only (single tenant)
  4. Redirect URI: Web → https://cesivi.example.com/_auth/entra-corp/callback
  5. Click Register. Copy the Application (client) ID and Directory (tenant) ID.

Step 2 — Create client secret

  1. Certificates & secretsNew client secret.
  2. Set an expiry (12 or 24 months recommended).
  3. Copy the secret value immediately (shown only once).

Only required if you also enable DirectoryBrowse (people-picker fan-out).

  1. API permissionsAdd a permissionMicrosoft GraphApplication permissions
  2. Add: User.Read.All, GroupMember.Read.All
  3. 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)

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:

  1. Look up iss in the OidcIssuerRegistry — a dictionary built from all Enabled providers' discovery documents at startup.
  2. If found: validate signature using that provider's JWKS.
  3. If not found and AllowJwtWithoutIssuer: true: try providers in Priority order.
  4. If not found: 401 Unauthorized with WWW-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.