Skip to Content
DocumentationAuthentication Architecture

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

OIDC Authentication Flow showing User, Eneo, and Identity Provider interaction

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 context
  • nonce - 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:

ClaimMeaning
token_versionRequired value 2; older session formats are rejected
user_idImmutable UUID of the authenticated Eneo account
tenant_idUUID of the account’s organization when the token was issued
credential_versionRequired integer matching the account’s current credential version
iss, aud, iat, expRequired issuer, audience, issue time and expiry
sub, usernameEmail 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:

ParameterValueSource
AlgorithmHS256federation_router.py
Signing keyJWT_SECRETconfig.py
TTL600 seconds (10 minutes)oidc_state_ttl_seconds
Nonce length32 hex chars (16 bytes)secrets.token_hex(16)
Correlation ID16 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:

SituationBehaviour
Redis reachable, key presentCached 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 unavailableWarning 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-formed redirect_uri from the state is accepted.
  • strict_oidc_redirect_validation = true (default): the old redirect_uri is accepted only if all of the following hold:
ConditionCheck
Config version changedcurrent_config_version != state_config_version
Grace period configuredgrace_period > 0
Cached state available and consistentcached_state.redirect_uri == redirect_uri (requires Redis)
State issued within grace periodseconds_since_issue <= grace_period
Config updated within grace periodseconds_since_update <= grace_period
Config changed after state issuedtenant_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:

PriorityMethodDescription
1Explicit configtoken_endpoint_auth_method in federation config
2client_secret_postCredentials in request body
3client_secret_basicHTTP Basic Authentication header
4First supportedFirst method from discovery
5DefaultFalls 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:

StageResponse detail
GET /auth/initiateTenant '{slug}' is not active. Contact your administrator.
POST /auth/callbackTenant is not active. Contact your administrator.
Tenant StateAuthentication Allowed
activeYes
suspendedNo

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:

FieldComparisonPurpose
tenant_idExact matchPrevents tenant ID substitution
tenant_slugCase-insensitivePrevents 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

Multi-Tenant Federation Architecture showing tenant resolution and IdP routing

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:

PriorityMethodDescription
1?tenant={slug} query parameterhttps://eneo.example.com/login?tenant=sundsvall starts the flow for that tenant directly
2Remembered slugThe 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
3Tenant selectorGET /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:

KeySourceNotes
providerrequestIdentity provider label (entra_id, mobilityguard, …)
discovery_endpointrequestOIDC discovery URL
client_idrequestOAuth client ID
client_secretrequestEncrypted with Fernet before storage
allowed_domainsrequestEmail domain allowlist (lower-cased)
canonical_public_originrequest (optional)Tenant’s access URL, falls back to PUBLIC_ORIGIN
redirect_pathrequest (optional)Defaults to /login/callback
additional_redirect_urisrequest (optional)Extra fully-qualified redirect URIs accepted at callback
issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uridiscoveryResolved at PUT/PATCH time
token_endpoint_auth_method, token_endpoint_auth_methods_supporteddiscoverySee Token Auth Method Selection
scopesdefault["openid", "email", "profile"] (not configurable via the API)
claims_mappingdefault{"email": "email", "username": "sub", "name": "name"}; only email is read at login
encrypted_atserverTimestamp of the last secret encryption

Secret Encryption

Client secrets are encrypted using Fernet (AES-128-CBC + HMAC-SHA256):

AspectDetails
AlgorithmFernet
Env VariableENCRYPTION_KEY
Key FormatBase64-encoded Fernet key
Encrypted Formatenc:fernet:v1:<ciphertext>

Key generation:

uv run python -m eneo.cli.generate_encryption_key

ENCRYPTION_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):

ClaimValidated?Details
audYesMust match client_id
expYesAutomatic PyJWT validation
issNoNot checked (no issuer is passed to PyJWT); trust comes from the signature against the configured jwks_uri
iatYesA token issued more than the clock leeway in the future is rejected as not yet valid (ImmatureSignatureError); the drift is logged
nonceNoNot validated in ID token
at_hashConditionalIf 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 stateResult
Existing active member of the tenantSign in, subject to any configured domain restriction. No new email-verification requirement is applied.
Existing account in another tenant, or inactive/removed account403; no account creation or reactivation.
No account, tenant.provisioning: false (default)403; provision through SCIM or administration.
No account, tenant.provisioning: trueCreation 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 wrapper

Caching:

  • 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 at PUT/PATCH time; the environment-variable configuration only has discovery_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:

SettingDefaultDescription
federation_enabled (FEDERATION_ENABLED)falsefalse: 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_seconds600State token TTL (10 minutes)
oidc_redirect_grace_period_seconds900Config change grace period (15 min, capped at the state TTL)
oidc_clock_leeway_seconds120Clock skew tolerance (2 minutes)
strict_oidc_redirect_validationtrueEnforce 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.

MethodPathDescription
PUT/tenants/{tenant_id}/federationProvide a complete config (replaces the current one)
PATCH/tenants/{tenant_id}/federationUpdate individual fields of an existing config
GET/tenants/{tenant_id}/federationView config (secrets masked)
DELETE/tenants/{tenant_id}/federationRemove config
POST/tenants/{tenant_id}/federation/testTest 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-ID header 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_cached
  • initiate.state_cache_failed / initiate.state_cache_skipped
  • initiate.authorization_ready

Callback phase:

  • callback.state_decoded
  • callback.state_cache_hit / callback.state_cache_miss
  • callback.state_cache_error / callback.state_cache_unavailable / callback.state_cache_missing
  • callback.state_tampered (with reason: tenant_id_mismatch or tenant_slug_mismatch)
  • callback.tenant_loaded
  • callback.tenant_inactive
  • callback.email_extracted
  • callback.user_denied
  • callback.jit_provisioning
  • callback.success
  • callback.unexpected_error

Troubleshooting Flow

OIDC Troubleshooting Flow


Provider-Specific Notes

Discovery URL:

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

Required permissions: openid, profile, email, User.Read

Common issues:

  • Multi-tenant apps: Use organizations or common instead of specific tenant ID
  • Missing email: Ensure email scope and user has email set in Azure AD

Public OIDC Endpoints

These endpoints do NOT require authentication:

MethodPathPurpose
GET/api/v1/auth/federation-statusTells 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/tenantsList active tenants with a slug for the selector
GET/api/v1/auth/initiateInitiate OIDC authentication (tenant, state, optional redirect_uri query parameters)
POST/api/v1/auth/callbackHandle OIDC callback (code, state) and issue the Eneo session token