Multi-Tenant OIDC Federation
Set up OIDC authentication where each tenant (organization, municipality, company) uses their own identity provider.
Overview
Multi-tenant federation allows each tenant in your Eneo deployment to authenticate against their own IdP. This is ideal for:
- SaaS platforms serving multiple customers
- Municipal deployments where each municipality has their own Azure AD
- Enterprise deployments with subsidiary companies
Prerequisites
Before starting, ensure you have:
- Eneo backend deployed and running
- Redis running (recommended - it caches auth state for tamper detection; the login flow still works on the signed state alone if Redis is unavailable)
- Super Admin API key (
ENEO_SUPER_API_KEYconfigured) - HTTPS enabled on every origin users reach Eneo through
- For each tenant, the public origin its users will use (the same origin for all tenants, or a dedicated hostname per tenant - Eneo does not derive the tenant from the hostname)
Multi-tenant federation requires FEDERATION_ENABLED=true. In this mode the OIDC_* environment variables are ignored - every tenant must be configured through the sysadmin API, and a tenant without a configuration cannot sign in.
Setup
Enable federation mode
Add these environment variables to your backend .env:
# Enable federation (per-tenant IdP configuration via the sysadmin API)
FEDERATION_ENABLED=true
# Encryption key for storing client secrets securely (backend refuses to start without it)
ENCRYPTION_KEY=your-fernet-encryption-key
# Super admin API key for management endpoints
ENEO_SUPER_API_KEY=your-super-admin-api-keyGenerate an encryption key:
uv run python -m eneo.cli.generate_encryption_keyRestart the backend:
docker compose restart backendFEDERATION_PER_TENANT_ENABLED is a deprecated alias for FEDERATION_ENABLED. It still works but logs a warning at startup; when both are set, FEDERATION_ENABLED wins.
Prepare tenants
Each tenant needs a URL-safe slug (a-z, 0-9, -). The slug identifies the tenant in the login URL (/login?tenant={slug}) and in the tenant selector. If you have existing tenants without slugs, run:
cd backend
uv run python -m eneo.cli.backfill_tenant_slugsThis derives slugs from the tenant names.
Register the application in each tenant’s IdP
For each tenant, register Eneo as an application in their IdP. The redirect URI is the tenant’s public origin plus /login/callback:
Azure Entra ID
- Go to Azure Portal → Azure Active Directory → App registrations
- Click New registration
- Enter a name (e.g., “Eneo SSO”)
- Set Redirect URI (type Web) to the tenant’s callback:
https://eneo.your-domain.com/login/callback - Click Register
- Note the Application (client) ID and Directory (tenant) ID
- Go to Certificates & secrets → New client secret
- Copy the secret value immediately
- Go to API permissions → Add
openid,profile,email,User.Read - Click Grant admin consent
Discovery URL for Azure:
https://login.microsoftonline.com/{directory-tenant-id}/v2.0/.well-known/openid-configurationIf a tenant is reached through more than one origin (for example a proxy URL and a clean URL), register every callback URL in the IdP and list the extra ones in additional_redirect_uris below.
Configure the tenant via API
Use the federation API to provide the tenant’s OIDC configuration. PUT is the full-definition endpoint for a new federation setup or a full replacement of the current one. The backend fetches the discovery document immediately and rejects the request with 400 if it is unreachable or incomplete.
Azure Entra ID
curl -X PUT "https://eneo.your-domain.com/api/v1/sysadmin/tenants/{tenant_id}/federation" \
-H "X-API-Key: your-super-admin-api-key" \
-H "Content-Type: application/json" \
-d '{
"provider": "entra_id",
"canonical_public_origin": "https://eneo.your-domain.com",
"discovery_endpoint": "https://login.microsoftonline.com/{azure-tenant-id}/v2.0/.well-known/openid-configuration",
"client_id": "azure-application-id",
"client_secret": "azure-client-secret",
"allowed_domains": ["sundsvall.se"]
}'Request fields:
| Field | Required | Description |
|---|---|---|
provider | Yes | Label for the IdP (e.g., entra_id, mobilityguard, okta, auth0) |
discovery_endpoint | Yes | The IdP’s OIDC discovery URL |
client_id | Yes | OAuth client ID |
client_secret | Yes | OAuth client secret (at least 8 characters, encrypted at rest) |
allowed_domains | No | Email domains allowed to authenticate. Empty means no domain filter for existing members; a non-empty list is required for JIT account creation |
canonical_public_origin | No | The URL where this tenant’s users access Eneo; used to build the redirect URI. Falls back to the backend’s PUBLIC_ORIGIN when omitted |
redirect_path | No | Callback path, default /login/callback |
additional_redirect_uris | No | Extra fully-qualified callback URLs accepted for this tenant (each must also be registered in the IdP) |
Scopes are fixed to openid email profile and cannot be set through the API.
Test the configuration
Verify the IdP connection before users try to log in:
curl -X POST "https://eneo.your-domain.com/api/v1/sysadmin/tenants/{tenant_id}/federation/test" \
-H "X-API-Key: your-super-admin-api-key"This test:
- Fetches the IdP’s discovery document from the stored
discovery_endpoint - Verifies the required OIDC fields are present (
issuer,authorization_endpoint,token_endpoint,jwks_uri) - Returns
{"success": true, "message": "Federation config is valid and IdP is reachable", "issuer": "…"}
The test does not fetch the JWKS, attempt actual authentication or validate client credentials - it only checks that the discovery endpoint is reachable and complete.
Test login
Open the tenant’s login URL and test the flow:
https://eneo.your-domain.com/login?tenant={tenant-slug}You are redirected to the tenant’s IdP. Authenticate with a user from that IdP; on return, Eneo issues a session for the matching Eneo user. Without the tenant parameter the login page shows the tenant selector instead.
Tenant Resolution
When federation is enabled and more than one active tenant exists, the login page determines which tenant’s IdP to use in this order:
| Priority | Method | Description |
|---|---|---|
| 1 | tenant query parameter | /login?tenant={slug} starts the flow for that tenant immediately. Use this for direct links from intranets or portals |
| 2 | Remembered slug | The last slug used in this browser session (stored in sessionStorage) stays the active tenant: the selector is skipped and the sign-in form is shown with a Choose another organisation link, and after a failed callback a Try logging in again button restarts the flow for that tenant. It does not start the IdP redirect by itself - use ?tenant={slug} for that |
| 3 | Tenant selector | The login page fetches GET /api/v1/auth/tenants and shows a selector. When exactly one tenant is returned it is selected automatically |
Eneo does not resolve tenants from subdomains or hostnames. A dedicated hostname per tenant is optional (set it as that tenant’s canonical_public_origin); the tenant is always carried explicitly via the slug and the signed state.
Tenant Selector
The selector lists every active tenant that has a slug - it does not check whether federation is configured for the tenant. A tenant listed without an IdP configuration fails at /api/v1/auth/initiate with 500 No identity provider configured for tenant '{slug}'. Configure every tenant with a slug, or remove the slug from tenants that should not appear.
Managing Tenants
View tenant configuration
curl "https://eneo.your-domain.com/api/v1/sysadmin/tenants/{tenant_id}/federation" \
-H "X-API-Key: your-super-admin-api-key"The response contains provider, client_id, masked_secret, issuer, allowed_domains, additional_redirect_uris, configured_at and encryption_status.
Client secrets are always masked in responses for security.
Update tenant configuration
Use the PATCH endpoint to update the current federation setup without resending every field. Omitted fields stay unchanged. Use PUT only when you want to provide a full new federation configuration.
curl -X PATCH "https://eneo.your-domain.com/api/v1/sysadmin/tenants/{tenant_id}/federation" \
-H "X-API-Key: your-super-admin-api-key" \
-H "Content-Type: application/json" \
-d '{
"allowed_domains": ["sundsvall.se", "new-domain.se"]
}'PATCH requires an existing configuration (404 No federation config found for tenant otherwise) and does not accept null for provider, discovery_endpoint, client_id or client_secret. Changing discovery_endpoint re-fetches the discovery document. To replace the whole federation definition, continue to use PUT and send the full required payload.
Remove tenant federation
To disable OIDC for a tenant:
curl -X DELETE "https://eneo.your-domain.com/api/v1/sysadmin/tenants/{tenant_id}/federation" \
-H "X-API-Key: your-super-admin-api-key"While FEDERATION_ENABLED=true there is no fallback to the OIDC_* environment variables, so the tenant’s users cannot sign in through OIDC until a new configuration is provided.
Adding a New Tenant
To onboard a new organization:
- Create the tenant in Eneo (via the sysadmin API)
- Ensure the tenant has a slug for the login URL and selector
- Register Eneo in their IdP with the callback URL
{canonical_public_origin}/login/callback - Configure via API with their IdP details
- Test the configuration
- Provision users through SCIM, or create/invite them, and keep tenant
provisioning: false. For deployments without SCIM, optionally enable JIT (POST /api/v1/sysadmin/tenants/{tenant_id}/with{"provisioning": true}and adefault_role_id), configure a non-emptyallowed_domains, and ensure the ID token provides booleanemail_verified: trueforemail.
An existing active tenant member can sign in without an email_verified claim. Creating a new member requires all JIT conditions above. A custom email claims mapping can be used for existing accounts, but JIT only accepts verification when the selected address equals the token’s standard email address. Missing verification is not an implied approval. Use SCIM or administrative provisioning when your IdP does not provide this guarantee.
JIT never restores inactive or removed accounts. SCIM or an administrator must reactivate the original account. Enabling SCIM does not automatically change provisioning; explicitly keep it false when SCIM manages membership.
Troubleshooting
”Email domain … is not allowed for this organization”
The user’s email domain isn’t in the tenant’s allowed_domains list (403).
Fix: Add the domain with PATCH:
curl -X PATCH "https://eneo.your-domain.com/api/v1/sysadmin/tenants/{tenant_id}/federation" \
-H "X-API-Key: your-super-admin-api-key" \
-H "Content-Type: application/json" \
-d '{"allowed_domains": ["domain1.com", "domain2.com"]}'“User not found. Provision the account through SCIM or contact your administrator for access.”
The user authenticated successfully with the IdP but doesn’t exist in Eneo and the tenant has JIT provisioning disabled (403).
Fix: Either:
- Create or invite the user in the tenant first
- For deployments without SCIM, enable JIT (
provisioning: true), configure a non-emptyallowed_domains, require booleanemail_verified: truefrom the IdP, and set adefault_role_id. Otherwise auto-created users have no permissions. Do not enable JIT to bypass a SCIM provisioning failure.
”Authorization session is invalid or has expired”
Redis is reachable but the cached state for this login was not found: the callback was replayed, or Redis was restarted or evicted the key mid-flow (400). A state older than 10 minutes is rejected earlier with 400 Authorization session expired. Please try logging in again.
Fix: Start the sign-in flow again. If it recurs, check the Redis logs and the oidc_state_ttl_seconds setting.
Redis connection problems
Auth state caching is best-effort. When Redis is down the backend logs Failed to persist OIDC state in Redis / Failed to retrieve cached OIDC state warnings and continues on the signed state alone, without tamper detection.
Fix: Verify Redis is running and accessible:
redis-cli pingDebug mode
For complex issues across multiple tenants:
curl -X POST "https://eneo.your-domain.com/api/v1/sysadmin/observability/oidc-debug/" \
-H "X-API-Key: your-super-admin-api-key" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "duration_minutes": 10, "reason": "Multi-tenant debug"}'Then search logs for the user’s correlation ID (shown on error screens):
docker compose logs backend | grep "correlation_id.*abc123"Remember to disable debug mode after:
curl -X POST "https://eneo.your-domain.com/api/v1/sysadmin/observability/oidc-debug/" \
-H "X-API-Key: your-super-admin-api-key" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'Correlation ID Tracking
Every authentication request is assigned a unique correlation ID for end-to-end tracing. This ID follows the request through the entire OIDC flow and appears in all related log entries.
Format
The correlation ID is a 16-character hexadecimal string generated using secrets.token_hex(8):
Example: a1b2c3d4e5f67890Using Correlation IDs for Debugging
- User-facing errors display the correlation ID on error screens
- Search logs using the correlation ID to trace the complete flow:
docker compose logs backend | grep "correlation_id.*a1b2c3d4e5f67890"- All OIDC events are logged with the correlation ID, including:
- Authentication initiation
- State token creation
- Callback processing
- Token exchange
- User resolution
When users report authentication issues, ask them for the correlation ID shown on the error screen. This allows you to quickly locate all related log entries.
Redis State Cache
Authentication state is cached in Redis to validate callbacks and detect tampering. The cache is best-effort: the signed state JWT is always verified, and the cached copy adds a second check when Redis is available.
Cache Key Format
State is stored using the nonce as the key identifier:
oidc:state:{nonce}Where {nonce} is a 32-character hexadecimal string generated per authentication request.
Cached Data
Each state entry contains:
| Field | Description |
|---|---|
tenant_id | UUID of the tenant initiating authentication |
tenant_slug | Tenant slug for validation on callback |
redirect_uri | Server-computed redirect URI |
config_version | Tenant configuration version (for stale config detection) |
iat | Issued-at timestamp |
TTL Configuration
State tokens expire after 10 minutes by default (controlled by oidc_state_ttl_seconds). After expiration:
- The Redis key is automatically deleted
- Callback attempts with expired state will fail
- Users must restart the authentication flow
The key is also deleted as soon as a callback has been processed, so a state cannot be replayed while Redis is available.
Debugging State Issues
To inspect cached state (requires Redis CLI access):
# List all active OIDC states
redis-cli KEYS "oidc:state:*"
# Inspect a specific state entry
redis-cli GET "oidc:state:{nonce}"
# Check TTL remaining
redis-cli TTL "oidc:state:{nonce}"Never manually delete or modify state entries in production. This could cause active authentication flows to fail.
Security Considerations
Client Secret Encryption
Client secrets are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) before being stored in the database.
Back up your ENCRYPTION_KEY securely. If you lose it, you’ll need to re-register all tenant IdPs and reconfigure their client secrets.
Domain Restrictions
Always configure allowed_domains for each tenant. This ensures only users from authorized email domains can authenticate - even if they have valid credentials in the IdP.
State Token Protection
Authentication requests use signed state tokens (JWT with HS256) to prevent CSRF attacks. Tokens expire after 10 minutes.
Next Steps
- Single-Tenant Setup - Simpler setup for single-organization deployments
- Authentication Architecture - Technical details on the OIDC implementation