Skip to Content
GuidesAuthentication & OIDCMulti-Tenant Federation

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

Multi-Tenant Federation Architecture showing tenant resolution and IdP routing


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_KEY configured)
  • 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-key

Generate an encryption key:

uv run python -m eneo.cli.generate_encryption_key

Restart the backend:

docker compose restart backend

FEDERATION_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_slugs

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

  1. Go to Azure Portal  → Azure Active Directory → App registrations
  2. Click New registration
  3. Enter a name (e.g., “Eneo SSO”)
  4. Set Redirect URI (type Web) to the tenant’s callback:
    https://eneo.your-domain.com/login/callback
  5. Click Register
  6. Note the Application (client) ID and Directory (tenant) ID
  7. Go to Certificates & secrets → New client secret
  8. Copy the secret value immediately
  9. Go to API permissions → Add openid, profile, email, User.Read
  10. Click Grant admin consent

Discovery URL for Azure:

https://login.microsoftonline.com/{directory-tenant-id}/v2.0/.well-known/openid-configuration

If 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.

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:

FieldRequiredDescription
providerYesLabel for the IdP (e.g., entra_id, mobilityguard, okta, auth0)
discovery_endpointYesThe IdP’s OIDC discovery URL
client_idYesOAuth client ID
client_secretYesOAuth client secret (at least 8 characters, encrypted at rest)
allowed_domainsNoEmail domains allowed to authenticate. Empty means no domain filter for existing members; a non-empty list is required for JIT account creation
canonical_public_originNoThe 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_pathNoCallback path, default /login/callback
additional_redirect_urisNoExtra 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:

PriorityMethodDescription
1tenant query parameter/login?tenant={slug} starts the flow for that tenant immediately. Use this for direct links from intranets or portals
2Remembered slugThe 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
3Tenant selectorThe 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:

  1. Create the tenant in Eneo (via the sysadmin API)
  2. Ensure the tenant has a slug for the login URL and selector
  3. Register Eneo in their IdP with the callback URL {canonical_public_origin}/login/callback
  4. Configure via API with their IdP details
  5. Test the configuration
  6. 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 a default_role_id), configure a non-empty allowed_domains, and ensure the ID token provides boolean email_verified: true for email.

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-empty allowed_domains, require boolean email_verified: true from the IdP, and set a default_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 ping

Debug 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: a1b2c3d4e5f67890

Using Correlation IDs for Debugging

  1. User-facing errors display the correlation ID on error screens
  2. Search logs using the correlation ID to trace the complete flow:
docker compose logs backend | grep "correlation_id.*a1b2c3d4e5f67890"
  1. 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:

FieldDescription
tenant_idUUID of the tenant initiating authentication
tenant_slugTenant slug for validation on callback
redirect_uriServer-computed redirect URI
config_versionTenant configuration version (for stale config detection)
iatIssued-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