Skip to main content

Authentication

AIBox uses Keycloak OIDC for browser users, optional corporate NTLM verification through egauth, the auth service for mobile-push 2FA/device pairing, signed principal headers for downstream identity, CapTokens for per-turn capability, and short-lived service JWTs for most internal calls. The gateway (services/gateway/) is the sole authority — every other service trusts only the gateway-stamped X-Aibox-Principal header.

Login flow

The realm import sets directAccessGrantsEnabled: false on aibox-frontend. Password login goes through a confidential aibox-web-password client that the browser never sees.

Browser login (PKCE)

Issuer: ${PUBLIC_URL}/keycloak/realms/aibox
Client: aibox-frontend
Flow: authorization_code + PKCE

Trusted issuers

Gateway auth lives in deploy/config/gateway/config.yaml. The gateway validates issuer, signature, and audience against each configured JWKS:

auth:
enabled: true
jwks_url: http://keycloak:8080/realms/aibox/protocol/openid-connect/certs
issuer: ${PUBLIC_URL}/keycloak/realms/aibox
additional_issuers:
- issuer: ${PUBLIC_URL}/egauth
jwks_url: http://egauth:8010/jwks
audience: aibox-frontend
- issuer: ${PUBLIC_URL}/auth # auth-signed tokens (mobile app via /pair/complete)
jwks_url: http://auth:8012/jwks
audience: aibox-frontend

The ${PUBLIC_URL}/auth issuer is required for the mobile app: auth mints its tokens with that iss (served at auth:8012/jwks), so without trusting it the gateway 401s every mobile API call. The issuer string is the public Keycloak/auth URL, not the internal service address — Keycloak stamps iss from KC_HOSTNAME=${PUBLIC_URL}/keycloak, and auth is configured to match (AUTH_KEYCLOAK_ISSUER).

Production guards (services/gateway/main.go):

  • Refuses to start if auth.enabled=false and AIBOX_ENV (or APP_ENV/ENVIRONMENT) is prod/production, unless AIBOX_ALLOW_AUTH_DISABLED=true is explicitly set.
  • Refuses to start if auth.password.enabled=true in production but INTERNAL_AUTH_CLIENT_SECRET is empty — mobile-push 2FA cannot be enforced without it, so the launcher fails closed instead of silently downgrading.
  • Refuses to start if AIBOX_PRINCIPAL_KEYS is empty while auth is enabled.

Stripped vs. stamped headers

External callers must not rely on identity headers — the gateway strips them on entry and re-stamps trusted values (services/gateway/internal/middleware/auth.go):

Stripped on inbound:

  • X-User-ID, X-User-Email, X-User-Roles
  • X-Tenant-ID
  • X-Aibox-Principal
  • X-Aibox-Turn-Id, X-Aibox-Cap-Token

Stamped from the verified JWT:

HeaderSource
X-User-IDJWT sub.
X-User-EmailJWT email.
X-User-RolesJWT realm_access.roles.
X-Tenant-IDPinned to tenancy.single_tenant_id. The gateway ignores the inbound JWT tenant claim and rejects a mismatched inbound X-Tenant-ID with 403.
X-Aibox-PrincipalHMAC-signed canonical Principal.
X-Aibox-Turn-IdNew ULID per authenticated request.
X-Aibox-Cap-TokenHMAC capability token bound to principal + turn id + scopes.

Older docs that mention manually supplied tenant_id claims or tenant headers are stale for gateway traffic. This is a single-tenant appliance: the gateway pins X-Tenant-ID to tenancy.single_tenant_id, ignores any inbound JWT tenant claim, and rejects a conflicting inbound tenant header with 403.

Roles

Runtime admin checks (services/shared-identity/aibox_identity/roles.py, services/gateway/internal/middleware/adminpath.go):

RoleMeaning
userNormal application user.
adminThe single admin role (ADMIN_ROLES = {"admin"}).
service-accountRealm role used by service clients.

This is a single-tenant appliance with one admin tier: admin is the only admin role, checked against the signed X-Aibox-Principal. The dev realm seeds admin, user, and service-account.

AIBOX_ADMIN_EMAIL_ALLOWLIST is a comma-separated list of emails whose verified JWTs get an admin role boost in front of the admin path guard (services/gateway/internal/middleware/admin_email.go). Useful for bootstrapping an operator before realm roles are wired.

Groups

groups (from the JWT groups claim) are carried in the signed principal alongside roles and serve two jobs in the single permission model:

  1. Group → role mapping (SSO). Membership can confer admin/user. The canonical path is a Keycloak group → realm-role mapping — assign realm roles to a group in deploy/config/keycloak/realm-aibox.json (the dev realm seeds aibox-admins["admin","user"] and aibox-users["user"]), so the gateway picks the roles up from realm_access.roles with no app code. For IdPs where a Keycloak mapper isn't available, the gateway also honours an optional AIBOX_GROUP_ROLE_MAP env var ("GroupA:admin,GroupB:user") that augments (never removes) roles from group membership. This augmentation is applied to both the signed principal and the X-User-Roles header, so the admin path guard honours group-mapped admins.

  2. Group-scoped resource access. permission_profiles rows carry a groups column (JSONB) alongside roles. A profile applies to a caller whose roles or groups intersect the profile's roles/groups, and effective access is the union over (roles ∪ groups). This lets a connector/tool/template grant be keyed to an SSO group without a new role tier. The resolver (services/agent-runtime/src/agent_runtime/permissions.py, PermissionResolver.resolve(user_roles=..., user_groups=...)) reads principal.groups end-to-end. Group-management UI/APIs are a tracked follow-up; grants are seeded via permission_profiles today.

Signed principal header

After validating the JWT, the gateway emits X-Aibox-Principal, an HMAC-signed JSON payload. Python services verify it with aibox_identity.fastapi_dep.principal_dependency against AIBOX_PRINCIPAL_KEYS. The canonical fields (services/shared-identity/aibox_identity/principal.py):

{
"id": "user-sub",
"tenant_id": "default",
"email": "alice@example.com",
"display_name": "Alice Example",
"roles": ["user"],
"groups": [],
"local_iss": "https://ai.example.com/keycloak/realms/aibox",
"local_sub": "user-sub",
"upstream_iss": "https://idp.example.com",
"upstream_sub": "external-id",
"upstream_preferred_username": "alice@example.com",
"auth_method": "password",
"session_id": "kc-session-id",
"iat": 1710000000,
"exp": 1710000300
}

For brokered upstream IdPs, configure Keycloak mappers for identity_provider, upstream_iss, upstream_sub, and upstream_preferred_username. auth_method is one of password, upstream_oidc, upstream_saml, service, anonymous.

CapTokens and turn context

The gateway mints a fresh turn context for every authenticated request (services/gateway/internal/middleware/turnctx.go, dataclass in services/shared-identity/aibox_identity/turnctx.py):

HeaderPurpose
X-Aibox-Turn-IdGateway-generated turn identifier.
X-Aibox-Cap-TokenShort-lived HMAC token bound to principal, tenant, scopes, and turn id.

CapTokens are signed with AIBOX_CAPTOKEN_KEYS. They currently grant broad end-user scopes (agent.invoke, tool.*, memory.*, knowledge.*, guardrail.*); finer per-route scoping is a tracked follow-up. The gRPC metadata equivalents are aibox-turn-id and aibox-cap-token (lowercase, no x- prefix per gRPC spec).

Mobile-Push 2FA

When auth.password.enabled=true and the user has paired a mobile device through services/auth/, the password-grant proxy gates token release on a second factor (services/gateway/internal/auth/login.go, services/gateway/internal/auth/login_2fa.go, services/auth/):

  1. POST /v1/auth/login verifies the password against Keycloak, falling back to the corporate NTLM path (when enabled) via the auth service's /loginauth runs the bind through egauth /verify and mints an iss=${PUBLIC_URL}/auth token (not an egauth-issued one), so it verifies against the same trusted issuers everywhere downstream.
  2. The gateway calls auth GET /login-challenge/required with a service JWT (INTERNAL_AUTH_CLIENT_ID=service-gateway).
  3. If enrolled or enforced is true, the gateway parks the Keycloak token bundle in a pending store keyed by the challenge id (Redis-backed when REDIS_URL is set, in-process map otherwise) and returns the challenge id to the browser.
  4. The user approves the push on their paired device (or enters a number-matching code).
  5. POST /v1/auth/login/2fa verifies through auth POST /login-challenge/verify and pops the bundle back out.

The browser only receives access/refresh tokens after the second factor succeeds. Recovery codes and device admin live under /v1/account/devices/*, served by the auth service via the gateway (device/2FA/pair handlers moved out of egauth in the egauth→auth split; pairing itself goes nginx → auth at /egauth/pair/*). Admins are not force-enrolled into 2FA (EGL-103); they opt in like any other user — see User management.

Mobile sessions use rotating refresh tokens. auth records each paired device's current refresh-token jti (egauth.mobile_devices.current_refresh_jti, seeded at /pair/complete) and compare-and-swaps it on every /egauth/refresh. Presenting a jti that no longer matches — a captured or replayed refresh token used after the device already rotated — is treated as compromise: the device row is revoked rather than issued a fresh pair, so a stolen refresh token can't outlive its first reuse.

Service-to-Service auth

Most gateway-to-service hops swap the user's bearer for a short-lived Keycloak service token (services/shared-go/serviceauth/). Receiving services validate issuer, audience, client identity, and an optional allowed-caller list. User identity is still carried by the stamped X-Aibox-* headers — the service JWT only proves "this is a peer service."

VariablePurpose
INTERNAL_AUTH_CLIENT_IDService client ID (e.g. service-gateway, service-agent-runtime).
INTERNAL_AUTH_CLIENT_SECRETSecret for the service client. Loadable from _FILE secret.
INTERNAL_AUTH_REALMKeycloak realm (default aibox).
INTERNAL_AUTH_AUDIENCERequired audience (default aibox-internal).
INTERNAL_AUTH_ALLOWED_CLIENTSOptional comma-separated caller allow-list.
INTERNAL_AUTH_TOKEN_URLOptional explicit token endpoint.
INTERNAL_AUTH_INTROSPECTION_URLOptional explicit introspection endpoint.

deploy/scripts/ensure-keycloak-clients.py reconciles the service-* clients, audience mappers, and the ROPC aibox-web-password client.

Inference-router /v1/models and /v1/routes are HTTP-only because the router has no gRPC surface. They still go through the gateway reverse proxy, which swaps the user bearer for a service JWT when internal auth is configured. Provider CRUD is separate: the gateway exposes /v1/admin/inference/*, rewrites it to inference-router /v1/internal/*, and guards it with admin RBAC plus audit logging.

SSO provider administration

The gateway mounts /v1/admin/sso/idps when these are configured (else it logs a warning and skips):

VariablePurpose
KEYCLOAK_ADMIN_BASE_URL (via topology.keycloak_admin_base_url)Internal Keycloak base URL.
KEYCLOAK_ADMIN_REALMRealm to manage.
KEYCLOAK_ADMIN_CLIENT_IDAdmin service-account client.
KEYCLOAK_ADMIN_CLIENT_SECRETAdmin service-account secret (supports _FILE).

The service account needs the realm-management roles documented by Keycloak. The same admin client also drives the sanitized GET /v1/auth/idps listing the SPA's login screen consumes.

Configuration quick reference

VariableSource fileNotes
AIBOX_PRINCIPAL_KEYSgateway, all Python servicesComma-separated HMAC keys, newest first. Required when auth is enabled.
AIBOX_CAPTOKEN_KEYSgatewayComma-separated HMAC keys for CapToken signing. Required when auth is enabled.
AIBOX_ALLOW_AUTH_DISABLEDgatewaytrue is the dev-only escape hatch.
AIBOX_ADMIN_EMAIL_ALLOWLISTgatewayEmail-based admin promotion.
AIBOX_ENV / APP_ENV / ENVIRONMENTgatewayprod / production activates strict launch guards.
KEYCLOAK_INTERNAL_URLgatewayDefaults to http://keycloak:8080.
KEYCLOAK_WEB_PASSWORD_CLIENT_IDgatewayDefaults to aibox-web-password.
EGAUTH_URLgatewayHistorical env name for the MFA/login-challenge backend. Compose now defaults it to http://auth:8012.
REDIS_URLgatewayEnables Redis-backed rate limiter + shared MFA pending store.

Verified against commit 5187b91e (2026-06-11) · sources 659438ee3a0e.