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=falseandAIBOX_ENV(orAPP_ENV/ENVIRONMENT) isprod/production, unlessAIBOX_ALLOW_AUTH_DISABLED=trueis explicitly set. - Refuses to start if
auth.password.enabled=truein production butINTERNAL_AUTH_CLIENT_SECRETis empty — mobile-push 2FA cannot be enforced without it, so the launcher fails closed instead of silently downgrading. - Refuses to start if
AIBOX_PRINCIPAL_KEYSis 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-RolesX-Tenant-IDX-Aibox-PrincipalX-Aibox-Turn-Id,X-Aibox-Cap-Token
Stamped from the verified JWT:
| Header | Source |
|---|---|
X-User-ID | JWT sub. |
X-User-Email | JWT email. |
X-User-Roles | JWT realm_access.roles. |
X-Tenant-ID | Pinned 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-Principal | HMAC-signed canonical Principal. |
X-Aibox-Turn-Id | New ULID per authenticated request. |
X-Aibox-Cap-Token | HMAC 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):
| Role | Meaning |
|---|---|
user | Normal application user. |
admin | The single admin role (ADMIN_ROLES = {"admin"}). |
service-account | Realm 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:
-
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 indeploy/config/keycloak/realm-aibox.json(the dev realm seedsaibox-admins→["admin","user"]andaibox-users→["user"]), so the gateway picks the roles up fromrealm_access.roleswith no app code. For IdPs where a Keycloak mapper isn't available, the gateway also honours an optionalAIBOX_GROUP_ROLE_MAPenv var ("GroupA:admin,GroupB:user") that augments (never removes) roles from group membership. This augmentation is applied to both the signed principal and theX-User-Rolesheader, so the admin path guard honours group-mapped admins. -
Group-scoped resource access.
permission_profilesrows carry agroupscolumn (JSONB) alongsideroles. A profile applies to a caller whose roles or groups intersect the profile'sroles/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=...)) readsprincipal.groupsend-to-end. Group-management UI/APIs are a tracked follow-up; grants are seeded viapermission_profilestoday.
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):
| Header | Purpose |
|---|---|
X-Aibox-Turn-Id | Gateway-generated turn identifier. |
X-Aibox-Cap-Token | Short-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/):
POST /v1/auth/loginverifies the password against Keycloak, falling back to the corporate NTLM path (when enabled) via theauthservice's/login—authruns the bind through egauth/verifyand mints aniss=${PUBLIC_URL}/authtoken (not an egauth-issued one), so it verifies against the same trusted issuers everywhere downstream.- The gateway calls auth
GET /login-challenge/requiredwith a service JWT (INTERNAL_AUTH_CLIENT_ID=service-gateway). - If
enrolledorenforcedis true, the gateway parks the Keycloak token bundle in a pending store keyed by the challenge id (Redis-backed whenREDIS_URLis set, in-process map otherwise) and returns the challenge id to the browser. - The user approves the push on their paired device (or enters a number-matching code).
POST /v1/auth/login/2faverifies through authPOST /login-challenge/verifyand 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."
| Variable | Purpose |
|---|---|
INTERNAL_AUTH_CLIENT_ID | Service client ID (e.g. service-gateway, service-agent-runtime). |
INTERNAL_AUTH_CLIENT_SECRET | Secret for the service client. Loadable from _FILE secret. |
INTERNAL_AUTH_REALM | Keycloak realm (default aibox). |
INTERNAL_AUTH_AUDIENCE | Required audience (default aibox-internal). |
INTERNAL_AUTH_ALLOWED_CLIENTS | Optional comma-separated caller allow-list. |
INTERNAL_AUTH_TOKEN_URL | Optional explicit token endpoint. |
INTERNAL_AUTH_INTROSPECTION_URL | Optional 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):
| Variable | Purpose |
|---|---|
KEYCLOAK_ADMIN_BASE_URL (via topology.keycloak_admin_base_url) | Internal Keycloak base URL. |
KEYCLOAK_ADMIN_REALM | Realm to manage. |
KEYCLOAK_ADMIN_CLIENT_ID | Admin service-account client. |
KEYCLOAK_ADMIN_CLIENT_SECRET | Admin 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
| Variable | Source file | Notes |
|---|---|---|
AIBOX_PRINCIPAL_KEYS | gateway, all Python services | Comma-separated HMAC keys, newest first. Required when auth is enabled. |
AIBOX_CAPTOKEN_KEYS | gateway | Comma-separated HMAC keys for CapToken signing. Required when auth is enabled. |
AIBOX_ALLOW_AUTH_DISABLED | gateway | true is the dev-only escape hatch. |
AIBOX_ADMIN_EMAIL_ALLOWLIST | gateway | Email-based admin promotion. |
AIBOX_ENV / APP_ENV / ENVIRONMENT | gateway | prod / production activates strict launch guards. |
KEYCLOAK_INTERNAL_URL | gateway | Defaults to http://keycloak:8080. |
KEYCLOAK_WEB_PASSWORD_CLIENT_ID | gateway | Defaults to aibox-web-password. |
EGAUTH_URL | gateway | Historical env name for the MFA/login-challenge backend. Compose now defaults it to http://auth:8012. |
REDIS_URL | gateway | Enables Redis-backed rate limiter + shared MFA pending store. |
Related
Verified against commit 5187b91e (2026-06-11) · sources 659438ee3a0e.