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 (required for auth state caching)
- Super Admin API key (
SUPER_API_KEYconfigured) - HTTPS enabled on your domain
- One of the following DNS setups:
- Wildcard DNS (
*.your-domain.com) pointing to Eneo - Per-tenant DNS records (e.g.,
tenant1.your-domain.com)
- Wildcard DNS (
- Matching TLS certificate (wildcard or per-tenant)
Multi-tenant federation requires FEDERATION_PER_TENANT_ENABLED=true. Configuration is managed via API only - environment variables are not used for individual tenant IdPs.
Setup
Enable federation mode
Add these environment variables to your backend .env:
# Enable multi-tenant federation
FEDERATION_PER_TENANT_ENABLED=true
# Encryption key for storing client secrets securely
ENCRYPTION_KEY=your-fernet-encryption-key
# Super admin API key for management endpoints
SUPER_API_KEY=your-super-admin-api-keyGenerate an encryption key:
uv run python -m intric.cli.generate_encryption_keyRestart the backend:
docker compose restart backendPrepare tenants
Each tenant needs a URL-safe slug for their subdomain. If you have existing tenants without slugs, run:
cd backend
uv run python -m intric.cli.backfill_tenant_slugsThis generates slugs from tenant names (e.g., “Sundsvall Municipality” → sundsvall-municipality).
Configure DNS
Set up DNS for each tenant to point to your Eneo deployment:
Option A: Wildcard DNS (recommended)
*.your-domain.com → your-eneo-server-ipOption B: Per-tenant DNS
tenant1.your-domain.com → your-eneo-server-ip
tenant2.your-domain.com → your-eneo-server-ipRegister the application in each tenant’s IdP
For each tenant, register Eneo as an application in their IdP:
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 to the tenant’s specific callback:
https://{tenant-slug}.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-configurationConfigure the tenant via API
Use the federation API to set up the tenant’s OIDC configuration:
Azure Entra ID
curl -X PUT "https://api.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://sundsvall.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",
"scopes": ["openid", "email", "profile"],
"allowed_domains": ["sundsvall.se"]
}'Important fields:
| Field | Description |
|---|---|
canonical_public_origin | The URL where this tenant’s users access Eneo (their subdomain) |
allowed_domains | Email domains allowed to authenticate - restricts who can log in |
Test the configuration
Verify the IdP connection before users try to log in:
curl -X POST "https://api.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
- Verifies required OIDC endpoints are present
- Fetches JWKS and verifies signing keys exist
The test does not attempt actual authentication or validate client credentials - it only checks that the IdP is reachable and properly configured.
Test login
Navigate to the tenant’s URL and test the login flow:
https://sundsvall.your-domain.comClick Sign In and authenticate with a user from the tenant’s IdP.
Tenant Resolution
When a user visits Eneo, the system determines which tenant’s IdP to use through a priority-based resolution:
| Priority | Method | Description |
|---|---|---|
| 1 (Primary) | Subdomain | Extract tenant slug from subdomain (e.g., sundsvall.your-domain.com resolves to tenant “sundsvall”) |
| 2 (Secondary) | Tenant Slug Parameter | Explicit tenant query parameter or path segment in the authentication URL |
| 3 (Fallback) | Tenant Selector UI | Interactive selector shown when no tenant can be determined automatically |
Subdomain Resolution (Primary)
The frontend extracts the subdomain from the current URL and passes it as the tenant parameter to the /api/v1/auth/initiate endpoint. This is the recommended approach for multi-tenant deployments.
Tenant Slug Parameter (Secondary)
If the subdomain doesn’t resolve to a valid tenant, the system checks for an explicit tenant slug in the request. This is useful for:
- Development environments without wildcard DNS
- Direct links to specific tenant login flows
Tenant Selector UI (Fallback)
If a user visits the main domain without a tenant subdomain and no tenant parameter is provided, they’ll see a tenant selector to choose their organization. The selector only displays tenants that have:
- A configured slug
- Active federation configuration
Managing Tenants
View tenant configuration
curl "https://api.your-domain.com/api/v1/sysadmin/tenants/{tenant_id}/federation" \
-H "X-API-Key: your-super-admin-api-key"Client secrets are always masked in responses for security.
Update tenant configuration
Use the PUT endpoint to update any field. Changes take effect immediately - no restart needed.
curl -X PUT "https://api.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"]
}'Remove tenant federation
To disable OIDC for a tenant:
curl -X DELETE "https://api.your-domain.com/api/v1/sysadmin/tenants/{tenant_id}/federation" \
-H "X-API-Key: your-super-admin-api-key"Adding a New Tenant
To onboard a new organization:
- Create the tenant in Eneo (via admin UI or API)
- Ensure the tenant has a slug for their subdomain
- Set up DNS for their subdomain
- Register Eneo in their IdP with their callback URL
- Configure via API with their IdP details
- Test the configuration
- Invite users or enable auto-provisioning
Troubleshooting
”Domain rejected” error
The user’s email domain isn’t in the tenant’s allowed_domains list.
Fix: Update the tenant configuration:
curl -X PUT "...federation" -d '{"allowed_domains": ["domain1.com", "domain2.com"]}'“User not found” error
The user authenticated successfully with the IdP but doesn’t exist in Eneo.
Fix: Either:
- Invite the user to the tenant in Eneo first
- Enable auto-provisioning for the tenant (if supported)
“State cache error”
Redis connectivity issue - auth state couldn’t be stored or retrieved.
Fix: Verify Redis is running and accessible:
redis-cli pingDebug mode
For complex issues across multiple tenants:
curl -X POST "https://api.your-domain.com/api/v1/sysadmin/observability/oidc-debug/" \
-H "X-API-Key: your-super-admin-api-key" \
-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 "...oidc-debug/" -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 prevent replay attacks. This ensures the callback request matches the original authentication initiation.
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
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