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> { + let row = sqlx::query( + r#" + SELECT + id, key_prefix, name, owner_type::TEXT, owner_id, + budget_amount, budget_period::TEXT, expires_at, last_used_at, created_at, revoked_at, + scopes, allowed_models, ip_allowlist, rate_limit_rpm, rate_limit_tpm, + rotated_from_key_id, rotation_grace_until + FROM api_keys + WHERE name = $1 AND owner_type = 'organization' AND owner_id = $2 AND revoked_at IS NULL + "#, + ) + .bind(name) + .bind(org_id) + .fetch_optional(&self.read_pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + Ok(Some(Self::parse_api_key(&row)?)) + } } diff --git a/src/db/repos/api_keys.rs b/src/db/repos/api_keys.rs index 5b4116b..a87582b 100644 --- a/src/db/repos/api_keys.rs +++ b/src/db/repos/api_keys.rs @@ -85,6 +85,11 @@ pub trait ApiKeyRepo: Send + Sync { /// /// Used for cache invalidation when a user is removed from an organization. async fn get_key_hashes_by_user(&self, user_id: Uuid) -> DbResult>; + + /// Find an active (non-revoked) API key by name and owning organization. + /// + /// Used by bootstrap to check if a key already exists before creating one. + async fn get_by_name_and_org(&self, org_id: Uuid, name: &str) -> DbResult>; } impl From for CachedApiKey { diff --git a/src/db/sqlite/api_keys.rs b/src/db/sqlite/api_keys.rs index 44cd7b9..aeb91fe 100644 --- a/src/db/sqlite/api_keys.rs +++ b/src/db/sqlite/api_keys.rs @@ -1045,6 +1045,29 @@ impl ApiKeyRepo for SqliteApiKeyRepo { Ok(hashes) } + + async fn get_by_name_and_org(&self, org_id: Uuid, name: &str) -> DbResult> { + let row = sqlx::query( + r#" + SELECT id, key_prefix, name, owner_type, owner_id, budget_amount, budget_period, + expires_at, last_used_at, created_at, revoked_at, + scopes, allowed_models, ip_allowlist, rate_limit_rpm, rate_limit_tpm, + rotated_from_key_id, rotation_grace_until + FROM api_keys + WHERE name = ? AND owner_type = 'organization' AND owner_id = ? AND revoked_at IS NULL + "#, + ) + .bind(name) + .bind(org_id.to_string()) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + Ok(Some(Self::parse_api_key(&row)?)) + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 17c9381..8ac351d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -322,10 +322,6 @@ pub struct AppState { /// Task tracker for background tasks (usage logging, etc.) /// Ensures all spawned tasks complete during graceful shutdown. pub task_tracker: TaskTracker, - /// Shared OIDC authenticator (if global OIDC auth is configured in config file). - /// This holds the session store which persists across requests. - #[cfg(feature = "sso")] - pub oidc_authenticator: Option>, /// Registry of per-organization OIDC authenticators. /// Loaded from org_sso_configs table at startup for multi-tenant SSO. #[cfg(feature = "sso")] @@ -337,9 +333,6 @@ pub struct AppState { /// Registry of per-org gateway JWT validators. /// Routes incoming JWTs to the correct org-scoped validator by issuer. pub gateway_jwt_registry: Option>, - /// Cached global JWT validator (from [auth.gateway.jwt] config). - /// Created once at startup so the JWKS cache is reused across requests. - pub global_jwt_validator: Option>, /// Registry of per-organization RBAC policies. /// Loaded from org_rbac_policies table at startup for per-org authorization. pub policy_registry: Option>, @@ -677,15 +670,12 @@ impl AppState { // Get session config from UI auth config // Note: Global OIDC config has been removed. Session config is used for per-org SSO. #[cfg(feature = "sso")] - let session_config = match &config.auth.admin { - Some(config::AdminAuthConfig::Session(config)) => config.clone(), - _ => config::SessionConfig::default(), - }; + let session_config = config.auth.session.clone().unwrap_or_default(); // Initialize per-org OIDC authenticator registry from database // This replaces the global OIDC authenticator #[cfg(feature = "sso")] - let (oidc_authenticator, oidc_registry) = if let Some(ref svc) = services { + let oidc_registry = if let Some(ref svc) = services { // Create session store for org authenticators (shared across all orgs) let enhanced = session_config.enhanced.enabled; let session_store = auth::create_session_store_with_enhanced(cache.clone(), enhanced); @@ -713,7 +703,7 @@ impl AppState { tracing::debug!("Per-org SSO registry initialized (empty, will lazy load)"); } // Always create the registry to support lazy loading from database - (None, Some(Arc::new(registry))) + Some(Arc::new(registry)) } Err(e) => { // Create an empty registry instead of None - this allows lazy loading @@ -728,11 +718,11 @@ impl AppState { default_session_config, default_redirect_uri, ); - (None, Some(Arc::new(empty_registry))) + Some(Arc::new(empty_registry)) } } } else { - (None, None) + None }; // Initialize per-org SAML authenticator registry from database @@ -795,20 +785,6 @@ impl AppState { None }; - // Build the global JWT validator (if [auth.gateway.jwt] is configured) - let global_jwt_validator = match &config.auth.gateway { - config::GatewayAuthConfig::Jwt(jwt_config) => Some(Arc::new( - auth::jwt::JwtValidator::with_client(jwt_config.clone(), http_client.clone()), - )), - config::GatewayAuthConfig::Multi(multi) => multi.jwt.as_ref().map(|jwt_config| { - Arc::new(auth::jwt::JwtValidator::with_client( - jwt_config.clone(), - http_client.clone(), - )) - }), - _ => None, - }; - // Initialize per-org RBAC policy registry from database let policy_registry = if let (Some(svc), Some(db_pool)) = (&services, &db) && config.auth.rbac.enabled @@ -1002,7 +978,7 @@ impl AppState { ); // Create default user and organization when auth is disabled (for anonymous access) - let (default_user_id, default_org_id) = if config.auth.admin.is_none() { + let (default_user_id, default_org_id) = if !config.auth.is_auth_enabled() { if let Some(ref svc) = services { let user_id = match Self::ensure_default_user(svc).await { Ok(id) => { @@ -1090,13 +1066,10 @@ impl AppState { provider_health: jobs::ProviderHealthStateRegistry::new(), task_tracker, #[cfg(feature = "sso")] - oidc_authenticator, - #[cfg(feature = "sso")] oidc_registry, #[cfg(feature = "saml")] saml_registry, gateway_jwt_registry, - global_jwt_validator, policy_registry, usage_buffer, response_cache, @@ -1978,6 +1951,17 @@ enum Command { /// Useful for Kubernetes init containers or CI/CD pipelines. /// Connects to the database, runs any pending migrations, and exits. Migrate, + /// Bootstrap organizations, SSO configs, and API keys from config. + /// + /// Reads [auth.bootstrap] from hadrian.toml and creates the initial org, + /// SSO configuration, auto-verified domains, and API key. Idempotent: + /// safe to run repeatedly (skips resources that already exist). + /// Operates directly against the database (no HTTP server needed). + Bootstrap { + /// Preview changes without applying them. + #[arg(long)] + dry_run: bool, + }, /// Show enabled compile-time features Features, } @@ -2142,9 +2126,9 @@ pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router { ); app = app.nest("/admin", public_admin_routes); - // Use protected routes if UI auth is configured, otherwise unprotected - // (for development or when using external auth proxy) - if config.auth.admin.is_some() { + // Use protected routes if admin auth is configured (Idp/Iap modes), otherwise + // unprotected (for local development with auth.mode = "none") + if config.auth.requires_admin_auth() { // Apply middleware in order: admin_auth_middleware runs first, // then authz_middleware runs second (layers are applied in reverse order) // IP rate limiting runs before auth for defense in depth @@ -2164,7 +2148,7 @@ pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router { app = app.merge(Router::new().nest("/admin", admin_routes)); } else { tracing::warn!( - "Admin routes are UNPROTECTED - configure auth.admin for Zero Trust or OIDC authentication" + "Admin routes are UNPROTECTED - configure auth.mode type = \"api_key\", \"idp\", or \"iap\" for authentication" ); // Apply permissive authz middleware so handlers can still require AuthzContext // (fail-closed pattern) but authorization checks will always pass @@ -2186,22 +2170,20 @@ pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router { // SSO routes are added when Session auth is configured or per-org SSO registries exist #[cfg(feature = "sso")] { - let has_session_auth = matches!( - &config.auth.admin, - Some(config::AdminAuthConfig::Session(_)) - ); + let has_session_auth = config.auth.requires_session(); let has_oidc_registry = state.oidc_registry.is_some(); #[cfg(feature = "saml")] let has_saml = state.saml_registry.is_some(); #[cfg(not(feature = "saml"))] let has_saml = false; - // When auth is fully disabled (no UI auth, API auth is none), always use permissive - // middleware for /auth/me. The OIDC registry is always created when a database exists - // (to support lazy loading), so has_oidc_registry alone doesn't mean SSO is configured. - let auth_disabled = config.auth.admin.is_none() && !config.auth.gateway.is_enabled(); + // Use admin auth middleware for /auth/me when the auth mode supports + // admin authentication (ApiKey/Idp/Iap). Only None mode leaves admin unprotected. + // The OIDC registry is always created when a database exists (to support lazy + // loading), so has_oidc_registry alone doesn't mean SSO is configured. + let has_admin_auth = config.auth.requires_admin_auth(); - if !auth_disabled && (has_session_auth || has_oidc_registry || has_saml) { + if has_admin_auth && (has_session_auth || has_oidc_registry || has_saml) { // When SSO is configured, /auth/me uses admin middleware let me_route = get(routes::auth_routes::me).route_layer(axum::middleware::from_fn_with_state( @@ -2210,7 +2192,10 @@ pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router { )); if has_session_auth || has_oidc_registry { - // Build OIDC auth routes with IP rate limiting + // Build OIDC auth routes with IP rate limiting. + // /me is added AFTER route_layer so it gets admin auth (from me_route) + // but NOT rate limiting. This also avoids Axum routing conflicts between + // nest("/auth") and route("/auth/me"). let auth_routes = Router::new() .route("/login", get(routes::auth_routes::login)) .route("/callback", get(routes::auth_routes::callback)) @@ -2218,9 +2203,10 @@ pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router { .route_layer(axum::middleware::from_fn_with_state( state.clone(), middleware::rate_limit_middleware, - )); + )) + .route("/me", me_route); - app = app.nest("/auth", auth_routes).route("/auth/me", me_route); + app = app.nest("/auth", auth_routes); } else { // SAML-only: just add /auth/me with admin middleware app = app.route("/auth/me", me_route); @@ -2376,6 +2362,9 @@ async fn main() { Some(Command::Migrate) => { run_migrate(args.config.as_deref()).await; } + Some(Command::Bootstrap { dry_run }) => { + run_bootstrap(args.config.as_deref(), dry_run).await; + } Some(Command::Features) => { run_features(); } @@ -2765,26 +2754,26 @@ async fn run_server(explicit_config_path: Option<&str>, no_browser: bool) { ); // Emit startup security warnings for insecure configurations - if let Some(crate::config::AdminAuthConfig::ProxyAuth(_)) = &config.auth.admin + if matches!(config.auth.mode, crate::config::AuthMode::Iap(_)) && !config.server.trusted_proxies.is_configured() { tracing::warn!( - "SECURITY RISK: Proxy auth is enabled but no trusted_proxies are configured. \ + "SECURITY RISK: IAP auth is enabled but no trusted_proxies are configured. \ Anyone can spoof identity headers by connecting directly to the gateway. \ Configure [server.trusted_proxies] with your reverse proxy's CIDR ranges." ); } - if config.auth.admin.is_none() { + if !config.auth.is_auth_enabled() { tracing::warn!( - "No authentication configured for admin routes — admin routes use permissive \ - authorization. Configure auth.admin in hadrian.toml for production deployments." + "No authentication configured — all routes use permissive authorization. \ + Configure [auth.mode] in hadrian.toml for production deployments." ); if !config.server.host.is_loopback() { tracing::error!( bind_address = %config.server.host, - "Gateway is bound to a non-localhost address without admin authentication. \ - Admin routes are accessible to anyone who can reach this address. \ - Configure auth.admin in hadrian.toml or bind to 127.0.0.1 for local-only access." + "Gateway is bound to a non-localhost address without authentication. \ + All routes are accessible to anyone who can reach this address. \ + Configure [auth.mode] in hadrian.toml or bind to 127.0.0.1 for local-only access." ); } } @@ -2847,6 +2836,7 @@ async fn run_server(explicit_config_path: Option<&str>, no_browser: bool) { if let (Some(registry), Some(db)) = (state.gateway_jwt_registry.clone(), state.db.clone()) { let http_client = state.http_client.clone(); let allow_loopback = config.server.allow_loopback_urls; + let allow_private = config.server.allow_private_urls; state.task_tracker.spawn(async move { let configs = match db.org_sso_configs().list_enabled().await { Ok(c) => c, @@ -2877,7 +2867,12 @@ async fn run_server(explicit_config_path: Option<&str>, no_browser: bool) { let http_client = &http_client; async move { if let Err(e) = registry - .register_from_sso_config(&cfg.config, http_client, allow_loopback) + .register_from_sso_config( + &cfg.config, + http_client, + allow_loopback, + allow_private, + ) .await { tracing::warn!( @@ -3042,6 +3037,14 @@ async fn run_server(explicit_config_path: Option<&str>, no_browser: bool) { // Format to prepend with http:// tracing::info!("Server listening on http://{}", bind_addr); + if config.server.allow_loopback_urls || config.server.allow_private_urls { + tracing::info!( + allow_loopback = config.server.allow_loopback_urls, + allow_private = config.server.allow_private_urls, + "SSRF validation relaxed for development/Docker" + ); + } + // Open UI if enabled and not disabled via CLI #[cfg(feature = "wizard")] if config.ui.enabled && !no_browser && is_new_config { @@ -3337,6 +3340,417 @@ async fn run_migrate(explicit_config_path: Option<&str>) { } } +/// Initialize a secret manager from the config. +/// +/// Used by `run_bootstrap` (CLI mode) to initialize a secret manager from config. +#[cfg(feature = "sso")] +async fn init_secret_manager( + config: &config::GatewayConfig, +) -> Result, String> { + match &config.secrets { + config::SecretsConfig::None | config::SecretsConfig::Env => { + Ok(Arc::new(secrets::MemorySecretManager::new())) + } + #[cfg(feature = "vault")] + config::SecretsConfig::Vault(vault_config) => { + use config::VaultAuth; + use secrets::SecretManager; + + let vault_cfg = match &vault_config.auth { + VaultAuth::Token { token } => { + secrets::VaultConfig::new(&vault_config.address, token) + } + VaultAuth::AppRole { + role_id, + secret_id, + auth_mount, + } => secrets::VaultConfig::with_approle( + &vault_config.address, + role_id, + secret_id, + ) + .with_auth_mount(auth_mount), + VaultAuth::Kubernetes { + role, + token_path, + auth_mount, + } => { + let jwt = std::fs::read_to_string(token_path).map_err(|e| { + format!( + "Failed to read Kubernetes ServiceAccount token from '{token_path}': {e}" + ) + })?; + secrets::VaultConfig::with_kubernetes( + &vault_config.address, + role, + jwt.trim(), + ) + .with_auth_mount(auth_mount) + } + } + .with_mount(&vault_config.mount) + .with_path_prefix(&vault_config.path_prefix); + + let manager = secrets::VaultSecretManager::new(vault_cfg) + .await + .map_err(|e| format!("Failed to create Vault client: {e}"))?; + + manager + .health_check() + .await + .map_err(|e| format!("Vault health check failed: {e}"))?; + + Ok(Arc::new(manager)) + } + #[cfg(feature = "secrets-aws")] + config::SecretsConfig::Aws(aws_config) => { + use secrets::SecretManager; + + let mut cfg = match &aws_config.region { + Some(region) => secrets::AwsSecretsManagerConfig::new(region), + None => secrets::AwsSecretsManagerConfig::from_env(), + } + .with_prefix(&aws_config.prefix); + + if let Some(endpoint_url) = &aws_config.endpoint_url { + cfg = cfg.with_endpoint_url(endpoint_url); + } + + let manager = secrets::AwsSecretsManager::new(cfg) + .await + .map_err(|e| format!("Failed to create AWS Secrets Manager client: {e}"))?; + + manager + .health_check() + .await + .map_err(|e| format!("AWS Secrets Manager health check failed: {e}"))?; + + Ok(Arc::new(manager)) + } + #[cfg(feature = "secrets-azure")] + config::SecretsConfig::Azure(azure_config) => { + use secrets::SecretManager; + + let cfg = secrets::AzureKeyVaultConfig::new(&azure_config.vault_url) + .with_prefix(&azure_config.prefix); + + let manager = secrets::AzureKeyVaultManager::new(cfg) + .await + .map_err(|e| format!("Failed to create Azure Key Vault client: {e}"))?; + + manager + .health_check() + .await + .map_err(|e| format!("Azure Key Vault health check failed: {e}"))?; + + Ok(Arc::new(manager)) + } + #[cfg(feature = "secrets-gcp")] + config::SecretsConfig::Gcp(gcp_config) => { + use secrets::SecretManager; + + let cfg = secrets::GcpSecretManagerConfig::new(&gcp_config.project_id) + .with_prefix(&gcp_config.prefix); + + let manager = secrets::GcpSecretManager::new(cfg) + .await + .map_err(|e| format!("Failed to create GCP Secret Manager client: {e}"))?; + + manager + .health_check() + .await + .map_err(|e| format!("GCP Secret Manager health check failed: {e}"))?; + + Ok(Arc::new(manager)) + } + } +} + +/// Run the bootstrap command: create initial org, SSO config, and API key from config. +async fn run_bootstrap(explicit_config_path: Option<&str>, dry_run: bool) { + // Resolve config path + let (config_path, _) = match resolve_config_path(explicit_config_path) { + Ok((path, is_new)) => (path, is_new), + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + let config = match config::GatewayConfig::from_file(&config_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to load config from {}: {e}", config_path.display()); + std::process::exit(1); + } + }; + + let _tracing_guard = + observability::init_tracing(&config.observability).expect("Failed to initialize tracing"); + + let bootstrap = match &config.auth.bootstrap { + Some(b) => b.clone(), + None => { + eprintln!("Error: No [auth.bootstrap] section in config file."); + eprintln!("Add an [auth.bootstrap] section with initial_org and/or initial_api_key."); + std::process::exit(1); + } + }; + + if config.database.is_none() { + eprintln!("Error: Database is not configured. Bootstrap requires a database."); + std::process::exit(1); + } + + if dry_run { + println!("=== Bootstrap Dry Run ==="); + println!("Config: {}", config_path.display()); + if let Some(ref org) = bootstrap.initial_org { + println!(" Create org: slug={}, name={}", org.slug, org.name); + #[cfg(feature = "sso")] + if let Some(ref sso) = org.sso { + println!( + " Configure SSO: provider={}, issuer={}", + sso.provider_type, + sso.issuer.as_deref().unwrap_or("(none)") + ); + if !sso.allowed_email_domains.is_empty() { + println!(" Email domains: {:?}", sso.allowed_email_domains); + } + } + if !org.admin_identities.is_empty() { + println!(" Admin identities: {:?}", org.admin_identities); + } + } + if !bootstrap.auto_verify_domains.is_empty() { + println!(" Auto-verify domains: {:?}", bootstrap.auto_verify_domains); + } + if let Some(ref key) = bootstrap.initial_api_key { + println!(" Create API key: name={}", key.name); + } + println!("=== No changes applied (dry run) ==="); + std::process::exit(0); + } + + // Connect to database and run migrations + let db = match db::DbPool::from_config(&config.database).await { + Ok(pool) => { + if let Err(e) = pool.run_migrations().await { + eprintln!("Error: Database migrations failed: {e}"); + std::process::exit(1); + } + std::sync::Arc::new(pool) + } + Err(e) => { + eprintln!("Error: Failed to connect to database: {e}"); + std::process::exit(1); + } + }; + + let file_storage: std::sync::Arc = + std::sync::Arc::new(services::DatabaseFileStorage::new(db.clone())); + let max_cel = config.auth.rbac.max_expression_length; + let services = services::Services::new(db.clone(), file_storage, max_cel); + + let api_key_prefix = config.auth.api_key_config().generation_prefix(); + let mut summary = Vec::new(); + + // 1. Create org if configured + let org_id = if let Some(ref org_config) = bootstrap.initial_org { + match services + .organizations + .create(models::CreateOrganization { + slug: org_config.slug.clone(), + name: org_config.name.clone(), + }) + .await + { + Ok(org) => { + let msg = format!("Created organization: {} ({})", org.slug, org.id); + tracing::info!("{msg}"); + summary.push(msg); + Some(org.id) + } + Err(db::DbError::Conflict(_)) => { + let existing = services + .organizations + .get_by_slug(&org_config.slug) + .await + .unwrap_or(None); + if let Some(org) = existing { + let msg = format!("Organization already exists: {} ({})", org.slug, org.id); + tracing::info!("{msg}"); + summary.push(msg); + Some(org.id) + } else { + eprintln!("Error: Organization conflict but not found by slug"); + std::process::exit(1); + } + } + Err(e) => { + eprintln!("Error creating organization: {e}"); + std::process::exit(1); + } + } + } else { + None + }; + + // 2. Configure SSO if specified + #[cfg(feature = "sso")] + if let Some(ref org_config) = bootstrap.initial_org + && let (Some(sso_config), Some(oid)) = (&org_config.sso, org_id) + { + // Check if SSO config already exists + let existing = services.org_sso_configs.get_by_org_id(oid).await; + if let Ok(Some(_)) = existing { + let msg = format!("SSO config already exists for org {oid}"); + tracing::info!("{msg}"); + summary.push(msg); + } else { + // Initialize secret manager for SSO (reuse same logic as AppState) + let secret_manager: std::sync::Arc = + match init_secret_manager(&config).await { + Ok(sm) => sm, + Err(e) => { + eprintln!("Error initializing secret manager for SSO: {e}"); + std::process::exit(1); + } + }; + + let provider_type = match sso_config.provider_type.as_str() { + "saml" => models::SsoProviderType::Saml, + _ => models::SsoProviderType::Oidc, + }; + + let create_input = models::CreateOrgSsoConfig { + provider_type, + issuer: sso_config.issuer.clone(), + discovery_url: sso_config.discovery_url.clone(), + client_id: sso_config.client_id.clone(), + client_secret: sso_config.client_secret.clone(), + redirect_uri: sso_config.redirect_uri.clone(), + allowed_email_domains: sso_config.allowed_email_domains.clone(), + ..Default::default() + }; + + match services + .org_sso_configs + .create(oid, create_input, secret_manager.as_ref()) + .await + { + Ok(created) => { + let msg = format!("Created SSO config for org {oid} ({})", created.id); + tracing::info!("{msg}"); + summary.push(msg); + + // Auto-verify domains + for domain in &bootstrap.auto_verify_domains { + if sso_config.allowed_email_domains.contains(domain) { + match services + .domain_verifications + .create_auto_verified(created.id, domain) + .await + { + Ok(_) => { + let msg = format!("Auto-verified domain: {domain}"); + tracing::info!("{msg}"); + summary.push(msg); + } + Err(e) => { + tracing::warn!("Failed to auto-verify domain {domain}: {e}"); + } + } + } + } + } + Err(e) => { + eprintln!("Error creating SSO config: {e}"); + std::process::exit(1); + } + } + } + } + + // 3. Create API key if configured + if let Some(ref key_config) = bootstrap.initial_api_key { + let oid = if let Some(oid) = org_id { + oid + } else { + eprintln!("Error: initial_api_key requires initial_org to be configured."); + std::process::exit(1); + }; + + // Check if key already exists (idempotent) + match services + .api_keys + .get_by_name_and_org(oid, &key_config.name) + .await + { + Ok(Some(existing)) => { + let msg = format!( + "API key already exists: {} ({})", + existing.name, existing.id + ); + tracing::info!("{msg}"); + summary.push(msg); + } + Ok(None) => { + let owner = models::ApiKeyOwner::Organization { org_id: oid }; + match services + .api_keys + .create( + models::CreateApiKey { + name: key_config.name.clone(), + owner, + budget_limit_cents: None, + budget_period: None, + expires_at: None, + scopes: None, + allowed_models: None, + ip_allowlist: None, + rate_limit_rpm: None, + rate_limit_tpm: None, + }, + &api_key_prefix, + ) + .await + { + Ok(created) => { + let msg = format!( + "Created API key: {} ({})", + created.api_key.name, created.api_key.id + ); + tracing::info!("{msg}"); + summary.push(msg); + // Print the raw key to stdout (only shown once) + println!("{}", created.key); + } + Err(e) => { + eprintln!("Error creating API key: {e}"); + std::process::exit(1); + } + } + } + Err(e) => { + eprintln!("Error checking for existing API key: {e}"); + std::process::exit(1); + } + } + } + + // Print summary + eprintln!(); + eprintln!("=== Bootstrap Summary ==="); + for line in &summary { + eprintln!(" {line}"); + } + if summary.is_empty() { + eprintln!(" No changes made (nothing configured in [auth.bootstrap])"); + } + eprintln!("========================="); +} + #[cfg(any( feature = "document-extraction-basic", feature = "document-extraction-full" diff --git a/src/middleware/admin.rs b/src/middleware/admin.rs index 39558c6..f29f7c8 100644 --- a/src/middleware/admin.rs +++ b/src/middleware/admin.rs @@ -28,7 +28,6 @@ use super::{ClientInfo, RequestId}; use crate::{ AppState, auth::{AuthError, AuthenticatedRequest, Identity, IdentityKind}, - config::AdminAuthConfig, observability::metrics, services::audit_logs::{AuthEventParams, auth_events}, }; @@ -588,6 +587,13 @@ async fn try_admin_auth( return Ok(identity); } + // Try API key (for ApiKey mode — admin panel sends key via Authorization/X-API-Key) + if matches!(state.config.auth.mode, crate::config::AuthMode::ApiKey) + && let Some(identity) = try_api_key_admin_auth(headers, state).await? + { + return Ok(identity); + } + // Try Bearer token (for service accounts / automation via per-org SSO) #[cfg(feature = "sso")] if let Some(identity) = try_bearer_token_auth(headers, state).await? { @@ -612,16 +618,153 @@ async fn try_admin_auth( } // No valid authentication found - // If OIDC is configured, return a redirect to start the auth flow + // If IdP mode is configured and we have a single org with SSO, redirect to its login #[cfg(feature = "sso")] - if let Some(authenticator) = &state.oidc_authenticator { - let (redirect_url, _) = authenticator.authorization_url(None).await?; - return Err(AuthError::OidcAuthRequired { redirect_url }); + if state.config.auth.requires_session() + && let Some(registry) = &state.oidc_registry + { + let org_ids = registry.list_orgs().await; + // Only redirect automatically when there's exactly one org (unambiguous) + if let [org_id] = org_ids.as_slice() + && let Some(authenticator) = registry.get(*org_id).await + { + let (redirect_url, _) = authenticator + .authorization_url_with_org(None, Some(*org_id)) + .await?; + return Err(AuthError::OidcAuthRequired { redirect_url }); + } } Err(AuthError::MissingCredentials) } +/// Try to authenticate via API key for admin access (ApiKey mode). +/// +/// Validates the API key from `Authorization: Bearer` or `X-API-Key` headers +/// using the same logic as API endpoint authentication. Builds an `Identity` +/// from the key owner's information (user, service account, or org). +async fn try_api_key_admin_auth( + headers: &axum::http::HeaderMap, + state: &AppState, +) -> Result, AuthError> { + let api_key_auth = match super::combined::try_api_key_auth(headers, state).await? { + Some(auth) => auth, + None => return Ok(None), + }; + + // Build Identity from the API key's owner information. + // For user-owned keys, look up the user's memberships from the database. + // For service-account-owned keys, use the SA roles. + // For org/team/project-owned keys, use the org context. + let identity = if let Some(user_id) = api_key_auth.user_id { + // User-owned API key — look up user and memberships + let db = state + .db + .as_ref() + .ok_or_else(|| AuthError::Internal("Database not configured".to_string()))?; + + let user = db + .users() + .get_by_id(user_id) + .await + .map_err(|e| AuthError::Internal(e.to_string()))?; + + let (email, name, external_id) = match &user { + Some(u) => (u.email.clone(), u.name.clone(), u.external_id.clone()), + None => (None, None, format!("api-key:{}", api_key_auth.key.id)), + }; + + // Look up memberships + let org_ids: Vec = if let Some(org_id) = api_key_auth.org_id { + vec![org_id.to_string()] + } else { + db.users() + .get_org_memberships_for_user(user_id) + .await + .map_err(|e| AuthError::Internal(e.to_string()))? + .iter() + .map(|m| m.org_id.to_string()) + .collect() + }; + + let team_ids: Vec = if let Some(team_id) = api_key_auth.team_id { + vec![team_id.to_string()] + } else { + db.users() + .get_team_memberships_for_user(user_id) + .await + .map_err(|e| AuthError::Internal(e.to_string()))? + .iter() + .map(|m| m.team_id.to_string()) + .collect() + }; + + let project_ids: Vec = if let Some(project_id) = api_key_auth.project_id { + vec![project_id.to_string()] + } else { + db.users() + .get_project_memberships_for_user(user_id) + .await + .map_err(|e| AuthError::Internal(e.to_string()))? + .iter() + .map(|m| m.project_id.to_string()) + .collect() + }; + + Identity { + external_id, + email, + name, + user_id: Some(user_id), + roles: vec![], + idp_groups: vec![], + org_ids, + team_ids, + project_ids, + } + } else if let Some(sa_id) = api_key_auth.service_account_id { + // Service-account-owned API key + Identity { + external_id: format!("service-account:{sa_id}"), + email: None, + name: Some(format!("Service Account {sa_id}")), + user_id: None, + roles: api_key_auth.service_account_roles.unwrap_or_default(), + idp_groups: vec![], + org_ids: api_key_auth + .org_id + .map(|id| vec![id.to_string()]) + .unwrap_or_default(), + team_ids: vec![], + project_ids: vec![], + } + } else { + // Org/team/project-owned API key (machine credential) + Identity { + external_id: format!("api-key:{}", api_key_auth.key.id), + email: None, + name: Some(api_key_auth.key.name.clone()), + user_id: None, + roles: vec![], + idp_groups: vec![], + org_ids: api_key_auth + .org_id + .map(|id| vec![id.to_string()]) + .unwrap_or_default(), + team_ids: api_key_auth + .team_id + .map(|id| vec![id.to_string()]) + .unwrap_or_default(), + project_ids: api_key_auth + .project_id + .map(|id| vec![id.to_string()]) + .unwrap_or_default(), + } + }; + + Ok(Some(identity)) +} + /// Try to authenticate via Bearer token (JWT). /// /// This enables programmatic admin access for service accounts using @@ -860,6 +1003,7 @@ async fn validate_bearer_token( &discovery_url, &state.http_client, state.config.server.allow_loopback_urls, + state.config.server.allow_private_urls, ) .await .map_err(|e| { @@ -929,9 +1073,9 @@ async fn try_proxy_auth_auth( connecting_ip: Option, state: &AppState, ) -> Result, AuthError> { - let config = match &state.config.auth.admin { - Some(AdminAuthConfig::ProxyAuth(config)) => config, - _ => return Ok(None), + let config = match state.config.auth.iap_config() { + Some(config) => config, + None => return Ok(None), }; // SECURITY: Validate that the request comes from a trusted proxy before trusting headers. @@ -1037,25 +1181,14 @@ async fn try_oidc_session_auth( state: &AppState, client_info: &ClientInfo, ) -> Result, AuthError> { - use crate::config::AdminAuthConfig; - - // Get the OIDC authenticator which holds the session store - let authenticator = match &state.oidc_authenticator { - Some(auth) => auth, + // Get the OIDC registry which holds the shared session store + let registry = match &state.oidc_registry { + Some(reg) => reg, None => return Ok(None), }; - // Get session config - supports Session variant or default config - let session_config = match &state.config.auth.admin { - Some(AdminAuthConfig::Session(config)) => config.clone(), - _ => { - tracing::warn!( - "No session config found in auth.admin, using defaults. \ - This may indicate misconfiguration if sessions are expected." - ); - crate::config::SessionConfig::default() - } - }; + // Get session config from auth config (or use defaults) + let session_config = state.config.auth.session_config_or_default(); let cookies = match cookies { Some(c) => c, @@ -1073,8 +1206,24 @@ async fn try_oidc_session_auth( .parse() .map_err(|_| AuthError::InvalidToken)?; - // Get session from the OIDC authenticator's session store - let session = authenticator.get_session(session_id).await?; + // Validate session (checks expiration, inactivity timeout, updates last_activity) + let session = match crate::auth::session_store::validate_and_refresh_session( + registry.session_store().as_ref(), + session_id, + &session_config.enhanced, + ) + .await + { + Ok(s) => s, + Err( + crate::auth::session_store::SessionError::NotFound + | crate::auth::session_store::SessionError::Expired, + ) => return Ok(None), + Err(e) => { + tracing::warn!(session_id = %session_id, error = %e, "Session validation failed"); + return Ok(None); + } + }; // Look up internal user and their memberships from the database // The database is the source of truth for org/team/project membership @@ -2174,20 +2323,20 @@ mod tests { use tokio_util::task::TaskTracker; use super::*; - use crate::config::{GatewayConfig, ProxyAuthConfig, TrustedProxiesConfig}; + use crate::config::{AuthMode, GatewayConfig, IapConfig, TrustedProxiesConfig}; /// Create a minimal AppState for testing with ProxyAuth config fn create_test_state(identity_header: &str, trusted_proxies: TrustedProxiesConfig) -> AppState { // Create minimal config from empty TOML let mut config = GatewayConfig::from_str("").unwrap(); - config.auth.admin = Some(AdminAuthConfig::ProxyAuth(Box::new(ProxyAuthConfig { + config.auth.mode = AuthMode::Iap(Box::new(IapConfig { identity_header: identity_header.to_string(), email_header: Some("X-Email".to_string()), name_header: None, groups_header: Some("X-Groups".to_string()), jwt_assertion: None, require_identity: true, - }))); + })); config.server.trusted_proxies = trusted_proxies; AppState { @@ -2202,13 +2351,10 @@ mod tests { provider_health: crate::jobs::ProviderHealthStateRegistry::new(), task_tracker: TaskTracker::new(), #[cfg(feature = "sso")] - oidc_authenticator: None, - #[cfg(feature = "sso")] oidc_registry: None, #[cfg(feature = "saml")] saml_registry: None, gateway_jwt_registry: None, - global_jwt_validator: None, policy_registry: None, usage_buffer: None, response_cache: None, @@ -2506,13 +2652,10 @@ mod tests { provider_health: crate::jobs::ProviderHealthStateRegistry::new(), task_tracker: TaskTracker::new(), #[cfg(feature = "sso")] - oidc_authenticator: None, - #[cfg(feature = "sso")] oidc_registry: None, #[cfg(feature = "saml")] saml_registry: None, gateway_jwt_registry: None, - global_jwt_validator: None, policy_registry: None, usage_buffer: None, response_cache: None, diff --git a/src/middleware/authz.rs b/src/middleware/authz.rs index d0f5e38..fbd0bc0 100644 --- a/src/middleware/authz.rs +++ b/src/middleware/authz.rs @@ -571,7 +571,7 @@ fn extract_client_ip( /// /// This middleware always inserts an AuthzContext with RBAC disabled, /// which means all authorization checks will pass. This is used when -/// `auth.admin` is not configured (development mode or external auth proxy). +/// auth is not configured (development mode or external auth proxy). /// /// SECURITY NOTE: Only use this for development or when an external auth /// proxy handles authentication/authorization. diff --git a/src/middleware/combined.rs b/src/middleware/combined.rs index 843f9cc..4d1cd1f 100644 --- a/src/middleware/combined.rs +++ b/src/middleware/combined.rs @@ -591,8 +591,21 @@ pub async fn api_middleware( }; req.extensions_mut().insert(client_info.clone()); + // Extract cookies for session-based auth (set by CookieManagerLayer) + let cookies = req.extensions().get::().cloned(); + // 2. Try to authenticate (optional - doesn't fail if no auth) - let auth_result = try_authenticate(&headers, connecting_ip, &state).await; + // Short-circuit: in None mode with no credential headers, skip auth entirely. + // This makes anonymous access explicit rather than relying on MissingCredentials + // being caught downstream. Credentials present in None mode are still validated. + let has_credentials = headers + .contains_key(state.config.auth.api_key_config().header_name.as_str()) + || headers.contains_key(axum::http::header::AUTHORIZATION); + let auth_result = if !state.config.auth.is_auth_enabled() && !has_credentials { + Err(AuthError::MissingCredentials) + } else { + try_authenticate(&headers, cookies.as_ref(), connecting_ip, &state).await + }; // Budget reservation (if applicable) let mut budget_reservation: Option = None; @@ -1184,58 +1197,93 @@ fn track_usage_async(ctx: UsageTrackingContext<'_>) { } } -/// Try to authenticate from headers. +/// Try to authenticate from headers based on the configured `AuthMode`. +/// +/// - `None` — optional auth (try API key if present, don't require it) +/// - `ApiKey` — require API key +/// - `Idp` — try session/API key/JWT with format-based detection; rejects ambiguous dual credentials +/// - `Iap` — try proxy identity headers, also accept API key /// -/// In multi-auth mode, uses **format-based detection**: +/// In `Idp` mode, **format-based detection** is used: /// - Tokens in `Authorization: Bearer` starting with the API key prefix are validated as API keys /// - Other Bearer tokens are validated as JWTs /// - `X-API-Key` header is always validated as an API key -/// -/// **Important:** In multi-auth mode, providing both `X-API-Key` and `Authorization` -/// headers simultaneously is rejected as ambiguous (returns 400 error). async fn try_authenticate( headers: &axum::http::HeaderMap, + cookies: Option<&tower_cookies::Cookies>, connecting_ip: Option, state: &AppState, ) -> Result { - use crate::config::GatewayAuthConfig; - - // Get the API key header name from config - let api_key_header = match &state.config.auth.gateway { - GatewayAuthConfig::ApiKey(config) => config.header_name.as_str(), - GatewayAuthConfig::Multi(config) => config.api_key.header_name.as_str(), - _ => "X-API-Key", - }; + use crate::config::AuthMode; + + let api_key_config = state.config.auth.api_key_config(); + #[cfg(feature = "sso")] + let api_key_header = api_key_config.header_name.as_str(); + #[cfg(not(feature = "sso"))] + let _ = (cookies, &api_key_config); + + match &state.config.auth.mode { + AuthMode::None => { + // Optional auth: try API key if header present, don't require it + let api_key = try_api_key_auth(headers, state).await?; + match api_key { + Some(api_key) => Ok(AuthenticatedRequest::new(IdentityKind::ApiKey(api_key))), + None => Err(AuthError::MissingCredentials), + } + } + AuthMode::ApiKey => { + // Require API key + let api_key = try_api_key_auth(headers, state).await?; + match api_key { + Some(api_key) => Ok(AuthenticatedRequest::new(IdentityKind::ApiKey(api_key))), + None => Err(AuthError::MissingCredentials), + } + } + #[cfg(feature = "sso")] + AuthMode::Idp => { + // Idp mode: reject ambiguous dual credentials + // (both X-API-Key and Authorization headers present) + let has_api_key_header = headers.contains_key(api_key_header); + let has_auth_header = headers.contains_key(axum::http::header::AUTHORIZATION); + if has_api_key_header && has_auth_header { + return Err(AuthError::AmbiguousCredentials); + } - // In multi-auth mode, reject ambiguous dual credentials - // (both X-API-Key and Authorization headers present) - if matches!(&state.config.auth.gateway, GatewayAuthConfig::Multi(_)) { - let has_api_key_header = headers.contains_key(api_key_header); - let has_auth_header = headers.contains_key(axum::http::header::AUTHORIZATION); - if has_api_key_header && has_auth_header { - return Err(AuthError::AmbiguousCredentials); + // Try session cookie → API key → JWT + // Session first because it's cheapest (no JWKS fetch, no DB hash lookup) + let api_key = try_api_key_auth(headers, state).await?; + let identity = if let Some(id) = try_session_api_auth(cookies, state).await? { + Some(id) + } else { + try_jwt_api_auth(headers, state).await? + }; + let kind = match (api_key, identity) { + (Some(api_key), Some(identity)) => IdentityKind::Both { + api_key: Box::new(api_key), + identity, + }, + (Some(api_key), None) => IdentityKind::ApiKey(api_key), + (None, Some(identity)) => IdentityKind::Identity(identity), + (None, None) => return Err(AuthError::MissingCredentials), + }; + Ok(AuthenticatedRequest::new(kind)) + } + AuthMode::Iap(_) => { + // Try proxy headers, also accept API key + let api_key = try_api_key_auth(headers, state).await?; + let identity = try_identity_auth(headers, connecting_ip, state).await?; + let kind = match (api_key, identity) { + (Some(api_key), Some(identity)) => IdentityKind::Both { + api_key: Box::new(api_key), + identity, + }, + (Some(api_key), None) => IdentityKind::ApiKey(api_key), + (None, Some(identity)) => IdentityKind::Identity(identity), + (None, None) => return Err(AuthError::MissingCredentials), + }; + Ok(AuthenticatedRequest::new(kind)) } } - - let api_key = try_api_key_auth(headers, state).await?; - - // Try identity auth from proxy headers first, then JWT auth for API endpoints - let identity = match try_identity_auth(headers, connecting_ip, state).await? { - Some(id) => Some(id), - None => try_jwt_api_auth(headers, state).await?, - }; - - let kind = match (api_key, identity) { - (Some(api_key), Some(identity)) => IdentityKind::Both { - api_key: Box::new(api_key), - identity, - }, - (Some(api_key), None) => IdentityKind::ApiKey(api_key), - (None, Some(identity)) => IdentityKind::Identity(identity), - (None, None) => return Err(AuthError::MissingCredentials), - }; - - Ok(AuthenticatedRequest::new(kind)) } /// Try to authenticate via API key. @@ -1244,26 +1292,19 @@ async fn try_authenticate( /// 1. `X-API-Key` header (or configured header name) /// 2. `Authorization: Bearer` header (only if token starts with API key prefix) /// -/// In multi-auth mode, format-based detection allows API keys in the Bearer header: +/// In idp mode, format-based detection allows API keys in the Bearer header: /// tokens starting with the configured prefix (e.g., `gw_`) are treated as API keys. #[allow(clippy::collapsible_if)] -async fn try_api_key_auth( +pub(crate) async fn try_api_key_auth( headers: &axum::http::HeaderMap, state: &AppState, ) -> Result, AuthError> { - use crate::config::GatewayAuthConfig; - // Get header name and key prefix from config - let (header_name, key_prefix) = match &state.config.auth.gateway { - GatewayAuthConfig::ApiKey(config) => { - (config.header_name.as_str(), config.key_prefix.as_str()) - } - GatewayAuthConfig::Multi(config) => ( - config.api_key.header_name.as_str(), - config.api_key.key_prefix.as_str(), - ), - _ => ("X-API-Key", "gw_"), // Default values for non-API-key auth modes - }; + let api_key_config = state.config.auth.api_key_config(); + let (header_name, key_prefix) = ( + api_key_config.header_name.as_str(), + api_key_config.key_prefix.as_str(), + ); use std::borrow::Cow; @@ -1469,6 +1510,128 @@ async fn try_api_key_auth( Ok(Some(api_key_auth)) } +/// Try to authenticate via session cookie for API endpoints. +/// +/// Validates OIDC/SAML session cookies so users who logged in via SSO can +/// use the chat UI on `/v1/*` endpoints without needing a separate API key. +/// Session cookies are cheaper to validate than JWTs (no JWKS fetch). +#[cfg(feature = "sso")] +async fn try_session_api_auth( + cookies: Option<&tower_cookies::Cookies>, + state: &AppState, +) -> Result, AuthError> { + // Get the OIDC registry which holds the shared session store + let registry = match &state.oidc_registry { + Some(reg) => reg, + None => return Ok(None), + }; + + let session_config = state.config.auth.session_config_or_default(); + + let cookies = match cookies { + Some(c) => c, + None => return Ok(None), + }; + + // Get session ID from cookie + let session_cookie = match cookies.get(&session_config.cookie_name) { + Some(c) => c, + None => return Ok(None), + }; + + let session_id: uuid::Uuid = match session_cookie.value().parse() { + Ok(id) => id, + Err(_) => return Ok(None), + }; + + // Validate session (checks expiration, inactivity timeout, updates last_activity) + let session = match crate::auth::session_store::validate_and_refresh_session( + registry.session_store().as_ref(), + session_id, + &session_config.enhanced, + ) + .await + { + Ok(s) => s, + Err( + crate::auth::session_store::SessionError::NotFound + | crate::auth::session_store::SessionError::Expired, + ) => return Ok(None), + Err(e) => { + tracing::debug!(session_id = %session_id, error = %e, "Session validation failed"); + return Ok(None); + } + }; + + // Look up internal user and memberships from the database + let (user_id, org_ids, team_ids, project_ids) = if let Some(db) = &state.db { + match db + .users() + .get_by_external_id(&session.external_id) + .await + .map_err(|e| AuthError::Internal(e.to_string()))? + { + Some(user) => { + let user_id = user.id; + + let org_ids: Vec = db + .users() + .get_org_memberships_for_user(user_id) + .await + .map_err(|e| AuthError::Internal(e.to_string()))? + .iter() + .map(|m| m.org_id.to_string()) + .collect(); + + let team_ids: Vec = db + .users() + .get_team_memberships_for_user(user_id) + .await + .map_err(|e| AuthError::Internal(e.to_string()))? + .iter() + .map(|m| m.team_id.to_string()) + .collect(); + + let project_ids: Vec = db + .users() + .get_project_memberships_for_user(user_id) + .await + .map_err(|e| AuthError::Internal(e.to_string()))? + .iter() + .map(|m| m.project_id.to_string()) + .collect(); + + (Some(user_id), org_ids, team_ids, project_ids) + } + None => { + // User not found in DB — they may need to log in via admin first + // to trigger JIT provisioning. Don't provision here on API path. + return Ok(None); + } + } + } else { + return Ok(None); + }; + + let roles = if session.roles.is_empty() { + session.groups.clone() + } else { + session.roles.clone() + }; + + Ok(Some(Identity { + external_id: session.external_id, + email: session.email, + name: session.name, + user_id, + roles, + idp_groups: session.groups.clone(), + org_ids, + team_ids, + project_ids, + })) +} + /// Try to authenticate via identity headers /// /// **Security:** This function validates that the connecting IP is from a trusted @@ -1479,9 +1642,9 @@ async fn try_identity_auth( connecting_ip: Option, state: &AppState, ) -> Result, AuthError> { - let config = match &state.config.auth.admin { - Some(crate::config::AdminAuthConfig::ProxyAuth(config)) => config, - _ => return Ok(None), + let config = match state.config.auth.iap_config() { + Some(config) => config, + None => return Ok(None), }; // SECURITY: Validate that the request comes from a trusted proxy before trusting headers. @@ -1522,8 +1685,9 @@ async fn try_identity_auth( None => return Ok(None), }; - let email = extract_header(headers, &state.config.auth.admin, "email"); - let name = extract_header(headers, &state.config.auth.admin, "name"); + let iap = state.config.auth.iap_config(); + let email = extract_header(headers, iap, "email"); + let name = extract_header(headers, iap, "name"); let user_id = if let Some(db) = &state.db { db.users() @@ -1536,7 +1700,7 @@ async fn try_identity_auth( }; // Extract roles from groups header if configured - let roles = extract_groups(headers, &state.config.auth.admin); + let roles = extract_groups(headers, iap); // For proxy auth, groups header serves as both roles and raw groups Ok(Some(Identity { @@ -1554,24 +1718,26 @@ async fn try_identity_auth( /// Try to authenticate via JWT for API endpoints. /// -/// This handles Bearer token authentication when `auth.gateway` is configured -/// as `jwt` or `multi` mode. Unlike `try_identity_auth` which handles -/// proxy-forwarded headers, this validates JWT tokens directly. +/// This handles Bearer token authentication in `Idp` mode, validating JWTs +/// via per-org SSO configurations in the `GatewayJwtRegistry`. +/// Unlike `try_identity_auth` which handles proxy-forwarded headers, +/// this validates JWT tokens directly. /// -/// In multi-auth mode, tokens starting with the API key prefix are skipped +/// Tokens starting with the API key prefix are skipped /// (they're already handled by `try_api_key_auth`). +#[cfg(feature = "sso")] async fn try_jwt_api_auth( headers: &axum::http::HeaderMap, state: &AppState, ) -> Result, AuthError> { - use crate::config::GatewayAuthConfig; + // JWT auth is only available via per-org GatewayJwtRegistry (Idp mode) + let is_idp = matches!(state.config.auth.mode, crate::config::AuthMode::Idp); + if !is_idp { + return Ok(None); + } - // Get API key prefix for format-based detection in multi-auth mode - let key_prefix = match &state.config.auth.gateway { - GatewayAuthConfig::Jwt(_) => None, - GatewayAuthConfig::Multi(config) => Some(config.api_key.key_prefix.as_str()), - _ => return Ok(None), // No JWT config for API endpoints - }; + // Use API key prefix for format-based detection to skip API key tokens + let key_prefix = Some(state.config.auth.api_key_config().key_prefix.as_str()); // Extract Bearer token from Authorization header let auth_header = match headers.get(axum::http::header::AUTHORIZATION) { @@ -1588,7 +1754,7 @@ async fn try_jwt_api_auth( return Ok(None); // Not a Bearer token }; - // In multi-auth mode, skip JWT validation if token has API key prefix + // In idp mode, skip JWT validation if token has API key prefix // (already handled by try_api_key_auth via format-based detection) if key_prefix.is_some_and(|prefix| token.starts_with(prefix)) { return Ok(None); @@ -1602,7 +1768,6 @@ async fn try_jwt_api_auth( // Look up validators, lazy-loading from DB on cache miss. // find_or_load_by_issuer deduplicates concurrent loads and caches // negative results to prevent DB query amplification. - #[cfg(feature = "sso")] let validators = if let Some(db) = &state.db { match registry .find_or_load_by_issuer( @@ -1610,6 +1775,7 @@ async fn try_jwt_api_auth( db, &state.http_client, state.config.server.allow_loopback_urls, + state.config.server.allow_private_urls, ) .await { @@ -1618,7 +1784,7 @@ async fn try_jwt_api_auth( tracing::warn!( issuer = %iss, error = %e, - "Per-org JWT registry lookup failed, falling through to global" + "Per-org JWT registry lookup failed" ); Vec::new() } @@ -1627,9 +1793,6 @@ async fn try_jwt_api_auth( registry.find_validators_by_issuer(&iss).await }; - #[cfg(not(feature = "sso"))] - let validators = registry.find_validators_by_issuer(&iss).await; - // Try each matching validator; first success wins. // Disambiguation for shared-issuer orgs works naturally: each validator // enforces its own audience (the org's client_id), so a token issued for @@ -1653,22 +1816,14 @@ async fn try_jwt_api_auth( } } - // Fall back to global JWT config (from [auth.gateway.jwt]). - // The global validator is built at startup in main.rs. - let Some(validator) = state.global_jwt_validator.clone() else { - // No per-org match and no global JWT config — not a JWT we can validate - return Ok(None); - }; - - let claims = validator.validate(token).await?; - - build_jwt_identity(&claims, &validator, state, None) - .await - .map(Some) + // No per-org match — not a JWT we can validate. + // In the new AuthMode system, JWT is only available via per-org GatewayJwtRegistry. + Ok(None) } /// Decode the `iss` claim from a JWT without verifying the signature. /// This is a cheap base64 decode of the payload used for routing to the right validator. +#[cfg(any(feature = "sso", test))] fn decode_jwt_issuer(token: &str) -> Option { use base64::Engine; @@ -1687,6 +1842,7 @@ fn decode_jwt_issuer(token: &str) -> Option { } /// Build an `Identity` from validated JWT claims. Shared by per-org and global paths. +#[cfg(feature = "sso")] async fn build_jwt_identity( claims: &crate::auth::jwt::JwtClaims, validator: &crate::auth::jwt::JwtValidator, @@ -1772,9 +1928,9 @@ async fn build_jwt_identity( fn extract_groups( headers: &axum::http::HeaderMap, - ui_config: &Option, + iap_config: Option<&crate::config::IapConfig>, ) -> Vec { - if let Some(crate::config::AdminAuthConfig::ProxyAuth(config)) = ui_config + if let Some(config) = iap_config && let Some(header_name) = &config.groups_header && let Some(value) = headers.get(header_name).and_then(|v| v.to_str().ok()) { @@ -1788,20 +1944,16 @@ fn extract_groups( fn extract_header( headers: &axum::http::HeaderMap, - ui_config: &Option, + iap_config: Option<&crate::config::IapConfig>, field: &str, ) -> Option { - if let Some(crate::config::AdminAuthConfig::ProxyAuth(config)) = ui_config { - let header_name = match field { - "email" => config.email_header.as_ref()?, - "name" => config.name_header.as_ref()?, - _ => return None, - }; - - headers.get(header_name)?.to_str().ok().map(String::from) - } else { - None - } + let config = iap_config?; + let header_name = match field { + "email" => config.email_header.as_ref()?, + "name" => config.name_header.as_ref()?, + _ => return None, + }; + headers.get(header_name)?.to_str().ok().map(String::from) } /// Log a budget exceeded event to the audit log (fire-and-forget) @@ -2048,33 +2200,25 @@ mod tests { use tokio_util::task::TaskTracker; use super::*; - use crate::config::{ - ApiKeyAuthConfig, GatewayAuthConfig, GatewayConfig, HashAlgorithm, JwtAuthConfig, - MultiAuthConfig, OneOrMany, - }; + use crate::config::{ApiKeyAuthConfig, AuthMode, GatewayConfig, HashAlgorithm}; - /// Create AppState with multi-auth configuration + /// Create AppState with Idp configuration fn create_multi_auth_state(header_name: &str, key_prefix: &str) -> AppState { let mut config = GatewayConfig::from_str("").unwrap(); - config.auth.gateway = GatewayAuthConfig::Multi(MultiAuthConfig { - api_key: ApiKeyAuthConfig { - header_name: header_name.to_string(), - key_prefix: key_prefix.to_string(), - generation_prefix: None, - hash_algorithm: HashAlgorithm::default(), - cache_ttl_secs: 300, - }, - jwt: Some(JwtAuthConfig { - issuer: "https://auth.example.com".to_string(), - jwks_url: "https://auth.example.com/.well-known/jwks.json".to_string(), - audience: OneOrMany::One("hadrian".to_string()), - identity_claim: "sub".to_string(), - org_claim: None, - additional_claims: Vec::new(), - allow_expired: false, - allowed_algorithms: Vec::new(), - jwks_refresh_secs: 3600, - }), + #[cfg(feature = "sso")] + { + config.auth.mode = AuthMode::Idp; + } + #[cfg(not(feature = "sso"))] + { + config.auth.mode = AuthMode::ApiKey; + } + config.auth.api_key = Some(ApiKeyAuthConfig { + header_name: header_name.to_string(), + key_prefix: key_prefix.to_string(), + generation_prefix: None, + hash_algorithm: HashAlgorithm::default(), + cache_ttl_secs: 300, }); AppState { @@ -2089,13 +2233,10 @@ mod tests { provider_health: crate::jobs::ProviderHealthStateRegistry::new(), task_tracker: TaskTracker::new(), #[cfg(feature = "sso")] - oidc_authenticator: None, - #[cfg(feature = "sso")] oidc_registry: None, #[cfg(feature = "saml")] saml_registry: None, gateway_jwt_registry: None, - global_jwt_validator: None, policy_registry: None, usage_buffer: None, response_cache: None, @@ -2122,7 +2263,8 @@ mod tests { /// Create AppState with API key only authentication fn create_api_key_only_state(header_name: &str, key_prefix: &str) -> AppState { let mut config = GatewayConfig::from_str("").unwrap(); - config.auth.gateway = GatewayAuthConfig::ApiKey(ApiKeyAuthConfig { + config.auth.mode = AuthMode::ApiKey; + config.auth.api_key = Some(ApiKeyAuthConfig { header_name: header_name.to_string(), key_prefix: key_prefix.to_string(), generation_prefix: None, @@ -2142,13 +2284,10 @@ mod tests { provider_health: crate::jobs::ProviderHealthStateRegistry::new(), task_tracker: TaskTracker::new(), #[cfg(feature = "sso")] - oidc_authenticator: None, - #[cfg(feature = "sso")] oidc_registry: None, #[cfg(feature = "saml")] saml_registry: None, gateway_jwt_registry: None, - global_jwt_validator: None, policy_registry: None, usage_buffer: None, response_cache: None, @@ -2183,11 +2322,12 @@ mod tests { map } - // ========== Multi-auth ambiguous credentials tests ========== + // ========== Idp mode ambiguous credentials tests ========== + #[cfg(feature = "sso")] #[tokio::test] - async fn test_multi_auth_ambiguous_credentials_rejected() { - // In multi-auth mode, providing both X-API-Key and Authorization headers + async fn test_idp_auth_ambiguous_credentials_rejected() { + // In Idp mode, providing both X-API-Key and Authorization headers // should be rejected as ambiguous let state = create_multi_auth_state("X-API-Key", "gw_"); let headers = make_headers(vec![ @@ -2198,14 +2338,15 @@ mod tests { ), ]); - let result = try_authenticate(&headers, None, &state).await; + let result = try_authenticate(&headers, None, None, &state).await; assert!(result.is_err()); assert!(matches!(result, Err(AuthError::AmbiguousCredentials))); } + #[cfg(feature = "sso")] #[tokio::test] - async fn test_multi_auth_custom_header_ambiguous_credentials_rejected() { + async fn test_idp_auth_custom_header_ambiguous_credentials_rejected() { // Ambiguous credentials check should respect custom header name let state = create_multi_auth_state("Api-Key", "hadrian_"); let headers = make_headers(vec![ @@ -2213,15 +2354,15 @@ mod tests { ("Authorization", "Bearer some.jwt.token"), ]); - let result = try_authenticate(&headers, None, &state).await; + let result = try_authenticate(&headers, None, None, &state).await; assert!(result.is_err()); assert!(matches!(result, Err(AuthError::AmbiguousCredentials))); } #[tokio::test] - async fn test_non_multi_auth_allows_both_headers() { - // In non-multi-auth mode (API key only), having both headers is not rejected + async fn test_non_idp_allows_both_headers() { + // In non-idp mode (API key only), having both headers is not rejected // (Authorization header is simply ignored) let state = create_api_key_only_state("X-API-Key", "gw_"); let headers = make_headers(vec![ @@ -2231,7 +2372,7 @@ mod tests { // This won't return AmbiguousCredentials error // (will fail later due to missing DB, but that's expected) - let result = try_authenticate(&headers, None, &state).await; + let result = try_authenticate(&headers, None, None, &state).await; // Should not be AmbiguousCredentials - it should be a different error // (InvalidApiKey since DB lookup fails) @@ -2299,6 +2440,7 @@ mod tests { assert!(result.unwrap().is_none()); } + #[cfg(feature = "sso")] #[tokio::test] async fn test_try_jwt_api_auth_skips_api_key_format() { // JWT handler should skip tokens that have API key prefix @@ -2402,7 +2544,7 @@ mod tests { let state = create_multi_auth_state("X-API-Key", "gw_"); let headers = make_headers(vec![]); - let result = try_authenticate(&headers, None, &state).await; + let result = try_authenticate(&headers, None, None, &state).await; assert!(result.is_err()); assert!(matches!(result, Err(AuthError::MissingCredentials))); @@ -2417,7 +2559,7 @@ mod tests { ("Accept", "application/json"), ]); - let result = try_authenticate(&headers, None, &state).await; + let result = try_authenticate(&headers, None, None, &state).await; assert!(result.is_err()); assert!(matches!(result, Err(AuthError::MissingCredentials))); diff --git a/src/openapi.rs b/src/openapi.rs index 6308337..d660d34 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -21,7 +21,7 @@ use crate::{ The gateway provides two main API surfaces: -- **Public API** (`/api/v1/*`) - OpenAI-compatible endpoints for LLM inference. Use these endpoints to create chat completions, text completions, embeddings, and list available models. Requires API key authentication. +- **Public API** (`/api/v1/*`) - OpenAI-compatible endpoints for LLM inference. Use these endpoints to create chat completions, text completions, embeddings, and list available models. Authentication depends on the configured `auth.mode` (API key, IdP, IAP, or none). - **Admin API** (`/admin/v1/*`) - RESTful management endpoints for multi-tenant configuration. Manage organizations, projects, users, API keys, dynamic providers, usage tracking, and model pricing. @@ -108,36 +108,34 @@ curl -H \"Authorization: Bearer eyJhbGciOiJSUzI1NiIs...\" https://gateway.exampl **API Key Authentication:** ```toml -[auth.gateway] +[auth.mode] type = \"api_key\" + +[auth.api_key] header_name = \"X-API-Key\" # Header to read API key from key_prefix = \"gw_\" # Valid key prefix cache_ttl_secs = 60 # Cache key lookups for 60 seconds ``` -**JWT Authentication:** -```toml -[auth.gateway] -type = \"jwt\" -issuer = \"https://auth.example.com\" -audience = \"gateway-api\" -jwks_url = \"https://auth.example.com/.well-known/jwks.json\" -identity_claim = \"sub\" # JWT claim for user identity -``` - -**Multi-Auth (both API key and JWT):** +**IdP Authentication (SSO + API keys + JWT):** ```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 = \"gateway-api\" -jwks_url = \"https://auth.example.com/.well-known/jwks.json\" +[auth.session] +secure = true +``` + +**Identity-Aware Proxy (IAP):** +```toml +[auth.mode] +type = \"iap\" +identity_header = \"X-Forwarded-User\" +email_header = \"X-Forwarded-Email\" ``` ## Pagination diff --git a/src/routes/admin/api_keys.rs b/src/routes/admin/api_keys.rs index 4be0a22..de976e7 100644 --- a/src/routes/admin/api_keys.rs +++ b/src/routes/admin/api_keys.rs @@ -12,11 +12,10 @@ use super::{AuditActor, error::AdminError, organizations::ListQuery}; use crate::{ AppState, cache::CacheKeys, - config::GatewayAuthConfig, middleware::{AdminAuth, AuthzContext, ClientInfo}, models::{ - ApiKey, ApiKeyScope, CreateApiKey, CreateAuditLog, CreatedApiKey, DEFAULT_API_KEY_PREFIX, - validate_ip_allowlist, validate_model_patterns, validate_scopes, + ApiKey, ApiKeyScope, CreateApiKey, CreateAuditLog, CreatedApiKey, validate_ip_allowlist, + validate_model_patterns, validate_scopes, }, openapi::PaginationMeta, services::Services, @@ -321,11 +320,7 @@ pub async fn create( } // Get the key generation prefix from config - let prefix = match &state.config.auth.gateway { - GatewayAuthConfig::ApiKey(config) => config.generation_prefix(), - GatewayAuthConfig::Multi(config) => config.api_key.generation_prefix(), - _ => DEFAULT_API_KEY_PREFIX.to_string(), - }; + let prefix = state.config.auth.api_key_config().generation_prefix(); // Capture owner info for audit log before consuming input let (org_id, project_id) = match &input.owner { @@ -819,11 +814,7 @@ pub async fn rotate( } // Get the key generation prefix from config - let prefix = match &state.config.auth.gateway { - GatewayAuthConfig::ApiKey(config) => config.generation_prefix(), - GatewayAuthConfig::Multi(config) => config.api_key.generation_prefix(), - _ => DEFAULT_API_KEY_PREFIX.to_string(), - }; + let prefix = state.config.auth.api_key_config().generation_prefix(); // Get old key info for audit log before rotating let old_key = services.api_keys.get_by_id(key_id).await?; diff --git a/src/routes/admin/me_api_keys.rs b/src/routes/admin/me_api_keys.rs index 324a47e..aec0cfb 100644 --- a/src/routes/admin/me_api_keys.rs +++ b/src/routes/admin/me_api_keys.rs @@ -17,11 +17,9 @@ use super::{ }; use crate::{ AppState, - config::GatewayAuthConfig, middleware::{AdminAuth, AuthzContext, ClientInfo}, models::{ ApiKey, ApiKeyOwner, CreateApiKey, CreateAuditLog, CreateSelfServiceApiKey, CreatedApiKey, - DEFAULT_API_KEY_PREFIX, }, openapi::PaginationMeta, services::Services, @@ -170,11 +168,7 @@ pub async fn create( } // Get the key generation prefix from config - let prefix = match &state.config.auth.gateway { - GatewayAuthConfig::ApiKey(config) => config.generation_prefix(), - GatewayAuthConfig::Multi(config) => config.api_key.generation_prefix(), - _ => DEFAULT_API_KEY_PREFIX.to_string(), - }; + let prefix = state.config.auth.api_key_config().generation_prefix(); let create_input = CreateApiKey { name: input.name, @@ -329,11 +323,7 @@ pub async fn rotate( } // Get the key generation prefix from config - let prefix = match &state.config.auth.gateway { - GatewayAuthConfig::ApiKey(config) => config.generation_prefix(), - GatewayAuthConfig::Multi(config) => config.api_key.generation_prefix(), - _ => DEFAULT_API_KEY_PREFIX.to_string(), - }; + let prefix = state.config.auth.api_key_config().generation_prefix(); // Perform the rotation let created = services diff --git a/src/routes/admin/mod.rs b/src/routes/admin/mod.rs index a33f4fe..a14d4a0 100644 --- a/src/routes/admin/mod.rs +++ b/src/routes/admin/mod.rs @@ -5646,8 +5646,10 @@ enabled = false r#" {} -[auth.gateway] +[auth.mode] type = "api_key" + +[auth.api_key] key_prefix = "gw_" "#, unique_db_config() @@ -5668,8 +5670,10 @@ key_prefix = "gw_" r#" {} -[auth.admin] -type = "session" +[auth.mode] +type = "idp" + +[auth.session] secure = true cookie_name = "__gw_session" "#, @@ -5699,8 +5703,8 @@ cookie_name = "__gw_session" [server] host = "127.0.0.1" -[auth.admin] -type = "proxy_auth" +[auth.mode] +type = "iap" identity_header = "X-Forwarded-User" email_header = "X-Forwarded-Email" "#, diff --git a/src/routes/admin/org_sso_configs.rs b/src/routes/admin/org_sso_configs.rs index 8978049..2dfef6b 100644 --- a/src/routes/admin/org_sso_configs.rs +++ b/src/routes/admin/org_sso_configs.rs @@ -321,13 +321,16 @@ pub async fn create( // SSRF-validate OIDC URLs at input time if input.provider_type == SsoProviderType::Oidc { - let allow_loopback = state.config.server.allow_loopback_urls; + let url_opts = crate::validation::UrlValidationOptions { + allow_loopback: state.config.server.allow_loopback_urls, + allow_private: state.config.server.allow_private_urls, + }; if let Some(ref issuer) = input.issuer { - crate::validation::validate_base_url(issuer, allow_loopback) + crate::validation::validate_base_url_opts(issuer, url_opts) .map_err(|e| AdminError::Validation(format!("Invalid issuer URL: {e}")))?; } if let Some(ref discovery_url) = input.discovery_url { - crate::validation::validate_base_url(discovery_url, allow_loopback) + crate::validation::validate_base_url_opts(discovery_url, url_opts) .map_err(|e| AdminError::Validation(format!("Invalid discovery URL: {e}")))?; } } @@ -415,6 +418,7 @@ pub async fn create( &config, &state.http_client, state.config.server.allow_loopback_urls, + state.config.server.allow_private_urls, ) .await { @@ -533,13 +537,16 @@ pub async fn update( // SSRF-validate OIDC URLs at input time (only check fields being updated) if final_provider_type == SsoProviderType::Oidc { - let allow_loopback = state.config.server.allow_loopback_urls; + let url_opts = crate::validation::UrlValidationOptions { + allow_loopback: state.config.server.allow_loopback_urls, + allow_private: state.config.server.allow_private_urls, + }; if let Some(ref issuer) = input.issuer { - crate::validation::validate_base_url(issuer, allow_loopback) + crate::validation::validate_base_url_opts(issuer, url_opts) .map_err(|e| AdminError::Validation(format!("Invalid issuer URL: {e}")))?; } if let Some(Some(ref discovery_url)) = input.discovery_url { - crate::validation::validate_base_url(discovery_url, allow_loopback) + crate::validation::validate_base_url_opts(discovery_url, url_opts) .map_err(|e| AdminError::Validation(format!("Invalid discovery URL: {e}")))?; } } @@ -586,6 +593,7 @@ pub async fn update( &updated, &state.http_client, state.config.server.allow_loopback_urls, + state.config.server.allow_private_urls, ) .await { diff --git a/src/routes/admin/session_info.rs b/src/routes/admin/session_info.rs index a503240..c253b69 100644 --- a/src/routes/admin/session_info.rs +++ b/src/routes/admin/session_info.rs @@ -310,17 +310,11 @@ pub async fn get( } // Get SSO connection info from config - // Note: Global OIDC is no longer supported. SSO connections are per-org. - // For Session auth, the connection info comes from the session's org. - let sso_connection = match &state.config.auth.admin { - Some(crate::config::AdminAuthConfig::Session(_)) => { - // With per-org SSO, connection info is stored in the session - // Return None here - the frontend can get org-specific SSO info via the SSO API - None - } - Some(crate::config::AdminAuthConfig::ProxyAuth(_)) => Some(SsoConnectionInfo { + // SSO connections are per-org. For IAP mode, expose the connection type. + let sso_connection = match &state.config.auth.mode { + crate::config::AuthMode::Iap(_) => Some(SsoConnectionInfo { name: "default".to_string(), - connection_type: "proxy_auth".to_string(), + connection_type: "iap".to_string(), issuer: None, groups_claim: None, jit_enabled: false, @@ -329,10 +323,12 @@ pub async fn get( }; // Determine auth method - let auth_method = match &state.config.auth.admin { - Some(crate::config::AdminAuthConfig::Session(_)) => "session".to_string(), - Some(crate::config::AdminAuthConfig::ProxyAuth(_)) => "proxy_auth".to_string(), - Some(crate::config::AdminAuthConfig::None) | None => "none".to_string(), + let auth_method = match &state.config.auth.mode { + crate::config::AuthMode::None => "none".to_string(), + crate::config::AuthMode::ApiKey => "api_key".to_string(), + #[cfg(feature = "sso")] + crate::config::AuthMode::Idp => "idp".to_string(), + crate::config::AuthMode::Iap(_) => "iap".to_string(), }; Ok(Json(SessionInfoResponse { diff --git a/src/routes/admin/sessions.rs b/src/routes/admin/sessions.rs index d6e52f1..a7c5383 100644 --- a/src/routes/admin/sessions.rs +++ b/src/routes/admin/sessions.rs @@ -72,11 +72,6 @@ pub fn get_session_store(state: &AppState) -> Result { - // Session-only auth - SSO connections are per-org, not global - // Return empty list - clients should use the org-specific SSO API - connections.push(SsoConnection { - name: "default".to_string(), - connection_type: "session".to_string(), - issuer: None, - client_id: None, - scopes: None, - identity_claim: None, - groups_claim: None, - jit_enabled: false, - organization_id: None, - default_team_id: None, - default_org_role: None, - default_team_role: None, - sync_memberships_on_login: false, - }); - } - AdminAuthConfig::ProxyAuth(_) => { - // Proxy auth doesn't have SSO connections in the traditional sense - connections.push(SsoConnection { - name: "default".to_string(), - connection_type: "proxy_auth".to_string(), - issuer: None, - client_id: None, - scopes: None, - identity_claim: None, - groups_claim: None, - jit_enabled: false, - organization_id: None, - default_team_id: None, - default_org_role: None, - default_team_role: None, - sync_memberships_on_login: false, - }); - } - AdminAuthConfig::None => { - // No SSO configured - } + // SSO connections are now per-org. This endpoint shows what auth mode is configured. + match &state.config.auth.mode { + #[cfg(feature = "sso")] + AuthMode::Idp => { + // IdP mode - SSO connections are per-org, not global + // Return a placeholder - clients should use the org-specific SSO API + connections.push(SsoConnection { + name: "default".to_string(), + connection_type: "idp".to_string(), + issuer: None, + client_id: None, + scopes: None, + identity_claim: None, + groups_claim: None, + jit_enabled: false, + organization_id: None, + default_team_id: None, + default_org_role: None, + default_team_role: None, + sync_memberships_on_login: false, + }); + } + AuthMode::Iap(_) => { + // IAP mode doesn't have SSO connections in the traditional sense + connections.push(SsoConnection { + name: "default".to_string(), + connection_type: "iap".to_string(), + issuer: None, + client_id: None, + scopes: None, + identity_claim: None, + groups_claim: None, + jit_enabled: false, + organization_id: None, + default_team_id: None, + default_org_role: None, + default_team_role: None, + sync_memberships_on_login: false, + }); + } + _ => { + // None or ApiKey - no SSO configured } } @@ -162,46 +160,45 @@ pub async fn get( } // Extract SSO connection info from config - // Note: Global OIDC config has been removed. SSO connections are now per-org. - if let Some(ref ui_auth) = state.config.auth.admin { - match ui_auth { - AdminAuthConfig::Session(_) => { - return Ok(Json(SsoConnection { - name: "default".to_string(), - connection_type: "session".to_string(), - issuer: None, - client_id: None, - scopes: None, - identity_claim: None, - groups_claim: None, - jit_enabled: false, - organization_id: None, - default_team_id: None, - default_org_role: None, - default_team_role: None, - sync_memberships_on_login: false, - })); - } - AdminAuthConfig::ProxyAuth(_) => { - return Ok(Json(SsoConnection { - name: "default".to_string(), - connection_type: "proxy_auth".to_string(), - issuer: None, - client_id: None, - scopes: None, - identity_claim: None, - groups_claim: None, - jit_enabled: false, - organization_id: None, - default_team_id: None, - default_org_role: None, - default_team_role: None, - sync_memberships_on_login: false, - })); - } - AdminAuthConfig::None => { - // Fall through to not found - } + // SSO connections are now per-org. This shows the gateway-level auth mode. + match &state.config.auth.mode { + #[cfg(feature = "sso")] + AuthMode::Idp => { + return Ok(Json(SsoConnection { + name: "default".to_string(), + connection_type: "idp".to_string(), + issuer: None, + client_id: None, + scopes: None, + identity_claim: None, + groups_claim: None, + jit_enabled: false, + organization_id: None, + default_team_id: None, + default_org_role: None, + default_team_role: None, + sync_memberships_on_login: false, + })); + } + AuthMode::Iap(_) => { + return Ok(Json(SsoConnection { + name: "default".to_string(), + connection_type: "iap".to_string(), + issuer: None, + client_id: None, + scopes: None, + identity_claim: None, + groups_claim: None, + jit_enabled: false, + organization_id: None, + default_team_id: None, + default_org_role: None, + default_team_role: None, + sync_memberships_on_login: false, + })); + } + _ => { + // None or ApiKey - fall through to not found } } diff --git a/src/routes/admin/ui_config.rs b/src/routes/admin/ui_config.rs index 70bcef3..d9451ca 100644 --- a/src/routes/admin/ui_config.rs +++ b/src/routes/admin/ui_config.rs @@ -4,8 +4,8 @@ use serde::Serialize; use crate::{ AppState, config::{ - AdminAuthConfig, AdminConfig, BrandingConfig, ChatConfig, ColorPalette, CustomFont, - FontsConfig, LoginConfig, UiConfig, + AdminConfig, AuthMode, BrandingConfig, ChatConfig, ColorPalette, CustomFont, FontsConfig, + LoginConfig, UiConfig, }, }; @@ -254,25 +254,24 @@ pub async fn get_ui_config(State(state): State) -> Json { - // Explicit no-auth for UI - // Fall through to check if API key auth should be offered - } - #[cfg(feature = "sso")] - AdminAuthConfig::Session(_) => { - // Session-only auth - users authenticate via per-org SSO - // The frontend should show email discovery to determine which org's IdP to use - auth_methods.push("session".to_string()); - } - AdminAuthConfig::ProxyAuth(_) => { - auth_methods.push("header".to_string()); - } + // Add auth methods based on the configured auth mode + match &state.config.auth.mode { + AuthMode::None => { + // No auth - fall through to "none" below + } + AuthMode::ApiKey => { + // API key mode - offer API key login for admin panel + auth_methods.push("api_key".to_string()); + } + #[cfg(feature = "sso")] + AuthMode::Idp => { + // IdP mode - users authenticate via per-org SSO + // The frontend should show email discovery to determine which org's IdP to use + auth_methods.push("session".to_string()); + } + AuthMode::Iap(_) => { + // IAP mode - reverse proxy handles auth + auth_methods.push("header".to_string()); } } @@ -289,12 +288,6 @@ pub async fn get_ui_config(State(state): State) -> Json, owner_type: VectorStoreOwnerType, diff --git a/src/routes/auth.rs b/src/routes/auth.rs index d770a6c..2e71382 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -33,7 +33,7 @@ use validator::ValidateEmail; use crate::{ AppState, auth::AuthError, - config::{AdminAuthConfig, SameSite, TrustedProxiesConfig}, + config::{SameSite, TrustedProxiesConfig}, middleware::AdminAuth, models::{DomainVerificationStatus, SsoEnforcementMode, SsoProviderType}, services::audit_logs::{AuthEventParams, auth_events}, @@ -401,17 +401,11 @@ pub async fn login( ); } - // Fall back to global OIDC authenticator (deprecated - use per-org SSO) - let authenticator = state.oidc_authenticator.as_ref().ok_or_else(|| { - AuthError::Forbidden( - "OIDC authentication not configured. Use per-org SSO with ?org= parameter." - .to_string(), - ) - })?; - - let (auth_url, _) = authenticator.authorization_url(query.return_to).await?; - - Ok(Redirect::to(&auth_url)) + // No global OIDC authenticator — all SSO is per-org + Err(AuthError::Forbidden( + "OIDC authentication not configured. Use per-org SSO with ?org= parameter." + .to_string(), + )) } /// Callback endpoint - handles IdP response. @@ -473,81 +467,58 @@ pub async fn callback( ))); } - // Get session config - use Session config for cookie settings - let session_config = match &state.config.auth.admin { - Some(AdminAuthConfig::Session(config)) => config.clone(), - _ => { - tracing::warn!( - "No session config found in auth.admin, using defaults for callback. \ - This may indicate misconfiguration if sessions are expected." - ); - crate::config::SessionConfig::default() - } - }; + // Get session config for cookie settings + let session_config = state.config.auth.session_config_or_default().into_owned(); // Determine which authenticator to use by peeking at the auth state. - // SECURITY: We must fail explicitly if org-specific SSO was requested but - // the authenticator is unavailable, rather than silently falling back to global. - let authenticator: Arc = if let Some(registry) = &state.oidc_registry { - match registry.peek_auth_state(&query.state).await { - Ok(Some(auth_state)) => { - if let Some(org_id) = auth_state.org_id { - // Org-specific SSO was requested - MUST use org authenticator - match registry.get(org_id).await { - Some(org_auth) => { - tracing::info!( - org_id = %org_id, - "Using org-specific authenticator for callback" - ); - org_auth - } - None => { - // This can happen if the org's SSO config was deleted during the auth flow - tracing::error!( - org_id = %org_id, - "Org SSO config deleted during auth flow" - ); - return Err(AuthError::Internal(format!( - "SSO configuration for organization {} is no longer available", - org_id - ))); - } + // All SSO is per-org — there is no global authenticator. + let registry = state.oidc_registry.as_ref().ok_or_else(|| { + tracing::warn!("OIDC callback received but no OIDC registry available"); + AuthError::SessionNotFound + })?; + + let authenticator: Arc = match registry.peek_auth_state(&query.state).await { + Ok(Some(auth_state)) => { + if let Some(org_id) = auth_state.org_id { + // Org-specific SSO was requested - MUST use org authenticator + match registry.get(org_id).await { + Some(org_auth) => { + tracing::info!( + org_id = %org_id, + "Using org-specific authenticator for callback" + ); + org_auth + } + None => { + tracing::error!( + org_id = %org_id, + "Org SSO config deleted during auth flow" + ); + return Err(AuthError::Internal(format!( + "SSO configuration for organization {} is no longer available", + org_id + ))); } - } else { - // No org_id in state - use global authenticator - state.oidc_authenticator.clone().ok_or_else(|| { - AuthError::Internal("OIDC authentication not configured".to_string()) - })? - } - } - Ok(None) => { - // State not found in registry's session store. - // With per-org SSO, all auth flows should have state in the registry. - // If not found, try global authenticator (if configured) or return 401. - if let Some(global_auth) = state.oidc_authenticator.clone() { - global_auth - } else { - tracing::warn!("Invalid or expired authentication state"); - return Err(AuthError::SessionNotFound); } - } - Err(e) => { - // Fail explicitly on state lookup errors rather than silently falling back - tracing::error!(error = %e, "Failed to peek auth state during callback"); - return Err(AuthError::Internal(format!( - "Failed to validate authentication state: {}", - e - ))); + } else { + // No org_id in state — all SSO is per-org, this shouldn't happen + tracing::warn!("Auth state has no org_id — cannot determine authenticator"); + return Err(AuthError::Internal( + "Authentication state missing organization. Use per-org SSO.".to_string(), + )); } } - } else { - // No registry configured - use global authenticator if available - if let Some(global_auth) = state.oidc_authenticator.clone() { - global_auth - } else { - tracing::warn!("OIDC callback received but no authenticator available"); + Ok(None) => { + tracing::warn!("Invalid or expired authentication state"); return Err(AuthError::SessionNotFound); } + Err(e) => { + tracing::error!(error = %e, "Failed to peek auth state during callback"); + return Err(AuthError::Internal(format!( + "Failed to validate authentication state: {}", + e + ))); + } }; // Build device info if enhanced sessions are enabled @@ -667,26 +638,25 @@ pub async fn logout( .get(axum::http::header::USER_AGENT) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - let session_config = match &state.config.auth.admin { - Some(AdminAuthConfig::Session(config)) => config.clone(), - _ => { - tracing::warn!( - "No session config found in auth.admin, using defaults for logout. \ - This may indicate misconfiguration if sessions are expected." - ); - crate::config::SessionConfig::default() - } - }; + let session_config = state.config.auth.session_config_or_default().into_owned(); - // Get session ID from cookie and logout using the shared authenticator + // Get session ID from cookie and logout using the shared session store if let Some(session_cookie) = cookies.get(&session_config.cookie_name) && let Ok(session_id) = session_cookie.value().parse::() - && let Some(authenticator) = &state.oidc_authenticator + && let Some(registry) = &state.oidc_registry { + let session_store = registry.session_store(); + // Get session info before logging out (for audit log) - let session_info = authenticator.get_session(session_id).await.ok(); + let session_info = crate::auth::session_store::validate_and_refresh_session( + session_store.as_ref(), + session_id, + &session_config.enhanced, + ) + .await + .ok(); - let _ = authenticator.logout(session_id).await; + let _ = session_store.delete_session(session_id).await; // Log logout to audit log if let Some(services) = &state.services { @@ -964,13 +934,7 @@ pub async fn saml_slo( .saml_registry .as_ref() .map(|r| r.default_session_config().clone()) - .or_else(|| { - if let Some(AdminAuthConfig::Session(config)) = &state.config.auth.admin { - Some(config.clone()) - } else { - None - } - }) + .or_else(|| state.config.auth.session_config().cloned()) .unwrap_or_default(); // Try to get IdP SLO redirect URL before clearing session @@ -1050,8 +1014,11 @@ pub async fn saml_slo( } // Also try OIDC session store (sessions are shared) - if let Some(oidc_auth) = &state.oidc_authenticator { - let _ = oidc_auth.logout(session_id).await; + if let Some(oidc_registry) = &state.oidc_registry { + let _ = oidc_registry + .session_store() + .delete_session(session_id) + .await; } } @@ -1448,8 +1415,10 @@ run_migrations = true wal_mode = false busy_timeout_ms = 5000 -[auth.admin] -type = "session" +[auth.mode] +type = "idp" + +[auth.session] secure = false cookie_name = "__test_session" @@ -1719,7 +1688,7 @@ type = "test" let response = app.oneshot(request).await.unwrap(); - // Auth routes are NOT registered when auth is fully disabled (no auth.admin, no auth.gateway) + // Auth routes are NOT registered when auth is fully disabled (auth.mode.type = "none" or omitted) // So /auth/login returns 404 Not Found assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -2126,7 +2095,7 @@ type = "test" let response = app.oneshot(request).await.unwrap(); - // Auth routes are NOT registered when auth is fully disabled (no auth.admin, no auth.gateway) + // Auth routes are NOT registered when auth is fully disabled (auth.mode.type = "none" or omitted) // So /auth/logout returns 404 Not Found assert_eq!( response.status(), @@ -2163,8 +2132,10 @@ run_migrations = true wal_mode = false busy_timeout_ms = 5000 -[auth.admin] -type = "session" +[auth.mode] +type = "idp" + +[auth.session] secure = false cookie_name = "__test_session" diff --git a/src/routes/execution.rs b/src/routes/execution.rs index e96e8ef..cbf03bc 100644 --- a/src/routes/execution.rs +++ b/src/routes/execution.rs @@ -798,13 +798,10 @@ mod tests { provider_health: crate::jobs::ProviderHealthStateRegistry::new(), task_tracker: tokio_util::task::TaskTracker::new(), #[cfg(feature = "sso")] - oidc_authenticator: None, - #[cfg(feature = "sso")] oidc_registry: None, #[cfg(feature = "saml")] saml_registry: None, gateway_jwt_registry: None, - global_jwt_validator: None, policy_registry: None, usage_buffer: None, response_cache: None, diff --git a/src/routes/ws.rs b/src/routes/ws.rs index 3e1715f..7a26d14 100644 --- a/src/routes/ws.rs +++ b/src/routes/ws.rs @@ -51,8 +51,6 @@ use tower_cookies::Cookies; #[cfg(feature = "sso")] use uuid::Uuid; -#[cfg(feature = "sso")] -use crate::config::AdminAuthConfig; use crate::{ AppState, auth::{AuthError, Identity}, @@ -184,7 +182,7 @@ async fn authenticate_ws( // Allow unauthenticated connections if require_auth is false // (for development or when auth is handled externally) - if state.config.auth.admin.is_none() && !state.config.auth.gateway.is_enabled() { + if !state.config.auth.is_auth_enabled() { return Ok(None); } @@ -197,14 +195,8 @@ async fn authenticate_with_api_key( token: &str, state: &AppState, ) -> Result, AuthError> { - use crate::config::GatewayAuthConfig; - // Get key prefix from config - let key_prefix = match &state.config.auth.gateway { - GatewayAuthConfig::ApiKey(config) => config.key_prefix.as_str(), - GatewayAuthConfig::Multi(config) => config.api_key.key_prefix.as_str(), - _ => "gw_", // Default for non-API-key auth modes - }; + let key_prefix = state.config.auth.api_key_config().key_prefix.as_str(); // Validate key prefix if !has_valid_prefix(token, key_prefix) { @@ -307,14 +299,14 @@ async fn try_session_auth( cookies: Option<&Cookies>, state: &AppState, ) -> Result, AuthError> { - let authenticator = match &state.oidc_authenticator { - Some(auth) => auth, + let registry = match &state.oidc_registry { + Some(registry) => registry, None => return Ok(None), }; - let session_config = match &state.config.auth.admin { - Some(AdminAuthConfig::Session(config)) => config, - _ => return Ok(None), + let session_config = match state.config.auth.session_config() { + Some(config) => config, + None => return Ok(None), }; let cookies = match cookies { @@ -332,7 +324,17 @@ async fn try_session_auth( .parse() .map_err(|_| AuthError::InvalidToken)?; - let session = authenticator.get_session(session_id).await?; + let session = crate::auth::session_store::validate_and_refresh_session( + registry.session_store().as_ref(), + session_id, + &session_config.enhanced, + ) + .await + .map_err(|e| match e { + crate::auth::session_store::SessionError::NotFound => AuthError::SessionNotFound, + crate::auth::session_store::SessionError::Expired => AuthError::SessionExpired, + _ => AuthError::Internal(format!("Session error: {}", e)), + })?; // Look up internal user ID let user_id = if let Some(db) = &state.db { diff --git a/src/services/api_keys.rs b/src/services/api_keys.rs index 9dd9216..1e4d33a 100644 --- a/src/services/api_keys.rs +++ b/src/services/api_keys.rs @@ -164,6 +164,17 @@ impl ApiKeyService { self.db.api_keys().get_key_hashes_by_user(user_id).await } + /// Find an active (non-revoked) API key by name and owning organization. + /// + /// Used by bootstrap to check if a key already exists before creating one. + pub async fn get_by_name_and_org( + &self, + org_id: Uuid, + name: &str, + ) -> DbResult> { + self.db.api_keys().get_by_name_and_org(org_id, name).await + } + /// Rotate an API key: create a new key with the same settings and set a grace period on the old key. /// /// During the grace period, both the old and new keys are valid. diff --git a/src/validation/mod.rs b/src/validation/mod.rs index 024659c..d6c95f5 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -32,3 +32,5 @@ pub use schema::{ResponseType, SchemaId, validate_response}; #[cfg(feature = "saml")] pub use url::require_https; pub use url::validate_base_url; +#[cfg(feature = "sso")] +pub use url::{UrlValidationOptions, validate_base_url_opts}; diff --git a/src/validation/url.rs b/src/validation/url.rs index 5964e92..f9a2060 100644 --- a/src/validation/url.rs +++ b/src/validation/url.rs @@ -21,26 +21,36 @@ pub enum UrlValidationError { BlockedAddress, } +/// Options controlling which IP ranges are permitted in SSRF validation. +#[derive(Debug, Clone, Copy, Default)] +pub struct UrlValidationOptions { + /// Allow loopback addresses (127.0.0.0/8, ::1). + pub allow_loopback: bool, + /// Allow private/internal IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). + pub allow_private: bool, +} + /// Check if an IP address is in a private/reserved range that should not be accessed /// by server-side requests. -fn is_blocked_ip(ip: IpAddr, allow_loopback: bool) -> bool { +fn is_blocked_ip(ip: IpAddr, opts: UrlValidationOptions) -> bool { match ip { IpAddr::V4(v4) => { // Loopback (127.0.0.0/8) if v4.is_loopback() { - return !allow_loopback; + return !opts.allow_loopback; } - // Private ranges - if v4.is_private() { + // Cloud metadata endpoint (169.254.169.254) — always blocked + if v4 == Ipv4Addr::new(169, 254, 169, 254) { return true; } - // Link-local (169.254.0.0/16) + // Link-local (169.254.0.0/16) — blocked unless allow_private + // (cloud metadata 169.254.169.254 is always blocked above) if v4.is_link_local() { - return true; + return !opts.allow_private; } - // Cloud metadata endpoint (169.254.169.254) - if v4 == Ipv4Addr::new(169, 254, 169, 254) { - return true; // Always block, even if allow_loopback + // Private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) + if v4.is_private() { + return !opts.allow_private; } // Broadcast if v4.is_broadcast() { @@ -63,24 +73,24 @@ fn is_blocked_ip(ip: IpAddr, allow_loopback: bool) -> bool { IpAddr::V6(v6) => { // Loopback (::1) if v6.is_loopback() { - return !allow_loopback; + return !opts.allow_loopback; } // Unspecified (::) if v6.is_unspecified() { return true; } - // Link-local (fe80::/10) + // Link-local (fe80::/10) — blocked unless allow_private let segments = v6.segments(); if segments[0] & 0xffc0 == 0xfe80 { - return true; + return !opts.allow_private; } // Unique local (fc00::/7) if segments[0] & 0xfe00 == 0xfc00 { - return true; + return !opts.allow_private; } // IPv4-mapped addresses (::ffff:x.x.x.x) — check the embedded IPv4 if let Some(v4) = v6.to_ipv4_mapped() { - return is_blocked_ip(IpAddr::V4(v4), allow_loopback); + return is_blocked_ip(IpAddr::V4(v4), opts); } false } @@ -95,9 +105,23 @@ fn is_blocked_ip(ip: IpAddr, allow_loopback: bool) -> bool { /// - Hostnames that resolve to private, loopback, or link-local IPs /// - Cloud metadata endpoints (169.254.169.254) /// -/// When `allow_loopback` is true, loopback addresses (127.0.0.1, ::1) are permitted -/// (useful for development). Private ranges and metadata endpoints are always blocked. +/// Use `allow_loopback = true` for development (permits 127.0.0.1/::1). +/// Use `allow_private = true` for Docker/Kubernetes (permits 10.x/172.16.x/192.168.x). pub fn validate_base_url(url: &str, allow_loopback: bool) -> Result<(), UrlValidationError> { + validate_base_url_opts( + url, + UrlValidationOptions { + allow_loopback, + allow_private: false, + }, + ) +} + +/// Validate a user-supplied URL with full options control. +pub fn validate_base_url_opts( + url: &str, + opts: UrlValidationOptions, +) -> Result<(), UrlValidationError> { let parsed = url::Url::parse(url).map_err(|e| UrlValidationError::InvalidUrl(e.to_string()))?; // Scheme check @@ -110,7 +134,7 @@ pub fn validate_base_url(url: &str, allow_loopback: bool) -> Result<(), UrlValid let host = parsed.host_str().ok_or(UrlValidationError::MissingHost)?; // Check for localhost string variants - if !allow_loopback + if !opts.allow_loopback && (host.eq_ignore_ascii_case("localhost") || host.eq_ignore_ascii_case("localhost.localdomain")) { @@ -119,7 +143,7 @@ pub fn validate_base_url(url: &str, allow_loopback: bool) -> Result<(), UrlValid // Try to parse as IP directly first if let Ok(ip) = host.parse::() { - if is_blocked_ip(ip, allow_loopback) { + if is_blocked_ip(ip, opts) { return Err(UrlValidationError::BlockedAddress); } return Ok(()); @@ -144,7 +168,15 @@ pub fn validate_base_url(url: &str, allow_loopback: bool) -> Result<(), UrlValid // ALL resolved addresses must be non-blocked (prevents DNS rebinding with mixed results) for addr in &socket_addrs { - if is_blocked_ip(addr.ip(), allow_loopback) { + if is_blocked_ip(addr.ip(), opts) { + tracing::warn!( + url = %url, + blocked_ip = %addr.ip(), + all_resolved = ?socket_addrs.iter().map(|a| a.ip()).collect::>(), + allow_loopback = opts.allow_loopback, + allow_private = opts.allow_private, + "URL blocked by SSRF validation" + ); return Err(UrlValidationError::BlockedAddress); } } @@ -260,6 +292,19 @@ mod tests { )); } + #[test] + fn test_rejects_metadata_even_with_private_allowed() { + // Cloud metadata should always be blocked, even with allow_private + let opts = UrlValidationOptions { + allow_loopback: true, + allow_private: true, + }; + assert!(matches!( + validate_base_url_opts("http://169.254.169.254", opts), + Err(UrlValidationError::BlockedAddress) + )); + } + #[test] fn test_rejects_link_local() { assert!(matches!( @@ -268,6 +313,48 @@ mod tests { )); } + #[test] + fn test_allows_link_local_with_private() { + // Link-local (non-metadata) allowed when allow_private is set (Docker/k8s) + let opts = UrlValidationOptions { + allow_loopback: false, + allow_private: true, + }; + assert!(validate_base_url_opts("http://169.254.1.1", opts).is_ok()); + } + + #[test] + fn test_allows_private_with_flag() { + let opts = UrlValidationOptions { + allow_loopback: false, + allow_private: true, + }; + assert!(validate_base_url_opts("http://10.0.0.1", opts).is_ok()); + assert!(validate_base_url_opts("http://172.16.0.1", opts).is_ok()); + assert!(validate_base_url_opts("http://192.168.1.1", opts).is_ok()); + } + + #[test] + fn test_allows_ipv6_link_local_with_private() { + let opts = UrlValidationOptions { + allow_loopback: false, + allow_private: true, + }; + assert!(validate_base_url_opts("http://[fe80::1]:8080", opts).is_ok()); + } + + #[test] + fn test_rejects_ipv6_link_local_without_private() { + let opts = UrlValidationOptions { + allow_loopback: true, + allow_private: false, + }; + assert!(matches!( + validate_base_url_opts("http://[fe80::1]:8080", opts), + Err(UrlValidationError::BlockedAddress) + )); + } + #[test] fn test_rejects_ipv6_loopback() { assert!(matches!( diff --git a/src/wizard.rs b/src/wizard.rs index 8b2fda5..395e53c 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -196,12 +196,12 @@ impl std::fmt::Display for ApiAuthType { } } -/// Gateway authentication configuration. +/// Authentication mode configuration. #[derive(Debug)] -enum GatewayAuthConfig { +enum AuthModeConfig { None, ApiKey { key_prefix: String }, - Oidc(OidcConfig), + Idp(OidcConfig), } /// OIDC configuration. @@ -234,7 +234,7 @@ struct WizardConfig { database: DatabaseConfig, cache: CacheConfig, providers: Vec, - auth: GatewayAuthConfig, + auth: AuthModeConfig, rate_limits: RateLimitConfig, budget: BudgetConfig, } @@ -326,7 +326,7 @@ fn configure_local_dev(theme: &ColorfulTheme) -> Result Result Result Result { } } -fn select_auth(theme: &ColorfulTheme) -> Result { +fn select_auth(theme: &ColorfulTheme) -> Result { let types = [ApiAuthType::None, ApiAuthType::ApiKey, ApiAuthType::Oidc]; let selection = Select::with_theme(theme) @@ -523,18 +523,18 @@ fn select_auth(theme: &ColorfulTheme) -> Result .ok_or(WizardError::Cancelled)?; match types[selection] { - ApiAuthType::None => Ok(GatewayAuthConfig::None), + ApiAuthType::None => Ok(AuthModeConfig::None), ApiAuthType::ApiKey => { let key_prefix: String = Input::with_theme(theme) .with_prompt("API key prefix (e.g., 'gw_')") .default("gw_".to_string()) .interact_text()?; - Ok(GatewayAuthConfig::ApiKey { key_prefix }) + Ok(AuthModeConfig::ApiKey { key_prefix }) } ApiAuthType::Oidc => { let oidc = configure_oidc(theme)?; - Ok(GatewayAuthConfig::Oidc(oidc)) + Ok(AuthModeConfig::Idp(oidc)) } } } @@ -763,10 +763,10 @@ fn validate_config(config: &WizardConfig) -> Result<(), WizardError> { // Validate auth match &config.auth { - GatewayAuthConfig::None => { + AuthModeConfig::None => { println!(" ✓ Authentication: None (local dev only)"); } - GatewayAuthConfig::ApiKey { key_prefix } => { + AuthModeConfig::ApiKey { key_prefix } => { if key_prefix.is_empty() { let msg = "API key prefix cannot be empty".to_string(); println!(" ✗ {}", msg); @@ -775,7 +775,7 @@ fn validate_config(config: &WizardConfig) -> Result<(), WizardError> { println!(" ✓ Authentication: API key (prefix: {})", key_prefix); } } - GatewayAuthConfig::Oidc(oidc) => { + AuthModeConfig::Idp(oidc) => { if oidc.issuer.is_empty() { let msg = "OIDC issuer URL cannot be empty".to_string(); println!(" ✗ {}", msg); @@ -785,7 +785,7 @@ fn validate_config(config: &WizardConfig) -> Result<(), WizardError> { println!(" ✗ {}", msg); errors.push(msg); } else { - println!(" ✓ Authentication: OIDC (issuer: {})", oidc.issuer); + println!(" ✓ Authentication: IdP (issuer: {})", oidc.issuer); } } } @@ -1024,40 +1024,48 @@ fn generate_config(mode: DeploymentMode, wizard_config: &WizardConfig) -> String // Authentication section match &wizard_config.auth { - GatewayAuthConfig::None => { - config.push_str("[auth.gateway]\n"); + AuthModeConfig::None => { + config.push_str("[auth.mode]\n"); config.push_str("type = \"none\"\n"); config.push('\n'); } - GatewayAuthConfig::ApiKey { key_prefix } => { - config.push_str("[auth.gateway]\n"); + AuthModeConfig::ApiKey { key_prefix } => { + config.push_str("[auth.mode]\n"); config.push_str("type = \"api_key\"\n"); + config.push('\n'); + config.push_str("[auth.api_key]\n"); config.push_str(&format!( "key_prefix = \"{}\"\n", escape_toml_string(key_prefix) )); config.push('\n'); } - GatewayAuthConfig::Oidc(oidc) => { - config.push_str("[auth.admin]\n"); - config.push_str("type = \"oidc\"\n"); + AuthModeConfig::Idp(oidc) => { + config.push_str("[auth.mode]\n"); + config.push_str("type = \"idp\"\n"); + config.push('\n'); + config.push_str("# Note: Per-org SSO is configured via the admin API.\n"); + config.push_str("# The OIDC settings below are for reference.\n"); config.push_str(&format!( - "issuer = \"{}\"\n", + "# issuer = \"{}\"\n", escape_toml_string(&oidc.issuer) )); config.push_str(&format!( - "client_id = \"{}\"\n", + "# client_id = \"{}\"\n", escape_toml_string(&oidc.client_id) )); config.push_str(&format!( - "client_secret = \"{}\"\n", + "# client_secret = \"{}\"\n", escape_toml_string(&oidc.client_secret) )); config.push_str(&format!( - "redirect_uri = \"{}\"\n", + "# redirect_uri = \"{}\"\n", escape_toml_string(&oidc.redirect_uri) )); config.push('\n'); + config.push_str("[auth.session]\n"); + config.push_str("secret = \"${SESSION_SECRET}\"\n"); + config.push('\n'); } } @@ -1163,7 +1171,7 @@ mod tests { region: None, project_id: None, }], - auth: GatewayAuthConfig::None, + auth: AuthModeConfig::None, rate_limits: RateLimitConfig::default(), budget: BudgetConfig::default(), }; @@ -1177,7 +1185,7 @@ mod tests { assert!(config.contains("type = \"memory\"")); assert!(config.contains("[providers.openai]")); assert!(config.contains("api_key = \"${OPENAI_API_KEY}\"")); - assert!(config.contains("[auth.gateway]")); + assert!(config.contains("[auth.mode]")); assert!(config.contains("type = \"none\"")); } @@ -1198,7 +1206,7 @@ mod tests { region: None, project_id: None, }], - auth: GatewayAuthConfig::ApiKey { + auth: AuthModeConfig::ApiKey { key_prefix: "test_".to_string(), }, rate_limits: RateLimitConfig { @@ -1214,8 +1222,9 @@ mod tests { let config = generate_config(DeploymentMode::SingleNode, &wizard_config); - assert!(config.contains("[auth.gateway]")); + assert!(config.contains("[auth.mode]")); assert!(config.contains("type = \"api_key\"")); + assert!(config.contains("[auth.api_key]")); assert!(config.contains("key_prefix = \"test_\"")); assert!(config.contains("[limits.rate_limits]")); assert!(config.contains("requests_per_minute = 100")); @@ -1241,7 +1250,7 @@ mod tests { region: None, project_id: None, }], - auth: GatewayAuthConfig::Oidc(OidcConfig { + auth: AuthModeConfig::Idp(OidcConfig { issuer: "https://auth.example.com".to_string(), client_id: "my-app".to_string(), client_secret: "${OIDC_CLIENT_SECRET}".to_string(), @@ -1253,10 +1262,11 @@ mod tests { let config = generate_config(DeploymentMode::MultiNode, &wizard_config); - assert!(config.contains("[auth.admin]")); - assert!(config.contains("type = \"oidc\"")); - assert!(config.contains("issuer = \"https://auth.example.com\"")); - assert!(config.contains("client_id = \"my-app\"")); - assert!(config.contains("client_secret = \"${OIDC_CLIENT_SECRET}\"")); + assert!(config.contains("[auth.mode]")); + assert!(config.contains("type = \"idp\"")); + assert!(config.contains("[auth.session]")); + // OIDC settings are now comments (per-org SSO is configured via admin API) + assert!(config.contains("# issuer = \"https://auth.example.com\"")); + assert!(config.contains("# client_id = \"my-app\"")); } } diff --git a/ui/package.json b/ui/package.json index 464e505..755f647 100644 --- a/ui/package.json +++ b/ui/package.json @@ -104,9 +104,14 @@ ] }, "overrides": { - "glob>minimatch": ">=10.2.1", - "filelist>minimatch": ">=10.2.1", - "@typescript-eslint/typescript-estree>minimatch": ">=10.2.1", + "glob>minimatch": ">=10.2.3", + "filelist>minimatch": ">=10.2.3", + "@typescript-eslint/typescript-estree>minimatch": ">=10.2.3", + "eslint>minimatch": "~3.1.4", + "@eslint/config-array>minimatch": "~3.1.4", + "@eslint/eslintrc>minimatch": "~3.1.4", + "eslint-plugin-jsx-a11y>minimatch": "~3.1.4", + "@rollup/plugin-terser>serialize-javascript": ">=7.0.3", "qs": ">=6.14.2", "workbox-build>ajv": ">=8.18.0", "@modelcontextprotocol/sdk>ajv": ">=8.18.0" diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 7c07818..2621838 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -5,9 +5,14 @@ settings: excludeLinksFromLockfile: false overrides: - glob>minimatch: '>=10.2.1' - filelist>minimatch: '>=10.2.1' - '@typescript-eslint/typescript-estree>minimatch': '>=10.2.1' + glob>minimatch: '>=10.2.3' + filelist>minimatch: '>=10.2.3' + '@typescript-eslint/typescript-estree>minimatch': '>=10.2.3' + eslint>minimatch: ~3.1.4 + '@eslint/config-array>minimatch': ~3.1.4 + '@eslint/eslintrc>minimatch': ~3.1.4 + eslint-plugin-jsx-a11y>minimatch: ~3.1.4 + '@rollup/plugin-terser>serialize-javascript': '>=7.0.3' qs: '>=6.14.2' workbox-build>ajv: '>=8.18.0' '@modelcontextprotocol/sdk>ajv': '>=8.18.0' @@ -3860,12 +3865,12 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.3: - resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -4139,9 +4144,6 @@ packages: quickjs-emscripten-core@0.31.0: resolution: {integrity: sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ==} - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4390,9 +4392,6 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -4425,8 +4424,9 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + serialize-javascript@7.0.3: + resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==} + engines: {node: '>=20.0.0'} serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} @@ -6107,7 +6107,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.3 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -6128,7 +6128,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.3 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -6276,7 +6276,7 @@ snapshots: '@mcp-ui/client@6.1.0(@preact/signals-core@1.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@modelcontextprotocol/ext-apps': 0.3.1(@modelcontextprotocol/sdk@1.27.0(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76) + '@modelcontextprotocol/ext-apps': 0.3.1(@modelcontextprotocol/sdk@1.27.0(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76) '@modelcontextprotocol/sdk': 1.27.0(zod@3.25.76) '@quilted/threads': 3.3.1(@preact/signals-core@1.13.0) '@r2wc/react-to-web-component': 2.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -6297,7 +6297,7 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 - '@modelcontextprotocol/ext-apps@0.3.1(@modelcontextprotocol/sdk@1.27.0(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76)': + '@modelcontextprotocol/ext-apps@0.3.1(@modelcontextprotocol/sdk@1.27.0(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76)': dependencies: '@modelcontextprotocol/sdk': 1.27.0(zod@3.25.76) zod: 3.25.76 @@ -6669,7 +6669,7 @@ snapshots: '@rollup/plugin-terser@0.4.4(rollup@2.80.0)': dependencies: - serialize-javascript: 6.0.2 + serialize-javascript: 7.0.3 smob: 1.6.1 terser: 5.46.0 optionalDependencies: @@ -7232,7 +7232,7 @@ snapshots: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -8099,7 +8099,7 @@ snapshots: hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.3 + minimatch: 3.1.5 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 @@ -8172,7 +8172,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.3 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -8286,7 +8286,7 @@ snapshots: filelist@1.0.5: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.4 finalhandler@2.1.1: dependencies: @@ -8411,14 +8411,14 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.2.3 - minimatch: 10.2.2 + minimatch: 10.2.4 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 2.0.2 glob@13.0.6: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.4 minipass: 7.1.3 path-scurry: 2.0.2 @@ -9316,11 +9316,11 @@ snapshots: min-indent@1.0.1: {} - minimatch@10.2.2: + minimatch@10.2.4: dependencies: brace-expansion: 5.0.3 - minimatch@3.1.3: + minimatch@3.1.5: dependencies: brace-expansion: 1.1.12 @@ -9596,10 +9596,6 @@ snapshots: dependencies: '@jitl/quickjs-ffi-types': 0.31.0 - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - range-parser@1.2.1: {} raw-body@3.0.2: @@ -9936,8 +9932,6 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 - safe-buffer@5.2.1: {} - safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -9975,9 +9969,7 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 + serialize-javascript@7.0.3: {} serve-static@2.2.1: dependencies: diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index e736a5b..c64fb79 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Hadrian Gateway API", - "description": "**Hadrian Gateway** is an AI Gateway providing a unified OpenAI-compatible API for routing requests to multiple LLM providers.\n\n## Overview\n\nThe gateway provides two main API surfaces:\n\n- **Public API** (`/api/v1/*`) - OpenAI-compatible endpoints for LLM inference. Use these endpoints to create chat completions, text completions, embeddings, and list available models. Requires API key authentication.\n\n- **Admin API** (`/admin/v1/*`) - RESTful management endpoints for multi-tenant configuration. Manage organizations, projects, users, API keys, dynamic providers, usage tracking, and model pricing.\n\n## Authentication\n\nThe gateway supports multiple authentication methods for API access.\n\n### API Key Authentication\n\nAPI keys are the primary authentication method for programmatic access. Keys are created via the Admin API and scoped to organizations, projects, or users.\n\n**Using the Authorization header (recommended):**\n```\nAuthorization: Bearer gw_live_abc123def456...\n```\n\n**Using the X-API-Key header:**\n```\nX-API-Key: gw_live_abc123def456...\n```\n\nBoth headers are supported. The `Authorization: Bearer` format is recommended for compatibility with OpenAI client libraries.\n\n**Example request:**\n```bash\ncurl https://gateway.example.com/api/v1/chat/completions \\\n -H \\\"Authorization: Bearer gw_live_abc123def456...\\\" \\\n -H \\\"Content-Type: application/json\\\" \\\n -d '{\\\"model\\\": \\\"openai/gpt-4\\\", \\\"messages\\\": [{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]}'\n```\n\n### JWT Authentication\n\nWhen JWT authentication is enabled, requests can be authenticated using a JWT token from your identity provider.\n\n```\nAuthorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...\n```\n\nThe gateway validates the JWT against the configured JWKS endpoint and extracts the identity from the token claims.\n\n**Example request:**\n```bash\ncurl https://gateway.example.com/api/v1/chat/completions \\\n -H \\\"Authorization: Bearer eyJhbGciOiJSUzI1NiIs...\\\" \\\n -H \\\"Content-Type: application/json\\\" \\\n -d '{\\\"model\\\": \\\"openai/gpt-4\\\", \\\"messages\\\": [{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]}'\n```\n\n### Multi-Auth Mode\n\nWhen configured for multi-auth, the gateway accepts both API keys and JWTs using **format-based detection**:\n\n- **X-API-Key header**: Always validated as an API key\n- **Authorization: Bearer header**: Uses format-based detection:\n - Tokens starting with the configured API key prefix (default: `gw_`) are validated as API keys\n - All other tokens are validated as JWTs\n\n**Important:** Providing both `X-API-Key` and `Authorization` headers simultaneously results in a 400 error (ambiguous credentials). Choose one authentication method per request.\n\n**Examples:**\n```bash\n# API key in X-API-Key header\ncurl -H \\\"X-API-Key: gw_live_abc123...\\\" https://gateway.example.com/v1/chat/completions\n\n# API key in Authorization: Bearer header (format-based detection)\ncurl -H \\\"Authorization: Bearer gw_live_abc123...\\\" https://gateway.example.com/v1/chat/completions\n\n# JWT in Authorization: Bearer header\ncurl -H \\\"Authorization: Bearer eyJhbGciOiJSUzI1NiIs...\\\" https://gateway.example.com/v1/chat/completions\n```\n\n### Authentication Errors\n\n| Error Code | HTTP Status | Description | Example Response |\n|------------|-------------|-------------|------------------|\n| `unauthorized` | 401 | No authentication credentials provided | `{\\\"error\\\": {\\\"code\\\": \\\"unauthorized\\\", \\\"message\\\": \\\"Authentication required\\\"}}` |\n| `ambiguous_credentials` | 400 | Both X-API-Key and Authorization headers provided | `{\\\"error\\\": {\\\"code\\\": \\\"ambiguous_credentials\\\", \\\"message\\\": \\\"Ambiguous credentials: provide either X-API-Key or Authorization header, not both\\\"}}` |\n| `invalid_api_key` | 401 | API key is invalid, malformed, or revoked | `{\\\"error\\\": {\\\"code\\\": \\\"invalid_api_key\\\", \\\"message\\\": \\\"Invalid API key\\\"}}` |\n| `not_authenticated` | 401 | JWT validation failed | `{\\\"error\\\": {\\\"code\\\": \\\"not_authenticated\\\", \\\"message\\\": \\\"Token validation failed\\\"}}` |\n| `forbidden` | 403 | Valid credentials but insufficient permissions | `{\\\"error\\\": {\\\"code\\\": \\\"forbidden\\\", \\\"message\\\": \\\"Insufficient permissions\\\"}}` |\n\n### Configuration Examples\n\n**API Key Authentication:**\n```toml\n[auth.gateway]\ntype = \\\"api_key\\\"\nheader_name = \\\"X-API-Key\\\" # Header to read API key from\nkey_prefix = \\\"gw_\\\" # Valid key prefix\ncache_ttl_secs = 60 # Cache key lookups for 60 seconds\n```\n\n**JWT Authentication:**\n```toml\n[auth.gateway]\ntype = \\\"jwt\\\"\nissuer = \\\"https://auth.example.com\\\"\naudience = \\\"gateway-api\\\"\njwks_url = \\\"https://auth.example.com/.well-known/jwks.json\\\"\nidentity_claim = \\\"sub\\\" # JWT claim for user identity\n```\n\n**Multi-Auth (both API key and JWT):**\n```toml\n[auth.gateway]\ntype = \\\"multi\\\"\n\n[auth.gateway.api_key]\nheader_name = \\\"X-API-Key\\\"\nkey_prefix = \\\"gw_\\\"\n\n[auth.gateway.jwt]\nissuer = \\\"https://auth.example.com\\\"\naudience = \\\"gateway-api\\\"\njwks_url = \\\"https://auth.example.com/.well-known/jwks.json\\\"\n```\n\n## Pagination\n\nAll Admin API list endpoints use **cursor-based pagination** for stable, performant navigation.\n\n**Query Parameters:**\n- `limit` (optional): Maximum records per page (default: 100, max: 1000)\n- `cursor` (optional): Opaque cursor from previous response's `next_cursor` or `prev_cursor`\n- `direction` (optional): `forward` (default) or `backward`\n\n**Response:**\n```json\n{\n \\\"data\\\": [...],\n \\\"pagination\\\": {\n \\\"limit\\\": 100,\n \\\"has_more\\\": true,\n \\\"next_cursor\\\": \\\"MTczMzU4MDgwMDAwMDphYmMxMjM0...\\\",\n \\\"prev_cursor\\\": null\n }\n}\n```\n\n## Model Routing\n\nModels can be addressed in several ways:\n\n- **Static routing**: `provider-name/model-name` routes to config-defined providers\n- **Dynamic routing**: `:org/{ORG}/{PROVIDER}/{MODEL}` routes to database-backed providers\n- **Default**: When no prefix is specified, routes to the default provider\n\n## Error Codes\n\nAll errors follow a consistent JSON format:\n\n```json\n{\n \\\"error\\\": {\n \\\"code\\\": \\\"error_code\\\",\n \\\"message\\\": \\\"Human-readable error message\\\",\n \\\"details\\\": { ... } // Optional additional context\n }\n}\n```\n\n### Authentication & Authorization Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `unauthorized` | 401 | Missing or invalid API key/token |\n| `invalid_api_key` | 401 | API key is invalid, expired, or revoked |\n| `forbidden` | 403 | Valid credentials but insufficient permissions |\n| `not_authenticated` | 401 | Authentication required for this operation |\n\n### Rate Limiting & Budget Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `rate_limit_exceeded` | 429 | Request rate limit exceeded. Check `Retry-After` header. |\n| `budget_exceeded` | 402 | Budget limit exceeded for the configured period. Details include `limit_cents`, `current_spend_cents`, and `period`. |\n| `cache_required` | 503 | Budget enforcement requires cache to be configured |\n\n### Request Validation Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `validation_error` | 400 | Request body validation failed |\n| `bad_request` | 400 | Malformed request |\n| `routing_error` | 400 | Model routing failed (invalid model string or provider not found) |\n| `not_found` | 404 | Requested resource not found |\n| `conflict` | 409 | Resource already exists or conflicts with existing state |\n\n### Provider & Gateway Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `provider_error` | 502 | Upstream LLM provider returned an error |\n| `request_failed` | 502 | Failed to communicate with upstream provider |\n| `circuit_breaker_open` | 503 | Provider circuit breaker is open due to repeated failures |\n| `response_read_error` | 500 | Failed to read provider response |\n| `response_builder` | 500 | Failed to build response from provider data |\n| `internal_error` | 500 | Internal server error |\n\n### Guardrails Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `guardrails_blocked` | 400 | Content blocked by guardrails policy. Response includes `violations` array. |\n| `guardrails_timeout` | 504 | Guardrails evaluation timed out |\n| `guardrails_provider_error` | 502 | Error communicating with guardrails provider |\n| `guardrails_auth_error` | 502 | Authentication failed with guardrails provider |\n| `guardrails_rate_limited` | 429 | Guardrails provider rate limit exceeded |\n| `guardrails_config_error` | 500 | Invalid guardrails configuration |\n| `guardrails_parse_error` | 400 | Failed to parse content for guardrails evaluation |\n\n### Admin API Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `database_required` | 503 | Database not configured (required for admin operations) |\n| `services_required` | 503 | Required services not initialized |\n| `not_configured` | 503 | Required feature or service not configured |\n| `database_error` | 500 | Database operation failed |\n\n## Rate Limiting\n\nThe gateway implements multiple layers of rate limiting to protect against abuse and ensure fair usage.\n\n### Rate Limit Types\n\n| Type | Scope | Default | Description |\n|------|-------|---------|-------------|\n| **Requests per minute** | API Key | 60 | Maximum requests per minute per API key |\n| **Requests per day** | API Key | Unlimited | Optional daily request limit per API key |\n| **Tokens per minute** | API Key | 100,000 | Maximum tokens processed per minute |\n| **Tokens per day** | API Key | Unlimited | Optional daily token limit |\n| **Concurrent requests** | API Key | 10 | Maximum simultaneous in-flight requests |\n| **IP requests per minute** | IP Address | 120 | Rate limit for unauthenticated requests |\n\n### Rate Limit Headers\n\nAll API responses include rate limit information in HTTP headers.\n\n#### Request Rate Limit Headers\n\n| Header | Description | Example |\n|--------|-------------|---------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the current window | `60` |\n| `X-RateLimit-Remaining` | Requests remaining in the current window | `45` |\n| `X-RateLimit-Reset` | Seconds until the rate limit window resets | `42` |\n\n#### Token Rate Limit Headers\n\n| Header | Description | Example |\n|--------|-------------|---------|\n| `X-TokenRateLimit-Limit` | Maximum tokens allowed per minute | `100000` |\n| `X-TokenRateLimit-Remaining` | Tokens remaining in the current minute | `85000` |\n| `X-TokenRateLimit-Used` | Tokens used in the current minute | `15000` |\n| `X-TokenRateLimit-Day-Limit` | Maximum tokens allowed per day (if configured) | `1000000` |\n| `X-TokenRateLimit-Day-Remaining` | Tokens remaining today (if configured) | `950000` |\n\n#### Rate Limit Exceeded Response\n\nWhen a rate limit is exceeded, the API returns HTTP 429 with:\n\n```json\n{\n \\\"error\\\": {\n \\\"code\\\": \\\"rate_limit_exceeded\\\",\n \\\"message\\\": \\\"Rate limit exceeded: 60 requests per minute\\\",\n \\\"details\\\": {\n \\\"limit\\\": 60,\n \\\"window\\\": \\\"minute\\\",\n \\\"retry_after_secs\\\": 42\n }\n }\n}\n```\n\nThe `Retry-After` header indicates seconds to wait before retrying:\n\n```\nHTTP/1.1 429 Too Many Requests\nRetry-After: 42\nX-RateLimit-Limit: 60\nX-RateLimit-Remaining: 0\nX-RateLimit-Reset: 42\n```\n\n### IP-Based Rate Limiting\n\nUnauthenticated requests (requests without a valid API key) are rate limited by IP address. This protects public endpoints like `/health` from abuse.\n\n- **Default:** 120 requests per minute per IP\n- **Client IP Detection:** Respects `X-Forwarded-For` and `X-Real-IP` headers when trusted proxies are configured\n- **Configuration:** Can be disabled or adjusted via `limits.rate_limits.ip_rate_limits` in config\n\n### Rate Limit Configuration\n\nRate limits are configured hierarchically:\n\n1. **Global defaults** (in `hadrian.toml`):\n```toml\n[limits.rate_limits]\nrequests_per_minute = 60\ntokens_per_minute = 100000\nconcurrent_requests = 10\n\n[limits.rate_limits.ip_rate_limits]\nenabled = true\nrequests_per_minute = 120\n```\n\n2. **Per-API key** limits can override global defaults (when creating API keys via Admin API)\n\n### Best Practices\n\n- **Implement exponential backoff**: When receiving 429 responses, wait the `Retry-After` duration before retrying\n- **Monitor rate limit headers**: Track `X-RateLimit-Remaining` to proactively throttle requests\n- **Use streaming for long responses**: Streaming responses don't hold connections during generation\n- **Batch requests when possible**: Combine multiple small requests into larger batches\n", + "description": "**Hadrian Gateway** is an AI Gateway providing a unified OpenAI-compatible API for routing requests to multiple LLM providers.\n\n## Overview\n\nThe gateway provides two main API surfaces:\n\n- **Public API** (`/api/v1/*`) - OpenAI-compatible endpoints for LLM inference. Use these endpoints to create chat completions, text completions, embeddings, and list available models. Authentication depends on the configured `auth.mode` (API key, IdP, IAP, or none).\n\n- **Admin API** (`/admin/v1/*`) - RESTful management endpoints for multi-tenant configuration. Manage organizations, projects, users, API keys, dynamic providers, usage tracking, and model pricing.\n\n## Authentication\n\nThe gateway supports multiple authentication methods for API access.\n\n### API Key Authentication\n\nAPI keys are the primary authentication method for programmatic access. Keys are created via the Admin API and scoped to organizations, projects, or users.\n\n**Using the Authorization header (recommended):**\n```\nAuthorization: Bearer gw_live_abc123def456...\n```\n\n**Using the X-API-Key header:**\n```\nX-API-Key: gw_live_abc123def456...\n```\n\nBoth headers are supported. The `Authorization: Bearer` format is recommended for compatibility with OpenAI client libraries.\n\n**Example request:**\n```bash\ncurl https://gateway.example.com/api/v1/chat/completions \\\n -H \\\"Authorization: Bearer gw_live_abc123def456...\\\" \\\n -H \\\"Content-Type: application/json\\\" \\\n -d '{\\\"model\\\": \\\"openai/gpt-4\\\", \\\"messages\\\": [{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]}'\n```\n\n### JWT Authentication\n\nWhen JWT authentication is enabled, requests can be authenticated using a JWT token from your identity provider.\n\n```\nAuthorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...\n```\n\nThe gateway validates the JWT against the configured JWKS endpoint and extracts the identity from the token claims.\n\n**Example request:**\n```bash\ncurl https://gateway.example.com/api/v1/chat/completions \\\n -H \\\"Authorization: Bearer eyJhbGciOiJSUzI1NiIs...\\\" \\\n -H \\\"Content-Type: application/json\\\" \\\n -d '{\\\"model\\\": \\\"openai/gpt-4\\\", \\\"messages\\\": [{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"Hello\\\"}]}'\n```\n\n### Multi-Auth Mode\n\nWhen configured for multi-auth, the gateway accepts both API keys and JWTs using **format-based detection**:\n\n- **X-API-Key header**: Always validated as an API key\n- **Authorization: Bearer header**: Uses format-based detection:\n - Tokens starting with the configured API key prefix (default: `gw_`) are validated as API keys\n - All other tokens are validated as JWTs\n\n**Important:** Providing both `X-API-Key` and `Authorization` headers simultaneously results in a 400 error (ambiguous credentials). Choose one authentication method per request.\n\n**Examples:**\n```bash\n# API key in X-API-Key header\ncurl -H \\\"X-API-Key: gw_live_abc123...\\\" https://gateway.example.com/v1/chat/completions\n\n# API key in Authorization: Bearer header (format-based detection)\ncurl -H \\\"Authorization: Bearer gw_live_abc123...\\\" https://gateway.example.com/v1/chat/completions\n\n# JWT in Authorization: Bearer header\ncurl -H \\\"Authorization: Bearer eyJhbGciOiJSUzI1NiIs...\\\" https://gateway.example.com/v1/chat/completions\n```\n\n### Authentication Errors\n\n| Error Code | HTTP Status | Description | Example Response |\n|------------|-------------|-------------|------------------|\n| `unauthorized` | 401 | No authentication credentials provided | `{\\\"error\\\": {\\\"code\\\": \\\"unauthorized\\\", \\\"message\\\": \\\"Authentication required\\\"}}` |\n| `ambiguous_credentials` | 400 | Both X-API-Key and Authorization headers provided | `{\\\"error\\\": {\\\"code\\\": \\\"ambiguous_credentials\\\", \\\"message\\\": \\\"Ambiguous credentials: provide either X-API-Key or Authorization header, not both\\\"}}` |\n| `invalid_api_key` | 401 | API key is invalid, malformed, or revoked | `{\\\"error\\\": {\\\"code\\\": \\\"invalid_api_key\\\", \\\"message\\\": \\\"Invalid API key\\\"}}` |\n| `not_authenticated` | 401 | JWT validation failed | `{\\\"error\\\": {\\\"code\\\": \\\"not_authenticated\\\", \\\"message\\\": \\\"Token validation failed\\\"}}` |\n| `forbidden` | 403 | Valid credentials but insufficient permissions | `{\\\"error\\\": {\\\"code\\\": \\\"forbidden\\\", \\\"message\\\": \\\"Insufficient permissions\\\"}}` |\n\n### Configuration Examples\n\n**API Key Authentication:**\n```toml\n[auth.mode]\ntype = \\\"api_key\\\"\n\n[auth.api_key]\nheader_name = \\\"X-API-Key\\\" # Header to read API key from\nkey_prefix = \\\"gw_\\\" # Valid key prefix\ncache_ttl_secs = 60 # Cache key lookups for 60 seconds\n```\n\n**IdP Authentication (SSO + API keys + JWT):**\n```toml\n[auth.mode]\ntype = \\\"idp\\\"\n\n[auth.api_key]\nheader_name = \\\"X-API-Key\\\"\nkey_prefix = \\\"gw_\\\"\n\n[auth.session]\nsecure = true\n```\n\n**Identity-Aware Proxy (IAP):**\n```toml\n[auth.mode]\ntype = \\\"iap\\\"\nidentity_header = \\\"X-Forwarded-User\\\"\nemail_header = \\\"X-Forwarded-Email\\\"\n```\n\n## Pagination\n\nAll Admin API list endpoints use **cursor-based pagination** for stable, performant navigation.\n\n**Query Parameters:**\n- `limit` (optional): Maximum records per page (default: 100, max: 1000)\n- `cursor` (optional): Opaque cursor from previous response's `next_cursor` or `prev_cursor`\n- `direction` (optional): `forward` (default) or `backward`\n\n**Response:**\n```json\n{\n \\\"data\\\": [...],\n \\\"pagination\\\": {\n \\\"limit\\\": 100,\n \\\"has_more\\\": true,\n \\\"next_cursor\\\": \\\"MTczMzU4MDgwMDAwMDphYmMxMjM0...\\\",\n \\\"prev_cursor\\\": null\n }\n}\n```\n\n## Model Routing\n\nModels can be addressed in several ways:\n\n- **Static routing**: `provider-name/model-name` routes to config-defined providers\n- **Dynamic routing**: `:org/{ORG}/{PROVIDER}/{MODEL}` routes to database-backed providers\n- **Default**: When no prefix is specified, routes to the default provider\n\n## Error Codes\n\nAll errors follow a consistent JSON format:\n\n```json\n{\n \\\"error\\\": {\n \\\"code\\\": \\\"error_code\\\",\n \\\"message\\\": \\\"Human-readable error message\\\",\n \\\"details\\\": { ... } // Optional additional context\n }\n}\n```\n\n### Authentication & Authorization Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `unauthorized` | 401 | Missing or invalid API key/token |\n| `invalid_api_key` | 401 | API key is invalid, expired, or revoked |\n| `forbidden` | 403 | Valid credentials but insufficient permissions |\n| `not_authenticated` | 401 | Authentication required for this operation |\n\n### Rate Limiting & Budget Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `rate_limit_exceeded` | 429 | Request rate limit exceeded. Check `Retry-After` header. |\n| `budget_exceeded` | 402 | Budget limit exceeded for the configured period. Details include `limit_cents`, `current_spend_cents`, and `period`. |\n| `cache_required` | 503 | Budget enforcement requires cache to be configured |\n\n### Request Validation Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `validation_error` | 400 | Request body validation failed |\n| `bad_request` | 400 | Malformed request |\n| `routing_error` | 400 | Model routing failed (invalid model string or provider not found) |\n| `not_found` | 404 | Requested resource not found |\n| `conflict` | 409 | Resource already exists or conflicts with existing state |\n\n### Provider & Gateway Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `provider_error` | 502 | Upstream LLM provider returned an error |\n| `request_failed` | 502 | Failed to communicate with upstream provider |\n| `circuit_breaker_open` | 503 | Provider circuit breaker is open due to repeated failures |\n| `response_read_error` | 500 | Failed to read provider response |\n| `response_builder` | 500 | Failed to build response from provider data |\n| `internal_error` | 500 | Internal server error |\n\n### Guardrails Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `guardrails_blocked` | 400 | Content blocked by guardrails policy. Response includes `violations` array. |\n| `guardrails_timeout` | 504 | Guardrails evaluation timed out |\n| `guardrails_provider_error` | 502 | Error communicating with guardrails provider |\n| `guardrails_auth_error` | 502 | Authentication failed with guardrails provider |\n| `guardrails_rate_limited` | 429 | Guardrails provider rate limit exceeded |\n| `guardrails_config_error` | 500 | Invalid guardrails configuration |\n| `guardrails_parse_error` | 400 | Failed to parse content for guardrails evaluation |\n\n### Admin API Errors\n\n| Code | HTTP Status | Description |\n|------|-------------|-------------|\n| `database_required` | 503 | Database not configured (required for admin operations) |\n| `services_required` | 503 | Required services not initialized |\n| `not_configured` | 503 | Required feature or service not configured |\n| `database_error` | 500 | Database operation failed |\n\n## Rate Limiting\n\nThe gateway implements multiple layers of rate limiting to protect against abuse and ensure fair usage.\n\n### Rate Limit Types\n\n| Type | Scope | Default | Description |\n|------|-------|---------|-------------|\n| **Requests per minute** | API Key | 60 | Maximum requests per minute per API key |\n| **Requests per day** | API Key | Unlimited | Optional daily request limit per API key |\n| **Tokens per minute** | API Key | 100,000 | Maximum tokens processed per minute |\n| **Tokens per day** | API Key | Unlimited | Optional daily token limit |\n| **Concurrent requests** | API Key | 10 | Maximum simultaneous in-flight requests |\n| **IP requests per minute** | IP Address | 120 | Rate limit for unauthenticated requests |\n\n### Rate Limit Headers\n\nAll API responses include rate limit information in HTTP headers.\n\n#### Request Rate Limit Headers\n\n| Header | Description | Example |\n|--------|-------------|---------|\n| `X-RateLimit-Limit` | Maximum requests allowed in the current window | `60` |\n| `X-RateLimit-Remaining` | Requests remaining in the current window | `45` |\n| `X-RateLimit-Reset` | Seconds until the rate limit window resets | `42` |\n\n#### Token Rate Limit Headers\n\n| Header | Description | Example |\n|--------|-------------|---------|\n| `X-TokenRateLimit-Limit` | Maximum tokens allowed per minute | `100000` |\n| `X-TokenRateLimit-Remaining` | Tokens remaining in the current minute | `85000` |\n| `X-TokenRateLimit-Used` | Tokens used in the current minute | `15000` |\n| `X-TokenRateLimit-Day-Limit` | Maximum tokens allowed per day (if configured) | `1000000` |\n| `X-TokenRateLimit-Day-Remaining` | Tokens remaining today (if configured) | `950000` |\n\n#### Rate Limit Exceeded Response\n\nWhen a rate limit is exceeded, the API returns HTTP 429 with:\n\n```json\n{\n \\\"error\\\": {\n \\\"code\\\": \\\"rate_limit_exceeded\\\",\n \\\"message\\\": \\\"Rate limit exceeded: 60 requests per minute\\\",\n \\\"details\\\": {\n \\\"limit\\\": 60,\n \\\"window\\\": \\\"minute\\\",\n \\\"retry_after_secs\\\": 42\n }\n }\n}\n```\n\nThe `Retry-After` header indicates seconds to wait before retrying:\n\n```\nHTTP/1.1 429 Too Many Requests\nRetry-After: 42\nX-RateLimit-Limit: 60\nX-RateLimit-Remaining: 0\nX-RateLimit-Reset: 42\n```\n\n### IP-Based Rate Limiting\n\nUnauthenticated requests (requests without a valid API key) are rate limited by IP address. This protects public endpoints like `/health` from abuse.\n\n- **Default:** 120 requests per minute per IP\n- **Client IP Detection:** Respects `X-Forwarded-For` and `X-Real-IP` headers when trusted proxies are configured\n- **Configuration:** Can be disabled or adjusted via `limits.rate_limits.ip_rate_limits` in config\n\n### Rate Limit Configuration\n\nRate limits are configured hierarchically:\n\n1. **Global defaults** (in `hadrian.toml`):\n```toml\n[limits.rate_limits]\nrequests_per_minute = 60\ntokens_per_minute = 100000\nconcurrent_requests = 10\n\n[limits.rate_limits.ip_rate_limits]\nenabled = true\nrequests_per_minute = 120\n```\n\n2. **Per-API key** limits can override global defaults (when creating API keys via Admin API)\n\n### Best Practices\n\n- **Implement exponential backoff**: When receiving 429 responses, wait the `Retry-After` duration before retrying\n- **Monitor rate limit headers**: Track `X-RateLimit-Remaining` to proactively throttle requests\n- **Use streaming for long responses**: Streaming responses don't hold connections during generation\n- **Batch requests when possible**: Combine multiple small requests into larger batches\n", "license": { "name": "Apache-2.0 OR MIT", "url": "https://github.com/ScriptSmith/hadrian/blob/main/LICENSE-APACHE"