Authentication Architecture
Technical reference for Eneo’s OIDC authentication system, including protocol details, security mechanisms, and internal components.
Looking to set up authentication? See the OIDC Federation guides (single-tenant or multi-tenant). For how optional module applications reuse the Eneo session through one-time tickets, see Module Authentication.
OIDC Protocol Flow
Eneo implements the OAuth 2.0 Authorization Code Flow with OpenID Connect. Both single-tenant and multi-tenant setups go through the same public endpoints (/api/v1/auth/initiate and /api/v1/auth/callback) and use server-signed state JWTs (HS256) for security.
Overview
Flow Steps Explained
Authorization Request
The frontend calls GET /api/v1/auth/initiate (with ?tenant={slug} in multi-tenant mode). The backend resolves the tenant’s IdP configuration, computes the redirect_uri server-side and generates:
state- JWT signed with HS256, contains tenant contextnonce- 32 hex characters (secrets.token_hex(16))correlation_id- 16 hex characters (secrets.token_hex(8)) for request tracing
A copy of the state is cached in Redis under oidc:state:{nonce} on a best-effort basis (a cache failure is logged and the flow continues).
User Authentication
User authenticates with their Identity Provider.
Authorization Response
IdP redirects to {origin}/login/callback with:
code- Authorization code (single-use)state- Original state JWT for validation
Token Exchange
The frontend posts code and state to POST /api/v1/auth/callback. The backend validates the state JWT signature (HS256) and expiry, compares it with the cached state when Redis is available, validates the redirect_uri, then exchanges the code for tokens at the IdP’s token endpoint.
ID Token Validation
JWT signature verified against the IdP’s JWKS (RS256). Claims validated: aud (audience), exp (expiration), at_hash when present.
Session Creation
After token validation, both OIDC entry points delegate tenant membership and JIT admission to UserService.resolve_federated_user. It checks the configured domain restriction, tenant and account state, and the additional JIT requirements below before an Eneo session JWT is issued.
Security Mechanisms
Eneo Session Identity
Eneo-issued browser, internal MCP and module tokens share one identity contract:
| Claim | Meaning |
|---|---|
token_version | Required value 2; older session formats are rejected |
user_id | Immutable UUID of the authenticated Eneo account |
tenant_id | UUID of the account’s organization when the token was issued |
credential_version | Required integer matching the account’s current credential version |
iss, aud, iat, exp | Required issuer, audience, issue time and expiry |
sub, username | Email and optional display name; never used to select the account |
After signature, issuer, audience and time validation, the backend resolves the
account by both user_id and tenant_id. Deleted accounts and mismatched
identity claims cannot authenticate. Account and tenant state checks and normal
resource permissions still apply. A name or email change cannot transfer an
existing session to another account, including one with the same username.
The identity contract belongs to Eneo session tokens. Identity-provider tokens are validated separately during OIDC login and exchanged for an Eneo session.
Deploying the identity contract
The transition requires users to sign in again. Pre-upgrade Eneo and module tokens, and pending module login tickets, are rejected; there is no fallback to username or email authentication. Module applications must restart the login handoff on an authentication failure.
Update all API instances and workers together, draining old instances before serving traffic with the new deployment. Old instances still resolve sessions by username and must not remain in a rolling deployment. No account rename, account merge or database migration is needed for this change. A recovery build must retain the identity validation; rolling back to username-based authentication reintroduces the vulnerability.
State Token (CSRF Protection)
The state parameter is a JWT signed with HS256:
# federation_router.py
state_payload: OIDCStatePayload = {
"tenant_id": str(tenant_obj.id),
"tenant_slug": tenant_obj.slug or tenant_obj.name,
"frontend_state": state or "",
"nonce": secrets.token_hex(16),
"redirect_uri": redirect_uri,
"correlation_id": correlation_id,
"exp": int(time.time()) + settings.oidc_state_ttl_seconds,
"iat": int(time.time()),
"config_version": tenant_config_version,
}
signed_state = pyjwt.encode(
cast(dict[str, Any], state_payload), settings.jwt_secret, algorithm="HS256"
)Key parameters:
| Parameter | Value | Source |
|---|---|---|
| Algorithm | HS256 | federation_router.py |
| Signing key | JWT_SECRET | config.py |
| TTL | 600 seconds (10 minutes) | oidc_state_ttl_seconds |
| Nonce length | 32 hex chars (16 bytes) | secrets.token_hex(16) |
| Correlation ID | 16 hex chars (8 bytes) | secrets.token_hex(8) |
An expired state is rejected with 400 Authorization session expired. Please try logging in again.; a state that fails signature validation is rejected with 400 Invalid state parameter.
Redis State Cache
# federation_router.py
state_cache_payload: OIDCStateCache = {
"tenant_id": str(tenant_obj.id),
"tenant_slug": tenant_obj.slug or tenant_obj.name,
"redirect_uri": redirect_uri,
"config_version": tenant_config_version,
"iat": state_payload["iat"],
}
state_cache_key = f"oidc:state:{state_payload['nonce']}"
await redis_client.setex(
state_cache_key,
settings.oidc_state_ttl_seconds,
json.dumps(state_cache_payload, separators=(",", ":")),
)The cache is best-effort:
| Situation | Behaviour |
|---|---|
| Redis reachable, key present | Cached state is compared with the signed state (tamper detection) and used for the grace-period check |
| Redis reachable, key missing (expired or already consumed) | 400 Authorization session is invalid or has expired. Please restart the sign-in flow. |
| Redis unavailable | Warning logged (initiate.state_cache_failed / callback.state_cache_error), the flow continues on the signed state alone |
The key is deleted when the callback finishes, whether it succeeded or failed, so a state cannot be replayed while Redis is available.
Redirect URI Grace Period
When tenant federation configuration changes (e.g., updating canonical_public_origin), users mid-authentication could encounter redirect URI mismatches. The grace period mechanism prevents failed logins during configuration updates.
How it works:
# federation_router.py
grace_period = min(
settings.oidc_redirect_grace_period_seconds,
settings.oidc_state_ttl_seconds,
)
if redirect_mismatch:
if not settings.strict_oidc_redirect_validation:
allow_redirect_mismatch = True
else:
# grace-period checks, see table below
...The redirect_uri carried in the state is first checked for well-formedness and then compared with the tenant’s currently configured redirect URIs (canonical plus additional_redirect_uris). On a mismatch:
strict_oidc_redirect_validation = false: any well-formedredirect_urifrom the state is accepted.strict_oidc_redirect_validation = true(default): the oldredirect_uriis accepted only if all of the following hold:
| Condition | Check |
|---|---|
| Config version changed | current_config_version != state_config_version |
| Grace period configured | grace_period > 0 |
| Cached state available and consistent | cached_state.redirect_uri == redirect_uri (requires Redis) |
| State issued within grace period | seconds_since_issue <= grace_period |
| Config updated within grace period | seconds_since_update <= grace_period |
| Config changed after state issued | tenant_updated_at > state_config_dt |
Otherwise the callback fails with 400 Redirect URI mismatch - authentication flow invalid. This may occur if tenant configuration changed during login. ….
The grace period defaults to 15 minutes but is capped at the state TTL (10 minutes), effectively making it 10 minutes maximum. The backend logs a warning at startup when OIDC_REDIRECT_GRACE_PERIOD_SECONDS exceeds OIDC_STATE_TTL_SECONDS.
Token Auth Method Selection
The backend automatically selects the token endpoint authentication method from IdP discovery:
# federation_router.py
def _select_auth_method(methods: list[str] | None) -> str | None:
if not methods:
return None
normalized = [str(m).lower() for m in methods if m]
if "client_secret_post" in normalized:
return "client_secret_post"
if "client_secret_basic" in normalized:
return "client_secret_basic"
return normalized[0]Selection priority:
| Priority | Method | Description |
|---|---|---|
| 1 | Explicit config | token_endpoint_auth_method in federation config |
| 2 | client_secret_post | Credentials in request body |
| 3 | client_secret_basic | HTTP Basic Authentication header |
| 4 | First supported | First method from discovery |
| 5 | Default | Falls back to client_secret_post |
Any other method selected from discovery is logged as unsupported and replaced by client_secret_post.
Tenant State Validation
Authentication is blocked for non-active tenants at both initiate and callback stages. Both return 403 Forbidden:
| Stage | Response detail |
|---|---|
GET /auth/initiate | Tenant '{slug}' is not active. Contact your administrator. |
POST /auth/callback | Tenant is not active. Contact your administrator. |
| Tenant State | Authentication Allowed |
|---|---|
active | Yes |
suspended | No |
If a tenant becomes suspended during a user’s authentication flow, the callback will fail even if initiate succeeded.
State Tamper Detection
When the cached state is available, the callback validates that the tenant context in the signed state matches the cached state to detect tampering:
# federation_router.py
expected_tenant_id = cached_state["tenant_id"]
if expected_tenant_id and expected_tenant_id != str(tenant_id):
...
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Authorization state mismatch detected. "
"Please restart the sign-in flow."
),
headers={"X-Correlation-ID": correlation_id},
)Validation checks:
| Field | Comparison | Purpose |
|---|---|---|
tenant_id | Exact match | Prevents tenant ID substitution |
tenant_slug | Case-insensitive | Prevents tenant slug substitution |
Both checks return 400 with the same detail and are logged as callback.state_tampered debug events with reason set to tenant_id_mismatch or tenant_slug_mismatch. The checks only run when Redis returned a cached state; the signed JWT itself is always verified.
Multi-Tenant Federation Architecture
Tenant Resolution
When federation is enabled (FEDERATION_ENABLED=true) and more than one active tenant exists, the login page has to know which tenant’s IdP to use. Eneo does not derive the tenant from the hostname; the frontend resolves it in this order:
| Priority | Method | Description |
|---|---|---|
| 1 | ?tenant={slug} query parameter | https://eneo.example.com/login?tenant=sundsvall starts the flow for that tenant directly |
| 2 | Remembered slug | The last used slug is stored in sessionStorage (eneo-last-tenant-slug) and stays the active tenant within the browser session: the selector is skipped and the sign-in form is shown with a Choose another organisation link. It does not start the IdP redirect by itself |
| 3 | Tenant selector | GET /api/v1/auth/tenants lists tenants; the user picks one. When exactly one tenant is returned it is selected automatically |
Slugs must match ^[a-z0-9-]+$. The selector lists every active tenant that has a slug, regardless of whether federation is configured for it; a tenant without an IdP fails at /auth/initiate with 500 No identity provider configured for tenant '{slug}'.
On the backend, GET /api/v1/auth/initiate without a tenant parameter auto-selects the tenant when exactly one active tenant exists and otherwise returns 400 Tenant parameter required when multiple tenants exist.
Federation Config Storage
Federation config is stored in the federation_config JSONB column of the tenants table. PUT …/federation fetches the discovery document immediately and stores the resolved endpoints alongside the request fields:
| Key | Source | Notes |
|---|---|---|
provider | request | Identity provider label (entra_id, mobilityguard, …) |
discovery_endpoint | request | OIDC discovery URL |
client_id | request | OAuth client ID |
client_secret | request | Encrypted with Fernet before storage |
allowed_domains | request | Email domain allowlist (lower-cased) |
canonical_public_origin | request (optional) | Tenant’s access URL, falls back to PUBLIC_ORIGIN |
redirect_path | request (optional) | Defaults to /login/callback |
additional_redirect_uris | request (optional) | Extra fully-qualified redirect URIs accepted at callback |
issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri | discovery | Resolved at PUT/PATCH time |
token_endpoint_auth_method, token_endpoint_auth_methods_supported | discovery | See Token Auth Method Selection |
scopes | default | ["openid", "email", "profile"] (not configurable via the API) |
claims_mapping | default | {"email": "email", "username": "sub", "name": "name"}; only email is read at login |
encrypted_at | server | Timestamp of the last secret encryption |
Secret Encryption
Client secrets are encrypted using Fernet (AES-128-CBC + HMAC-SHA256):
| Aspect | Details |
|---|---|
| Algorithm | Fernet |
| Env Variable | ENCRYPTION_KEY |
| Key Format | Base64-encoded Fernet key |
| Encrypted Format | enc:fernet:v1:<ciphertext> |
Key generation:
uv run python -m eneo.cli.generate_encryption_keyENCRYPTION_KEY is required when FEDERATION_ENABLED=true; the backend refuses to start without a valid key. Back up securely - loss means re-registering all tenant IdPs.
ID Token Validation
Claims Validated
The backend validates these claims (auth_service.py):
| Claim | Validated? | Details |
|---|---|---|
aud | Yes | Must match client_id |
exp | Yes | Automatic PyJWT validation |
iss | No | Not checked (no issuer is passed to PyJWT); trust comes from the signature against the configured jwks_uri |
iat | Yes | A token issued more than the clock leeway in the future is rejected as not yet valid (ImmatureSignatureError); the drift is logged |
nonce | No | Not validated in ID token |
at_hash | Conditional | If present, must match access token hash |
Clock skew tolerance: 120 seconds (2 minutes) - configurable via oidc_clock_leeway_seconds
User Claims Extracted
Only the email claim is used. The claim name comes from claims_mapping.email (default email); the ID token’s sub is logged for diagnostics but not persisted:
# federation_router.py
claims_mapping: OIDCClaimsMapping = federation_config.get(
"claims_mapping"
) or {"email": "email"}
email_claim = claims_mapping.get("email", "email")
...
email = payload.get(email_claim)A missing claim is rejected with 401 Email claim not found in ID token; a malformed address with 401 Email claim from identity provider is invalid. Contact your administrator.
Domain Validation
When allowed_domains is configured, email domains are compared case-insensitively using IDNA2008. Unicode and punycode forms of the same domain match; distinct domains such as faß.de and fass.de do not. A non-matching domain is rejected with:
403 Email domain '{domain}' is not allowed for this organization. Contact your administrator to add your domain.Global OIDC uses OIDC_ALLOWED_DOMAINS (JSON array, default []); per-tenant federation uses allowed_domains. An empty list leaves existing active tenant members unrestricted by domain, but cannot admit new members through JIT. The federation callback records admission refusals as callback.user_denied with a reason.
JIT Provisioning
UserService.resolve_federated_user is the single admission owner for the federation callback and the global OIDC/MobilityGuard login endpoint. It reuses the active account in the configured tenant, rejects inactive accounts and cross-tenant matches, and checks for removed rows before considering account creation. The removed-account lookup is tenant-scoped and tests for existence because several historical rows can share an email.
| Account state | Result |
|---|---|
| Existing active member of the tenant | Sign in, subject to any configured domain restriction. No new email-verification requirement is applied. |
| Existing account in another tenant, or inactive/removed account | 403; no account creation or reactivation. |
No account, tenant.provisioning: false (default) | 403; provision through SCIM or administration. |
No account, tenant.provisioning: true | Creation requires a non-empty domain allowlist, a matching domain and the ID token’s boolean email_verified: true for the selected address. |
FederatedIdentity.from_claims receives validated token claims and preserves only a valid email address and its verification status. Values such as "true" or 1 do not count as verification. When email is selected through a custom claims mapping, standard email_verified only authorizes JIT if the selected address equals the standard email claim.
Provisioning is a per-tenant flag set through the sysadmin API (POST /api/v1/sysadmin/tenants/{id}/). Keep it false when SCIM manages membership. A SCIM token does not implicitly change this setting. SCIM or an administrator can restore an account; login cannot.
New accounts use state=ACTIVE, the email local part as username (falling back to the full email, then no username, on collisions), and the tenant’s default role. Without a default role they have no roles and a warning is logged. Both paths write USER_CREATED with actor SYSTEM and provisioning_method: jit_federation; audit-log failure does not prevent account creation.
JWKS Handling
The signing key is fetched with PyJWT’s PyJWKClient, constructed anew for every callback:
# federation_router.py
jwk_client = JWKClient(jwks_uri)
signing_key = jwk_client.get_signing_key_from_jwt(
id_token
).key # Extract raw key from PyJWK wrapperCaching:
- No JWKS caching across requests - each callback creates a fresh client and downloads the key set
- No Redis/persistent caching for JWKS keys
- Endpoints missing from the stored config (
authorization_endpoint,token_endpoint,jwks_uri) are resolved by fetching the discovery document, also without caching. API-configured tenants have these stored atPUT/PATCHtime; the environment-variable configuration only hasdiscovery_endpoint, so discovery is fetched on every login
A JWKS fetch or key lookup failure is reported as 401 Failed to validate ID token signature.
Configuration Parameters
From config.py:
| Setting | Default | Description |
|---|---|---|
federation_enabled (FEDERATION_ENABLED) | false | false: IdP from OIDC_* env vars; true: per-tenant config via the sysadmin API |
federation_per_tenant_enabled (FEDERATION_PER_TENANT_ENABLED) | - | Deprecated alias for FEDERATION_ENABLED; logs a warning at startup, FEDERATION_ENABLED wins when both are set |
oidc_discovery_endpoint, oidc_client_id, oidc_client_secret | - | Global IdP used when FEDERATION_ENABLED=false; active when discovery endpoint and client secret are both set |
oidc_allowed_domains (OIDC_ALLOWED_DOMAINS) | [] | Global OIDC email-domain restriction; non-empty and matching domains are required for JIT |
public_origin (PUBLIC_ORIGIN) | - | Origin used to build redirect_uri when the tenant has no canonical_public_origin; must be https:// (or http://localhost) without a path |
encryption_key (ENCRYPTION_KEY) | - | Fernet key; required when FEDERATION_ENABLED=true |
eneo_super_api_key (ENEO_SUPER_API_KEY) | - | Super admin key for the sysadmin API (sent as X-API-Key) |
oidc_state_ttl_seconds | 600 | State token TTL (10 minutes) |
oidc_redirect_grace_period_seconds | 900 | Config change grace period (15 min, capped at the state TTL) |
oidc_clock_leeway_seconds | 120 | Clock skew tolerance (2 minutes) |
strict_oidc_redirect_validation | true | Enforce exact redirect_uri match (with grace period) |
Federation Admin API
Endpoints
All paths are relative to /api/v1/sysadmin and return 404 Federation is not enabled unless FEDERATION_ENABLED=true.
| Method | Path | Description |
|---|---|---|
PUT | /tenants/{tenant_id}/federation | Provide a complete config (replaces the current one) |
PATCH | /tenants/{tenant_id}/federation | Update individual fields of an existing config |
GET | /tenants/{tenant_id}/federation | View config (secrets masked) |
DELETE | /tenants/{tenant_id}/federation | Remove config |
POST | /tenants/{tenant_id}/federation/test | Test IdP discovery endpoint |
Authentication: X-API-Key header with the super admin API key (ENEO_SUPER_API_KEY).
PUT and PATCH (when discovery_endpoint changes) fetch the discovery document before saving and fail with 400 if it is unreachable or lacks issuer, authorization_endpoint, token_endpoint or jwks_uri. Every change writes a FEDERATION_UPDATED audit event.
Test Endpoint Behavior
POST /tenants/{tenant_id}/federation/test performs:
- Fetches the discovery document from the stored
discovery_endpoint - Validates HTTP 200 response
- Verifies 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": "…"} - Does NOT fetch the JWKS
- Does NOT attempt authentication
- Does NOT validate client credentials
Failures are reported as 500 with the reason in detail (HTTP status from the IdP, missing fields, or connection error).
Observability
Correlation IDs
Generated with secrets.token_hex(8) → 16 hex characters.
- Created in
/auth/initiate - Embedded in state JWT
- Reused in
/auth/callback - Included in all log entries and returned as the
X-Correlation-IDheader on callback errors
OIDC Debug Events
Enable debug logging via the observability API (POST /api/v1/sysadmin/observability/oidc-debug/). Events follow pattern [OIDC DEBUG] {event}:
Initiate phase:
initiate.state_cachedinitiate.state_cache_failed/initiate.state_cache_skippedinitiate.authorization_ready
Callback phase:
callback.state_decodedcallback.state_cache_hit/callback.state_cache_misscallback.state_cache_error/callback.state_cache_unavailable/callback.state_cache_missingcallback.state_tampered(withreason:tenant_id_mismatchortenant_slug_mismatch)callback.tenant_loadedcallback.tenant_inactivecallback.email_extractedcallback.user_deniedcallback.jit_provisioningcallback.successcallback.unexpected_error
Troubleshooting Flow
Provider-Specific Notes
Azure Entra ID
Discovery URL:
https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configurationRequired permissions: openid, profile, email, User.Read
Common issues:
- Multi-tenant apps: Use
organizationsorcommoninstead of specific tenant ID - Missing email: Ensure
emailscope and user has email set in Azure AD
Public OIDC Endpoints
These endpoints do NOT require authentication:
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/auth/federation-status | Tells the login page which mode is active (has_single_tenant_federation, has_multi_tenant_federation, has_global_oidc_config, tenant_count) |
GET | /api/v1/auth/tenants | List active tenants with a slug for the selector |
GET | /api/v1/auth/initiate | Initiate OIDC authentication (tenant, state, optional redirect_uri query parameters) |
POST | /api/v1/auth/callback | Handle OIDC callback (code, state) and issue the Eneo session token |