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 Multi-Tenant OIDC Federation guide.


OIDC Protocol Flow

Eneo implements the OAuth 2.0 Authorization Code Flow with OpenID Connect. Multi-tenant federation uses server-signed state JWTs (HS256) for security.

Overview

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

Flow Steps Explained

Authorization Request

User clicks “Sign In”. Backend generates security tokens:

  • 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

State cached in Redis with key oidc:state:{nonce}.

User Authentication

User authenticates with their Identity Provider.

Authorization Response

IdP redirects to callback URL with:

  • code - Authorization code (single-use)
  • state - Original state JWT for validation

Token Exchange

Backend validates state JWT signature (HS256), retrieves cached data from Redis, exchanges code for tokens.

ID Token Validation

JWT signature verified against IdP’s JWKS. Claims validated: aud (audience), exp (expiration).

Session Creation

User matched in Eneo by email, session JWT issued.


Security Mechanisms

State Token (CSRF Protection)

The state parameter is a JWT signed with HS256:

# federation_router.py state_payload = { "tenant_id": str(tenant_obj.id), "tenant_slug": tenant_obj.slug or tenant_obj.name, "frontend_state": state or "", "nonce": secrets.token_hex(16), # 32 hex chars "redirect_uri": redirect_uri, "correlation_id": correlation_id, # 16 hex chars "exp": int(time.time()) + 600, # 10 minute TTL "iat": int(time.time()), "config_version": tenant_config_version, } signed_state = pyjwt.encode(state_payload, settings.jwt_secret, algorithm="HS256")

Key parameters:

ParameterValueSource
AlgorithmHS256config.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)

Redis State Cache

# Cache key format state_cache_key = f"oidc:state:{nonce}" # Cached payload (5 fields) state_cache_payload = { "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"], } # Storage with TTL await redis_client.setex( state_cache_key, settings.oidc_state_ttl_seconds, # 600 seconds json.dumps(state_cache_payload) )

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, # 900 seconds (15 min) settings.oidc_state_ttl_seconds, # 600 seconds (10 min) )

The system accepts the old redirect URI from the state token if all conditions are met:

ConditionCheck
Strict validation disabledstrict_oidc_redirect_validation = false
Config version changedcurrent_config_version != state_config_version
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
State redirect matches cachedcached_state.redirect_uri == redirect_uri

The grace period defaults to 15 minutes but is capped at the state TTL (10 minutes), effectively making it 10 minutes maximum.

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

Tenant State Validation

Authentication is blocked for non-active tenants at both initiate and callback stages:

# federation_router.py if tenant_obj.state != TenantState.ACTIVE: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Authentication unavailable - tenant is not active", )
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

The callback validates that the tenant context in the state token matches the cached state to detect tampering:

# federation_router.py # Check tenant_id matches if expected_tenant_id and expected_tenant_id != str(tenant_id): # Event: callback.state_tampered, reason: tenant_id_mismatch raise HTTPException(403, "Invalid state - security validation failed") # Check tenant_slug matches (case-insensitive) if expected_tenant_slug and expected_tenant_slug != tenant_slug.lower(): # Event: callback.state_tampered, reason: tenant_slug_mismatch raise HTTPException(403, "Invalid state - security validation failed")

Validation checks:

FieldComparisonPurpose
tenant_idExact matchPrevents tenant ID substitution
tenant_slugCase-insensitivePrevents tenant slug substitution

Both checks are logged as callback.state_tampered debug events with specific reason fields for diagnostics.


Multi-Tenant Federation Architecture

Multi-Tenant Federation Architecture showing tenant resolution and IdP routing

Tenant Resolution

When federation is enabled (FEDERATION_PER_TENANT_ENABLED=true):

PriorityMethodExample
1Subdomainsundsvall.eneo.local → tenant “sundsvall”
2Tenant Selector UIUser selects from list

Federation Config Storage

Federation config is stored as JSONB in the tenants table:

-- tenant_table.py ALTER TABLE tenants ADD COLUMN federation_config JSONB NOT NULL DEFAULT '{}';

Stored fields:

federation_config = { "provider": "entra_id", # Identity provider label "client_id": "...", # OAuth client ID "client_secret": "enc:fernet:v1:...", # Encrypted with Fernet "discovery_endpoint": "https://...", # OIDC discovery URL "issuer": "...", # From discovery "authorization_endpoint": "...", # From discovery "token_endpoint": "...", # From discovery "jwks_uri": "...", # From discovery "scopes": ["openid", "email", "profile"], # Default scopes "allowed_domains": ["example.com"], # Email domain whitelist "canonical_public_origin": "https://...", # Tenant's access URL "redirect_path": "/login/callback", # Callback path "encrypted_at": "2025-01-07T12:00:00Z", # When secret was encrypted }

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 intric.cli.generate_encryption_key

ENCRYPTION_KEY is required when FEDERATION_PER_TENANT_ENABLED=true. 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
issImplicitValidated via JWKS signature
iatLogged onlyExtracted but not enforced
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

ID Token ClaimUser FieldNotes
emailemailVia configurable claims_mapping
suboidc_subjectSubject identifier

Email claim mapping:

claims_mapping = federation_config.get("claims_mapping", {"email": "email"}) email_claim = claims_mapping.get("email", "email") email = payload.get(email_claim)

Domain Validation

When allowed_domains is configured:

email = id_token.get("email") domain = email.split("@")[1].lower() # Case-insensitive if domain not in [d.lower() for d in allowed_domains]: raise HTTPException(403, "Domain not allowed")

JWKS Handling

JWKS is fetched using PyJWT’s built-in PyJWKClient:

# federation_router.py jwk_client = PyJWKClient(jwks_uri) signing_key = jwk_client.get_signing_key_from_jwt(id_token).key

Caching:

  • PyJWT provides built-in per-process in-memory cache
  • No Redis/persistent caching for JWKS keys
  • Fresh discovery fetch on each callback

Configuration Parameters

From config.py:

SettingDefaultDescription
oidc_state_ttl_seconds600State token TTL (10 minutes)
oidc_redirect_grace_period_seconds900Config change grace period (15 min)
oidc_clock_leeway_seconds120Clock skew tolerance (2 minutes)
strict_oidc_redirect_validationtrueEnforce exact redirect_uri match

Federation Admin API

Endpoints

MethodPathDescription
PUT/tenants/{tenant_id}/federationCreate/update config
GET/tenants/{tenant_id}/federationView config (secrets masked)
DELETE/tenants/{tenant_id}/federationRemove config
POST/tenants/{tenant_id}/federation/testTest IdP connectivity

Authentication: X-API-Key header with super admin API key.

Test Endpoint Behavior

POST /tenants/{tenant_id}/federation/test performs:

  • Fetches discovery document
  • Validates HTTP 200 response
  • Verifies required OIDC endpoints present (issuer, authorization_endpoint, token_endpoint, jwks_uri)
  • Does NOT attempt authentication
  • Does NOT validate client credentials

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

OIDC Debug Events

Enable debug logging via observability API. Events follow pattern [OIDC DEBUG] {event}:

Initiate phase:

  • initiate.state_cached
  • initiate.state_cache_failed
  • initiate.authorization_ready

Callback phase:

  • callback.state_decoded
  • callback.state_cache_hit / callback.state_cache_miss
  • callback.state_cache_error
  • callback.state_tampered (with reason: tenant_id_mismatch or tenant_slug_mismatch)
  • callback.tenant_loaded
  • callback.tenant_inactive
  • callback.domain_rejected / callback.domain_allowed
  • callback.email_extracted
  • callback.user_missing
  • callback.user_tenant_mismatch
  • callback.success

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/auth/tenantsList tenants for selector
GET/auth/initiateInitiate OIDC authentication
POST/auth/callbackHandle OIDC callback
Last updated on