diff --git a/Dockerfile b/Dockerfile
index 1d5f6aa..f53f6c7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -89,8 +89,15 @@ COPY --from=frontend-builder /app/docs/out ./docs/out/
# Fetch model catalog (embedded at compile time via include_str!)
RUN mkdir -p data && curl -sSL https://models.dev/api.json -o data/models-dev-catalog.json
-# Touch main.rs to invalidate the dummy build
-RUN touch src/main.rs
+# Force fresh build of the main crate by removing cached artifacts.
+# The --mount=type=cache for target/ persists across builds, but fingerprints
+# may not detect all source changes. Removing the crate's artifacts ensures
+# a full recompile of application code (dependencies remain cached).
+RUN touch src/main.rs && \
+ rm -rf target/release/.fingerprint/hadrian-* \
+ target/release/deps/hadrian-* \
+ target/release/deps/libhadrian-* \
+ target/release/hadrian
# Build the actual application
RUN --mount=type=cache,target=/usr/local/cargo/registry \
diff --git a/deploy/config/hadrian.dlq.toml b/deploy/config/hadrian.dlq.toml
index 9163ed0..7d51286 100644
--- a/deploy/config/hadrian.dlq.toml
+++ b/deploy/config/hadrian.dlq.toml
@@ -9,11 +9,8 @@ path = "/app/data/hadrian.db"
type = "redis"
url = "${REDIS_URL}"
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 300
+[auth.mode]
+type = "none"
[providers]
default_provider = "test"
diff --git a/deploy/config/hadrian.keycloak.toml b/deploy/config/hadrian.keycloak.toml
index 46a0b0c..634d563 100644
--- a/deploy/config/hadrian.keycloak.toml
+++ b/deploy/config/hadrian.keycloak.toml
@@ -2,6 +2,12 @@
# Development setup with Keycloak for per-org OIDC authentication
# OIDC SSO is configured via Admin API, not in this config file
+[server]
+# Allow Docker-internal private IPs for OIDC discovery (Keycloak runs in Docker)
+allow_private_urls = true
+# Keycloak advertises issuer as http://localhost:8080 (host-mapped port)
+allow_loopback_urls = true
+
[ui]
enabled = true
@@ -15,13 +21,19 @@ enabled = true
api_key = "gw_test_bootstrap_key_for_e2e"
# ==============================================================================
-# Session Configuration (for per-org SSO)
+# Auth Mode: IdP (per-org SSO + API keys)
# ==============================================================================
# OIDC authentication is configured per-organization via the Admin API.
-# This section only configures session management for authenticated users.
-# The test setup creates the SSO connection with OIDC settings.
-[auth.admin]
-type = "session"
+# Session management for authenticated users is configured below.
+[auth.mode]
+type = "idp"
+
+[auth.api_key]
+header_name = "X-API-Key"
+key_prefix = "gw_"
+cache_ttl_secs = 300
+
+[auth.session]
secure = false # For local dev over HTTP
[database]
@@ -32,12 +44,6 @@ path = "/app/data/hadrian.db"
type = "redis"
url = "${REDIS_URL}"
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 300
-
# RBAC is disabled for this basic keycloak test to allow unauthenticated admin API access.
# For comprehensive RBAC testing with OIDC authentication, see the university deployment tests.
diff --git a/deploy/config/hadrian.observability.toml b/deploy/config/hadrian.observability.toml
index a30180c..4177e4b 100644
--- a/deploy/config/hadrian.observability.toml
+++ b/deploy/config/hadrian.observability.toml
@@ -9,11 +9,8 @@ path = "/app/data/hadrian.db"
type = "redis"
url = "${REDIS_URL}"
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 300
+[auth.mode]
+type = "none"
[providers]
default_provider = "test"
diff --git a/deploy/config/hadrian.postgres-ha.toml b/deploy/config/hadrian.postgres-ha.toml
index c43cf5b..ada5564 100644
--- a/deploy/config/hadrian.postgres-ha.toml
+++ b/deploy/config/hadrian.postgres-ha.toml
@@ -11,11 +11,8 @@ read_url = "${DATABASE_READ_URL}"
type = "redis"
url = "${REDIS_URL}"
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 300
+[auth.mode]
+type = "none"
[providers]
default_provider = "test"
diff --git a/deploy/config/hadrian.postgres.toml b/deploy/config/hadrian.postgres.toml
index 9367392..dbbc515 100644
--- a/deploy/config/hadrian.postgres.toml
+++ b/deploy/config/hadrian.postgres.toml
@@ -9,17 +9,14 @@ url = "${DATABASE_URL}"
type = "redis"
url = "${REDIS_URL}"
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 300 # 5 minutes with Redis
+[auth.mode]
+type = "none"
-# Optional: Reverse proxy authentication for UI
+# Optional: IAP (Identity-Aware Proxy) authentication
# Trusts identity headers from an authenticating proxy (Cloudflare Access, oauth2-proxy, etc.)
# IMPORTANT: Configure [server.trusted_proxies] to prevent header spoofing
-# [auth.admin]
-# type = "proxy_auth"
+# [auth.mode]
+# type = "iap"
# identity_header = "Cf-Access-Authenticated-User-Email"
# email_header = "Cf-Access-Authenticated-User-Email"
# name_header = "X-Forwarded-User"
diff --git a/deploy/config/hadrian.production.toml b/deploy/config/hadrian.production.toml
index f807f76..0209a9d 100644
--- a/deploy/config/hadrian.production.toml
+++ b/deploy/config/hadrian.production.toml
@@ -11,8 +11,10 @@ url = "${DATABASE_URL}"
type = "redis"
url = "${REDIS_URL}"
-[auth.gateway]
+[auth.mode]
type = "api_key"
+
+[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
cache_ttl_secs = 300
diff --git a/deploy/config/hadrian.provider-health.toml b/deploy/config/hadrian.provider-health.toml
index 6776ced..47be5de 100644
--- a/deploy/config/hadrian.provider-health.toml
+++ b/deploy/config/hadrian.provider-health.toml
@@ -8,10 +8,8 @@ path = "/app/data/hadrian.db"
[cache]
type = "memory"
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
+[auth.mode]
+type = "none"
[providers]
default_provider = "test"
diff --git a/deploy/config/hadrian.redis-cluster.toml b/deploy/config/hadrian.redis-cluster.toml
index 926c793..25bdf80 100644
--- a/deploy/config/hadrian.redis-cluster.toml
+++ b/deploy/config/hadrian.redis-cluster.toml
@@ -17,11 +17,8 @@ retry_delay_ms = 100
connection_timeout_secs = 5
response_timeout_secs = 1
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 300
+[auth.mode]
+type = "none"
[providers]
default_provider = "test"
diff --git a/deploy/config/hadrian.saml.toml b/deploy/config/hadrian.saml.toml
index 918d0c2..68edcd6 100644
--- a/deploy/config/hadrian.saml.toml
+++ b/deploy/config/hadrian.saml.toml
@@ -16,6 +16,10 @@
# - SAML groups are captured for the group mapping feature
# - Group mappings are configured via Admin API, not parsed from assertion attributes
+[server]
+# Allow Docker-internal private IPs for SAML/OIDC discovery (Authentik runs in Docker)
+allow_private_urls = true
+
[ui]
enabled = true
@@ -31,22 +35,16 @@ api_key = "gw_test_bootstrap_key_for_e2e"
auto_verify_domains = ["university.edu"]
# ==============================================================================
-# Session Configuration (for per-org SSO)
+# Auth Mode: IdP (per-org SSO)
# ==============================================================================
# SAML authentication is configured per-organization via the Admin API.
-# This section configures session management for authenticated users.
-[auth.admin]
-type = "session"
-secure = false # For local dev over HTTP
+# Session management for authenticated users is configured below.
+# No API key requirement for this test — focuses on SAML SSO flow.
+[auth.mode]
+type = "idp"
-# ==============================================================================
-# API Authentication
-# ==============================================================================
-# For SAML E2E tests, we disable API authentication to allow bootstrapping.
-# In production, you would use API keys for programmatic access.
-# The test focuses on SAML SSO flow, not API key management.
-[auth.gateway]
-type = "none"
+[auth.session]
+secure = false # For local dev over HTTP
# ==============================================================================
# RBAC Configuration
diff --git a/deploy/config/hadrian.sqlite-redis.toml b/deploy/config/hadrian.sqlite-redis.toml
index 8a4635d..9fd78e7 100644
--- a/deploy/config/hadrian.sqlite-redis.toml
+++ b/deploy/config/hadrian.sqlite-redis.toml
@@ -9,11 +9,8 @@ path = "/app/data/hadrian.db"
type = "redis"
url = "${REDIS_URL}"
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 300 # 5 minutes with Redis
+[auth.mode]
+type = "none"
[providers]
default_provider = "test"
diff --git a/deploy/config/hadrian.sqlite.toml b/deploy/config/hadrian.sqlite.toml
index 8ac45d3..d5ce2af 100644
--- a/deploy/config/hadrian.sqlite.toml
+++ b/deploy/config/hadrian.sqlite.toml
@@ -8,11 +8,8 @@ path = "/app/data/hadrian.db"
[cache]
type = "memory"
-[auth.gateway]
-type = "api_key"
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 60
+[auth.mode]
+type = "none"
[providers]
default_provider = "test"
diff --git a/deploy/config/hadrian.traefik.toml b/deploy/config/hadrian.traefik.toml
index 738ea86..e50e58f 100644
--- a/deploy/config/hadrian.traefik.toml
+++ b/deploy/config/hadrian.traefik.toml
@@ -9,8 +9,10 @@ url = "${DATABASE_URL}"
type = "redis"
url = "${REDIS_URL}"
-[auth.gateway]
+[auth.mode]
type = "api_key"
+
+[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
cache_ttl_secs = 300
diff --git a/deploy/config/hadrian.university.toml b/deploy/config/hadrian.university.toml
index 355e722..03415b3 100644
--- a/deploy/config/hadrian.university.toml
+++ b/deploy/config/hadrian.university.toml
@@ -9,6 +9,7 @@
# - Single organization with department teams
#
# Key architecture points:
+# - allow_private_urls is needed for Docker-internal OIDC discovery
# - OIDC SSO is configured per-organization via Admin API (not in this config file)
# - One SSO connection = One organization (all users provisioned into "university" org)
# - Departments are teams within the university org (cs-faculty, med-research, it-platform, etc.)
@@ -16,6 +17,12 @@
# - IdP groups are captured for the group mapping feature (raw paths like /cs/faculty)
# - Group mappings are configured via Admin API, not parsed from group paths
+[server]
+# Allow Docker-internal private IPs for OIDC discovery (Keycloak runs in Docker)
+allow_private_urls = true
+# Keycloak advertises issuer as http://localhost:8080 (host-mapped port)
+allow_loopback_urls = true
+
[ui]
enabled = true
@@ -31,33 +38,21 @@ api_key = "gw_test_bootstrap_key_for_e2e"
auto_verify_domains = ["university.edu"]
# ==============================================================================
-# Session Configuration (for per-org SSO)
+# Auth Mode: IdP (per-org SSO + API keys + JWT)
# ==============================================================================
# OIDC authentication is configured per-organization via the Admin API.
-# This section only configures session management for authenticated users.
-# The test setup creates the SSO connection with OIDC settings.
-[auth.admin]
-type = "session"
-secure = false # For local dev over HTTP
-
-# ==============================================================================
-# Gateway Authentication
-# ==============================================================================
-# Use multi-auth to support both API keys and JWT tokens
-# Gateway RBAC policies require JWT tokens (roles come from OIDC claims)
-[auth.gateway]
-type = "multi"
+# Session management for authenticated users is configured below.
+# JWT validation is handled per-org via GatewayJwtRegistry (loaded from SSO config).
+[auth.mode]
+type = "idp"
-[auth.gateway.api_key]
+[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
cache_ttl_secs = 300
-[auth.gateway.jwt]
-issuer = "http://localhost:8080/realms/hadrian"
-jwks_url = "http://keycloak:8080/realms/hadrian/protocol/openid-connect/certs"
-audience = "hadrian-gateway"
-identity_claim = "preferred_username"
+[auth.session]
+secure = false # For local dev over HTTP
# ==============================================================================
# RBAC Configuration with CEL Policies
diff --git a/docs/content/docs/api/authentication.mdx b/docs/content/docs/api/authentication.mdx
index 8cf7be2..3b24caa 100644
--- a/docs/content/docs/api/authentication.mdx
+++ b/docs/content/docs/api/authentication.mdx
@@ -32,10 +32,10 @@ curl http://localhost:8080/v1/chat/completions \
### Configuration
```toml
-[auth.gateway]
+[auth.mode]
type = "api_key"
-[auth.gateway.api_key]
+[auth.api_key]
header_name = "X-API-Key" # or "Authorization" for Bearer tokens
key_prefix = "gw_" # Required prefix for all keys
cache_ttl_secs = 300 # Cache key lookups for 5 minutes
@@ -75,7 +75,7 @@ Response:
## JWT Authentication
-Use JWT tokens from your identity provider (IdP) for service-to-service authentication or SSO.
+Use JWT tokens from your identity provider (IdP) for service-to-service authentication or SSO. JWT validation is configured per-organization through SSO configs in the Admin UI — there is no global JWT configuration.
### Request Format
@@ -86,27 +86,22 @@ curl http://localhost:8080/v1/chat/completions \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
```
-### Configuration
+### Per-Org JWT Routing
-```toml
-[auth.gateway]
-type = "jwt"
-issuer = "https://auth.example.com"
-audience = "hadrian"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
-
-# Optional: Map JWT claims to user identity
-[auth.gateway.jwt.claims]
-user_id = "sub" # Use 'sub' claim as user ID
-email = "email" # Use 'email' claim
-org_id = "org_id" # Custom claim for organization
-```
+In `idp` mode, each organization configures their own identity provider (OIDC or SAML) via the Admin UI. When a JWT is presented, the gateway:
-### Per-Org JWT Validation
+1. Decodes the `iss` (issuer) claim from the token
+2. Matches the issuer to the correct organization's SSO config
+3. Validates the token against that organization's IdP JWKS keys
-In multi-tenant deployments, each organization can configure their own identity provider via the Admin UI. JWTs from per-org IdPs are automatically validated — the gateway decodes the `iss` claim and routes the token to the correct org's validator. No global `[auth.gateway.jwt]` config is needed for this to work.
+This happens automatically — no per-org JWT configuration is needed in the config file. Configure SSO for each organization through **Admin > Organizations > [Org] > SSO**.
-See [Per-Org JWT Routing](/docs/authentication#per-org-jwt-routing) for details.
+See the [SSO Admin Guide](/docs/features/sso-admin-guide) for setup instructions.
+
+
+ Per-org JWT validation requires `[auth.mode]` set to `type = "idp"`. Each organization's SSO
+ config provides the issuer URL, JWKS endpoint, and claim mappings.
+
### Supported Algorithms
@@ -116,50 +111,45 @@ See [Per-Org JWT Routing](/docs/authentication#per-org-jwt-routing) for details.
| ES256, ES384, ES512 | ECDSA signatures |
| HS256, HS384, HS512 | HMAC signatures (shared secret) |
-## Multi-Auth Mode
+## Combined Authentication (IdP Mode)
-Combine API key and JWT authentication. The gateway tries each method in order.
+IdP mode combines API key and JWT authentication. The gateway tries each method in order, with JWT validation provided automatically by per-org SSO configs.
```toml
-[auth.gateway]
-type = "multi"
+[auth.mode]
+type = "idp"
-[auth.gateway.api_key]
+[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
-
-[auth.gateway.jwt]
-issuer = "https://auth.example.com"
-audience = "hadrian"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
```
With this configuration:
1. If `X-API-Key` header is present, validate as API key
-2. If `Authorization: Bearer` header is present with JWT format, validate as JWT
+2. If `Authorization: Bearer` header is present with JWT format, validate against the issuer's per-org SSO config
3. If `Authorization: Bearer` header is present with `gw_` prefix, validate as API key
- The `[auth.gateway.jwt]` section is optional in multi-auth mode. When per-organization SSO is
- configured, each org's SSO config provides JWT validation automatically. Omit `[auth.gateway.jwt]`
- if all JWT validation should come from per-org configs.
+ JWT validation in `idp` mode is fully automatic. Each organization's SSO config (configured via
+ the Admin UI) provides the issuer, JWKS endpoint, and claim mappings. No JWT settings are needed
+ in the config file.
## Error Responses
All authentication errors return HTTP 401 with a consistent error format:
-| Error Code | Description | Solution |
-| ------------------- | -------------------------- | --------------------------- |
-| `invalid_api_key` | Key not found or malformed | Check key format and prefix |
-| `key_revoked` | Key has been revoked | Generate a new key |
-| `key_expired` | Key past expiration date | Generate a new key |
-| `invalid_token` | JWT malformed or invalid | Check token format |
-| `token_expired` | JWT past expiration | Obtain fresh token from IdP |
-| `invalid_issuer` | JWT issuer doesn't match | Verify issuer in config |
-| `invalid_audience` | JWT audience doesn't match | Verify audience in config |
-| `jwks_fetch_failed` | Cannot reach JWKS URL | Check network connectivity |
+| Error Code | Description | Solution |
+| ------------------- | -------------------------- | ----------------------------- |
+| `invalid_api_key` | Key not found or malformed | Check key format and prefix |
+| `key_revoked` | Key has been revoked | Generate a new key |
+| `key_expired` | Key past expiration date | Generate a new key |
+| `invalid_token` | JWT malformed or invalid | Check token format |
+| `token_expired` | JWT past expiration | Obtain fresh token from IdP |
+| `invalid_issuer` | JWT issuer doesn't match | Verify issuer in SSO config |
+| `invalid_audience` | JWT audience doesn't match | Verify audience in SSO config |
+| `jwks_fetch_failed` | Cannot reach JWKS URL | Check network connectivity |
### Example Error Response
diff --git a/docs/content/docs/authentication.mdx b/docs/content/docs/authentication.mdx
index 892c6d0..d62e0fa 100644
--- a/docs/content/docs/authentication.mdx
+++ b/docs/content/docs/authentication.mdx
@@ -1,58 +1,41 @@
---
title: Authentication
-description: API keys, SSO, OIDC, SAML, JWT, and reverse proxy authentication for the gateway and web UI
+description: Authentication modes, API keys, SSO, JWT, and reverse proxy authentication
---
import { Callout } from "fumadocs-ui/components/callout";
-Hadrian authenticates two independent contexts — **gateway** (API) and **admin** (UI) — each configured separately in `hadrian.toml`.
+Hadrian uses a single `[auth.mode]` setting that determines how all requests are authenticated across both the API and the admin panel.
-## Two Auth Contexts
+## Auth Modes
-| Context | Config Section | Protects | Used by |
-| ----------- | ---------------- | -------------------------------------------------------------- | ----------------------------- |
-| **Gateway** | `[auth.gateway]` | `/v1/*` endpoints (chat completions, embeddings, models, etc.) | SDKs, curl, the chat UI |
-| **Admin** | `[auth.admin]` | `/admin/*` endpoints and the web UI login | Browser sessions, admin tools |
+Configure authentication by setting `[auth.mode]` in `hadrian.toml`. Four modes are available:
-These are fully independent. A request to `/v1/chat/completions` only checks gateway auth. A request to `/admin/v1/users` only checks admin auth. The web UI uses **both** — admin auth for login, then either a session cookie or an API key for gateway calls depending on the configuration.
+| Mode | Config | Description |
+| --------- | ------------------ | ---------------------------------------------------------------- |
+| `none` | `type = "none"` | No authentication. Development only. |
+| `api_key` | `type = "api_key"` | API key required for all access (admin panel + API). |
+| `idp` | `type = "idp"` | Per-org SSO with session cookies, JWT validation, and API keys. |
+| `iap` | `type = "iap"` | Reverse proxy headers for admin access, API keys for API access. |
-### Gateway Auth Types
+### Supporting Config Sections
-| Type | How it works |
-| ---------- | ------------------------------------------------------------------------------------------------------------------------------- |
-| `none` | No credentials required. Requests proceed as an anonymous user. Never use in production. |
-| `api_key` | Validate API keys created via the Admin UI or API. Keys are sent via `X-API-Key` or `Authorization: Bearer` header. |
-| `jwt` | Validate JWT tokens signed by an identity provider. Requires JWKS URL for signature verification. |
-| `multi` | Accept **both** API keys and JWTs. Format-based detection: tokens starting with the key prefix (e.g. `gw_`) are treated as API keys, everything else as JWTs. |
-
-### Admin Auth Types
-
-| Type | How it works |
-| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `none` | No login required. The admin panel and all admin API endpoints are completely unprotected. Only safe on localhost or trusted private networks. |
-| `oidc` | Single global OIDC provider. Users log in via browser redirect. A session cookie is set that also authenticates `/v1/*` requests — no API key needed. |
-| `session` | Per-organization SSO. Each org configures their own OIDC or SAML provider via the Admin UI. Session cookies authenticate both admin and gateway requests. |
-| `proxy_auth` | Trust identity headers from a reverse proxy (Cloudflare Access, Tailscale, oauth2-proxy). Only authenticates admin routes — gateway requires separate auth. |
-
-
- With `oidc` or `session` admin auth, the session cookie authenticates **both** `/admin/*` and `/v1/*`
- requests. Users log in once and the chat UI works immediately — no API key needed.
- With `none` or `proxy_auth`, the chat UI requires an API key created through the admin panel.
-
+| Section | Used by | Purpose |
+| --------------------------- | ----------------------- | ---------------------------------------------------------- |
+| `[auth.api_key]` | `api_key`, `idp`, `iap` | API key settings (header name, key prefix, cache TTL) |
+| `[auth.session]` | `idp` | Session cookie settings (cookie name, secure flag, secret) |
+| IAP fields in `[auth.mode]` | `iap` | Identity and email header names from the reverse proxy |
## Deployment Scenarios
Pick the scenario closest to your deployment. Each includes a minimal working config.
-| # | Scenario | Gateway | Admin | Web UI experience | SDK / API access |
-| --- | ------------------------------------------------------------------- | -------------- | ------------ | --------------------------------------------------------------- | ------------------------- |
-| 1 | [Local development](#1-local-development) | `none` | `none` | No login, everything open | No credentials needed |
-| 2 | [Single-user / internal tool](#2-single-user--internal-tool) | `api_key` | `none` | No login; create an API key in admin panel, paste into settings | API key |
-| 3 | [Production API with single IdP](#3-production-api-with-single-idp) | `jwt` | `oidc` | OIDC login, session cookie handles everything | JWT from IdP |
-| 4 | [API keys + SSO admin UI](#4-api-keys--sso-admin-ui) | `api_key` | `session` | Per-org SSO login; create API key for chat | API key |
-| 5 | [Combined API keys + JWT](#5-combined-api-keys--jwt-single-idp) | `multi` | `oidc` | OIDC login, session cookie handles everything | API key or JWT |
-| 6 | [Multi-org, per-org IdP](#6-multi-org-each-org-with-own-idp) | `multi` | `session` | Per-org SSO login, session cookie handles everything | API key or per-org JWT |
-| 7 | [Zero-trust / reverse proxy](#7-zero-trust--reverse-proxy) | `api_key` | `proxy_auth` | Proxy handles login; create API key for chat | API key |
+| # | Scenario | Mode | Web UI experience | SDK / API access |
+| --- | ------------------------------------------- | --------- | ---------------------------------------------------- | ---------------------- |
+| 1 | [Local development](#1-local-development) | `none` | No login, everything open | No credentials needed |
+| 2 | [API key](#2-api-key) | `api_key` | Enter API key to log in | API key |
+| 3 | [SSO / IdP](#3-sso--idp) | `idp` | Per-org SSO login, session cookie handles everything | API key or per-org JWT |
+| 4 | [Reverse proxy (IAP)](#4-reverse-proxy-iap) | `iap` | Proxy handles login | API key |
---
@@ -61,10 +44,7 @@ Pick the scenario closest to your deployment. Each includes a minimal working co
No authentication. All requests proceed anonymously. The admin panel, chat UI, and API are all open.
```toml
-[auth.gateway]
-type = "none"
-
-[auth.admin]
+[auth.mode]
type = "none"
```
@@ -73,119 +53,37 @@ type = "none"
attributed to users.
-### 2. Single-user / internal tool
-
-API keys for programmatic access, no admin login required. The admin panel is unprotected — anyone with network access can create API keys, users, and organizations.
-
-```toml
-[auth.gateway]
-type = "api_key"
-key_prefix = "gw_"
-cache_ttl_secs = 300
-
-[auth.admin]
-type = "none"
-```
-
-**Getting started:** Open the admin panel, create an API key, then use it with the OpenAI SDK or paste it into the chat UI settings.
-
-
- The admin panel has no authentication in this scenario. Only use this on private networks
- where you trust all users with admin access.
-
-
-### 3. Production API with single IdP
-
-JWT tokens from your identity provider for API access, OIDC login for the web UI. Users log in once via OIDC and the session cookie authenticates both the admin panel and chat UI — no API key needed.
-
-```toml
-[auth.gateway]
-type = "jwt"
-issuer = "https://auth.example.com"
-audience = "hadrian"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
-
-[auth.admin]
-type = "oidc"
-issuer = "https://auth.example.com"
-client_id = "hadrian"
-client_secret = "${OIDC_CLIENT_SECRET}"
-redirect_uri = "https://gateway.example.com/auth/callback"
-
-[auth.admin.provisioning]
-enabled = true
-create_users = true
-organization_id = "acme-corp"
-```
-
-**Getting started:** Configure your IdP (Okta, Auth0, Keycloak, etc.) with Hadrian as a client. Users are auto-provisioned on first login via JIT provisioning.
-
-### 4. API keys + SSO admin UI
+### 2. API key
-API keys for programmatic access, per-org SSO for the admin panel. Each organization configures their own OIDC or SAML provider through the Admin UI at runtime.
+API keys required for all access. The admin panel shows an "enter API key" login screen. SDK and API clients send keys via headers.
```toml
-[auth.gateway]
+[auth.mode]
type = "api_key"
-key_prefix = "gw_"
-cache_ttl_secs = 300
-
-[auth.admin]
-type = "session"
-secret = "${SESSION_SECRET}"
-```
-
-**Getting started:** Use the [bootstrap API key](/docs/configuration/auth#bootstrap-configuration) to create the initial organization and SSO config, then org admins log in via SSO and create API keys for their teams. See the [SSO Admin Guide](/docs/features/sso-admin-guide).
-
-
- Since gateway auth is `api_key` only, the chat UI requires users to create an API key in the admin
- panel and paste it into settings. For a seamless chat experience without API keys, use scenario 5 or 6.
-
-
-### 5. Combined API keys + JWT (single IdP)
-
-Support both API keys and JWT tokens on `/v1/*` endpoints. The gateway uses format-based detection to distinguish them. Users log in via OIDC and the session cookie handles the chat UI automatically.
-```toml
-[auth.gateway]
-type = "multi"
-
-[auth.gateway.api_key]
+[auth.api_key]
+header_name = "X-API-Key"
key_prefix = "gw_"
cache_ttl_secs = 300
-
-[auth.gateway.jwt]
-issuer = "https://auth.example.com"
-audience = "hadrian"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
-
-[auth.admin]
-type = "oidc"
-issuer = "https://auth.example.com"
-client_id = "hadrian"
-client_secret = "${OIDC_CLIENT_SECRET}"
-redirect_uri = "https://gateway.example.com/auth/callback"
```
-**Getting started:** Same as scenario 3. Additionally, create API keys via the admin panel for CI pipelines, scripts, or OpenAI SDK clients that can't use browser-based OIDC.
+**Getting started:** Use the [bootstrap API key](/docs/configuration/auth#bootstrap-configuration) to create the initial organization and users. Then create API keys via the admin panel for each user. Paste a key into the chat UI settings or pass it to the OpenAI SDK.
-### 6. Multi-org, each org with own IdP
+### 3. SSO / IdP
-Each organization brings their own identity provider. `[auth.gateway.jwt]` is **not needed** in the config — per-org SSO configs automatically provide JWT validation on API endpoints.
+Per-org SSO for browser sessions, with API key and JWT support on `/v1/*` endpoints. Each organization configures their own OIDC or SAML provider through the Admin UI at runtime.
```toml
-[auth.gateway]
-type = "multi"
+[auth.mode]
+type = "idp"
-[auth.gateway.api_key]
+[auth.api_key]
key_prefix = "gw_"
cache_ttl_secs = 300
-# No [auth.gateway.jwt] section needed.
-# Per-org SSO configs provide JWT validation automatically.
-
-[auth.admin]
-type = "session"
+[auth.session]
+cookie_name = "hadrian_session"
+secure = true
secret = "${SESSION_SECRET}"
[auth.bootstrap]
@@ -193,38 +91,42 @@ api_key = "${HADRIAN_BOOTSTRAP_KEY}"
auto_verify_domains = ["acme.com"]
```
-When a JWT arrives on `/v1/*`, the gateway decodes the `iss` claim and looks up the matching per-org SSO config. See [Per-Org JWT Routing](#per-org-jwt-routing) for details.
+**Getting started:** Use the [bootstrap API key](/docs/configuration/auth#bootstrap-configuration) to create organizations. Each org admin then configures their IdP via the [SSO Admin Guide](/docs/features/sso-admin-guide). Users are auto-provisioned on first SSO login. Per-org SSO configs automatically enable JWT validation on `/v1/*` endpoints -- no global OIDC configuration needed.
-**Getting started:** Use the [bootstrap API key](/docs/configuration/auth#bootstrap-configuration) to create organizations. Each org admin then configures their IdP via the [SSO Admin Guide](/docs/features/sso-admin-guide). Users are auto-provisioned on first SSO login.
+
+ There is no global OIDC or SAML config in `hadrian.toml`. All identity provider configurations are
+ per-organization and managed through the Admin UI. This enables multi-tenant deployments where
+ each organization uses a different IdP.
+
-### 7. Zero-trust / reverse proxy
+### 4. Reverse proxy (IAP)
-Trust identity headers from an authenticating reverse proxy (Cloudflare Access, Tailscale, oauth2-proxy, etc.). The proxy authenticates all admin panel access automatically.
+Trust identity headers from an authenticating reverse proxy (Cloudflare Access, Tailscale, oauth2-proxy, etc.) for admin access. API access uses API keys.
```toml
[server.trusted_proxies]
cidrs = ["173.245.48.0/20", "103.21.244.0/22"]
-[auth.gateway]
-type = "api_key"
-key_prefix = "gw_"
-
-[auth.admin]
-type = "proxy_auth"
+[auth.mode]
+type = "iap"
identity_header = "Cf-Access-Authenticated-User-Email"
email_header = "Cf-Access-Authenticated-User-Email"
+
+[auth.api_key]
+key_prefix = "gw_"
+cache_ttl_secs = 300
```
-**Getting started:** The proxy handles admin panel authentication. Create API keys via the admin panel for chat UI and SDK access.
+**Getting started:** Configure your reverse proxy to authenticate all traffic to Hadrian and forward identity headers. Create API keys via the admin panel for chat UI and SDK access.
Always configure `[server.trusted_proxies]` to prevent header spoofing. Without this, attackers
- can forge identity headers.
+ can forge identity headers and impersonate any user.
- Proxy auth only covers the admin panel. The chat UI requires an API key because
- `/v1/*` uses `api_key` auth, which the proxy headers don't satisfy.
+ In `iap` mode, both proxy headers and API keys work for authentication. The proxy authenticates
+ admin panel access, while API keys authenticate `/v1/*` requests.
## Architecture
@@ -236,41 +138,38 @@ flowchart TD
Browser[Browser / Web UI]
end
- subgraph API_Auth["API Authentication"]
- A1[API Keys]
- A2[JWT Tokens
global or per-org]
- A3[Multi-Auth]
+ subgraph Auth_Mode["Auth Mode"]
+ NONE[None
development only]
+ APIKEY[API Keys
admin + API]
+ IDP[IdP
per-org SSO + JWT + API keys]
+ IAP[IAP
reverse proxy + API keys]
end
- subgraph UI_Auth["UI Authentication"]
- U2[Reverse Proxy - Cloudflare, etc.]
- subgraph U3["Session — Per-Org SSO"]
- U1[OIDC - Okta, Keycloak...]
- U4[SAML 2.0 - AD FS, Azure AD...]
- end
+ subgraph IDP_Detail["IdP — Per-Org SSO"]
+ OIDC[OIDC - Okta, Keycloak...]
+ SAML[SAML 2.0 - AD FS, Azure AD...]
end
AuthZ[Authorization
CEL-based RBAC]
- SDK --> API_Auth
- Browser --> UI_Auth
- API_Auth --> AuthZ
- UI_Auth --> AuthZ
- U3 -.->|per-org JWT
validation| A2
+ SDK --> Auth_Mode
+ Browser --> Auth_Mode
+ Auth_Mode --> AuthZ
+ IDP -.->|per-org SSO config| IDP_Detail
```
-### Default Behavior (No Auth)
+### Default Behavior
-By default, both `auth.gateway` and `auth.admin` are set to `type = "none"`, which allows anonymous access. When both are disabled:
+When `[auth.mode]` is omitted from `hadrian.toml`, authentication defaults to `type = "none"`. This means:
- **API requests** proceed without credentials. A default anonymous user and organization are created for usage tracking.
-- **Admin routes** are unprotected — the web UI works without login.
-- **RBAC is permissive** — all authorization checks pass automatically.
+- **Admin routes** are unprotected -- the web UI works without login.
+- **RBAC is permissive** -- all authorization checks pass automatically.
#### Credential Handling in No-Auth Mode
-Even when authentication is disabled, the gateway validates credentials that are explicitly provided:
+Even with `type = "none"`, the gateway validates credentials that are explicitly provided:
| Scenario | Result |
| ----------------------------------------------------------- | ------------------------------------------ |
@@ -278,15 +177,11 @@ Even when authentication is disabled, the gateway validates credentials that are
| Valid credentials sent | Request proceeds as the authenticated user |
| Invalid credentials sent (expired JWT, wrong API key, etc.) | **401 Rejected** |
-This means you can incrementally adopt authentication — existing anonymous clients continue working while authenticated clients get identity tracking and RBAC. However, clients must not send placeholder or invalid credentials.
-
-## Gateway Authentication
+This means you can incrementally adopt authentication -- existing anonymous clients continue working while authenticated clients get identity tracking and RBAC. However, clients must not send placeholder or invalid credentials.
-Gateway authentication secures programmatic access to `/v1/*` endpoints (chat completions, embeddings, etc.).
+## API Key Authentication
-### API Keys
-
-The most common method for API access. Keys are created via the Admin UI or API and validated against the database.
+API keys are the primary method for programmatic access to `/v1/*` endpoints. Keys are created via the Admin UI or API and validated against the database.
```bash
# Using X-API-Key header
@@ -306,59 +201,45 @@ curl -H "Authorization: Bearer gw_live_abc123..." \
- Usage tracking per key
- Owner binding (org, team, project, or user)
-### JWT Tokens
-
-Validate JWTs signed by an identity provider for service-to-service authentication.
-
-```bash
-curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
- https://gateway.example.com/v1/chat/completions
-```
-
-**Capabilities:**
-
-- JWKS-based signature verification
-- Configurable issuer and audience validation
-- Custom claim extraction for RBAC
-- Algorithm allowlisting (RS256, ES256, EdDSA, etc.)
+In `api_key` mode, API keys authenticate everything -- including the admin panel. In `idp` and `iap` modes, API keys authenticate `/v1/*` endpoints alongside the mode's primary mechanism.
-### Multi-Auth
+### Format-Based Detection
-Support both API keys and JWT tokens simultaneously. The gateway uses format-based detection:
+When both API keys and JWTs are accepted (in `idp` mode), the gateway uses format-based detection:
- **`X-API-Key` header**: Always validated as an API key
- **`Authorization: Bearer` header**: Tokens starting with the API key prefix (default `gw_`) are validated as API keys; all others are validated as JWTs
-### Per-Org JWT Routing
+## Per-Org JWT Routing
-In multi-tenant deployments where each organization has their own identity provider, incoming JWTs are routed to the correct per-org validator automatically:
+In `idp` mode, per-org SSO configurations automatically enable JWT validation on `/v1/*` endpoints. There is no global JWT configuration in the config file -- all JWT validation derives from per-org SSO configs.
+
+**How it works:**
-1. **Decode `iss` claim** — Extract the issuer from the JWT (without verifying the signature yet)
-2. **Per-org registry lookup** — Search the `GatewayJwtRegistry` for validators matching that issuer
-3. **Lazy-load from DB** — On cache miss, query the database for enabled OIDC SSO configs with that issuer, perform OIDC discovery to fetch the JWKS URI, and build a validator
-4. **Fall back to global** — If no per-org config matches and `[auth.gateway.jwt]` is configured, try the global validator
-5. **Validate** — Verify the signature, expiry, audience, and issuer against the matched validator
+1. **Decode `iss` claim** -- Extract the issuer from the JWT without verifying the signature yet
+2. **Per-org registry lookup** -- Search the `GatewayJwtRegistry` for validators matching that issuer
+3. **Lazy-load from DB** -- On cache miss, query the database for enabled OIDC SSO configs with that issuer, perform OIDC discovery to fetch the JWKS URI, and build a validator
+4. **Validate** -- Verify the signature, expiry, audience, and issuer against the matched validator
**Key behaviors:**
-- Validators are cached across requests — JWKS keys are not re-fetched per request
+- Validators are cached across requests -- JWKS keys are not re-fetched per request
- Unknown issuers are negatively cached for 60 seconds to prevent DB query amplification
- When an SSO config is created or updated via the Admin API, the registry is updated immediately
- Multiple organizations can share the same issuer (each gets its own validator with its own audience)
- Per-org JWT routing requires `[auth.gateway]` to be `type = "multi"` or `type = "jwt"`. When using
- `type = "multi"` without a `[auth.gateway.jwt]` section, only per-org SSO configs provide JWT
- validation — there is no global fallback.
+ Per-org JWT routing only applies in `idp` mode. Per-org SSO configs provide all JWT validation
+ automatically -- no global JWT configuration is needed in the config file.
-
+
-## Admin Authentication
+## Session-Based Authentication
-Admin authentication secures browser sessions for the web interface.
+In `idp` mode, browser sessions use session cookies set during the SSO login flow. The session cookie authenticates both `/admin/*` and `/v1/*` requests, so users log in once and the chat UI works immediately -- no API key needed.
### OIDC / SSO
@@ -373,13 +254,6 @@ Full OpenID Connect flow with browser redirects. Works with any OIDC-compliant i
| Google Workspace | Google account authentication |
| OneLogin | Enterprise identity management |
-**Capabilities:**
-
-- Automatic user provisioning (JIT)
-- Group-to-team mapping
-- Configurable claims extraction
-- Session management
-
### SAML 2.0
SAML 2.0 authentication for enterprise identity providers that require the Security Assertion Markup Language protocol:
@@ -392,35 +266,15 @@ SAML 2.0 authentication for enterprise identity providers that require the Secur
| Keycloak | Self-hosted, open source |
| PingFederate | Enterprise identity federation |
-**Capabilities:**
-
-- XML digital signature verification
-- Configurable attribute mappings
-- SP metadata auto-generation
-- Works with legacy IdPs that only support SAML
-
-
-
-
-
-### Per-Organization SSO
-
-In multi-tenant deployments, each organization can configure their own identity provider. This enables:
-
-- **Self-service SSO setup** - Org admins configure their own IdP via the Admin UI
-- **Domain verification** - Prove domain ownership before enabling SSO
-- **Enforcement modes** - Optional, test, or required SSO
-- **Email-based IdP discovery** - Users enter email, redirected to correct IdP
-- **Automatic API authentication** - Per-org SSO configs also enable JWT validation on `/v1/*` endpoints (see [Per-Org JWT Routing](#per-org-jwt-routing))
-
- Per-organization SSO is configured through the Admin UI, not the gateway config file. See the [SSO
- Admin Guide](/docs/features/sso-admin-guide) for setup instructions.
+ Both OIDC and SAML providers are configured per-organization through the Admin UI, not the config
+ file. See the [SSO Admin Guide](/docs/features/sso-admin-guide) and [SAML Admin
+ Guide](/docs/features/saml) for setup instructions.
-### Reverse Proxy Auth
+## Reverse Proxy Authentication
-Trust identity headers from an authenticating reverse proxy:
+In `iap` mode, Hadrian trusts identity headers from an authenticating reverse proxy:
| Service | Headers |
| -------------------- | --------------------------------------- |
@@ -436,7 +290,7 @@ Trust identity headers from an authenticating reverse proxy:
-
+
@@ -446,10 +300,10 @@ Automatically create users and add them to organizations when they first authent
**How it works:**
-1. User authenticates via OIDC
+1. User authenticates via OIDC or SAML
2. Gateway checks if user exists in database
-3. If not, creates user with attributes from ID token
-4. Adds user to configured organization with default role
+3. If not, creates user with attributes from the identity token
+4. Adds user to the configured organization with default role
5. Optionally adds user to a default team
**Capabilities:**
diff --git a/docs/content/docs/configuration/auth.mdx b/docs/content/docs/configuration/auth.mdx
index 6e1dd4f..f6bb1e3 100644
--- a/docs/content/docs/configuration/auth.mdx
+++ b/docs/content/docs/configuration/auth.mdx
@@ -1,11 +1,11 @@
---
title: Authentication Configuration
-description: API keys, JWT/OIDC, reverse proxy auth, and CEL-based RBAC policies
+description: Auth modes, API keys, IAP, IdP sessions, and CEL-based RBAC policies
---
import { Callout } from "fumadocs-ui/components/callout";
-This page is the **configuration reference** for authentication settings in `hadrian.toml`. For conceptual overviews and guides, see:
+Configure authentication and authorization for Hadrian in `hadrian.toml` using the `[auth]` section. For conceptual overviews and guides, see:
@@ -13,49 +13,197 @@ This page is the **configuration reference** for authentication settings in `had
-The `[auth]` section configures authentication and authorization for both API requests and the web UI.
+## Auth Mode
-## Authentication Architecture
+Hadrian uses a single `[auth.mode]` to control authentication for both the API and the web UI. Select exactly one mode.
-Hadrian separates authentication into two contexts:
+| Mode | `type` | API Keys | IdP Sessions | Identity Headers | Use Case |
+| ------- | --------- | -------- | ------------ | ---------------- | ------------------------------ |
+| None | `none` | No | No | No | Local development |
+| API Key | `api_key` | Yes | No | No | Programmatic access only |
+| IdP | `idp` | Yes | Yes | No | Full deployment with SSO + API |
+| IAP | `iap` | Yes | No | Yes | Behind an identity-aware proxy |
-| Context | Section | Purpose |
-| ----------- | ---------------- | ---------------------------------------------- |
-| **Gateway** | `[auth.gateway]` | Authenticate programmatic gateway API requests |
-| **Admin** | `[auth.admin]` | Authenticate browser sessions for the admin UI |
+### None
-You can configure different authentication methods for each context. For example, use API keys for programmatic access and OIDC for the admin UI.
+Allow all requests without credentials. Only suitable for local development.
-## Gateway Authentication
+```toml
+[auth.mode]
+type = "none"
+```
-Configure how gateway API requests (`/v1/*` endpoints) are authenticated.
+
+ Never use `type = "none"` in production. All requests are unauthenticated and usage cannot be
+ tracked or billed.
+
-### No Authentication
+### API Key
-Allows any request without credentials. Only suitable for local development.
+Validate API keys stored in the database. Keys are created via the Admin API or UI. No browser-based login is available in this mode.
```toml
-[auth.gateway]
-type = "none"
+[auth.mode]
+type = "api_key"
+```
+
+API key settings (header name, prefix, caching) are configured separately in `[auth.api_key]`. See [API Key Settings](#api-key-settings).
+
+### IdP
+
+Full identity provider integration with per-organization SSO. Supports both API keys for programmatic access and browser sessions for the web UI. Each organization configures its own OIDC or SAML provider via the Admin UI.
+
+```toml
+[auth.mode]
+type = "idp"
```
+Session settings (cookie name, duration, secret) are configured in `[auth.session]`. See [Session Configuration](#session-configuration).
+
+When a bearer token is presented, Hadrian uses **format-based detection** to determine whether it is an API key or a JWT:
+
+- **`X-API-Key` header**: Always validated as an API key
+- **`Authorization: Bearer` header**: Tokens starting with the configured API key prefix (default: `gw_`) are validated as API keys; all other tokens are validated as JWTs via per-org SSO configuration
+
- Never use `type = "none"` in production. All requests will be unauthenticated and usage cannot be
- tracked or billed.
+ Providing both `X-API-Key` and `Authorization` headers simultaneously results in a 400 error
+ (ambiguous credentials). Choose one authentication method per request.
+
+
+```bash
+# API key in X-API-Key header
+curl -H "X-API-Key: gw_live_abc123..." https://gateway.example.com/v1/chat/completions
+
+# API key in Authorization: Bearer header (format-based detection)
+curl -H "Authorization: Bearer gw_live_abc123..." https://gateway.example.com/v1/chat/completions
+
+# JWT in Authorization: Bearer header (routed to org's IdP for validation)
+curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." https://gateway.example.com/v1/chat/completions
+```
+
+#### Login Flow
+
+1. User navigates to `/auth/login?org=` or uses email discovery via `/auth/discover`
+2. Gateway redirects to the organization's configured IdP
+3. After IdP authentication, callback creates a session cookie
+4. Subsequent requests are authenticated via the session cookie
+
+#### Per-Organization SSO
+
+SSO configurations are stored in the database and managed via the Admin API or UI:
+
+- `POST /admin/v1/organizations/{org_slug}/sso-configs` -- Create SSO configuration
+- `GET /admin/v1/organizations/{org_slug}/sso-configs` -- Get SSO configuration
+- `PUT /admin/v1/organizations/{org_slug}/sso-configs` -- Update SSO configuration
+- `DELETE /admin/v1/organizations/{org_slug}/sso-configs` -- Delete SSO configuration
+
+Each SSO configuration includes:
+
+- Provider type (OIDC or SAML)
+- Issuer URL and discovery settings
+- Client credentials (stored encrypted)
+- Allowed email domains
+- JIT provisioning settings
+
+
+ With `idp` mode, SSO is configured **per-organization** via the Admin UI. Each organization can
+ have its own OIDC or SAML identity provider. Users authenticate by entering their email, which
+ discovers their organization's SSO configuration. See the [SSO Admin
+ Guide](/docs/features/sso-admin-guide).
-### API Key Authentication
+#### Single-Org Membership
+
+Users can only belong to **one organization**. When a user authenticates via an organization's SSO, they are associated with that organization. If a user tries to authenticate via a different organization's SSO, they receive an error.
+
+To move a user to a different organization, first remove them from their current organization.
+
+### IAP (Identity-Aware Proxy)
+
+Trust identity headers set by an authenticating reverse proxy. Also supports API keys for programmatic access. Works with:
-Validates API keys stored in the database. Keys are created via the Admin API or UI.
+- Cloudflare Access
+- oauth2-proxy
+- Tailscale
+- Authelia / Authentik
+- Keycloak Gatekeeper
+- Pomerium
```toml
-[auth.gateway]
-type = "api_key"
+[auth.mode]
+type = "iap"
+identity_header = "X-Forwarded-User"
+email_header = "X-Forwarded-Email"
+name_header = "X-Forwarded-Name"
+groups_header = "X-Forwarded-Groups"
+require_identity = true
+```
+
+| Setting | Type | Default | Description |
+| ------------------ | ------- | ------- | ------------------------------------------------------------------------------- |
+| `identity_header` | string | --- | Header containing the authenticated user's ID (required). |
+| `email_header` | string | None | Header containing the user's email. |
+| `name_header` | string | None | Header containing the user's display name. |
+| `groups_header` | string | None | Header containing groups/roles (comma-separated or JSON array). |
+| `require_identity` | boolean | `true` | Require identity headers on all requests. If `false`, anonymous access allowed. |
+
+
+ **Critical Security:** Configure `[server.trusted_proxies]` to prevent header spoofing. Without
+ this, attackers can forge identity headers and impersonate any user.
+
+
+#### Cloudflare Access Example
+
+```toml
+[server.trusted_proxies]
+cidrs = ["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22"]
+
+[auth.mode]
+type = "iap"
+identity_header = "Cf-Access-Authenticated-User-Email"
+email_header = "Cf-Access-Authenticated-User-Email"
+```
+
+#### oauth2-proxy Example
+
+```toml
+[server.trusted_proxies]
+cidrs = ["10.0.0.0/8"]
+
+[auth.mode]
+type = "iap"
+identity_header = "X-Forwarded-User"
+email_header = "X-Forwarded-Email"
+groups_header = "X-Forwarded-Groups"
+```
+
+#### JWT Assertion (Optional)
+
+For additional security, validate a signed JWT from the proxy. Configure `jwt_assertion` inside `[auth.mode]`:
+
+```toml
+[auth.mode]
+type = "iap"
+identity_header = "X-Forwarded-User"
+
+[auth.mode.jwt_assertion]
+header = "Cf-Access-Jwt-Assertion"
+jwks_url = "https://acme-corp.cloudflareaccess.com/cdn-cgi/access/certs"
+issuer = "https://acme-corp.cloudflareaccess.com"
+audience = "your-audience-id"
+```
+
+## API Key Settings
+
+Configure shared API key behavior used by `api_key`, `idp`, and `iap` modes.
+
+```toml
+[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
generation_prefix = "gw_live_"
hash_algorithm = "sha256"
-cache_ttl_secs = 60
+cache_ttl_secs = 300
```
| Setting | Type | Default | Description |
@@ -64,16 +212,16 @@ cache_ttl_secs = 60
| `key_prefix` | string | `gw_` | Prefix for validating keys. Keys not starting with this prefix are rejected. |
| `generation_prefix` | string | `gw_live_` | Prefix for generating new keys. Distinguishes live keys from test keys. |
| `hash_algorithm` | string | `sha256` | Algorithm for hashing stored keys. Options: `sha256`, `argon2`. |
-| `cache_ttl_secs` | integer | `60` | Cache validated keys for this duration. Set to `0` for no caching. |
+| `cache_ttl_secs` | integer | `300` | Cache validated keys for this duration. Set to `0` for no caching. |
-#### Hash Algorithms
+### Hash Algorithms
| Algorithm | Speed | Use Case |
| --------- | ----- | ------------------------------------------------------------------- |
| `sha256` | Fast | High-entropy keys (recommended). Minimal latency impact. |
| `argon2` | Slow | Low-entropy keys or extra security. Adds ~50ms per uncached lookup. |
-#### Request Format
+### Request Format
API keys can be sent in two formats:
@@ -85,11 +233,11 @@ curl -H "X-API-Key: gw_live_abc123..." https://gateway.example.com/v1/chat/compl
curl -H "Authorization: Bearer gw_live_abc123..." https://gateway.example.com/v1/chat/completions
```
-### API Key Scoping
+## API Key Scoping
API keys can be restricted with permission scopes, model restrictions, IP allowlists, and per-key rate limits.
-#### Permission Scopes
+### Permission Scopes
Control which API endpoints a key can access:
@@ -118,7 +266,7 @@ curl -X POST https://gateway.example.com/admin/v1/api-keys \
When `scopes` is null or omitted, the key has full access to all endpoints.
-#### Model Restrictions
+### Model Restrictions
Limit which models a key can use with wildcard patterns:
@@ -134,7 +282,7 @@ Pattern rules:
- **Trailing wildcard:** `"gpt-4*"` matches `gpt-4`, `gpt-4o`, `gpt-4-turbo`
- **No bare `*`:** Use `null` for unrestricted model access
-#### IP Allowlists
+### IP Allowlists
Restrict key usage to specific IP addresses or CIDR ranges:
@@ -146,7 +294,7 @@ Restrict key usage to specific IP addresses or CIDR ranges:
Supports both IPv4 and IPv6 addresses and CIDR notation.
-#### Per-Key Rate Limits
+### Per-Key Rate Limits
Override global rate limits for specific keys:
@@ -164,7 +312,7 @@ Override global rate limits for specific keys:
By default, per-key limits cannot exceed global limits. Set `allow_per_key_above_global = true` in `[limits.rate_limits]` to allow per-key limits higher than global defaults.
-#### Key Rotation
+### Key Rotation
Rotate keys with a grace period during which both old and new keys work:
@@ -185,270 +333,30 @@ The response includes the new API key (store securely). The old key remains vali
new key during the grace period, then the old key automatically becomes inactive.
-### JWT Authentication
-
-Validates JWTs signed by an identity provider. Tokens are verified against a JWKS endpoint.
-
-```toml
-[auth.gateway]
-type = "jwt"
-issuer = "https://auth.example.com"
-audience = "hadrian"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
-jwks_refresh_secs = 3600
-identity_claim = "sub"
-org_claim = "org_id"
-additional_claims = ["email", "name"]
-allowed_algorithms = ["RS256", "ES256"]
-```
-
-| Setting | Type | Default | Description |
-| -------------------- | --------------- | ---------------------------------- | ------------------------------------------------------------- |
-| `issuer` | string | — | Expected `iss` claim. Tokens from other issuers are rejected. |
-| `audience` | string or array | — | Expected `aud` claim. Can be a single value or list. |
-| `jwks_url` | string | — | URL to fetch public keys for signature verification. |
-| `jwks_refresh_secs` | integer | `3600` | How often to refresh the JWKS (seconds). |
-| `identity_claim` | string | `sub` | Claim to use as the user's identity ID. |
-| `org_claim` | string | None | Claim containing the user's organization ID (optional). |
-| `additional_claims` | string[] | `[]` | Extra claims to extract and include in the request context. |
-| `allow_expired` | boolean | `false` | Accept expired tokens. **For testing only.** |
-| `allowed_algorithms` | string[] | `["RS256", "RS384", "RS512", ...]` | JWT signing algorithms to accept. |
+## Per-Org JWT Routing
-#### Allowed Algorithms
+When using `idp` mode, JWT validation is handled per-organization through SSO configurations -- there is no global JWT config in `hadrian.toml`. Each organization's SSO config provides the issuer, audience, and JWKS URL for validating JWTs from that organization's identity provider.
-
- Always explicitly specify `allowed_algorithms` to prevent algorithm confusion attacks. Avoid HMAC
- algorithms (`HS256`, `HS384`, `HS512`) unless you control both signing and verification.
-
+When a JWT is presented on a `/v1/*` endpoint:
-| Algorithm | Type | Security Level | Notes |
-| --------- | ------- | -------------- | --------------------------------- |
-| `RS256` | RSA | Recommended | Most widely supported |
-| `RS384` | RSA | Recommended | Stronger hash |
-| `RS512` | RSA | Recommended | Strongest RSA option |
-| `ES256` | ECDSA | Recommended | Compact signatures, P-256 curve |
-| `ES384` | ECDSA | Recommended | P-384 curve |
-| `PS256` | RSA-PSS | Recommended | Probabilistic signatures |
-| `PS384` | RSA-PSS | Recommended | Probabilistic signatures |
-| `PS512` | RSA-PSS | Recommended | Probabilistic signatures |
-| `EdDSA` | EdDSA | Recommended | Ed25519, modern and fast |
-| `HS256` | HMAC | Use with care | Symmetric, requires shared secret |
-| `HS384` | HMAC | Use with care | Symmetric, requires shared secret |
-| `HS512` | HMAC | Use with care | Symmetric, requires shared secret |
-
-### Multi-Auth (API Key + JWT)
-
-Support both API keys and JWTs. The gateway uses **format-based detection** to determine which authentication method to use:
-
-- **X-API-Key header**: Always validated as an API key
-- **Authorization: Bearer header**: Uses format-based detection:
- - Tokens starting with the configured API key prefix (default: `gw_`) are validated as API keys
- - All other tokens are validated as JWTs
+1. Hadrian decodes the `iss` (issuer) claim from the token
+2. Looks up the organization whose SSO config matches that issuer
+3. Validates the token against that organization's JWKS
+4. Associates the request with the matched organization
-
- Providing both `X-API-Key` and `Authorization` headers simultaneously results in a 400 error
- (ambiguous credentials). Choose one authentication method per request.
-
-
-```toml
-[auth.gateway]
-type = "multi"
-
-[auth.gateway.api_key]
-header_name = "X-API-Key"
-key_prefix = "gw_"
-cache_ttl_secs = 300
-
-[auth.gateway.jwt]
-issuer = "https://auth.example.com"
-audience = "hadrian"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
-```
+This enables true multi-tenant deployments where each organization uses a different identity provider without any global JWT configuration.
- The `[auth.gateway.jwt]` section is **optional** in multi-auth mode. When per-organization SSO is
- configured, each org's SSO config automatically provides JWT validation on `/v1/*` endpoints. If
- `[auth.gateway.jwt]` is omitted, only per-org SSO configs and API keys are used — there is no
- global JWT fallback. See [Per-Org JWT Routing](/docs/authentication#per-org-jwt-routing).
+ Configure each organization's SSO via the Admin UI or API. See the [SSO Admin
+ Guide](/docs/features/sso-admin-guide) for setup instructions.
-**Multi-auth with API keys only (per-org JWT):**
+## Session Configuration
-```toml
-[auth.gateway]
-type = "multi"
-
-[auth.gateway.api_key]
-key_prefix = "gw_"
-cache_ttl_secs = 300
-
-# No [auth.gateway.jwt] — per-org SSO configs provide JWT validation
-```
-
-**Request Examples:**
-
-```bash
-# API key in X-API-Key header
-curl -H "X-API-Key: gw_live_abc123..." https://gateway.example.com/v1/chat/completions
-
-# API key in Authorization: Bearer header (format-based detection)
-curl -H "Authorization: Bearer gw_live_abc123..." https://gateway.example.com/v1/chat/completions
-
-# JWT in Authorization: Bearer header
-curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." https://gateway.example.com/v1/chat/completions
-```
-
-## Admin Authentication
-
-Configure how browser sessions for the admin UI are authenticated.
-
-### No Authentication
-
-Allows anonymous access to the UI. Suitable for local development or internal tools.
+Configure browser session settings for `idp` mode. Sessions are created after successful SSO login.
```toml
-[auth.admin]
-type = "none"
-```
-
-### Reverse Proxy Authentication
-
-Trust identity headers set by an authenticating reverse proxy. Works with:
-
-- Cloudflare Access
-- oauth2-proxy
-- Tailscale
-- Authelia / Authentik
-- Keycloak Gatekeeper
-- Pomerium
-
-```toml
-[auth.admin]
-type = "proxy_auth"
-identity_header = "X-Forwarded-User"
-email_header = "X-Forwarded-Email"
-name_header = "X-Forwarded-Name"
-groups_header = "X-Forwarded-Groups"
-require_identity = true
-```
-
-| Setting | Type | Default | Description |
-| ------------------ | ------- | ------- | ------------------------------------------------------------------------------- |
-| `identity_header` | string | — | Header containing the authenticated user's ID (required). |
-| `email_header` | string | None | Header containing the user's email. |
-| `name_header` | string | None | Header containing the user's display name. |
-| `groups_header` | string | None | Header containing groups/roles (comma-separated or JSON array). |
-| `require_identity` | boolean | `true` | Require identity headers on all requests. If `false`, anonymous access allowed. |
-
-
- **Critical Security:** Configure `[server.trusted_proxies]` to prevent header spoofing. Without
- this, attackers can forge identity headers and impersonate any user.
-
-
-#### Cloudflare Access Example
-
-```toml
-[server.trusted_proxies]
-cidrs = ["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22"]
-
-[auth.admin]
-type = "proxy_auth"
-identity_header = "Cf-Access-Authenticated-User-Email"
-email_header = "Cf-Access-Authenticated-User-Email"
-```
-
-#### oauth2-proxy Example
-
-```toml
-[server.trusted_proxies]
-cidrs = ["10.0.0.0/8"]
-
-[auth.admin]
-type = "proxy_auth"
-identity_header = "X-Forwarded-User"
-email_header = "X-Forwarded-Email"
-groups_header = "X-Forwarded-Groups"
-```
-
-#### JWT Assertion (Optional)
-
-For additional security, validate a signed JWT from the proxy:
-
-```toml
-[auth.admin]
-type = "proxy_auth"
-identity_header = "X-Forwarded-User"
-
-[auth.admin.jwt_assertion]
-header = "Cf-Access-Jwt-Assertion"
-jwks_url = "https://your-team.cloudflareaccess.com/cdn-cgi/access/certs"
-issuer = "https://your-team.cloudflareaccess.com"
-audience = "your-audience-id"
-```
-
-### Session Authentication
-
-Session-based authentication using per-organization SSO. Each organization configures their own identity provider (OIDC or SAML) via the Admin UI, and users authenticate by discovering their organization via email domain.
-
-```toml
-[auth.admin]
-type = "session"
-cookie_name = "__gw_session"
-duration_secs = 604800 # 7 days
-secure = true
-same_site = "lax"
-secret = "${SESSION_SECRET}"
-```
-
-| Setting | Type | Default | Description |
-| --------------- | ------- | ----------------- | ---------------------------------------------------------------------------- |
-| `cookie_name` | string | `__gw_session` | Session cookie name. |
-| `duration_secs` | integer | `604800` (7 days) | Session duration. |
-| `secure` | boolean | `true` | HTTPS-only cookies. Set to `false` for local development over HTTP. |
-| `same_site` | string | `lax` | SameSite attribute. Options: `strict`, `lax`, `none`. |
-| `secret` | string | Auto-generated | Secret for signing session cookies. Sessions are lost on restart if not set. |
-
-
- With session auth, SSO is configured **per-organization** via the Admin UI. Each organization can
- have their own OIDC or SAML identity provider. Users authenticate by entering their email, which
- discovers their organization's SSO configuration. See the [SSO Admin
- Guide](/docs/features/sso-admin-guide).
-
-
-#### Login Flow
-
-1. User navigates to `/auth/login?org=` or uses email discovery via `/auth/discover`
-2. Gateway redirects to the organization's configured IdP
-3. After IdP authentication, callback creates a session cookie
-4. Subsequent requests are authenticated via the session cookie
-
-#### Per-Organization SSO
-
-SSO configurations are stored in the database and managed via the Admin API or UI:
-
-- `POST /admin/v1/organizations/{org_slug}/sso-configs` - Create SSO configuration
-- `GET /admin/v1/organizations/{org_slug}/sso-configs` - Get SSO configuration
-- `PUT /admin/v1/organizations/{org_slug}/sso-configs` - Update SSO configuration
-- `DELETE /admin/v1/organizations/{org_slug}/sso-configs` - Delete SSO configuration
-
-Each SSO configuration includes:
-
-- Provider type (OIDC or SAML)
-- Issuer URL and discovery settings
-- Client credentials (stored encrypted)
-- Allowed email domains
-- JIT provisioning settings
-
-#### Single-Org Membership
-
-Users can only belong to **one organization**. When a user authenticates via an organization's SSO, they are associated with that organization. If a user tries to authenticate via a different organization's SSO, they will receive an error.
-
-To move a user to a different organization, first remove them from their current organization.
-
-#### Session Configuration
-
-```toml
-[auth.admin.session]
+[auth.session]
cookie_name = "__gw_session"
duration_secs = 604800 # 7 days
secure = true
@@ -477,86 +385,35 @@ secret = "${SESSION_SECRET}"
share them across nodes.
-#### JIT (Just-in-Time) Provisioning
+## JIT Provisioning
-Automatically create users and add them to organizations when they first authenticate via OIDC. This eliminates the need to pre-provision users before they can access the system.
-
-```toml
-[auth.admin.provisioning]
-enabled = true
-create_users = true
-organization_id = "acme-corp" # Required: org slug or UUID
-default_team_id = "engineering" # Optional: team slug or UUID
-default_org_role = "member"
-default_team_role = "member"
-sync_attributes_on_login = true
-# sync_memberships_on_login = true
-# allowed_email_domains = ["example.com", "acme.org"]
-```
-
-| Setting | Type | Default | Description |
-| --------------------------- | -------- | -------- | ------------------------------------------------------------------------- |
-| `enabled` | boolean | `false` | Enable JIT provisioning. |
-| `create_users` | boolean | `false` | Create users in the database on first login. |
-| `organization_id` | string | None | Organization slug or UUID to provision users into. |
-| `default_team_id` | string | None | Team slug or UUID to add users to (requires `organization_id`). |
-| `default_org_role` | string | `member` | Role assigned to users when added to organizations. |
-| `default_team_role` | string | `member` | Role assigned to users when added to teams. |
-| `allowed_email_domains` | string[] | `[]` | Restrict provisioning to specific email domains. Empty = allow all. |
-| `sync_attributes_on_login` | boolean | `false` | Update user name/email from IdP on subsequent logins. |
-| `sync_memberships_on_login` | boolean | `false` | Remove memberships not in current provisioning config. See warning below. |
+JIT (Just-in-Time) provisioning automatically creates users and adds them to organizations when they first authenticate via SSO. JIT provisioning is configured **per-organization** via the Admin UI or Admin API, not in `hadrian.toml`.
- The `organization_id` can be either a UUID or a slug. The organization must exist in the database
- before users can be provisioned into it. Create it via the Admin API or UI first.
+ See the [SSO Admin Guide](/docs/features/sso-admin-guide) for instructions on configuring JIT
+ provisioning per organization.
+Each organization's JIT provisioning settings include:
+
+| Setting | Type | Default | Description |
+| --------------------------- | -------- | -------- | ------------------------------------------------------------------- |
+| `enabled` | boolean | `false` | Enable JIT provisioning for this organization. |
+| `create_users` | boolean | `false` | Create users in the database on first login. |
+| `default_team_id` | string | None | Team to add users to on first login. |
+| `default_org_role` | string | `member` | Role assigned when added to the organization. |
+| `default_team_role` | string | `member` | Role assigned when added to the default team. |
+| `allowed_email_domains` | string[] | `[]` | Restrict provisioning to specific email domains. Empty = allow all. |
+| `sync_attributes_on_login` | boolean | `false` | Update user name/email from IdP on subsequent logins. |
+| `sync_memberships_on_login` | boolean | `false` | Remove memberships not in current provisioning config. |
+
**Membership Sync Warning:** When `sync_memberships_on_login` is enabled, users will lose access
to organizations and teams not configured in the provisioning settings. All membership changes are
logged in the audit log.
-**Example: Basic Setup**
-
-Provision all SSO users into a single organization:
-
-```toml
-[auth.admin.provisioning]
-enabled = true
-create_users = true
-organization_id = "acme-corp"
-default_org_role = "member"
-sync_attributes_on_login = true
-```
-
-**Example: With Default Team**
-
-Provision users into an organization and a default team:
-
-```toml
-[auth.admin.provisioning]
-enabled = true
-create_users = true
-organization_id = "acme-corp"
-default_team_id = "new-employees"
-default_org_role = "member"
-default_team_role = "member"
-```
-
-**Example: Restrict by Email Domain**
-
-Only provision users from specific email domains:
-
-```toml
-[auth.admin.provisioning]
-enabled = true
-create_users = true
-organization_id = "acme-corp"
-allowed_email_domains = ["acme.com", "acme.org"]
-```
-
-#### SSO Group Mappings
+### SSO Group Mappings
Map IdP groups to Hadrian teams for automatic team assignment during JIT provisioning.
@@ -615,11 +472,11 @@ A user in the `Engineering` group is added to all three teams with `member` role
Group mappings can also be managed programmatically via the Admin API:
-- `GET /admin/v1/organizations/{org_slug}/sso-group-mappings` - List mappings
-- `POST /admin/v1/organizations/{org_slug}/sso-group-mappings` - Create mapping
-- `PATCH /admin/v1/organizations/{org_slug}/sso-group-mappings/{id}` - Update mapping
-- `DELETE /admin/v1/organizations/{org_slug}/sso-group-mappings/{id}` - Delete mapping
-- `POST /admin/v1/organizations/{org_slug}/sso-group-mappings/test` - Test mappings against IdP groups
+- `GET /admin/v1/organizations/{org_slug}/sso-group-mappings` -- List mappings
+- `POST /admin/v1/organizations/{org_slug}/sso-group-mappings` -- Create mapping
+- `PATCH /admin/v1/organizations/{org_slug}/sso-group-mappings/{id}` -- Update mapping
+- `DELETE /admin/v1/organizations/{org_slug}/sso-group-mappings/{id}` -- Delete mapping
+- `POST /admin/v1/organizations/{org_slug}/sso-group-mappings/test` -- Test mappings against IdP groups
See the [API Reference](/docs/api) for full documentation.
@@ -629,11 +486,11 @@ For large mapping sets, use the import/export feature in the Admin UI:
- **Export**: Download mappings as CSV or JSON for backup or migration
- **Import**: Upload a JSON file with mappings and choose conflict resolution:
- - `skip` - Skip mappings that already exist
- - `overwrite` - Update existing mappings with imported values
- - `error` - Fail if any mapping already exists
+ - `skip` -- Skip mappings that already exist
+ - `overwrite` -- Update existing mappings with imported values
+ - `error` -- Fail if any mapping already exists
-#### SCIM Provisioning
+### SCIM Provisioning
SCIM (System for Cross-domain Identity Management) provides real-time user provisioning and deprovisioning from your identity provider. Unlike JIT provisioning which only triggers on login, SCIM syncs changes immediately.
@@ -662,11 +519,11 @@ SCIM (System for Cross-domain Identity Management) provides real-time user provi
SCIM configuration is managed via the Admin API:
-- `GET /admin/v1/organizations/{org_slug}/scim-configs` - Get SCIM configuration
-- `POST /admin/v1/organizations/{org_slug}/scim-configs` - Create SCIM configuration
-- `PUT /admin/v1/organizations/{org_slug}/scim-configs` - Update SCIM configuration
-- `DELETE /admin/v1/organizations/{org_slug}/scim-configs` - Delete SCIM configuration
-- `POST /admin/v1/organizations/{org_slug}/scim-configs/rotate-token` - Rotate bearer token
+- `GET /admin/v1/organizations/{org_slug}/scim-configs` -- Get SCIM configuration
+- `POST /admin/v1/organizations/{org_slug}/scim-configs` -- Create SCIM configuration
+- `PUT /admin/v1/organizations/{org_slug}/scim-configs` -- Update SCIM configuration
+- `DELETE /admin/v1/organizations/{org_slug}/scim-configs` -- Delete SCIM configuration
+- `POST /admin/v1/organizations/{org_slug}/scim-configs/rotate-token` -- Rotate bearer token
## RBAC Configuration
@@ -717,12 +574,12 @@ priority = 100
| Field | Type | Default | Description |
| ------------- | ------- | ------- | -------------------------------------------------------------------- |
-| `name` | string | — | Unique policy identifier (required). |
+| `name` | string | --- | Unique policy identifier (required). |
| `description` | string | None | Human-readable description. |
| `resource` | string | `*` | Resource type this policy applies to. |
| `action` | string | `*` | Action this policy applies to. |
-| `condition` | string | — | CEL expression that must evaluate to `true` (required). |
-| `effect` | string | — | `allow` or `deny` (required). |
+| `condition` | string | --- | CEL expression that must evaluate to `true` (required). |
+| `effect` | string | --- | `allow` or `deny` (required). |
| `priority` | integer | `0` | Evaluation order. Higher = evaluated first. Ties: deny before allow. |
### CEL Variables
@@ -857,24 +714,19 @@ default_effect = "allow"
### Authentication Requirements
-Gateway RBAC policies require identity information (roles, org membership) from JWT tokens. Configure multi-auth to support both API keys and JWTs:
+Gateway RBAC policies require identity information (roles, org membership) from JWT tokens. Use `idp` mode to support both API keys and JWTs:
```toml
-[auth.gateway]
-type = "multi"
+[auth.mode]
+type = "idp"
-[auth.gateway.api_key]
+[auth.api_key]
key_prefix = "gw_"
-
-[auth.gateway.jwt]
-issuer = "https://auth.example.com"
-audience = "hadrian-api"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
```
- API key authentication alone does not provide role information. Use JWT authentication or
- multi-auth for Gateway RBAC policies that check `subject.roles`.
+ API key authentication alone does not provide role information. Use JWT authentication (via
+ per-org SSO) for Gateway RBAC policies that check `subject.roles`.
### Policy Examples
@@ -1113,12 +965,12 @@ For scenarios where users will authenticate via IdP before accessing the Admin U
```toml
[auth.bootstrap]
-admin_identities = ["alice@example.com", "bob@example.com"]
+admin_identities = ["alice@acme.com", "bob@acme.com"]
[auth.bootstrap.initial_org]
slug = "acme-corp"
name = "Acme Corporation"
-admin_identities = ["alice@example.com"]
+admin_identities = ["alice@acme.com"]
```
| Setting | Type | Description |
@@ -1133,6 +985,93 @@ admin_identities = ["alice@example.com"]
and organizations are not modified.
+### Bootstrap API Key Generation
+
+Create an API key during bootstrap for programmatic access:
+
+```toml
+[auth.bootstrap]
+api_key = "${HADRIAN_BOOTSTRAP_KEY}"
+auto_verify_domains = ["acme.com"]
+
+[auth.bootstrap.initial_org]
+slug = "acme-corp"
+name = "Acme Corporation"
+admin_identities = ["admin@acme.com"]
+
+[auth.bootstrap.initial_api_key]
+name = "production-api-key"
+```
+
+| Setting | Type | Description |
+| ---------------------- | ------ | ------------------------------------------------------------- |
+| `initial_api_key.name` | string | Name for the auto-created API key (scoped to the initial org) |
+
+The generated API key is printed to stdout on first creation. Subsequent runs skip creation if a key with the same name already exists.
+
+### Bootstrap SSO Configuration
+
+Pre-configure SSO for the initial organization directly in the config file, avoiding manual Admin UI setup:
+
+```toml
+[auth.bootstrap.initial_org]
+slug = "acme-corp"
+name = "Acme Corporation"
+admin_identities = ["admin@acme.com"]
+
+[auth.bootstrap.initial_org.sso]
+provider_type = "oidc"
+issuer = "https://accounts.google.com"
+client_id = "${OIDC_CLIENT_ID}"
+client_secret = "${OIDC_CLIENT_SECRET}"
+redirect_uri = "https://gateway.example.com/auth/callback"
+discovery_url = "https://accounts.google.com/.well-known/openid-configuration"
+allowed_email_domains = ["acme.com"]
+```
+
+| Setting | Type | Description |
+| --------------------------- | -------- | ----------------------------------------------- |
+| `sso.provider_type` | string | `"oidc"` or `"saml"` |
+| `sso.issuer` | string | IdP issuer URL |
+| `sso.client_id` | string | OAuth client ID |
+| `sso.client_secret` | string | OAuth client secret (stored in secrets manager) |
+| `sso.redirect_uri` | string | OAuth redirect URI |
+| `sso.discovery_url` | string | OIDC discovery endpoint (optional) |
+| `sso.allowed_email_domains` | string[] | Restrict SSO login to these email domains |
+
+Domains listed in both `auto_verify_domains` and `sso.allowed_email_domains` are automatically verified during bootstrap.
+
+### Bootstrap CLI
+
+Run bootstrap as a standalone CLI command instead of at server startup. This is the recommended approach for GitOps and IaC workflows:
+
+```bash
+# Run bootstrap against the database
+hadrian bootstrap --config hadrian.toml
+
+# Preview what would be created without making changes
+hadrian bootstrap --config hadrian.toml --dry-run
+```
+
+The `hadrian bootstrap` command:
+
+- Connects directly to the database (no HTTP server started)
+- Runs pending migrations before bootstrapping
+- Creates the initial organization, SSO config, and API key as specified in `[auth.bootstrap]`
+- Is fully idempotent — safe to run repeatedly (skips resources that already exist)
+- Prints the generated API key to stdout on first creation (pipe to a secret manager or file)
+
+```bash
+# Example: capture the generated API key
+API_KEY=$(hadrian bootstrap --config hadrian.toml 2>/dev/null)
+echo "Generated key: $API_KEY"
+```
+
+
+ Use `--dry-run` to verify your bootstrap configuration before applying it to a production
+ database.
+
+
## Emergency Access Configuration
Emergency access provides break-glass admin access when SSO is unavailable. Unlike bootstrap mode, emergency access remains available indefinitely (when enabled) and is designed for disaster recovery scenarios.
@@ -1146,7 +1085,7 @@ allowed_ips = ["10.0.0.0/8"] # Optional: restrict to admin network
id = "emergency-admin-1"
name = "Primary Emergency Admin"
key = "${EMERGENCY_KEY_1}"
-email = "admin@company.com"
+email = "admin@acme.com"
roles = ["_emergency_admin", "super_admin"]
[auth.emergency.rate_limit]
@@ -1159,10 +1098,10 @@ lockout_secs = 3600
| ------------------------- | -------- | ------- | -------------------------------------------- |
| `enabled` | boolean | `false` | Enable emergency access |
| `allowed_ips` | string[] | `[]` | Global IP allowlist (CIDR notation) |
-| `accounts[].id` | string | - | Unique identifier for audit logs |
-| `accounts[].name` | string | - | Human-readable account name |
-| `accounts[].key` | string | - | Emergency access key (secret) |
-| `accounts[].email` | string | - | Email for audit logging |
+| `accounts[].id` | string | --- | Unique identifier for audit logs |
+| `accounts[].name` | string | --- | Human-readable account name |
+| `accounts[].key` | string | --- | Emergency access key (secret) |
+| `accounts[].email` | string | --- | Email for audit logging |
| `accounts[].roles` | string[] | `[]` | Roles granted on authentication |
| `accounts[].allowed_ips` | string[] | `[]` | Per-account IP restrictions (CIDR) |
| `rate_limit.max_attempts` | u32 | `5` | Failed attempts before lockout |
@@ -1185,51 +1124,38 @@ curl -H "X-Emergency-Key: $EMERGENCY_KEY" https://gateway.example.com/admin/v1/o
### Development (No Auth)
```toml
-[auth.gateway]
+[auth.mode]
type = "none"
-
-# No UI auth config = anonymous access
```
### Production API Keys Only
```toml
-[auth.gateway]
+[auth.mode]
type = "api_key"
+
+[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
cache_ttl_secs = 300
```
-### Keycloak OIDC with JIT Provisioning
+### IdP with Per-Org SSO
-```toml
-[auth.admin]
-type = "oidc"
-issuer = "https://keycloak.example.com/realms/hadrian"
-client_id = "hadrian"
-client_secret = "${OIDC_CLIENT_SECRET}"
-redirect_uri = "https://gateway.example.com/auth/callback"
-identity_claim = "preferred_username"
-groups_claim = "groups" # Required for SSO group mappings
+Each organization configures their own OIDC or SAML provider via the Admin UI. API keys provide programmatic access.
-[auth.admin.session]
-secure = true
-secret = "${SESSION_SECRET}"
-
-# JIT provisioning - auto-create users and add to organization
-[auth.admin.provisioning]
-enabled = true
-create_users = true
-organization_id = "acme-corp"
-default_org_role = "member"
-sync_attributes_on_login = true
+```toml
+[auth.mode]
+type = "idp"
-[auth.gateway]
-type = "api_key"
+[auth.api_key]
key_prefix = "gw_"
cache_ttl_secs = 300
+[auth.session]
+secure = true
+secret = "${SESSION_SECRET}"
+
[auth.rbac]
enabled = true
default_effect = "deny"
@@ -1251,11 +1177,15 @@ action = "*"
condition = "context.org_id in subject.org_ids"
effect = "allow"
priority = 50
+
+[auth.bootstrap]
+api_key = "${HADRIAN_BOOTSTRAP_KEY}"
+auto_verify_domains = ["acme.com"]
```
- After deploying this configuration, use the Admin UI to set up SSO group mappings that map
- Keycloak groups to Hadrian teams.
+ After deploying, use the bootstrap API key to create organizations and configure SSO. Once SSO is
+ active, use the Admin UI to set up group mappings that map IdP groups to Hadrian teams.
### Cloudflare Access + API Keys
@@ -1264,44 +1194,34 @@ priority = 50
[server.trusted_proxies]
cidrs = ["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22"]
-[auth.admin]
-type = "proxy_auth"
+[auth.mode]
+type = "iap"
identity_header = "Cf-Access-Authenticated-User-Email"
email_header = "Cf-Access-Authenticated-User-Email"
-[auth.admin.jwt_assertion]
+[auth.mode.jwt_assertion]
header = "Cf-Access-Jwt-Assertion"
-jwks_url = "https://your-team.cloudflareaccess.com/cdn-cgi/access/certs"
-issuer = "https://your-team.cloudflareaccess.com"
+jwks_url = "https://acme-corp.cloudflareaccess.com/cdn-cgi/access/certs"
+issuer = "https://acme-corp.cloudflareaccess.com"
audience = "your-app-audience-tag"
-[auth.gateway]
-type = "api_key"
+[auth.api_key]
key_prefix = "gw_"
```
### Multi-Tenant with Full RBAC
```toml
-[auth.admin]
-type = "oidc"
-issuer = "https://auth.example.com"
-client_id = "hadrian"
-client_secret = "${OIDC_CLIENT_SECRET}"
-redirect_uri = "https://gateway.example.com/auth/callback"
-groups_claim = "groups"
-
-[auth.gateway]
-type = "multi"
+[auth.mode]
+type = "idp"
-[auth.gateway.api_key]
+[auth.api_key]
key_prefix = "gw_"
cache_ttl_secs = 300
-[auth.gateway.jwt]
-issuer = "https://auth.example.com"
-audience = "hadrian-api"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
+[auth.session]
+secure = true
+secret = "${SESSION_SECRET}"
[auth.rbac]
enabled = true
@@ -1368,37 +1288,40 @@ condition = "context.org_id in subject.org_ids"
effect = "allow"
priority = 20
+[auth.rbac.gateway]
+enabled = true
+default_effect = "allow"
+
[auth.bootstrap]
-admin_identities = ["admin@example.com"]
+admin_identities = ["admin@acme.com"]
[auth.bootstrap.initial_org]
-slug = "default"
-name = "Default Organization"
-admin_identities = ["admin@example.com"]
+slug = "acme-corp"
+name = "Acme Corporation"
+admin_identities = ["admin@acme.com"]
+
+[auth.bootstrap.initial_api_key]
+name = "admin-key"
```
### Multi-Org with Per-IdP API Authentication
-Each organization configures their own identity provider via the Admin UI. No global `[auth.gateway.jwt]` is needed — per-org SSO configs automatically enable JWT validation on `/v1/*` endpoints.
+Each organization configures their own identity provider via the Admin UI. No global JWT config is needed -- per-org SSO configs automatically enable JWT validation on `/v1/*` endpoints.
```toml
-[auth.gateway]
-type = "multi"
+[auth.mode]
+type = "idp"
-[auth.gateway.api_key]
+[auth.api_key]
key_prefix = "gw_"
cache_ttl_secs = 300
-# No [auth.gateway.jwt] section.
-# Per-org SSO configs provide JWT validation for each org's IdP.
+# No global JWT config -- per-org SSO configs provide JWT validation for each org's IdP.
-[auth.admin]
-type = "session"
-secret = "${SESSION_SECRET}"
-
-[auth.admin.session]
+[auth.session]
secure = true
same_site = "lax"
+secret = "${SESSION_SECRET}"
[auth.rbac]
enabled = true
diff --git a/docs/content/docs/features/multi-tenancy.mdx b/docs/content/docs/features/multi-tenancy.mdx
index a809b57..1fd95bf 100644
--- a/docs/content/docs/features/multi-tenancy.mdx
+++ b/docs/content/docs/features/multi-tenancy.mdx
@@ -582,11 +582,11 @@ Hadrian provides administrative control over user browser sessions, enabling the
Enable enhanced session tracking in your configuration:
```toml
-[auth.admin.session]
+[auth.session]
cookie_name = "__gw_session"
duration_secs = 604800 # 7 days
-[auth.admin.session.enhanced]
+[auth.session.enhanced]
enabled = true # Enable session tracking
track_devices = true # Store user agent and device info
max_concurrent_sessions = 3 # Limit sessions per user (0 = unlimited)
diff --git a/docs/content/docs/features/sso-admin-guide.mdx b/docs/content/docs/features/sso-admin-guide.mdx
index b87c538..287533f 100644
--- a/docs/content/docs/features/sso-admin-guide.mdx
+++ b/docs/content/docs/features/sso-admin-guide.mdx
@@ -36,9 +36,8 @@ curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
The gateway decodes the `iss` claim from the JWT, matches it to your organization's SSO config, and validates the token against your IdP's JWKS keys. This works alongside API keys — users can authenticate with either method.
- API JWT validation requires the gateway to be configured with `[auth.gateway]` set to `type =
- "multi"` or `type = "jwt"`. See [Per-Org JWT Routing](/docs/authentication#per-org-jwt-routing)
- for details on how token routing works.
+ API JWT validation requires `[auth.mode]` set to `type = "idp"`. See [Per-Org JWT
+ Routing](/docs/api/authentication#per-org-jwt-routing) for details on how token routing works.
## Prerequisites
diff --git a/docs/content/docs/security/index.mdx b/docs/content/docs/security/index.mdx
index 2e36c98..312c8ee 100644
--- a/docs/content/docs/security/index.mdx
+++ b/docs/content/docs/security/index.mdx
@@ -9,14 +9,13 @@ This document describes Hadrian Gateway's security model, covering authenticatio
Hadrian supports multiple authentication methods that can be used independently or combined.
-| Method | Use Case | Token Location |
-| -------------- | ----------------------- | --------------------------------------------- |
-| **API Key** | Programmatic access | `X-API-Key` header or `Authorization: Bearer` |
-| **JWT** | Service-to-service auth | `Authorization: Bearer` header |
-| **OIDC** | Browser-based SSO | Session cookie |
-| **Proxy Auth** | Zero-trust networks | Trusted proxy headers |
-| **Multi-Auth** | Flexible access | Tries multiple methods in order |
-| **Emergency** | Break-glass access | `X-Emergency-Key` header |
+| Method | Use Case | Token Location |
+| ------------- | ----------------------- | --------------------------------------------- |
+| **API Key** | Programmatic access | `X-API-Key` header or `Authorization: Bearer` |
+| **JWT** | Service-to-service auth | `Authorization: Bearer` header |
+| **OIDC** | Browser-based SSO | Session cookie |
+| **IAP** | Zero-trust networks | Trusted proxy headers |
+| **Emergency** | Break-glass access | `X-Emergency-Key` header |
For disaster recovery when SSO is unavailable, see [Emergency
@@ -47,14 +46,14 @@ API keys provide simple, secure authentication for programmatic access.
**Configuration:**
```toml
-[auth.gateway]
+[auth.mode]
type = "api_key"
-[auth.gateway.api_key]
+[auth.api_key]
header_name = "X-API-Key" # Header to check (or "Authorization")
key_prefix = "gw_" # Required prefix for all keys
hash_algorithm = "sha256" # "sha256" or "argon2"
-cache_ttl_secs = 60 # Cache valid keys for 60 seconds
+cache_ttl_secs = 300 # Cache valid keys for 5 minutes
```
**API Key Properties:**
@@ -116,17 +115,19 @@ JWT authentication validates tokens issued by external identity providers for se
**Configuration:**
```toml
-[auth.gateway]
-type = "jwt"
-
-[auth.gateway.jwt]
-issuer = "https://auth.example.com"
-audience = ["api", "gateway"] # Single value or array
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
-jwks_refresh_secs = 3600 # Refresh JWKS every hour
-identity_claim = "sub" # Claim for user identity
-org_claim = "org_ids" # Optional: organization membership
-allowed_algorithms = ["RS256", "ES256"] # Algorithm allowlist
+[auth.mode]
+type = "idp"
+
+# JWT validation is configured per-organization via the Admin UI.
+# Each org configures its own OIDC or SAML identity provider with
+# issuer, audience, JWKS URL, and claim mappings.
+# See the SSO Admin Guide for setup details.
+
+[auth.session]
+cookie_name = "__gw_session"
+duration_secs = 604800 # 7 days
+secure = true # HTTPS only
+same_site = "lax" # CSRF protection
```
**Supported Algorithms:**
@@ -161,23 +162,15 @@ OIDC provides browser-based single sign-on with identity providers like Keycloak
**Configuration:**
```toml
-[auth.admin]
-type = "oidc"
-
-[auth.admin.oidc]
-issuer = "https://auth.example.com"
-client_id = "hadrian"
-client_secret = "${OIDC_CLIENT_SECRET}"
-redirect_uri = "https://gateway.example.com/auth/callback"
-scopes = ["openid", "email", "profile", "groups"]
+[auth.mode]
+type = "idp"
-# Claim mapping
-identity_claim = "sub" # User identity
-org_claim = "org_ids" # Organization membership
-groups_claim = "groups" # Group membership
+# OIDC/SAML identity providers are configured per-organization via
+# the Admin UI or Admin API. Each org can have its own IdP with
+# issuer, client credentials, scopes, and claim mappings.
+# See the SSO Admin Guide for setup details.
-# Session settings
-[auth.admin.oidc.session]
+[auth.session]
cookie_name = "__gw_session"
duration_secs = 604800 # 7 days
secure = true # HTTPS only
@@ -190,13 +183,13 @@ same_site = "lax" # CSRF protection
- **Redis**: Multi-node deployments (recommended for production)
```toml
-[auth.admin.oidc.session]
+[auth.session]
store = "cache" # Use Redis cache for sessions
```
-### Proxy Authentication
+### Identity-Aware Proxy (IAP)
-Proxy authentication trusts identity headers from an authenticating reverse proxy, enabling integration with zero-trust networks.
+IAP authentication trusts identity headers from an authenticating reverse proxy, enabling integration with zero-trust networks.
**Supported Proxies:**
@@ -217,10 +210,8 @@ Proxy authentication trusts identity headers from an authenticating reverse prox
[server]
trusted_proxies = ["10.0.0.1", "10.0.0.2", "192.168.1.0/24"]
-[auth.admin]
-type = "proxy_auth"
-
-[auth.admin.proxy_auth]
+[auth.mode]
+type = "iap"
identity_header = "CF-Access-Authenticated-User-Email"
email_header = "X-Forwarded-Email"
name_header = "X-Forwarded-Name"
@@ -228,32 +219,27 @@ groups_header = "X-Forwarded-Groups" # Comma-separated or JSON array
require_identity = true
# Optional: Also validate JWT assertion from proxy
-[auth.admin.proxy_auth.jwt_assertion]
+[auth.mode.jwt_assertion]
header = "CF-Access-JWT-Assertion"
jwks_url = "https://your-team.cloudflareaccess.com/cdn-cgi/access/certs"
issuer = "https://your-team.cloudflareaccess.com"
audience = "your-application-audience"
```
-### Multi-Auth
+### IdP Mode (Multiple Methods)
-Combine multiple authentication methods with configurable priority order.
+In `idp` mode, Hadrian automatically supports session cookies, API keys, and JWTs via format-based detection. No additional configuration is needed beyond setting the mode.
```toml
-[auth.gateway]
-type = "multi"
+[auth.mode]
+type = "idp"
-[auth.gateway.api_key]
+[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
-[auth.gateway.jwt]
-issuer = "https://auth.example.com"
-audience = "api"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
-
-# Try API key first, fall back to JWT
-order = ["api_key", "jwt"]
+# JWT validation is configured per-organization via the Admin UI.
+# Session cookies, API keys, and JWTs are all accepted automatically.
```
## Authorization
@@ -519,8 +505,8 @@ secret_path = "hadrian/gateway"
### Production Checklist
-- [ ] Enable gateway authentication (`auth.gateway.type != "none"`)
-- [ ] Configure `server.trusted_proxies` if using proxy auth
+- [ ] Enable authentication (`auth.mode.type != "none"`)
+- [ ] Configure `server.trusted_proxies` if using IAP auth
- [ ] Set strong session secrets
- [ ] Enable RBAC with `default_effect = "deny"`
- [ ] Configure virus scanning for file uploads
diff --git a/docs/content/docs/troubleshooting.mdx b/docs/content/docs/troubleshooting.mdx
index ba6e8c8..aaa86cc 100644
--- a/docs/content/docs/troubleshooting.mdx
+++ b/docs/content/docs/troubleshooting.mdx
@@ -111,10 +111,10 @@ curl http://localhost:8080/v1/chat/completions \
**Configuration reference:**
```toml
-[auth.gateway]
+[auth.mode]
type = "api_key"
-[auth.gateway.api_key]
+[auth.api_key]
header_name = "X-API-Key" # or "Authorization"
key_prefix = "gw_" # Required prefix for all keys
```
@@ -144,11 +144,12 @@ echo "eyJhbGciOi..." | cut -d'.' -f2 | base64 -d | jq .
**Configuration reference:**
```toml
-[auth.gateway]
-type = "jwt"
-issuer = "https://auth.example.com"
-audience = "hadrian"
-jwks_url = "https://auth.example.com/.well-known/jwks.json"
+[auth.mode]
+type = "idp"
+
+# JWT validation is configured per-organization via the Admin UI (SSO settings).
+# Each org can configure its own OIDC/SAML identity provider with issuer,
+# audience, and JWKS URL. See the SSO Admin Guide for details.
```
### Revoked Key Still Working
@@ -166,7 +167,7 @@ API keys are cached to reduce database load. When a key is revoked:
- **Adjust TTL**: Lower `cache_ttl_secs` for faster revocation (at cost of more DB queries)
```toml
-[auth.gateway.api_key]
+[auth.api_key]
cache_ttl_secs = 60 # Default: 60 seconds
```
diff --git a/src/auth/discovery.rs b/src/auth/discovery.rs
index 921842e..38f114a 100644
--- a/src/auth/discovery.rs
+++ b/src/auth/discovery.rs
@@ -14,14 +14,19 @@ struct DiscoveryDocument {
/// Fetch the `jwks_uri` from an OIDC discovery endpoint.
///
/// Validates both `discovery_url` and the returned `jwks_uri` against SSRF
-/// using [`crate::validation::validate_base_url`].
+/// using [`crate::validation::validate_base_url_opts`].
pub async fn fetch_jwks_uri(
discovery_url: &str,
http_client: &reqwest::Client,
allow_loopback: bool,
+ allow_private: bool,
) -> Result {
+ let url_opts = crate::validation::UrlValidationOptions {
+ allow_loopback,
+ allow_private,
+ };
// SSRF-validate the discovery URL before fetching
- crate::validation::validate_base_url(discovery_url, allow_loopback)
+ crate::validation::validate_base_url_opts(discovery_url, url_opts)
.map_err(|e| AuthError::Internal(format!("Discovery URL failed SSRF validation: {e}")))?;
let url = if discovery_url.ends_with("/.well-known/openid-configuration") {
@@ -54,7 +59,7 @@ pub async fn fetch_jwks_uri(
.map_err(|e| AuthError::Internal(format!("Failed to parse OIDC discovery: {e}")))?;
// SSRF-validate the returned JWKS URI before returning it
- crate::validation::validate_base_url(&doc.jwks_uri, allow_loopback)
+ crate::validation::validate_base_url_opts(&doc.jwks_uri, url_opts)
.map_err(|e| AuthError::Internal(format!("JWKS URI failed SSRF validation: {e}")))?;
Ok(doc.jwks_uri)
diff --git a/src/auth/gateway_jwt.rs b/src/auth/gateway_jwt.rs
index a5a3c03..fd659e6 100644
--- a/src/auth/gateway_jwt.rs
+++ b/src/auth/gateway_jwt.rs
@@ -6,7 +6,9 @@
use std::{collections::HashMap, sync::Arc, time::Instant};
-use tokio::sync::{Mutex, RwLock};
+#[cfg(feature = "sso")]
+use tokio::sync::Mutex;
+use tokio::sync::RwLock;
use uuid::Uuid;
use super::jwt::JwtValidator;
@@ -15,10 +17,12 @@ use crate::config::JwtAuthConfig;
/// How long to cache "no SSO config exists for this issuer" results.
/// Prevents repeated DB queries from JWTs with unknown/attacker-controlled issuers.
+#[cfg(feature = "sso")]
const NEGATIVE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
/// Maximum number of negative cache entries before eviction kicks in.
/// Prevents unbounded memory growth from attacker-controlled JWT issuers.
+#[cfg(feature = "sso")]
const MAX_NEGATIVE_CACHE_ENTRIES: usize = 10_000;
/// Internal state behind the single `RwLock`.
@@ -36,6 +40,7 @@ pub struct GatewayJwtRegistry {
inner: RwLock,
/// Serializes lazy-load operations to prevent thundering herd on cache miss.
/// Only held during DB query + OIDC discovery for unknown issuers.
+ #[cfg(feature = "sso")]
load_mutex: Mutex<()>,
}
@@ -48,6 +53,7 @@ impl GatewayJwtRegistry {
issuer_index: HashMap::new(),
negative_cache: HashMap::new(),
}),
+ #[cfg(feature = "sso")]
load_mutex: Mutex::new(()),
}
}
@@ -62,6 +68,7 @@ impl GatewayJwtRegistry {
config: &crate::models::OrgSsoConfig,
http_client: &reqwest::Client,
allow_loopback: bool,
+ allow_private: bool,
) -> Result<(), super::AuthError> {
use super::AuthError;
@@ -77,7 +84,9 @@ impl GatewayJwtRegistry {
// Determine the discovery URL
let discovery_url = config.discovery_url.as_deref().unwrap_or(issuer);
- let jwks_url = super::fetch_jwks_uri(discovery_url, http_client, allow_loopback).await?;
+ let jwks_url =
+ super::fetch_jwks_uri(discovery_url, http_client, allow_loopback, allow_private)
+ .await?;
let jwt_config = build_jwt_config_from_sso(issuer, client_id, &jwks_url, config);
let validator = Arc::new(JwtValidator::with_client(jwt_config, http_client.clone()));
@@ -130,6 +139,7 @@ impl GatewayJwtRegistry {
db: &crate::db::DbPool,
http_client: &reqwest::Client,
allow_loopback: bool,
+ allow_private: bool,
) -> Result)>, super::AuthError> {
// Fast path: already cached
let validators = self.find_validators_by_issuer(issuer).await;
@@ -199,7 +209,7 @@ impl GatewayJwtRegistry {
for config in &configs {
if let Err(e) = self
- .register_from_sso_config(config, http_client, allow_loopback)
+ .register_from_sso_config(config, http_client, allow_loopback, allow_private)
.await
{
tracing::warn!(
diff --git a/src/auth/oidc.rs b/src/auth/oidc.rs
index 777c93a..20912a4 100644
--- a/src/auth/oidc.rs
+++ b/src/auth/oidc.rs
@@ -598,8 +598,9 @@ pub async fn fetch_jwks_uri(
discovery_url: &str,
http_client: &reqwest::Client,
allow_loopback: bool,
+ allow_private: bool,
) -> Result {
- super::fetch_jwks_uri(discovery_url, http_client, allow_loopback).await
+ super::fetch_jwks_uri(discovery_url, http_client, allow_loopback, allow_private).await
}
#[cfg(test)]
diff --git a/src/auth/registry.rs b/src/auth/registry.rs
index f89ac7f..3f6759c 100644
--- a/src/auth/registry.rs
+++ b/src/auth/registry.rs
@@ -100,8 +100,10 @@ impl OidcAuthenticatorRegistry {
) -> Result {
let registry = Self::new(session_store, default_session_config, default_redirect_uri);
- // Load all enabled SSO configs with their secrets
- let configs = service.list_enabled_with_secrets(secret_manager).await?;
+ // Load only OIDC SSO configs (not SAML — those use SamlAuthenticatorRegistry)
+ let configs = service
+ .list_enabled_with_secrets_by_type(secret_manager, crate::models::SsoProviderType::Oidc)
+ .await?;
for config in configs {
let org_id = config.config.org_id;
diff --git a/src/auth/session_store.rs b/src/auth/session_store.rs
index e834b69..9c5b5c4 100644
--- a/src/auth/session_store.rs
+++ b/src/auth/session_store.rs
@@ -157,7 +157,7 @@ pub struct OidcSession {
pub session_index: Option,
/// Device information (for enhanced session management)
- /// Populated when `auth.admin.session.enhanced.track_devices = true`
+ /// Populated when `auth.session.enhanced.track_devices = true`
#[serde(default)]
pub device: Option,
diff --git a/src/config/auth.rs b/src/config/auth.rs
index 75e848b..d0fb65f 100644
--- a/src/config/auth.rs
+++ b/src/config/auth.rs
@@ -5,17 +5,28 @@ use serde::{Deserialize, Serialize};
use super::ConfigError;
/// Authentication and authorization configuration.
+///
+/// Uses a single `mode` to control authentication for all endpoints:
+/// - `none` — No authentication (local dev, all access is anonymous)
+/// - `api_key` — API key required everywhere (admin shows "enter key" login)
+/// - `idp` — Per-org SSO + session cookies + JWT + API keys
+/// - `iap` — Reverse proxy headers + API keys
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
- /// Gateway (data-plane) authentication configuration for `/v1/*` endpoints.
+ /// Authentication mode. Exactly one of: none, api_key, idp, iap.
#[serde(default)]
- pub gateway: GatewayAuthConfig,
+ pub mode: AuthMode,
- /// Admin (control-plane) authentication configuration for `/admin/*` endpoints and the web UI.
+ /// Shared API key settings (used by api_key, idp, and iap modes).
#[serde(default)]
- pub admin: Option,
+ pub api_key: Option,
+
+ /// Session settings (used by idp mode for SSO cookie management).
+ #[cfg(feature = "sso")]
+ #[serde(default)]
+ pub session: Option,
/// Authorization (RBAC) configuration.
#[serde(default)]
@@ -34,21 +45,99 @@ pub struct AuthConfig {
impl AuthConfig {
pub fn validate(&mut self) -> Result<(), ConfigError> {
- self.gateway.validate()?;
- // Normalize Some(AdminAuthConfig::None) → None so that is_some()/is_none()
- // checks throughout the codebase correctly treat "type = none" as disabled.
- if matches!(&self.admin, Some(AdminAuthConfig::None)) {
- self.admin = None;
+ if let Some(ref api_key) = self.api_key {
+ api_key.validate()?;
}
- if let Some(admin) = &mut self.admin {
- admin.validate()?;
+ #[cfg(feature = "sso")]
+ if let Some(ref session) = self.session {
+ session.validate()?;
}
+ self.mode.validate()?;
self.rbac.validate()?;
+ if let Some(ref bootstrap) = self.bootstrap {
+ bootstrap.validate()?;
+ }
if let Some(emergency) = &self.emergency {
emergency.validate()?;
}
Ok(())
}
+
+ /// Whether authentication is enabled (any mode other than None).
+ pub fn is_auth_enabled(&self) -> bool {
+ !matches!(self.mode, AuthMode::None)
+ }
+
+ /// Whether admin routes should be protected by authentication middleware.
+ ///
+ /// Returns true for all modes except `None`. In `ApiKey` mode, admin routes
+ /// require a valid API key. In `Idp` mode, sessions/bearer tokens are used.
+ /// In `Iap` mode, proxy headers provide identity. Only `None` mode leaves
+ /// admin routes unprotected (for local development).
+ pub fn requires_admin_auth(&self) -> bool {
+ match self.mode {
+ AuthMode::None => false,
+ AuthMode::ApiKey => true,
+ #[cfg(feature = "sso")]
+ AuthMode::Idp => true,
+ AuthMode::Iap(_) => true,
+ }
+ }
+
+ /// Whether session cookie management is needed (Idp mode).
+ pub fn requires_session(&self) -> bool {
+ #[cfg(feature = "sso")]
+ {
+ matches!(self.mode, AuthMode::Idp)
+ }
+ #[cfg(not(feature = "sso"))]
+ {
+ false
+ }
+ }
+
+ /// Whether API key validation is available.
+ pub fn requires_api_keys(&self) -> bool {
+ match self.mode {
+ AuthMode::ApiKey => true,
+ AuthMode::Iap(_) => true,
+ #[cfg(feature = "sso")]
+ AuthMode::Idp => true,
+ _ => false,
+ }
+ }
+
+ /// Get the API key configuration (or defaults if not configured).
+ pub fn api_key_config(&self) -> &ApiKeyAuthConfig {
+ use std::sync::OnceLock;
+ static DEFAULT: OnceLock = OnceLock::new();
+ self.api_key
+ .as_ref()
+ .unwrap_or_else(|| DEFAULT.get_or_init(ApiKeyAuthConfig::default))
+ }
+
+ /// Get the IAP configuration if in IAP mode.
+ pub fn iap_config(&self) -> Option<&IapConfig> {
+ match &self.mode {
+ AuthMode::Iap(config) => Some(config.as_ref()),
+ _ => None,
+ }
+ }
+
+ /// Get the session configuration if available.
+ #[cfg(feature = "sso")]
+ pub fn session_config(&self) -> Option<&SessionConfig> {
+ self.session.as_ref()
+ }
+
+ /// Get the session configuration or a default.
+ #[cfg(feature = "sso")]
+ pub fn session_config_or_default(&self) -> std::borrow::Cow<'_, SessionConfig> {
+ match &self.session {
+ Some(config) => std::borrow::Cow::Borrowed(config),
+ None => std::borrow::Cow::Owned(SessionConfig::default()),
+ }
+ }
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -395,45 +484,102 @@ fn default_wildcard() -> String {
}
// ─────────────────────────────────────────────────────────────────────────────
-// Gateway Authentication
+// Authentication Mode
// ─────────────────────────────────────────────────────────────────────────────
-/// Gateway (data-plane) authentication configuration for `/v1/*` endpoints.
+/// Authentication mode for the gateway.
+///
+/// Controls how both API (`/v1/*`) and admin (`/admin/*`) endpoints are protected:
+///
+/// - **none** — No authentication. Suitable for local development only.
+/// API keys may still be used optionally for cost attribution.
+/// - **api_key** — API key required for all requests. The admin panel shows
+/// an "enter key" login prompt.
+/// - **idp** — Per-org SSO via OIDC/SAML. Session cookies for the web UI,
+/// JWTs for programmatic access, and API keys for machine clients.
+/// - **iap** — Identity-Aware Proxy. Identity is extracted from headers set
+/// by a reverse proxy (Cloudflare Access, oauth2-proxy, Tailscale, etc.).
+/// API keys are also accepted.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
#[serde(deny_unknown_fields)]
-pub enum GatewayAuthConfig {
+pub enum AuthMode {
/// No authentication. Any request is allowed.
- /// Only suitable for local development.
#[default]
None,
- /// API key authentication.
- /// Keys are stored in the database and validated on each request.
- ApiKey(ApiKeyAuthConfig),
+ /// API key authentication required everywhere.
+ ApiKey,
- /// JWT authentication.
- /// Tokens are validated against a JWKS endpoint.
- Jwt(JwtAuthConfig),
+ /// Per-org SSO + session cookies + JWT + API keys.
+ #[cfg(feature = "sso")]
+ Idp,
- /// Support both API key and JWT authentication.
- /// The gateway tries API key first, then JWT.
- Multi(MultiAuthConfig),
+ /// Identity-Aware Proxy (reverse proxy headers) + API keys.
+ Iap(Box),
}
-impl GatewayAuthConfig {
- pub fn is_enabled(&self) -> bool {
- !matches!(self, GatewayAuthConfig::None)
- }
-
+impl AuthMode {
pub fn validate(&self) -> Result<(), ConfigError> {
match self {
- GatewayAuthConfig::None => Ok(()),
- GatewayAuthConfig::ApiKey(c) => c.validate(),
- GatewayAuthConfig::Jwt(c) => c.validate(),
- GatewayAuthConfig::Multi(c) => c.validate(),
+ AuthMode::None | AuthMode::ApiKey => Ok(()),
+ #[cfg(feature = "sso")]
+ AuthMode::Idp => Ok(()),
+ AuthMode::Iap(config) => config.validate(),
+ }
+ }
+}
+
+/// Identity-Aware Proxy configuration.
+///
+/// Trusts identity headers set by an authenticating reverse proxy.
+/// Common proxies that work with this include:
+/// - Cloudflare Access (Cf-Access-Authenticated-User-Email)
+/// - oauth2-proxy (X-Forwarded-User, X-Forwarded-Email)
+/// - Tailscale (Tailscale-User-Login)
+/// - Authelia, Authentik, Keycloak Gatekeeper, etc.
+///
+/// **Security:** Configure `server.trusted_proxies` to ensure headers are only
+/// trusted from known proxy IPs. Without this, attackers can spoof identity headers.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
+#[serde(deny_unknown_fields)]
+pub struct IapConfig {
+ /// Header containing the authenticated user's identity.
+ pub identity_header: String,
+
+ /// Header containing the user's email (if different from identity).
+ #[serde(default)]
+ pub email_header: Option,
+
+ /// Header containing the user's name.
+ #[serde(default)]
+ pub name_header: Option,
+
+ /// Header containing groups/roles (comma-separated or JSON array).
+ #[serde(default)]
+ pub groups_header: Option,
+
+ /// Optional: JWT assertion header for additional validation.
+ /// If set, the JWT is validated and claims are extracted.
+ #[serde(default)]
+ pub jwt_assertion: Option,
+
+ /// Require all requests to have identity headers.
+ /// If false, unauthenticated requests are allowed to public endpoints.
+ #[serde(default = "default_true")]
+ pub require_identity: bool,
+}
+
+impl IapConfig {
+ fn validate(&self) -> Result<(), ConfigError> {
+ if self.identity_header.is_empty() {
+ return Err(ConfigError::Validation(
+ "IAP identity header cannot be empty".into(),
+ ));
}
+ Ok(())
}
}
@@ -465,6 +611,18 @@ pub struct ApiKeyAuthConfig {
pub cache_ttl_secs: u64,
}
+impl Default for ApiKeyAuthConfig {
+ fn default() -> Self {
+ Self {
+ header_name: default_api_key_header(),
+ key_prefix: default_api_key_prefix(),
+ generation_prefix: None,
+ hash_algorithm: HashAlgorithm::default(),
+ cache_ttl_secs: default_key_cache_ttl(),
+ }
+ }
+}
+
impl ApiKeyAuthConfig {
fn validate(&self) -> Result<(), ConfigError> {
if self.header_name.is_empty() {
@@ -496,7 +654,7 @@ fn default_api_key_prefix() -> String {
}
fn default_key_cache_ttl() -> u64 {
- 60 // 1 minute
+ 300 // 5 minutes
}
/// Hash algorithm for API keys.
@@ -552,36 +710,6 @@ pub struct JwtAuthConfig {
pub allowed_algorithms: Vec,
}
-impl JwtAuthConfig {
- fn validate(&self) -> Result<(), ConfigError> {
- if self.issuer.is_empty() {
- return Err(ConfigError::Validation("JWT issuer cannot be empty".into()));
- }
- if self.jwks_url.is_empty() {
- return Err(ConfigError::Validation("JWKS URL cannot be empty".into()));
- }
- if self.allowed_algorithms.is_empty() {
- return Err(ConfigError::Validation(
- "At least one JWT algorithm must be allowed".into(),
- ));
- }
- // Check for insecure algorithms
- for alg in &self.allowed_algorithms {
- if matches!(
- alg,
- JwtAlgorithm::HS256 | JwtAlgorithm::HS384 | JwtAlgorithm::HS512
- ) {
- tracing::warn!(
- algorithm = ?alg,
- "HMAC algorithms (HS256/HS384/HS512) are less secure for public key scenarios. \
- Consider using asymmetric algorithms (RS256, ES256) instead."
- );
- }
- }
- Ok(())
- }
-}
-
/// JWT signing algorithm.
/// SECURITY: Asymmetric algorithms (RS*, ES*) are strongly recommended.
/// HMAC algorithms (HS*) should only be used when you control both signing and verification.
@@ -658,143 +786,6 @@ fn default_identity_claim() -> String {
"sub".to_string()
}
-/// Multiple authentication methods configuration.
-///
-/// When using multi-auth, the gateway uses **format-based detection** to determine
-/// which authentication method to use:
-///
-/// - Tokens in the `Authorization: Bearer` header starting with the configured
-/// API key prefix (default: `gw_`) are validated as API keys
-/// - All other Bearer tokens are validated as JWTs
-/// - The `X-API-Key` header is always validated as an API key
-///
-/// **Important:** Providing both `X-API-Key` and `Authorization` headers simultaneously
-/// results in a 400 error (ambiguous credentials). Choose one authentication method per request.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
-#[serde(deny_unknown_fields)]
-pub struct MultiAuthConfig {
- /// API key configuration.
- pub api_key: ApiKeyAuthConfig,
-
- /// JWT configuration (optional — omit when using only per-org SSO JWTs).
- #[serde(default)]
- pub jwt: Option,
-}
-
-impl MultiAuthConfig {
- fn validate(&self) -> Result<(), ConfigError> {
- self.api_key.validate()?;
- if let Some(jwt) = &self.jwt {
- jwt.validate()?;
- }
- Ok(())
- }
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Admin Authentication
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Admin (control-plane) authentication configuration for `/admin/*` endpoints and the web UI.
-///
-/// **Note:** Global OIDC configuration has been removed. For SSO authentication,
-/// configure per-organization SSO connections via the admin API or database.
-/// Users authenticate by visiting `/auth/login?org=` which redirects
-/// to the organization's configured IdP.
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
-#[serde(tag = "type", rename_all = "snake_case")]
-#[serde(deny_unknown_fields)]
-pub enum AdminAuthConfig {
- /// No authentication required for UI access.
- /// Useful for local development or internal deployments.
- #[default]
- None,
-
- /// Reverse proxy authentication.
- /// Identity is extracted from headers set by an authenticating reverse proxy
- /// (Cloudflare Access, oauth2-proxy, Tailscale, Authelia, etc.)
- ///
- /// **Security:** Headers are only trusted when the request originates from
- /// a trusted proxy IP (configured via `server.trusted_proxies`).
- ProxyAuth(Box),
-
- /// Session-only authentication (for per-org SSO).
- /// Configures session management without global OIDC. Users authenticate
- /// through their organization's SSO connection (configured in the database).
- ///
- /// Use this when:
- /// - Different organizations use different IdPs
- /// - You want SSO configuration to be dynamic (via admin API)
- /// - You're migrating from global OIDC to per-org SSO
- #[cfg(feature = "sso")]
- Session(SessionConfig),
-}
-
-impl AdminAuthConfig {
- fn validate(&mut self) -> Result<(), ConfigError> {
- match self {
- AdminAuthConfig::None => Ok(()),
- AdminAuthConfig::ProxyAuth(c) => c.validate(),
- #[cfg(feature = "sso")]
- AdminAuthConfig::Session(c) => c.validate(),
- }
- }
-}
-
-/// Reverse proxy authentication configuration.
-///
-/// This auth method trusts identity headers set by an authenticating reverse proxy.
-/// Common proxies that work with this include:
-/// - Cloudflare Access (Cf-Access-Authenticated-User-Email)
-/// - oauth2-proxy (X-Forwarded-User, X-Forwarded-Email)
-/// - Tailscale (Tailscale-User-Login)
-/// - Authelia, Authentik, Keycloak Gatekeeper, etc.
-///
-/// **Security:** Configure `server.trusted_proxies` to ensure headers are only
-/// trusted from known proxy IPs. Without this, attackers can spoof identity headers.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
-#[serde(deny_unknown_fields)]
-pub struct ProxyAuthConfig {
- /// Header containing the authenticated user's identity.
- pub identity_header: String,
-
- /// Header containing the user's email (if different from identity).
- #[serde(default)]
- pub email_header: Option,
-
- /// Header containing the user's name.
- #[serde(default)]
- pub name_header: Option,
-
- /// Header containing groups/roles (comma-separated or JSON array).
- #[serde(default)]
- pub groups_header: Option,
-
- /// Optional: JWT assertion header for additional validation.
- /// If set, the JWT is validated and claims are extracted.
- #[serde(default)]
- pub jwt_assertion: Option,
-
- /// Require all requests to have identity headers.
- /// If false, unauthenticated requests are allowed to public endpoints.
- #[serde(default = "default_true")]
- pub require_identity: bool,
-}
-
-impl ProxyAuthConfig {
- fn validate(&self) -> Result<(), ConfigError> {
- if self.identity_header.is_empty() {
- return Err(ConfigError::Validation(
- "Proxy auth identity header cannot be empty".into(),
- ));
- }
- Ok(())
- }
-}
-
fn default_true() -> bool {
true
}
@@ -1251,6 +1242,11 @@ pub struct BootstrapConfig {
/// Initial organization to create.
#[serde(default)]
pub initial_org: Option,
+
+ /// Initial API key to create (owned by the initial org).
+ /// The raw key is printed to stdout on first creation.
+ #[serde(default)]
+ pub initial_api_key: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1266,6 +1262,97 @@ pub struct BootstrapOrg {
/// Identity IDs to add as org admins.
#[serde(default)]
pub admin_identities: Vec,
+
+ /// Optional SSO configuration to create for this organization.
+ #[cfg(feature = "sso")]
+ #[serde(default)]
+ pub sso: Option,
+}
+
+/// SSO configuration for bootstrap.
+///
+/// Creates an OIDC or SAML SSO configuration for the initial organization.
+/// Client secrets are stored via the configured secrets manager.
+#[cfg(feature = "sso")]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
+#[serde(deny_unknown_fields)]
+pub struct BootstrapSsoConfig {
+ /// SSO provider type: "oidc" or "saml".
+ #[serde(default)]
+ pub provider_type: String,
+
+ /// OIDC issuer URL.
+ #[serde(default)]
+ pub issuer: Option,
+
+ /// OIDC client ID.
+ #[serde(default)]
+ pub client_id: Option,
+
+ /// OIDC client secret.
+ #[serde(default)]
+ pub client_secret: Option,
+
+ /// OAuth redirect URI.
+ #[serde(default)]
+ pub redirect_uri: Option,
+
+ /// Allowed email domains for SSO users.
+ #[serde(default)]
+ pub allowed_email_domains: Vec,
+
+ /// OIDC discovery URL (if different from issuer).
+ #[serde(default)]
+ pub discovery_url: Option,
+}
+
+#[cfg(feature = "sso")]
+impl BootstrapSsoConfig {
+ /// Validate the bootstrap SSO configuration.
+ pub fn validate(&self) -> Result<(), ConfigError> {
+ if self.provider_type.is_empty() {
+ return Err(ConfigError::Validation(
+ "Bootstrap SSO provider_type cannot be empty".into(),
+ ));
+ }
+ if self.provider_type == "oidc" {
+ fn require_non_empty(value: &Option, field: &str) -> Result<(), ConfigError> {
+ match value.as_deref() {
+ None | Some("") => Err(ConfigError::Validation(format!(
+ "Bootstrap SSO OIDC {field} is required and cannot be empty"
+ ))),
+ _ => Ok(()),
+ }
+ }
+ require_non_empty(&self.issuer, "issuer")?;
+ require_non_empty(&self.client_id, "client_id")?;
+ require_non_empty(&self.client_secret, "client_secret")?;
+ }
+ Ok(())
+ }
+}
+
+impl BootstrapConfig {
+ /// Validate the bootstrap configuration.
+ pub fn validate(&self) -> Result<(), ConfigError> {
+ #[cfg(feature = "sso")]
+ if let Some(ref org) = self.initial_org
+ && let Some(ref sso) = org.sso
+ {
+ sso.validate()?;
+ }
+ Ok(())
+ }
+}
+
+/// API key to create during bootstrap.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
+#[serde(deny_unknown_fields)]
+pub struct BootstrapApiKey {
+ /// Display name for the API key.
+ pub name: String,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -1757,34 +1844,94 @@ mod tests {
}
#[test]
- fn test_multi_auth_config_jwt_optional() {
- // jwt omitted — should default to None
+ fn test_auth_mode_none_default() {
+ let toml_str = "";
+ let config: AuthConfig = toml::from_str(toml_str).unwrap();
+ assert!(matches!(config.mode, AuthMode::None));
+ assert!(!config.is_auth_enabled());
+ assert!(!config.requires_session());
+ assert!(!config.requires_api_keys());
+ }
+
+ #[test]
+ fn test_auth_mode_api_key() {
let toml_str = r#"
+ [mode]
+ type = "api_key"
+
[api_key]
- header_name = "X-API-Key"
- key_prefix = "gw_"
+ key_prefix = "sk_"
"#;
- let config: MultiAuthConfig = toml::from_str(toml_str).unwrap();
- assert!(config.jwt.is_none());
- config.validate().unwrap();
+ let config: AuthConfig = toml::from_str(toml_str).unwrap();
+ assert!(matches!(config.mode, AuthMode::ApiKey));
+ assert!(config.is_auth_enabled());
+ assert!(config.requires_admin_auth());
+ assert!(config.requires_api_keys());
+ assert_eq!(config.api_key_config().key_prefix, "sk_");
+ }
- // jwt present — should parse as Some
+ #[cfg(feature = "sso")]
+ #[test]
+ fn test_auth_mode_idp() {
let toml_str = r#"
+ [mode]
+ type = "idp"
+
[api_key]
- header_name = "X-API-Key"
key_prefix = "gw_"
- [jwt]
- issuer = "https://auth.example.com"
- audience = "hadrian"
- jwks_url = "https://auth.example.com/.well-known/jwks.json"
+ [session]
+ secret = "test-secret"
"#;
- let config: MultiAuthConfig = toml::from_str(toml_str).unwrap();
- assert!(config.jwt.is_some());
- assert_eq!(
- config.jwt.as_ref().unwrap().issuer,
- "https://auth.example.com"
- );
- config.validate().unwrap();
+ let config: AuthConfig = toml::from_str(toml_str).unwrap();
+ assert!(matches!(config.mode, AuthMode::Idp));
+ assert!(config.is_auth_enabled());
+ assert!(config.requires_session());
+ assert!(config.requires_api_keys());
+ assert!(config.session.is_some());
+ }
+
+ #[test]
+ fn test_auth_mode_iap() {
+ let toml_str = r#"
+ [mode]
+ type = "iap"
+ identity_header = "X-Forwarded-User"
+ email_header = "X-Forwarded-Email"
+ "#;
+ let config: AuthConfig = toml::from_str(toml_str).unwrap();
+ assert!(matches!(config.mode, AuthMode::Iap(_)));
+ assert!(config.is_auth_enabled());
+ assert!(config.requires_api_keys());
+ let iap = config.iap_config().unwrap();
+ assert_eq!(iap.identity_header, "X-Forwarded-User");
+ assert_eq!(iap.email_header.as_deref(), Some("X-Forwarded-Email"));
+ }
+
+ #[test]
+ fn test_auth_mode_iap_empty_header_fails() {
+ let toml_str = r#"
+ [mode]
+ type = "iap"
+ identity_header = ""
+ "#;
+ let mut config: AuthConfig = toml::from_str(toml_str).unwrap();
+ assert!(config.validate().is_err());
+ }
+
+ #[test]
+ fn test_api_key_config_defaults() {
+ let config = ApiKeyAuthConfig::default();
+ assert_eq!(config.header_name, "X-API-Key");
+ assert_eq!(config.key_prefix, "gw_");
+ assert_eq!(config.cache_ttl_secs, 300);
+ }
+
+ #[test]
+ fn test_api_key_config_from_auth_config_default() {
+ let config = AuthConfig::default();
+ let api_key = config.api_key_config();
+ assert_eq!(api_key.key_prefix, "gw_");
+ assert_eq!(api_key.header_name, "X-API-Key");
}
}
diff --git a/src/config/mod.rs b/src/config/mod.rs
index a99440f..eb82e65 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -150,19 +150,19 @@ impl GatewayConfig {
/// Validate the configuration for consistency and completeness.
fn validate(&mut self) -> Result<(), ConfigError> {
// If auth is enabled, we need a database
- if self.auth.gateway.is_enabled() && self.database.is_none() {
+ if self.auth.is_auth_enabled() && self.database.is_none() {
return Err(ConfigError::Validation(
- "API authentication requires a database configuration".into(),
+ "Authentication requires a database configuration".into(),
));
}
- // Proxy auth without trusted_proxies is dangerous — anyone can spoof identity headers.
- if matches!(self.auth.admin, Some(AdminAuthConfig::ProxyAuth(_)))
+ // IAP without trusted_proxies is dangerous — anyone can spoof identity headers.
+ if matches!(self.auth.mode, AuthMode::Iap(_))
&& !self.server.trusted_proxies.is_configured()
{
if !self.server.host.is_loopback() {
return Err(ConfigError::Validation(
- "Proxy auth (auth.admin.type = \"proxy_auth\") is enabled and the server \
+ "IAP mode (auth.mode.type = \"iap\") is enabled and the server \
binds to a non-localhost address, but server.trusted_proxies is not \
configured. This allows any client to spoof identity headers. Either \
configure server.trusted_proxies.cidrs with your proxy's IP ranges, \
@@ -171,7 +171,7 @@ impl GatewayConfig {
));
}
tracing::warn!(
- "Proxy auth is enabled without server.trusted_proxies configured. \
+ "IAP mode is enabled without server.trusted_proxies configured. \
Identity headers will be accepted from ANY source. This is safe only if \
the gateway is exclusively accessible through a trusted reverse proxy. \
Configure server.trusted_proxies.cidrs for production deployments."
@@ -194,7 +194,7 @@ impl GatewayConfig {
/// Check if this is a minimal/local configuration (no auth, no database).
pub fn is_local_mode(&self) -> bool {
- self.database.is_none() && !self.auth.gateway.is_enabled()
+ self.database.is_none() && !self.auth.is_auth_enabled()
}
/// Generate the JSON schema for the gateway configuration.
@@ -275,6 +275,9 @@ fn check_disabled_features(raw: &toml::Value) -> Result<(), ConfigError> {
check_cache_feature(type_val, &mut issues);
}
+ // Check auth mode (IdP requires SSO)
+ check_auth_mode_feature(raw, &mut issues);
+
// Check RBAC (requires CEL)
if raw
.get("auth")
@@ -435,6 +438,22 @@ fn check_otlp_feature(_issues: &mut Vec<(String, &str)>) {
));
}
+fn check_auth_mode_feature(_raw: &toml::Value, _issues: &mut Vec<(String, &str)>) {
+ #[cfg(not(feature = "sso"))]
+ if _raw
+ .get("auth")
+ .and_then(|v| v.get("mode"))
+ .and_then(|v| v.get("type"))
+ .and_then(|v| v.as_str())
+ == Some("idp")
+ {
+ _issues.push((
+ "auth.mode type 'idp' requires the 'sso' feature for SSO authentication".into(),
+ "sso",
+ ));
+ }
+}
+
/// Expand environment variables in the format `${VAR_NAME}`.
/// Skips commented lines (lines where content before the variable is a comment).
fn expand_env_vars(input: &str) -> Result {
@@ -733,15 +752,20 @@ key3 = "literal""#
}
#[test]
- fn test_proxy_auth_without_trusted_proxies_non_localhost_errors() {
- // Proxy auth on 0.0.0.0 without trusted_proxies should fail
+ #[cfg(feature = "database-sqlite")]
+ fn test_iap_without_trusted_proxies_non_localhost_errors() {
+ // IAP on 0.0.0.0 without trusted_proxies should fail
let err = GatewayConfig::from_str(
r#"
[server]
host = "0.0.0.0"
- [auth.admin]
- type = "proxy_auth"
+ [database]
+ type = "sqlite"
+ path = ":memory:"
+
+ [auth.mode]
+ type = "iap"
identity_header = "X-Forwarded-User"
[providers.my-openai]
@@ -757,21 +781,26 @@ key3 = "literal""#
"should mention trusted_proxies: {msg}"
);
assert!(
- msg.contains("proxy_auth") || msg.contains("Proxy auth"),
- "should mention proxy auth: {msg}"
+ msg.contains("iap") || msg.contains("IAP"),
+ "should mention IAP: {msg}"
);
}
#[test]
- fn test_proxy_auth_without_trusted_proxies_localhost_warns_but_ok() {
- // Proxy auth on localhost without trusted_proxies should succeed (just warn)
+ #[cfg(feature = "database-sqlite")]
+ fn test_iap_without_trusted_proxies_localhost_warns_but_ok() {
+ // IAP on localhost without trusted_proxies should succeed (just warn)
let result = GatewayConfig::from_str(
r#"
[server]
host = "127.0.0.1"
- [auth.admin]
- type = "proxy_auth"
+ [database]
+ type = "sqlite"
+ path = ":memory:"
+
+ [auth.mode]
+ type = "iap"
identity_header = "X-Forwarded-User"
[providers.my-openai]
@@ -782,14 +811,15 @@ key3 = "literal""#
assert!(
result.is_ok(),
- "proxy auth on localhost without trusted_proxies should be allowed: {:?}",
+ "IAP on localhost without trusted_proxies should be allowed: {:?}",
result.err()
);
}
#[test]
- fn test_proxy_auth_with_trusted_proxies_non_localhost_ok() {
- // Proxy auth on 0.0.0.0 with trusted_proxies configured should succeed
+ #[cfg(feature = "database-sqlite")]
+ fn test_iap_with_trusted_proxies_non_localhost_ok() {
+ // IAP on 0.0.0.0 with trusted_proxies configured should succeed
let result = GatewayConfig::from_str(
r#"
[server]
@@ -798,8 +828,12 @@ key3 = "literal""#
[server.trusted_proxies]
cidrs = ["10.0.0.0/8"]
- [auth.admin]
- type = "proxy_auth"
+ [database]
+ type = "sqlite"
+ path = ":memory:"
+
+ [auth.mode]
+ type = "iap"
identity_header = "X-Forwarded-User"
[providers.my-openai]
@@ -810,14 +844,15 @@ key3 = "literal""#
assert!(
result.is_ok(),
- "proxy auth with trusted_proxies should be allowed: {:?}",
+ "IAP with trusted_proxies should be allowed: {:?}",
result.err()
);
}
#[test]
- fn test_proxy_auth_with_dangerously_trust_all_non_localhost_ok() {
- // Proxy auth with dangerously_trust_all should also pass validation
+ #[cfg(feature = "database-sqlite")]
+ fn test_iap_with_dangerously_trust_all_non_localhost_ok() {
+ // IAP with dangerously_trust_all should also pass validation
let result = GatewayConfig::from_str(
r#"
[server]
@@ -826,8 +861,12 @@ key3 = "literal""#
[server.trusted_proxies]
dangerously_trust_all = true
- [auth.admin]
- type = "proxy_auth"
+ [database]
+ type = "sqlite"
+ path = ":memory:"
+
+ [auth.mode]
+ type = "iap"
identity_header = "X-Forwarded-User"
[providers.my-openai]
@@ -838,7 +877,7 @@ key3 = "literal""#
assert!(
result.is_ok(),
- "proxy auth with dangerously_trust_all should be allowed: {:?}",
+ "IAP with dangerously_trust_all should be allowed: {:?}",
result.err()
);
}
diff --git a/src/config/server.rs b/src/config/server.rs
index 9164899..bca41a4 100644
--- a/src/config/server.rs
+++ b/src/config/server.rs
@@ -83,6 +83,16 @@ pub struct ServerConfig {
/// are always blocked regardless of this setting.
#[serde(default)]
pub allow_loopback_urls: bool,
+
+ /// Allow private/internal IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) in
+ /// user-supplied URLs.
+ ///
+ /// When false (default), URLs resolving to private IPs are blocked to prevent SSRF.
+ /// Enable for Docker, Kubernetes, or other environments where services communicate
+ /// over private networks (e.g., Keycloak at `http://keycloak:8080`).
+ /// Cloud metadata endpoints (169.254.169.254) are always blocked.
+ #[serde(default)]
+ pub allow_private_urls: bool,
}
impl Default for ServerConfig {
@@ -102,6 +112,7 @@ impl Default for ServerConfig {
security_headers: SecurityHeadersConfig::default(),
http_client: HttpClientConfig::default(),
allow_loopback_urls: false,
+ allow_private_urls: false,
}
}
}
diff --git a/src/db/postgres/api_keys.rs b/src/db/postgres/api_keys.rs
index a5fa967..69dae80 100644
--- a/src/db/postgres/api_keys.rs
+++ b/src/db/postgres/api_keys.rs
@@ -1044,4 +1044,28 @@ impl ApiKeyRepo for PostgresApiKeyRepo {
Ok(hashes)
}
+
+ async fn get_by_name_and_org(&self, org_id: Uuid, name: &str) -> DbResult