diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md index 9a848b629..0a27def77 100644 --- a/.github/RELEASE_NOTICE.md +++ b/.github/RELEASE_NOTICE.md @@ -54,3 +54,44 @@ The server edition now enforces a **25-token quota per signed-in user** on top o - Server edition: a user at 25 tokens gets a `409 Conflict` from `POST /user/tokens` that names *their* quota — permanently deleting one of their unused tokens frees a slot — so one user can no longer take the whole pool. The 100-record deployment cap remains and every stored token still counts toward it, so a deployment whose stored records add up to 100 refuses the next token for everyone until an administrator frees records; a caller who is still under their own quota then gets the `409` that says the limit is shared and points at an administrator (the quota is checked first, so a user already at 25 sees their own-quota message instead). A user who already holds more than 25 tokens keeps them; they cannot create another until they are back under the quota. - Personal edition: every token is ownerless, so the quota does not apply and the 100-token limit is unchanged. - No configuration change is needed. Details: [agent tokens](https://docs.mcpproxy.app/features/agent-tokens/). + +## Server edition: `/mcp` always requires a credential + +When `server_edition.enabled` is true, `/mcp` now behaves as if `require_mcp_auth` were `true` whatever the file says (spec 107, FR-029). Before this release a server-edition deployment with `require_mcp_auth` off (the default) handed every unauthenticated `/mcp` caller an **anonymous administrator** context. + +- No credential → `401`. A session cookie or a user JWT on `/mcp` → `401` (they were never valid there; they are no longer silently promoted). Agent tokens (`mcp_agt_…`), the global API key and the Unix socket work exactly as before. +- An explicit `"require_mcp_auth": false` is **not** a validation error, so no deployment fails to boot on upgrade. It logs one notice — `require_mcp_auth: false is overridden to true because server_edition.enabled is true` — and `mcpproxy doctor` reports the same line. Remove the key, or set it to `true`, to silence both. +- Personal edition: unchanged; `require_mcp_auth` keeps its configured value. +- If an AI client reached `/mcp` on a server-edition deployment without any credential, it now needs an agent token: each user mints one from the Web UI or `POST /api/v1/user/tokens` and sends it as `Authorization: Bearer mcp_agt_…`. + +## `trusted_proxies` now gates every forwarded header — behind an ingress, set it or `public_url` + +`X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-Host` used to be believed from **any** peer: a direct client could choose its own session IP, force the OAuth callback to `https`, or move it to another host. They are now honoured only when the request's `RemoteAddr` is inside the new top-level `trusted_proxies` list (CIDRs or addresses; env `MCPPROXY_TRUSTED_PROXIES`, comma-separated; hot-reloadable; both editions), taking the right-most hop that is not itself a trusted proxy as the client IP. The default is empty — **trust nobody** (FR-027). + +**What changes behind a reverse proxy or ingress if you do nothing** + +- The OAuth `redirect_uri` sent to your IdP is built from the listener's own scheme and `Host` — typically `http://…` — instead of the ingress's `X-Forwarded-Proto: https`. Your IdP's exact-match registration then refuses the callback and every SSO login fails. +- The session's recorded IP, the audit `client.ip`, the connect-flow base URL and the swagger server URL all show the ingress's address, not the user's. + +**Fix (either one)** + +- Set `server_edition.public_url` to the origin users reach (`https://mcp.example.com`; env `MCPPROXY_PUBLIC_URL`). It becomes the sole source of the callback URL (`/api/v1/auth/callback`), the connect-flow base URL and the cookie `Secure` decision; `Host` and `X-Forwarded-*` are ignored for those (FR-025). When it is unset and the listener is not loopback — the published image listens on `0.0.0.0:8080` — boot logs a warning and `mcpproxy doctor` reports it; it is not a validation error. +- Or list your ingress in `trusted_proxies` (`["10.0.0.0/8"]`, `["fd00::/8"]`, a single address). Do both if you also want the real client IP in sessions and audit lines. +- An invalid entry (`trusted_proxies[0] "…" is not a valid CIDR or IP address`) is a validation error at load and on `PATCH /api/v1/config`. No forwarded header ever feeds the local/remote or administrator classification. Details: [reverse proxy](https://docs.mcpproxy.app/operations/reverse-proxy/), [config file](https://docs.mcpproxy.app/configuration/config-file/), [environment variables](https://docs.mcpproxy.app/configuration/environment-variables/). + +**Session cookie** — `server_edition.session_cookie_secure` is new: `auto` (default) sets `Secure` when the effective scheme is https (`public_url`, in-process TLS, or `X-Forwarded-Proto: https` from a *trusted* proxy), `true` forces it, `false` disables it. Earlier releases never set `Secure`. Validation refuses `false` together with an `https://` `public_url` or in-process TLS; an explicit `false` elsewhere is honoured with one boot warning and a `mcpproxy doctor` finding. `HttpOnly` and `SameSite=Lax` are unchanged (FR-026). + +**Post-login redirect** — `redirect_uri` on `GET /api/v1/auth/login` is accepted only as a same-origin path (a single leading `/`, no scheme, host, `//`, `/\`, backslash or control character); anything else lands on `/ui/` and the login's `auth_event` line carries `redirect_rejected`. The Web UI is unaffected (FR-028). + +## Server edition: generic `oidc` identity provider + +`server_edition.oauth.provider` accepts `oidc` next to `google`, `github` and `microsoft`, so Okta, Entra ID, Keycloak, Authentik, Auth0 and any other OpenID Connect provider work without provider-specific code (FR-020). The three existing providers behave exactly as before. + +- **Configuration**: `issuer_url` (required; `https`, or `http` only for a loopback host **and** `allow_insecure_issuer: true`), `scopes` (default `["openid","profile","email"]`; `openid` is added if missing), `groups_claim` (default `"groups"`), `email_verified_policy` (default `refuse_false`), `display_name` (login-button label; falls back to the provider name). `client_id`/`client_secret` stay `${env:}`-referenced; there is no environment variable for nested keys. `authorization_endpoint`, `token_endpoint`, `jwks_uri` and `userinfo_endpoint` come from `/.well-known/openid-configuration`, fetched lazily on the first login and cached, so boot and readiness never wait on the IdP. +- **Every ID token is verified before any claim is read**: signature against the issuer's JWKS (RS/PS/ES families only — never `none`, never HS*), exact `iss`, `aud`/`azp`, `exp`/`nbf`/`iat` with 60 s skew, and a per-login `nonce`. Discovered endpoints must be absolute `https` (same loopback exception), and the back-channel client never follows a redirect: the client secret and code are still sent to your configured token endpoint (and a bearer token to your configured userinfo endpoint) as normal, but a 3xx answer from any of the token, JWKS or userinfo endpoints refuses the login instead of being followed — so a compromised or misconfigured endpoint cannot redirect that credential to another host (FR-021). +- **`email_verified_policy`** — `refuse_false` (default) refuses a login whose ID token says `email_verified: false` and admits one where the claim is absent; `require_true` also refuses an absent claim; `ignore` admits both. Pick `require_true` when your IdP always sets the claim; `refuse_false` exists so providers that omit it still work out of the box. `email` itself is required (`email_missing` otherwise). +- **Groups are captured**: on every successful `oidc` login the user record stores the groups claim from the verified ID token (or from `userinfo` when the token lacks it — accepted only when the userinfo `sub` equals the token's `sub`), replacing the previous list wholesale with a `groups_updated_at` timestamp; a missing or malformed claim stores `[]` and logs `groups_claim_missing`. `google`/`github`/`microsoft` logins store `[]`. `GET /api/v1/auth/me` returns your groups; `GET /api/v1/admin/users` shows every user's groups and `groups_updated_at`. Storing groups has no authorisation effect on its own; they are the input to the server-edition `access` grant (FR-008). +- **Subject binding**: a user record now remembers `(provider, provider_subject_id)` and refreshes both on every login. Same provider, same email, **different** subject is refused (`subject_mismatch`), so an IdP email collision cannot take over an existing account; an administrator re-arms the binding for a genuinely re-created IdP account by disabling and re-enabling the user — the next successful login rebinds (FR-023). +- **Refusals are uniform**: every denied login renders one generic `403` page ("Sign-in was not permitted") with a reference id; the reason (`email_unverified`, `subject_mismatch`, `state_invalid`, …) reaches only the server log and the `auth_event` line under that id. IdP-side failures (`discovery_failed`, `provider_error`) and proxy-side failures after verification (`internal_error`) render a `503` "Sign-in is temporarily unavailable" instead, so an outage is never shown as "not permitted" (FR-024). +- **Login page label and edition probe**: public `GET /api/v1/auth/provider` returns only `{"display_name": "…"}` — never the issuer, client id, tenant, scopes or domains — so the Web UI labels the sign-in button and detects the edition before login; the personal build answers `404` (FR-030). +- Guide: [multi-user authentication](https://docs.mcpproxy.app/development/server-edition-multiuser-auth/). diff --git a/ROADMAP.md b/ROADMAP.md index f12b3013b..c857ba989 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -423,16 +423,18 @@ graph LR sso_pr_b_oidc_front_door --> sso_pr_c_group_allowlist sso_pr_c_group_allowlist --> sso_pr_d_audit_line + classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff; classDef in_progress fill:#1f6feb,stroke:#0b3d91,color:#ffffff; classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; - class sso_pr_a_freeze_cut in_progress; - class sso_pr_b_oidc_front_door,sso_pr_c_group_allowlist,sso_pr_d_audit_line todo; + class sso_pr_a_freeze_cut done; + class sso_pr_b_oidc_front_door in_progress; + class sso_pr_c_group_allowlist,sso_pr_d_audit_line todo; ``` | Task | Status | Refs | | --- | --- | --- | -| PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) | 🔵 In progress | #1287 | -| PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) | ⚪ Todo | — | +| PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) | 🟢 Done | #1287 | +| PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) | 🔵 In progress | #1292 | | PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4) | ⚪ Todo | — | | PR-D attributable JSONL audit line + auth_event + config/doctor/metrics (US3) | ⚪ Todo | — | @@ -894,7 +896,7 @@ graph LR | Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | | | MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 19/81 (23%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | | | Planning/docs truth automation | In progress | P2 | — | | | -| Spec 107 server edition SSO front door hardened for real IdPs | In progress | P2 | 32/126 (25%) | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | | +| Spec 107 server edition SSO front door hardened for real IdPs | In progress | P2 | 69/126 (55%) | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | | | Discovery-quality eval harness (Spec 065 second half) | In progress | P3 | — | [065-evaluation-foundation](./specs/065-evaluation-foundation/) | | | tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | — | [101-tpa-db](./specs/101-tpa-db/) | | | Auto routing mode: budget-fitted tool surface per session (spec 104) | Todo | P1 | — | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | | @@ -1036,4 +1038,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — | | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `drafted` | 0/109 (0%) | | [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) | -| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 32/126 (25%) | +| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 69/126 (55%) | diff --git a/cmd/mcpproxy/listen_flag_test.go b/cmd/mcpproxy/listen_flag_test.go index e38f14c66..03e1c791e 100644 --- a/cmd/mcpproxy/listen_flag_test.go +++ b/cmd/mcpproxy/listen_flag_test.go @@ -55,7 +55,7 @@ func TestLoadConfig_ListenFlag(t *testing.T) { t.Fatal(err) } - cfg, err := loadConfig(cmd) + cfg, _, err := loadConfig(cmd) if err != nil { t.Fatalf("loadConfig: %v", err) } diff --git a/docs/configuration.md b/docs/configuration.md index d9df0f076..95f4acfba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -654,6 +654,7 @@ See [OAuth Documentation](mcp-go-oauth.md) for complete details. { "api_key": "your-secret-api-key", "trusted_hosts": ["mcp.example.com"], + "trusted_proxies": ["127.0.0.1"], "read_only_mode": false, "disable_management": false, "allow_server_add": true, @@ -665,6 +666,7 @@ See [OAuth Documentation](mcp-go-oauth.md) for complete details. |-------|------|---------|-------------| | `api_key` | string | Auto-generated | API key for REST API authentication. Required; if empty, one is auto-generated and enforced (logged on startup) | | `trusted_hosts` | string[] | `[]` | Non-loopback `Host` header values accepted on loopback listeners (reverse-proxy deployments). See below | +| `trusted_proxies` | string[] | `[]` (trust nobody) | CIDRs or IP addresses whose `X-Forwarded-For` / `X-Real-IP` / `X-Forwarded-Proto` / `X-Forwarded-Host` headers are honoured; any other peer's forwarded headers are ignored and `RemoteAddr` is used. Env `MCPPROXY_TRUSTED_PROXIES`. Hot-reloadable. Invalid entry: `trusted_proxies[N] "value" is not a valid CIDR or IP address` (boot, PATCH and apply). See [Reverse Proxy Deployment](operations/reverse-proxy.md#trusted_proxies-forwarded-headers) | | `read_only_mode` | boolean | `false` | Prevent all configuration modifications | | `disable_management` | boolean | `false` | Disable server management operations (restart, enable, disable) | | `allow_server_add` | boolean | `true` | Allow adding new servers via API/tools | @@ -1627,6 +1629,8 @@ Many configuration options can be overridden via environment variables: |----------------------|--------------|-------------| | `MCPPROXY_LISTEN` / `MCPP_LISTEN` | `listen` | Network binding address | | `MCPPROXY_API_KEY` | `api_key` | API key for authentication (empty values trigger auto-generation; auth remains enabled) | +| `MCPPROXY_TRUSTED_HOSTS` | `trusted_hosts` | Comma-separated `Host` allowlist for loopback listeners behind a reverse proxy | +| `MCPPROXY_TRUSTED_PROXIES` | `trusted_proxies` | Comma-separated CIDRs/IPs whose `X-Forwarded-*` headers are honoured | | `MCPPROXY_TLS_ENABLED` | `tls.enabled` | Enable HTTPS/TLS | | `MCPPROXY_TLS_REQUIRE_CLIENT_CERT` | `tls.require_client_cert` | Enable mTLS | | `MCPPROXY_CERTS_DIR` | `tls.certs_dir` | Custom certificates directory | diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index a26297413..5a04dc879 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -59,8 +59,9 @@ MCPProxy uses a JSON configuration file located at `~/.mcpproxy/mcp_config.json` | `listen` | string | `127.0.0.1:8080` | Address and port to listen on | | `data_dir` | string | `~/.mcpproxy` | Directory for data storage | | `api_key` | string | auto-generated | API key for REST API authentication | -| `trusted_hosts` | string[] | `[]` | Non-loopback `Host` header values accepted on a loopback listener. Needed when running behind a reverse proxy — see [Reverse Proxy Deployment](/operations/reverse-proxy) | -| `require_mcp_auth` | boolean | `false` | Require an API key on the `/mcp` endpoint (off by default for client compatibility). Enable when exposing MCPProxy beyond localhost | +| `trusted_hosts` | string[] | `[]` | Non-loopback `Host` header values accepted on a loopback listener. Needed when running behind a reverse proxy — see [Reverse Proxy Deployment](/operations/reverse-proxy). This is DNS-rebinding protection only; it is **not** the control for forwarded headers (see `trusted_proxies`) | +| `trusted_proxies` | string[] | `[]` (trust nobody) | CIDRs or IP addresses whose `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-Host` headers are believed. Headers from any other peer are ignored and the direct `RemoteAddr` is used. Env `MCPPROXY_TRUSTED_PROXIES` (comma list). Live (hot-reload, no restart). Validation: `trusted_proxies[N] "value" is not a valid CIDR or IP address` — refused identically at boot, `PATCH /api/v1/config` and `/config/apply`. See [Reverse Proxy Deployment](/operations/reverse-proxy#trusted_proxies-forwarded-headers) | +| `require_mcp_auth` | boolean | `false` | Require an API key on the `/mcp` endpoint (off by default for client compatibility). Enable when exposing MCPProxy beyond localhost. **Server edition:** forced to `true` whenever `server_edition.enabled` is `true` — an explicit `false` is not an error, but boot logs `require_mcp_auth: false is overridden to true because server_edition.enabled is true` and `mcpproxy doctor` reports the same finding | | `enable_socket` | boolean | `true` | Enable Unix socket/named pipe for local communication | ### HTTP Server Timeouts @@ -244,6 +245,125 @@ Full reference — validation rules, metrics, and which origins are limited — lives in [`docs/configuration.md`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/configuration.md#concurrency-limits--request-queueing) in the repository. +### Server Edition (`server_edition`) {#server-edition} + +The `server_edition` block configures multi-user SSO in the **Server edition** +(`mcpproxy-server`, distributed as the Docker image only — no `.deb`/tar.gz). +The Personal edition carries the +block as opaque JSON: it is preserved key-for-key and value-for-value through load, save and +`PATCH /api/v1/config`, never validated and never acted on. The block is not +part of the OpenAPI schema; this section is its reference. Development notes +and the REST endpoints live in +[Server Multi-User Authentication](/development/server-edition-multiuser-auth). + +```json +{ + "listen": "0.0.0.0:8080", + "trusted_proxies": ["10.42.0.0/16"], + "server_edition": { + "enabled": true, + "admin_emails": ["admin@example.com"], + "public_url": "https://mcp.example.com", + "session_cookie_secure": "auto", + "session_ttl": "24h", + "bearer_token_ttl": "24h", + "credential_encryption_key": "${env:MCPPROXY_CRED_KEY}", + "oauth": { + "provider": "oidc", + "issuer_url": "https://login.example.com/realms/team", + "client_id": "mcpproxy", + "client_secret": "${env:OIDC_CLIENT_SECRET}", + "scopes": ["openid", "profile", "email", "groups"], + "groups_claim": "groups", + "email_verified_policy": "refuse_false", + "display_name": "Example SSO", + "allowed_domains": ["example.com"] + } + } +} +``` + +**Reload column.** *Restart* keys are bound when the login handler and session +store are built: an edit is reported by the hot-reloader as +`server_edition` with `RequiresRestart=true` (`server_edition settings are +bound at startup`) and takes effect on the next start. *Live* keys apply on the +next request after the file reload lands. Validation messages are the exact +strings emitted at boot, by `PATCH /api/v1/config` and by `/config/apply` +(all three refuse the same input the same way). + +| Key | Type | Default | Reload | Validation / notes | +|-----|------|---------|--------|--------------------| +| `enabled` | boolean | `false` | Restart | Turns the block on. When `true`: `admin_emails` and `oauth` become required, `/mcp` requires a credential regardless of `require_mcp_auth` (agent tokens, the API key and the socket are unchanged; a session cookie or user JWT is **never** an MCP credential) | +| `admin_emails` | string[] | — (required when enabled) | **Live** | `server_edition.admin_emails must contain at least one admin email`. Case-insensitive match; the single source of the admin role, re-derived on every request (a removed admin is demoted on their next request without re-login) | +| `public_url` | string | `""` | Restart | Absolute origin only — `server_edition.public_url must be an absolute origin (scheme://host[:port]) with no path`. Env alias `MCPPROXY_PUBLIC_URL` (the only nested `server_edition.*` key with one; the Personal edition ignores it). When set it is the **sole** source of the IdP `redirect_uri` (`/api/v1/auth/callback`), of the connect-flow base URL and of the scheme behind the `Secure` cookie decision — `Host` and `X-Forwarded-*` are ignored for those. Unset on a non-loopback listener (the Docker image listens on `0.0.0.0:8080`) is a boot warning + `mcpproxy doctor` finding, never an error | +| `session_cookie_secure` | `auto` \| `true` \| `false` | `auto` | Restart | `auto` = `Secure` when the effective scheme is https (an https `public_url`, in-process TLS, or `X-Forwarded-Proto: https` from a peer in `trusted_proxies`). `false` with an https `public_url` or `tls.enabled` is refused: `server_edition.session_cookie_secure=false cannot be combined with an https public_url or tls.enabled`. An explicit `false` elsewhere is honoured with one boot warning + `doctor` finding (loopback/test deployments only). Any other value: `server_edition.session_cookie_secure must be one of: auto, true, false`. `HttpOnly` and `SameSite=Lax` are always set | +| `session_ttl` | duration | `24h` | Restart | `server_edition.session_ttl must be positive` | +| `bearer_token_ttl` | duration | `24h` | Restart | `server_edition.bearer_token_ttl must be positive`. Lifetime of user JWTs minted by `POST /api/v1/auth/token` (REST/CLI only) | +| `credential_encryption_key` | string | env `MCPPROXY_CRED_KEY` | Restart | Encrypts per-user upstream credentials at rest (`oauth_connect` broker). An explicit value wins over the environment. Secret — keep it as `${env:...}` or set only the variable; it never appears in the Settings UI | +| `store_idp_tokens` | boolean | `false` | — | **Deprecated no-op.** Accepted so older files load; `true` logs `server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it` once at load. See [IdP Token Storage](/features/idp-token-storage) | +| ~~`max_user_servers`~~, ~~`workspace_idle_timeout`~~ | — | — | never reported | **Removed.** A file that still carries them loads: each is dropped with one startup diagnostic, `server_edition.max_user_servers is no longer supported and was ignored` (likewise `workspace_idle_timeout`), and the next write-back omits them. `PATCH /api/v1/config` and `/config/apply` refuse them with the same text | +| `oauth` | object | — (required when enabled) | Restart | `server_edition.oauth configuration is required when server_edition is enabled`. Every `oauth.*` edit is restart-pinned (`server_edition.oauth.* is bound at login handler construction`) | +| `oauth.provider` | `google` \| `github` \| `microsoft` \| `oidc` | — | Restart | `server_edition.oauth.provider must be one of: google, github, microsoft, oidc (got: X)`. The three legacy providers keep their provider-specific exchange exactly as before (no nonce, no JWKS step, `client_secret_post`, groups always `[]`); `oidc` is the generic OpenID Connect Discovery provider with a verified ID token. The front-door keys (`public_url`, `session_cookie_secure`, `trusted_proxies`, forced MCP auth, subject binding, relative post-login redirect) apply to all four | +| `oauth.client_id` | string | — | Restart | `server_edition.oauth.client_id is required` | +| `oauth.client_secret` | string (`${env:...}`) | — | Restart | `server_edition.oauth.client_secret is required`. Masked in every API response and log; never a Settings row — reference a variable | +| `oauth.tenant_id` | string | `common` | Restart | `microsoft` only (multi-tenant `common` when unset); ignored by the other providers | +| `oauth.allowed_domains` | string[] | `[]` = allow all | Restart | Email-domain allowlist, matched case-insensitively after login. Applies to every provider | +| `oauth.issuer_url` | string | — | Restart | **`oidc` only, required**: `server_edition.oauth.issuer_url is required when provider is oidc`. Must be absolute `https`; `http` is admitted only for a loopback host together with `allow_insecure_issuer: true` — `server_edition.oauth.issuer_url must use https (http is allowed only for a loopback host with allow_insecure_issuer: true)`. Discovery reads `/.well-known/openid-configuration` lazily on the first login (never at boot — readiness does not depend on the IdP), and the document's `issuer` must equal the configured value **byte for byte** (a trailing slash or path difference refuses the login as `discovery_failed`; the log line names both values) | +| `oauth.allow_insecure_issuer` | boolean | `false` | Restart | Development toggle for an in-process or loopback fake IdP: admits a plain-`http` issuer and plain-`http` discovered endpoints **only** when their host is loopback. Non-loopback `http` is refused regardless. Raw-JSON only — deliberately has no Settings row | +| `oauth.scopes` | string[] | `["openid","profile","email"]` | Restart | `oidc` only. `openid` is appended when missing. Add whatever your IdP needs for the groups claim (Okta: `groups`; see the table below) | +| `oauth.groups_claim` | string | `"groups"` | Restart | `oidc` only. Name of the ID-token (then userinfo) claim carrying group memberships. Accepted shapes: a flat JSON array of strings or a single string; anything else is treated as absent. Compared as exact strings by the group → server map | +| `oauth.email_verified_policy` | `refuse_false` \| `require_true` \| `ignore` | `refuse_false` | Restart | `server_edition.oauth.email_verified_policy must be one of: refuse_false, require_true, ignore`. See the cost note below | +| `oauth.display_name` | string | provider family name | Restart | Login-button label; at most 64 characters (`server_edition.oauth.display_name must be at most 64 characters`). It is the **only** field returned by the public `GET /api/v1/auth/provider` probe (never the issuer, client id, tenant, scopes or domains) | + +#### `email_verified_policy` — what each value costs + +| Value | `email_verified: false` | claim absent | Use when | +|-------|------------------------|--------------|----------| +| `refuse_false` (default) | refused (`email_unverified`) | login proceeds | Most IdPs. Refuses only what the IdP explicitly marks unverified | +| `require_true` | refused | refused | The IdP always emits the claim and every account must be verified — unverified or self-registered accounts can never pass `allowed_domains` | +| `ignore` | ignored | ignored | The IdP never emits the claim. **Cost:** a self-asserted email is trusted, so an attacker who can register `anything@example.com` at the IdP passes an `allowed_domains: ["example.com"]` check. Compensate at the IdP (verified-only registration) or with the group map | + +#### Groups claim by identity provider + +The proxy compares group strings exactly and case-sensitively. What the claim +carries is decided by the IdP: + +| IdP | `groups_claim` | Values | Notes | +|-----|----------------|--------|-------| +| Keycloak | `groups` | `/path/names` (e.g. `/engineering/backend`) | Needs a **Group Membership** mapper on the client scope; untick *Full group path* to get bare names | +| Okta | `groups` | group names | Add the `groups` scope to `scopes` and a groups claim filter to the authorization server | +| Auth0 | `https://example.com/groups` | whatever your Action emits | Custom claims must be namespaced (a URL-shaped name); set it verbatim | +| Authentik | `groups` | group names | Shipped in the default `profile` scope, no extra mapping | +| Microsoft Entra ID | `groups` | group **object ids** (GUIDs) | Use the GUIDs in the map, or app roles. Above ~200 groups Entra omits the claim and sends an overage marker (`_claim_names`); the proxy treats that as **no groups** (`[]`) and logs a warning | + +Groups are read from the verified ID token first; only when the token lacks +the claim is `userinfo_endpoint` consulted once, and only if its `sub` equals +the token's `sub`. A claim absent from both means `[]` (fail closed: the user +still logs in and receives only the default grant) with one warning naming the +user id and the claim looked for — never the token. + +#### `public_url` and `trusted_proxies` in a container + +```text +browser ──https──▶ ingress / TLS terminator (10.42.0.7) ──http──▶ mcpproxy-server 0.0.0.0:8080 + sets X-Forwarded-Proto: https + X-Forwarded-For: + Host: 127.0.0.1:8080 (or the public host) +``` + +| Setting | What it decides | If you leave it out | +|---------|-----------------|---------------------| +| `server_edition.public_url: "https://mcp.example.com"` | The exact `redirect_uri` registered at the IdP, the connect-flow base URL and the `Secure` cookie decision — from configuration, not from `Host`/`X-Forwarded-*` | The callback URL is derived from the request: the IdP's exact-match `redirect_uri` registration is then the only guard against a rewritten `Host`, and you get a boot warning + `doctor` finding on a non-loopback listener | +| `trusted_proxies: ["10.42.0.0/16"]` (the ingress's source range) | Which peers may set the forwarded scheme, host and client IP — for the session IP, the per-request client IP tagged for attribution and, without `public_url`, the callback scheme | Every forwarded header is ignored: the session IP is the ingress's address, and without `public_url` the callback is `http://…` (`redirect_uri_mismatch` at the IdP) | +| `session_cookie_secure: "auto"` (default) | `Secure` when the effective scheme is https | — | + +Set both keys in the container. `trusted_hosts` is unrelated to this door: it +is DNS-rebinding protection for **loopback** listeners and never runs on +`0.0.0.0:8080`. When `public_url` is https but the callback reaches the proxy +over plain http, the login proceeds and one warning is logged +(`public_url is https but the OAuth callback arrived over http … check the +ingress forwards X-Forwarded-Proto from an address in trusted_proxies`). + ### MCP Servers See [Upstream Servers](/configuration/upstream-servers) for detailed server configuration. @@ -252,7 +372,7 @@ See [Upstream Servers](/configuration/upstream-servers) for detailed server conf MCPProxy watches the configuration file for changes and automatically reloads when modifications are detected. No restart is required for most configuration changes. -Exceptions that require a restart include `listen`, `data_dir`, `api_key`, the TLS block, and the three `http_*_timeout` options. +Exceptions that require a restart include `listen`, `data_dir`, `api_key`, the TLS block, the three `http_*_timeout` options, and — in the Server edition — every `server_edition` key except `admin_emails` (see [Server Edition](#server-edition)). `trusted_proxies` is live. ## Environment Variable Overrides diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index c50af6da6..bbad1e7a5 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -38,6 +38,7 @@ Environment variables are useful for CI/CD environments or temporary overrides d | Variable | Description | Default | |----------|-------------|---------| | `MCPPROXY_TRUSTED_HOSTS` | Comma-separated `Host` header allowlist for loopback listeners behind a reverse proxy (see [Reverse Proxy Deployment](/operations/reverse-proxy)) | - | +| `MCPPROXY_TRUSTED_PROXIES` | Comma-separated CIDRs or IP addresses whose `X-Forwarded-For` / `X-Real-IP` / `X-Forwarded-Proto` / `X-Forwarded-Host` headers are honoured (overrides `trusted_proxies`). Any other peer's forwarded headers are ignored. An entry that is neither a CIDR nor an IP is refused at boot with `trusted_proxies[N] "value" is not a valid CIDR or IP address`, exactly like a file value. See [trusted_proxies](/operations/reverse-proxy#trusted_proxies-forwarded-headers) | - (trust nobody) | | `MCPPROXY_TLS_ENABLED` | Enable TLS/HTTPS | `false` | | `MCPPROXY_TLS_CERT` | Path to TLS certificate | - | | `MCPPROXY_TLS_KEY` | Path to TLS private key | - | @@ -52,6 +53,18 @@ Environment variables are useful for CI/CD environments or temporary overrides d |----------|-------------|---------| | `MCPPROXY_DISABLE_OAUTH` | Disable OAuth for testing | `false` | +### Server Edition (SSO) + +These are read only by the Server edition binary (`mcpproxy-server`, the Docker +image); the Personal edition ignores them. Every other `server_edition.*` key is +file-only — reference secrets with `${env:NAME}` inside the config file instead +(see [Server Edition](./config-file.md#server-edition)). + +| Variable | Config key | Description | Default | +|----------|------------|-------------|---------| +| `MCPPROXY_PUBLIC_URL` | `server_edition.public_url` | Absolute origin users reach the deployment at (`https://mcp.example.com`, no path). Sole source of the IdP `redirect_uri`, the connect-flow base URL and the `Secure` cookie decision when set. Overrides the file value; validated with the same message (`server_edition.public_url must be an absolute origin (scheme://host[:port]) with no path`) | - | +| `MCPPROXY_CRED_KEY` | `server_edition.credential_encryption_key` | Key for encrypting per-user upstream credentials at rest (the `oauth_connect` broker — see [Auth Broker](/features/auth-broker)). Used only when the config key is empty; an explicit config value wins | - | + ### Browser Detection These variables control browser behavior for OAuth flows: diff --git a/docs/development/server-edition-multiuser-auth.md b/docs/development/server-edition-multiuser-auth.md index 87bdd51a2..bde34e50f 100644 --- a/docs/development/server-edition-multiuser-auth.md +++ b/docs/development/server-edition-multiuser-auth.md @@ -1,25 +1,41 @@ --- title: "Server Multi-User Authentication" sidebar_label: "Server Multi-User Auth" -description: "OAuth-based multi-user authentication for the server edition with Google, GitHub, or Microsoft identity providers." +description: "SSO multi-user authentication for the server edition: generic OIDC (Keycloak, Okta, Auth0, Authentik, Entra) with JWKS-verified ID tokens, plus the Google, GitHub and Microsoft legacy providers, behind a TLS-terminating ingress." --- -# Server Multi-User Authentication (Spec 024) +# Server Multi-User Authentication (Spec 024, hardened by Spec 107) -Server edition supports OAuth-based multi-user authentication with Google, GitHub, or Microsoft identity providers. All server code is behind `//go:build server`; the personal edition is unaffected. +Server edition supports SSO multi-user authentication with four identity +providers: the generic **`oidc`** provider (any OpenID Connect Discovery issuer — +Keycloak, Okta, Auth0, Authentik, Microsoft Entra ID — with a JWKS-verified ID +token and a groups claim) and the three legacy providers **`google`**, +**`github`** and **`microsoft`**. All server code is behind `//go:build server`; +the personal edition is unaffected. The complete key table with defaults, +validation text and reload behaviour is +[Server Edition](../configuration/config-file.md#server-edition); this page is +the developer view. ## Server Configuration ```json { + "listen": "0.0.0.0:8080", + "trusted_proxies": ["10.42.0.0/16"], "server_edition": { "enabled": true, "admin_emails": ["admin@company.com"], + "public_url": "https://mcp.company.com", + "session_cookie_secure": "auto", "oauth": { - "provider": "google", - "client_id": "xxx.apps.googleusercontent.com", - "client_secret": "GOCSPX-xxx", - "tenant_id": "", + "provider": "oidc", + "issuer_url": "https://login.company.com/realms/team", + "client_id": "mcpproxy", + "client_secret": "${env:OIDC_CLIENT_SECRET}", + "scopes": ["openid", "profile", "email", "groups"], + "groups_claim": "groups", + "email_verified_policy": "refuse_false", + "display_name": "Company SSO", "allowed_domains": ["company.com"] }, "session_ttl": "24h", @@ -28,6 +44,122 @@ Server edition supports OAuth-based multi-user authentication with Google, GitHu } ``` +A legacy provider needs only `provider`, `client_id`, `client_secret` (and +`tenant_id` for `microsoft`, default `common`); the six `oidc` keys are ignored +for it. `oauth.provider` is validated in one place +(`internal/config/server_edition_config.go`, `Validate`); the Web UI select in +`frontend/src/views/settings/fields.ts` and `users.User.Validate` must change +in lockstep with it. + +### The `oidc` provider (`internal/serveredition/auth/oidc_provider.go`) + +- **Discovery is lazy.** Nothing is fetched at boot or for readiness: the first + `GET /api/v1/auth/login` reads `/.well-known/openid-configuration`, + checks the document's `issuer` equals the configured value **byte for byte** + (a trailing slash is a mismatch → `discovery_failed`, both values logged), + and caches it with a bounded TTL (cache headers respected, clamped to + 5 min – 24 h). Every discovered endpoint (`authorization_endpoint`, + `token_endpoint`, `jwks_uri` required; `userinfo_endpoint` optional) must be + absolute `https`; plain `http` is admitted only for a loopback host together + with `allow_insecure_issuer: true` (`config.IsAllowedOIDCEndpoint` — the same + rule gates the configured issuer). A violating document is rejected before + any redirect or any request carrying the client secret. +- **The back channel never follows redirects** and has a 10 s timeout. A 3xx + from discovery, JWKS, the token endpoint or userinfo is `provider_error`, so + a misconfigured or compromised IdP cannot downgrade the code or the client + secret to another origin. +- **Client authentication is chosen once, before the single exchange**, from + `token_endpoint_auth_methods_supported`: `client_secret_basic` when + advertised (also the default when the key is absent), else + `client_secret_post`, else `discovery_failed`. An authorization code is + single-use, so there is no retry with a second method. PKCE S256 and a + per-login `nonce` are always sent; `openid` is appended to `scopes` if missing. +- **The ID token is verified before any claim is read** + (`oidc_jwks.go` parses RSA/EC JWKs with the standard library and + `golang-jwt/jwt/v5` verifies): signature by `kid` against the cached JWKS + (an unknown `kid` triggers exactly one JWKS refetch per login), algorithm + restricted to RS256/RS384/RS512/PS256/PS384/PS512/ES256/ES384/ES512 (never + `none`, never HS*), `exp`/`nbf`/`iat` with 60 s skew, exact `iss`, `aud` + containing the client id (with `azp` required and checked when there are + several audiences), and the nonce stored beside the pending state and + consumed once. The unverified `parseIDToken` survives only for the three + legacy providers and is unreachable for `oidc`. +- **Claims.** `sub` and `email` are required (`email_missing`); + `email_verified` is applied per `email_verified_policy`; groups are read from + the ID token's `groups_claim` (a flat string array or a single string), then + from `userinfo` once — and no userinfo claim is used before its `sub` is + compared with the verified token's `sub` (`userinfo_subject_mismatch`). A + failed userinfo fetch is `provider_error` (503, store untouched, stored + groups **not** reset); a claim absent from both, or an Entra overage marker + (`_claim_names`), stores `[]` and logs a warning naming the user id and the + claim looked for — never the token. +- **Pending login state** is in-process, 10-minute TTL, capped at 10,000 + entries with the oldest evicted on insert. A second replica is unsupported. +- **Never log tokens or the client secret.** Log lines name the issuer, the + failed check and the error class only. + +### Subject binding and login refusals (every provider) + +- A user record stores `(provider, provider_subject_id)` and refreshes both on + every successful login. Same provider + same normalised email + different + `sub` is refused (`subject_mismatch`); a configured-provider change re-binds + on the first login and flags the attempt `provider_rebound`. Email stays the + lookup key (no store migration). An administrator re-arms the binding for a + genuinely re-created IdP account through `disable` → `enable`: the enable + transition sets `subject_rebind_armed_at`, the next successful login consumes + it (single-use, persisted across restarts, cleared atomically in the same + store write); a failed attempt does not consume it. +- Every denial renders **one generic page** — `Sign-in was not permitted (ref + )` with the same status for every reason. The closed + `LoginRefusal` enum (`oauth_handler.go`: `authorization_denied`, + `state_invalid`, `id_token_invalid`, `issuer_mismatch`, `audience_mismatch`, + `token_expired`, `nonce_mismatch`, `email_missing`, `email_unverified`, + `domain_not_allowed`, `subject_mismatch`, `userinfo_subject_mismatch`, + `user_disabled`) reaches only the server log, + keyed by that request id. The one distinct class is unavailability — + `discovery_failed`, `provider_error` and the post-verification + `internal_error` — rendered as `503 Sign-in is temporarily unavailable (ref + )`; the proxy stays up, readiness is unaffected and the next login + retries. A failure that is not the user's fault is never rendered as "not + permitted". +- `redirect_uri` on `GET /api/v1/auth/login` is accepted only as a same-origin + path (single leading `/`, no `//`, `/\`, scheme, host, backslash or control + character); anything else becomes `/ui/` silently and flags the attempt + `redirect_rejected`. The Web UI passes `window.location.pathname`. + +### Front door behind an ingress + +The published image listens on `0.0.0.0:8080` behind a TLS-terminating ingress. +Three keys make the SSO door safe there; `trusted_hosts` is **not** one of them +(it is DNS-rebinding protection for loopback listeners and never runs on a +non-loopback one). + +| Key | Role | Code | +|-----|------|------| +| `server_edition.public_url` (env `MCPPROXY_PUBLIC_URL`) | When set, the sole source of the IdP `redirect_uri` (`/api/v1/auth/callback`), of the connect-flow base URL and of the scheme behind the `Secure` cookie decision; `Host` and `X-Forwarded-*` are ignored for those. Unset on a non-loopback listener → boot warning + `doctor` finding (never an error: every existing container deployment must keep booting). When set to https but the callback arrives over http, one warning with the request id is logged and login proceeds | `buildCallbackURL` (`oauth_handler.go`), `connector_provider.go`, `internal/config/env_serveredition.go` | +| `trusted_proxies` (top-level, edition-neutral, env `MCPPROXY_TRUSTED_PROXIES`, **live**) | The only gate on `X-Forwarded-For` / `X-Real-IP` / `X-Forwarded-Proto` / `X-Forwarded-Host`: honoured only when `RemoteAddr` is inside the list, client IP = right-most untrusted hop. Default empty = trust nobody. Every reader evaluates a `func() []string` provider per request — never a slice captured at construction | `config.ForwardedHeaders` (`internal/config/trusted_proxies.go`) — the one reader; consumers: session store, callback, connector base URL, swagger, `httpapi.tagRequestMeta` | +| `server_edition.session_cookie_secure` | `auto` (default: `Secure` when the effective scheme is https — `public_url`, in-process TLS, or a **trusted** `X-Forwarded-Proto: https`), `true`, `false`. `false` × (https `public_url` or `tls.enabled`) is refused at boot, PATCH and apply (`validateServerEditionConfig`, the `*Config`-level bridge that can see `tls`); an explicit `false` elsewhere is honoured with a warning + `doctor` finding | `NewSessionManager(..., securePolicy)`, `setup.go` | + +Forced MCP auth: when `server_edition.enabled` is `true`, +`config.EffectiveRequireMCPAuth` makes `mcpAuthMiddleware` behave as if +`require_mcp_auth` were `true` — no credential → 401, a session cookie or user +JWT → 401; agent tokens, the API key and the socket are unchanged. An explicit +`false` is not a validation error: boot logs `require_mcp_auth: false is +overridden to true because server_edition.enabled is true` and `mcpproxy +doctor` names the override. The accessors live in +`internal/config/serveredition_accessors.go` (+ `_stub.go` for the personal +build) so `internal/server` stays edition-neutral. + +`mcpproxy doctor` renders these findings from `config.DoctorFindings(cfg)` +(`internal/config/doctor_findings*.go`), registered as a runtime-warning source +on the management service; there is no producer in `cmd/mcpproxy`. + +Reload semantics: `enabled`, `oauth.*`, `public_url`, `session_cookie_secure`, +`session_ttl`, `bearer_token_ttl` and `credential_encryption_key` are bound at +setup and reported as `server_edition` with `RequiresRestart=true` +(`server_edition settings are bound at startup`); `admin_emails` is live +(`server_edition.admin_emails`); `trusted_proxies` is live. + The former knobs `workspace_idle_timeout` and `max_user_servers` never controlled anything and were removed (Spec 107). A config file that still carries them loads: the server edition drops each with one startup warning @@ -43,8 +175,9 @@ ignored (one deprecation warning when `true`); see | Endpoint | Auth | Description | |----------|------|-------------| -| `GET /api/v1/auth/login` | Public | Initiate OAuth login flow | -| `GET /api/v1/auth/callback` | Public | OAuth callback (creates session) | +| `GET /api/v1/auth/provider` | Public | Edition + label probe: returns only `{"display_name": "..."}` (`oauth.display_name`, falling back to the provider family name), no side effects. Never the issuer, client id, tenant, scopes or domains. Registered only when the block is enabled; the personal build answers 404 — the Web UI uses that to detect the edition before any authenticated call | +| `GET /api/v1/auth/login` | Public | Initiate OAuth login flow (PKCE S256, `state`, `nonce`; `?redirect_uri=` must be a same-origin path) | +| `GET /api/v1/auth/callback` | Public | OAuth callback (verifies the ID token for `oidc`, creates session; one generic refusal page) | | `GET /api/v1/auth/me` | Session/JWT | Get current user profile | | `POST /api/v1/auth/token` | Session | Mint a user JWT for the REST API and CLI (`/api/v1/user/*`); a JWT is **never** an MCP credential — `/mcp` accepts only agent tokens, the API key and the socket | | `POST /api/v1/auth/logout` | Session | Invalidate session | @@ -59,7 +192,7 @@ ignored (one deprecation warning when `true`); see ## Server Architecture -- **Auth flow**: OAuth 2.0 + PKCE → Session cookie (Web UI) + JWT bearer (REST API / CLI only). Neither is accepted on `/mcp`; a user reaches tools only through an agent token they own. +- **Auth flow**: OAuth 2.0 + PKCE (+ nonce and a JWKS-verified ID token for `oidc`) → Session cookie (`HttpOnly; SameSite=Lax`; `Secure` per `session_cookie_secure`) for the Web UI + JWT bearer (REST API / CLI only). Neither is accepted on `/mcp`; a user reaches tools only through an agent token they own. - **Server types**: Shared (config file) + Personal (DB rows a user adds through `POST /api/v1/user/servers`). Every upstream connection is the process's single shared connection — there is no per-user connection, per-user workspace or per-user credential on the tool-call path. - **Isolation**: REST listing scope (users see only shared + own personal servers), agent-token `allowed_servers` scope narrowed on every authentication, and user-scoped activity logs. - **Admin**: Identified by `admin_emails` config. Sees all activity, manages users. @@ -251,7 +384,9 @@ bucket whose `allowed_servers` contains `"*"` and whose `user_id` is non-empty | `cmd/mcpproxy/edition.go` | Default edition = "personal" | | `cmd/mcpproxy/edition_teams.go` | Build-tagged override for server edition | | `cmd/mcpproxy/serveredition_register.go` | Server feature registration entry point | -| `internal/serveredition/auth/` | OAuth, sessions, JWT tokens, middleware | +| `internal/serveredition/auth/` | OAuth (legacy providers), `oidc_provider.go` + `oidc_jwks.go` (discovery, JWKS, verified ID token), sessions, JWT tokens, middleware, `login_pages.go` (generic refusal / unavailability pages) | +| `internal/config/{server_edition_config,trusted_proxies,serveredition_accessors,env_serveredition,doctor_findings*}.go` | Block schema + validation, `ForwardedHeaders`, edition accessors (`EffectiveRequireMCPAuth`), `MCPPROXY_PUBLIC_URL`, doctor findings | +| `tests/oauthserver/` (`-oidc`) | In-process fake OpenID Provider with discovery, JWKS, userinfo, per-user claims and a tamper matrix (bad signature, wrong `iss`/`aud`, expired, wrong nonce, `alg: none`, HS256, `email_verified: false`, `http` endpoints, redirecting token endpoint) | | `internal/serveredition/users/` | User/session models, BBolt store | | `internal/serveredition/multiuser/` | Activity isolation (user-scoped activity queries). The per-user router, tool filter and workspace packages that once lived here had no production caller and were deleted in Spec 107. | | `internal/serveredition/broker/` | Per-user `oauth_connect` credential store (stored, not injected — see [Auth Broker](../features/auth-broker.md)) | @@ -261,8 +396,46 @@ bucket whose `allowed_servers` contains `"*"` and whose `user_id` is non-empty ```bash go test -tags server ./internal/serveredition/... -v -race # All server unit + integration tests -go build -tags server ./cmd/mcpproxy # Build server edition +go build -tags server -o mcpproxy-server ./cmd/mcpproxy # Build server edition (always -o: a bare build overwrites ./mcpproxy) go build ./cmd/mcpproxy # Verify personal edition unaffected ``` +### Local rig: `scripts/dev-server-edition.sh` + +The Spec 107 verification rig runs the whole SSO path against a fake OpenID +Provider, loopback-only, in a scratch directory — it never touches +`~/.mcpproxy`, the tray's core or a real IdP. It is the executable form of +`specs/107-server-edition-sso-hardening/quickstart.md`; the script is the +source of truth and that page is its narrative. + +```bash +scripts/dev-server-edition.sh # phase b: build, fake IdP, boot, headless login, /auth/me +scripts/dev-server-edition.sh --phase c # + tenant principal on core REST (PR-C) +scripts/dev-server-edition.sh --phase d --keep # + token mint, /mcp gate, audit tail (PR-D); keep the scratch dir +scripts/dev-server-edition.sh --idp-args "-token-error bad-signature" # one US2 tamper case: expects a 403, no session +``` + +What it does, in order: builds `mcpproxy-server` (`-tags server -o`, never +bare) and the fake IdP (`tests/oauthserver/cmd/server -oidc`) into the scratch +dir; runs `npm ci --prefix tests/echo-rugpull-server` once for the stdio +fixture (`node tests/echo-rugpull-server/index.js`, a deterministic `echo` +tool while `DESC_FILE` is unset); writes `mcp_config.json` with +`server_edition.oauth.provider: "oidc"` pointing at the IdP through +`${env:OIDC_CLIENT_ID}` / `${env:OIDC_CLIENT_SECRET}`; boots the server +edition with **both** `--config` and `--data-dir` on a free `18xxx` port and +waits for `/readyz` + `/api/v1/status` (`edition: server`); performs the +headless login as `alice@example.com` (302 to the IdP with `S256` + `nonce`, +POST the login form without following redirects, then GET the callback) and +prints `/api/v1/auth/me`; repeats the login with +`redirect_uri=https://evil.example/` and asserts the 302 lands on `/ui/`. + +Rules it encodes: every wait loop is bounded and every `curl` carries +`--max-time`, so a missing piece (an older branch without the `oidc` provider +answers exit 4 at config load) is reported with the reason, never hung on; +teardown kills only the PIDs it started (never `pkill` by name) and removes +the scratch dir only when it created it via `mktemp` (`--scratch DIR` and +`MCPPROXY_RIG_SCRATCH` are always kept, as is any failed run). Gates for the +script itself: `bash -n scripts/dev-server-edition.sh` and `shellcheck +scripts/dev-server-edition.sh`. + > Note: server-edition `//go:build server` routes are invisible to `swag` / `verify-oas-coverage.sh` (which don't pass `--build-tags server`), so document endpoints here. CI lints twice — bare and with `--build-tags server` — and race-tests `internal/server`, `internal/httpapi` and `internal/storage` under the tag (Spec 107 FR-047); run both lint passes locally before pushing (see the Lint block in `CLAUDE.md`). diff --git a/docs/features/telemetry.md b/docs/features/telemetry.md index 0e60a30fd..6de1b86ef 100644 --- a/docs/features/telemetry.md +++ b/docs/features/telemetry.md @@ -10,7 +10,7 @@ MCPProxy collects anonymous usage statistics to help improve the product. This p ## What is collected -MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 12** (`schema_version: 12` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize. +MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 13** (`schema_version: 13` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize. | Field | Example | Purpose | |-------|---------|---------| @@ -43,6 +43,10 @@ MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying | `tpa_scanner` | `{"scans_completed":4,"scans_failed":0,"scans_with_findings":1,"findings":{"high":2},"tool_change_gate_scans":6,"prompt_scans":11}` | Security/TPA scanner activity (schema v8, extended in v9) — counts only, keyed by the fixed severity enum. Omitted entirely when no scan of any kind ran | | `trust_mode_distribution` | `{"auto":1,"scan":3,"manual":8}` | Configured servers per effective trust tier (schema v9) — fixed enum keys `auto`/`scan`/`manual`, counts only. Never server names | | `feature_flags.deep_scan_enabled` | `false` | Whether the opt-in deep-scan layer is turned on (schema v8) | +| `feature_flags.server_edition_enabled` | `false` | Whether the `server_edition` block is present and enabled (schema v13, Spec 107). Always `false` on the personal edition, where the block is never interpreted | +| `feature_flags.idp_provider` | `none` | The configured identity-provider **family** for server-edition SSO (schema v13) — fixed enum `google` / `github` / `microsoft` / `oidc` / `none`. The kind only: never the issuer URL, tenant, client id or display name. `none` when the block is disabled or unset | +| `member_count_bucket` | `1-10` | Number of server-edition user accounts, bucketed (schema v13) — fixed enum `0` / `1-10` / `11-100` / `101-1000` / `1000+`. `0` on the personal edition (no counter is installed); omitted only when the counter fails. A count only, never an identity. (Named `member_…`, not `user_…`: the anonymity scanner blocks the home-dir basename as a substring of the whole payload, and `user` is the username of every `USER user` container image) | +| `env_markers.is_container` | `true` | Unchanged in v13; read together with the fields above to see how server-edition installs are deployed | | `preflight` | `{"filter_diag_emitted_24h":3,"availability_block_24h":2,"availability_block_reasons_24h":{"server_quarantined":2},"discovery_omission_24h":5}` | Preflight baseline counters (issue #969) — counts only, reason map keyed by a fixed enum. Omitted entirely when nothing was counted. See below | The `server_protocol_counts` map uses a **fixed enum of keys** (`stdio`, `http`, `sse`, `streamable_http`, `auto`) — server names and URLs are never included. Unknown or misconfigured protocol values are bucketed into `auto`. @@ -86,6 +90,7 @@ The following is **never** collected: - File paths or environment variables - IP addresses (stripped by our server before storage) - User identity, email, or account information +- Server-edition IdP issuer URLs, tenant ids, client ids, or group names (only the provider family and a bucketed member count are sent — schema v13) - Tool call content, arguments, or responses - Any user-generated content - The **raw** OS machine id or any reversible hardware identifier (only the salted, non-reversible `machine_id` hash is sent — see below) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index e5cf68d5d..e2e1698bb 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -399,6 +399,65 @@ docker run -d --name mcpproxy \ container is awkward. - **Web UI**: `http://localhost:8080/ui/`. +### Behind an ingress with SSO + +To put the container behind a TLS-terminating ingress and sign users in through your +IdP, add the `server_edition` block to the config on the volume and pass the secrets as +environment variables that the file references with `${env:...}` — nothing secret is +written into `mcp_config.json`: + +```bash +docker run -d --name mcpproxy \ + -p 8080:8080 \ + -e MCPPROXY_API_KEY \ + -e OIDC_CLIENT_SECRET \ + -e MCPPROXY_CRED_KEY \ + -e MCPPROXY_PUBLIC_URL="https://mcp.example.com" \ + -e MCPPROXY_TRUSTED_PROXIES="10.42.0.0/16" \ + -v mcpproxy-data:/root/.mcpproxy \ + ghcr.io/smart-mcp-proxy/mcpproxy-server:latest +``` + +```json +{ + "listen": "0.0.0.0:8080", + "trusted_proxies": ["10.42.0.0/16"], + "server_edition": { + "enabled": true, + "admin_emails": ["admin@example.com"], + "public_url": "https://mcp.example.com", + "credential_encryption_key": "${env:MCPPROXY_CRED_KEY}", + "oauth": { + "provider": "oidc", + "issuer_url": "https://login.example.com/realms/team", + "client_id": "mcpproxy", + "client_secret": "${env:OIDC_CLIENT_SECRET}" + } + } +} +``` + +- **`public_url`** is the origin users reach — the IdP `redirect_uri` becomes + `https://mcp.example.com/api/v1/auth/callback` and the session cookie is `Secure`, + independent of what the ingress puts in `Host` or `X-Forwarded-*`. The image listens on + `0.0.0.0:8080`, so leaving it unset is a boot warning and a `mcpproxy doctor` finding. + `MCPPROXY_PUBLIC_URL` overrides the file value; it is the only nested `server_edition` + key with an environment alias. +- **`trusted_proxies`** is the ingress's source range as the container sees it. Forwarded + headers from anywhere else are ignored, so a direct client cannot spoof its address or + scheme. `MCPPROXY_TRUSTED_PROXIES` (comma list) overrides the file value. `trusted_hosts` + is unrelated here — it never runs on a non-loopback listener. +- **Secrets** stay in the environment: `client_secret`, `credential_encryption_key` (or + just `MCPPROXY_CRED_KEY`, its fallback) and the API key. `${env:NAME}` is expanded when + the file is loaded and the secret is masked in every API response. +- **`/mcp` requires a credential** as soon as `server_edition.enabled` is `true`, whatever + `require_mcp_auth` says — agent tokens, the API key or the socket; a browser session is + never an MCP credential. + +The full key table — `session_cookie_secure`, `scopes`, `groups_claim`, +`email_verified_policy`, `display_name`, the per-IdP groups-claim notes — is in +[Server Edition](/configuration/config-file#server-edition). + Note that this image ships the Server edition binary (`mcpproxy version` reports `(server)`); it is the headless core only, with no system tray. diff --git a/docs/operations/reverse-proxy.md b/docs/operations/reverse-proxy.md index d5271f94b..4e254d0e5 100644 --- a/docs/operations/reverse-proxy.md +++ b/docs/operations/reverse-proxy.md @@ -2,16 +2,17 @@ id: reverse-proxy title: Reverse Proxy Deployment sidebar_label: Reverse Proxy -description: 'Run MCPProxy behind nginx or Caddy — fix "403 Forbidden: invalid Host header" with trusted_hosts, and secure the exposed endpoint.' -keywords: [reverse proxy, nginx, caddy, trusted_hosts, invalid Host header, DNS rebinding, 403 Forbidden, Host header] +description: 'Run MCPProxy behind nginx or Caddy — fix "403 Forbidden: invalid Host header" with trusted_hosts, honour X-Forwarded-* only from trusted_proxies, and secure the exposed endpoint.' +keywords: [reverse proxy, nginx, caddy, trusted_hosts, trusted_proxies, X-Forwarded-For, X-Forwarded-Proto, invalid Host header, DNS rebinding, 403 Forbidden, Host header] --- # Reverse Proxy Deployment MCPProxy listens on a loopback address by default (`127.0.0.1:8080`) and is designed to run locally. You can still put it behind a reverse proxy (nginx, Caddy, Traefik, -CloudPanel) to add HTTPS, a public hostname, or shared access — but two things need -attention: **Host-header validation** and **authentication**. +CloudPanel) to add HTTPS, a public hostname, or shared access — but three things need +attention: **Host-header validation**, **forwarded headers** (`trusted_proxies`) and +**authentication**. ## The `403 Forbidden: invalid Host header` error @@ -54,6 +55,11 @@ The fix is to add your public domain(s) to the `trusted_hosts` allowlist. `MCPPROXY_TRUSTED_HOSTS="mcp.example.com,mcp.internal:8443"`. - **Hot-reloadable:** editing the config file applies without a restart. +`trusted_hosts` decides only which `Host` values a **loopback** listener accepts. It +never runs on a non-loopback listener (the Docker image's `0.0.0.0:8080`), and it says +nothing about whether the proxy believes `X-Forwarded-*` — that is `trusted_proxies`, +below. In particular it is **not** the control that secures the Server-edition SSO door. + ### Origin validation (browser requests) Per the MCP specification's security best practices, MCPProxy also validates the @@ -68,6 +74,55 @@ client served **from your public domain** works as soon as that domain is in frontend hosted on a **different** origin must have its own host added to `trusted_hosts` too, or its requests are rejected. +## `trusted_proxies` — forwarded headers {#trusted_proxies-forwarded-headers} + +A reverse proxy rewrites the connection MCPProxy sees: the peer address becomes the +proxy's, the scheme becomes plain `http`, and the real client, scheme and host arrive +only as `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-Host`. +Anyone who can reach the listener directly can send those headers too, so by default +MCPProxy **believes none of them**. `trusted_proxies` lists the peers it does believe: + +```json +{ + "listen": "127.0.0.1:8080", + "trusted_hosts": ["mcp.example.com"], + "trusted_proxies": ["127.0.0.1", "10.42.0.0/16"] +} +``` + +| Behaviour | Detail | +|-----------|--------| +| Entries | CIDRs (`10.42.0.0/16`, `fd00::/8`) or single IP addresses (`127.0.0.1`, `::1`). Hostnames are not accepted | +| Default | Empty (`[]`) — **trust nobody**: every forwarded header is ignored and the direct `RemoteAddr` and listener scheme are used | +| Trusted peer | When `RemoteAddr` is inside the list, `X-Forwarded-Proto` (`http`/`https`) and `X-Forwarded-Host` are honoured, and the client IP is the **right-most `X-Forwarded-For` hop that is not itself a trusted proxy** (then `X-Real-IP`) | +| Untrusted peer | Headers ignored, no error. A direct client cannot spoof its IP, the request scheme or the host | +| What it feeds | The session client IP (and the per-request client IP tagged for attribution), the `Secure` decision of the Server-edition session cookie, the OAuth callback scheme/host when `server_edition.public_url` is unset, and the Swagger UI base URL | +| What it never feeds | Local/remote or administrator classification — no forwarded header can promote a caller | +| Validation | `trusted_proxies[0] "nginx" is not a valid CIDR or IP address` — the same message at boot, on `PATCH /api/v1/config` and on `/config/apply` | +| Environment override | `MCPPROXY_TRUSTED_PROXIES="127.0.0.1,10.42.0.0/16"` | +| Hot reload | Live — every reader evaluates the current list per request, no restart | + +List the proxy's **source** address as MCPProxy sees it: `127.0.0.1` for nginx or Caddy on +the same host, the ingress pod or node range in Kubernetes, the Docker bridge network for a +proxy container in front of `mcpproxy-server`. Do not list `0.0.0.0/0` — that re-opens the +spoof. + +nginx sends the standard headers with: + +```nginx +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +``` + +Caddy's `reverse_proxy` sets `X-Forwarded-For` and `X-Forwarded-Proto` by default. + +**Server edition (SSO):** behind a TLS-terminating ingress set **both** +`server_edition.public_url` (the exact origin registered at the IdP — the callback URL and +the `Secure` cookie then no longer depend on any header) and `trusted_proxies` (the ingress +range, so the session IP and the attributed client IP are the real client). See +[Server Edition](/configuration/config-file#server-edition) for the container topology. + ## nginx With `trusted_hosts` configured, a standard nginx block works without rewriting the @@ -82,6 +137,11 @@ server { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; + # Honoured only if 127.0.0.1 is in trusted_proxies (see above). + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + # MCPProxy streams responses (SSE at /events, streamable HTTP at /mcp). # Disable proxy buffering so events are delivered as they are produced. proxy_buffering off; @@ -129,11 +189,16 @@ authenticate differently: MCP clients then authenticate with the same API key (`X-API-Key` header). + In the **Server edition** `require_mcp_auth` is forced to `true` whenever + `server_edition.enabled` is `true`; an explicit `false` is overridden with one boot + notice and a `mcpproxy doctor` finding. A browser session cookie or user JWT is never + an MCP credential — `/mcp` accepts agent tokens, the API key and the socket only. + Prefer the `X-API-Key` header over `?apikey=` where the client supports it — query strings are more likely to be logged by intermediate proxies. ## Related - [Configuration File](/configuration/config-file) — full option reference -- [Environment Variables](/configuration/environment-variables) — `MCPPROXY_TRUSTED_HOSTS` +- [Environment Variables](/configuration/environment-variables) — `MCPPROXY_TRUSTED_HOSTS`, `MCPPROXY_TRUSTED_PROXIES` - [REST API](/api/rest-api) — authentication details diff --git a/frontend/src/services/auth-api.ts b/frontend/src/services/auth-api.ts index 58bef32df..d017b8d3a 100644 --- a/frontend/src/services/auth-api.ts +++ b/frontend/src/services/auth-api.ts @@ -16,7 +16,34 @@ export interface BearerTokenResponse { expires_at: string } +// Spec 107 FR-030: the whole body of the public edition probe — an +// operator-chosen label and nothing else (never issuer, client id, tenant, +// scopes, domains or provider family). +export interface ProviderInfo { + display_name: string +} + export const authApi = { + // Public edition probe (Spec 107 FR-030 / FR-041). No authentication, no + // side effects. 200 {display_name} = server edition; 404 = personal build + // or server_edition.enabled=false. Any other failure is reported as null so + // the UI degrades to the personal surface rather than hanging on a spinner. + // This is called BEFORE any authenticated call: a tenant holds no API key, + // so learning the edition from a keyed endpoint would 401 for exactly the + // people the server edition exists for. + async getProvider(): Promise { + try { + const response = await fetch(`${API_BASE}/auth/provider`) + if (response.status === 404) return null + if (!response.ok) return null + const body = await response.json() + if (!body || typeof body.display_name !== 'string') return null + return { display_name: body.display_name } + } catch { + return null + } + }, + // Get current user profile (returns null if not authenticated) async getMe(): Promise { try { diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 085a801c2..5289d72ab 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,12 +1,14 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { authApi, type UserProfile } from '@/services/auth-api' -import api from '@/services/api' +import { authApi, type ProviderInfo, type UserProfile } from '@/services/auth-api' export const useAuthStore = defineStore('auth', () => { const user = ref(null) const loading = ref(true) const isTeamsEdition = ref(false) + // Spec 107 FR-030: the operator-chosen login label from the public probe; + // null on the personal edition. Login.vue renders it. + const provider = ref(null) const isAuthenticated = computed(() => !!user.value) const isAdmin = computed(() => user.value?.role === 'admin') @@ -25,13 +27,15 @@ export const useAuthStore = defineStore('auth', () => { async function probe() { try { - // Check if this is server edition using the API service (includes API key) - const statusRes = await api.getStatus() - isTeamsEdition.value = statusRes.data?.edition === 'server' + // Spec 107 FR-030 / FR-041: learn the edition from the PUBLIC probe + // before any authenticated call. The previous detection went through + // GET /api/v1/status with the API key, which a tenant never holds — so + // every tenant read as "personal edition" and was bounced off /login. + const probe = await authApi.getProvider() + provider.value = probe + isTeamsEdition.value = probe != null - if (isTeamsEdition.value) { - user.value = await authApi.getMe() - } + user.value = isTeamsEdition.value ? await authApi.getMe() : null } catch { // Not authenticated or not server edition user.value = null @@ -68,6 +72,7 @@ export const useAuthStore = defineStore('auth', () => { user, loading, isTeamsEdition, + provider, isAuthenticated, isAdmin, displayName, diff --git a/frontend/src/views/settings/fields.ts b/frontend/src/views/settings/fields.ts index 4c87c40c4..3176ecd7c 100644 --- a/frontend/src/views/settings/fields.ts +++ b/frontend/src/views/settings/fields.ts @@ -34,6 +34,12 @@ export interface DangerSpec { // which are validated from the control type). Centralised in validateField. export type ValueKind = 'hostport' | 'bytesize' | 'cpu' | 'hostname' | 'url' | 'secretkey' +// A `[]string` config key edited through a textarea. The form shows a joined +// string ('comma' = "a, b", 'lines' = one entry per line) and the PATCH +// partial carries the split, trimmed array — see listToText / textToList. +// Without this the textarea would PATCH a plain string into a []string key. +export type ListKind = 'comma' | 'lines' + export interface SettingField { key: string // dot-path, e.g. "docker_isolation.enabled" label: string @@ -54,6 +60,8 @@ export interface SettingField { // for `omitempty` fields whose zero value is meaningful (the serialization // modes: "" means "full"). See normalizeFieldDefaults below for why. defaultValue?: string + // Set for a textarea that edits a []string key (see ListKind). + listKind?: ListKind } export interface SettingsAccordion { @@ -235,6 +243,18 @@ export const SECURITY_FIELDS: SettingField[] = [ 'Binding to a non-loopback address (e.g. 0.0.0.0) exposes mcpproxy to your network. Make sure “Require API key for MCP clients” is enabled. Continue?', }, }, + { + // Spec 107 FR-027 (edition-neutral, hot-reloaded): X-Forwarded-For / + // X-Forwarded-Proto / X-Forwarded-Host / X-Real-IP are honoured only when + // the direct peer is in this list. Empty = trust nobody. + key: 'trusted_proxies', + label: 'Trusted reverse proxies', + help: 'One CIDR or IP address per line (e.g. 10.0.0.0/8). Forwarded headers (X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Real-IP) are honoured only from these peers; leave empty when mcpproxy is not behind a proxy. Applies without a restart.', + control: 'textarea', + listKind: 'lines', + optional: true, + placeholder: '10.0.0.0/8', + }, ] // ---- Section 2: General ---- @@ -321,9 +341,102 @@ export const GENERAL_FIELDS: SettingField[] = [ // so old configs hydrate the form while edits always save under `server_edition`. export const SERVER_EDITION_TAB_LABEL = 'Server Edition' export const SERVER_EDITION_SECTION_TITLE = '👥 Server Edition' +// +// Spec 107 PR-B (T054): this row set is asserted EXACTLY by +// `tests/unit/settings-server-edition-wording.spec.ts`. The `Settings` +// disposition paragraph of `specs/107-server-edition-sso-hardening/contracts/ +// config-keys.md` is the authority. Every row is restart-pinned (bound at +// login-handler construction). Deliberately absent, Raw-JSON-only: +// `access.*` (no map control), `oauth.allow_insecure_issuer` (a loopback-only +// development toggle that must not look like a normal setting), and the +// retained keys with no row today (`admin_emails`, `session_ttl`, +// `bearer_token_ttl`, `oauth.client_id`, `oauth.tenant_id`, +// `oauth.allowed_domains` — SC-007, no UI widening). NEVER a row: +// `oauth.client_secret` and `credential_encryption_key` (secrets; `${env:}` / +// `MCPPROXY_CRED_KEY`) and `store_idp_tokens` (deprecated no-op). export const SERVER_EDITION_FIELDS: SettingField[] = [ { key: 'server_edition.enabled', label: 'Enable multi-user mode', control: 'toggle', restart: true }, - { key: 'server_edition.oauth.provider', label: 'OAuth provider', control: 'select', options: ['', 'google', 'github', 'microsoft'].map((v) => ({ value: v, label: v || '(none)' })) }, + { + key: 'server_edition.oauth.provider', + label: 'OAuth provider', + help: 'Identity provider family. Choose "oidc" for any OpenID Connect provider (Keycloak, Okta, Entra, Authentik, …) and set the issuer URL below. Client id and secret are set in the config file (use ${env:…} for the secret).', + control: 'select', + options: ['', 'google', 'github', 'microsoft', 'oidc'].map((v) => ({ value: v, label: v || '(none)' })), + restart: true, + }, + { + key: 'server_edition.oauth.display_name', + label: 'Login button label', + help: 'Shown on the sign-in page as "Sign in with …". Defaults to the provider family name. Up to 64 characters.', + control: 'text', + optional: true, + placeholder: 'Acme SSO', + restart: true, + }, + { + key: 'server_edition.oauth.issuer_url', + label: 'OIDC issuer URL', + help: 'Required when the provider is "oidc". Must be https (plain http is accepted only for a loopback host with allow_insecure_issuer set in the config file). Discovery runs at /.well-known/openid-configuration and the discovered issuer must match byte-for-byte.', + control: 'text', + valueKind: 'url', + optional: true, + placeholder: 'https://login.example.com/realms/acme', + restart: true, + }, + { + key: 'server_edition.oauth.scopes', + label: 'OIDC scopes', + help: 'Comma-separated scopes requested at login. "openid" is always added. Default: openid, profile, email.', + control: 'textarea', + listKind: 'comma', + optional: true, + placeholder: 'openid, profile, email', + restart: true, + }, + { + key: 'server_edition.oauth.groups_claim', + label: 'Groups claim', + help: 'Name of the ID-token / userinfo claim that carries the user’s groups (used by the access map). Default: groups.', + control: 'text', + optional: true, + placeholder: 'groups', + restart: true, + }, + { + key: 'server_edition.oauth.email_verified_policy', + label: 'Email verification policy', + help: 'refuse_false = reject a login whose ID token says email_verified: false (default); require_true = also reject when the claim is missing; ignore = never check.', + control: 'select', + options: [ + { value: 'refuse_false', label: 'refuse_false — reject unverified (default)' }, + { value: 'require_true', label: 'require_true — require the claim to be true' }, + { value: 'ignore', label: 'ignore — never check' }, + ], + restart: true, + }, + { + key: 'server_edition.public_url', + label: 'Public URL', + help: 'The absolute origin users reach mcpproxy at behind a reverse proxy (scheme://host[:port], no path). Used to build the OAuth callback and to decide whether the session cookie is Secure. MCPPROXY_PUBLIC_URL overrides it.', + control: 'text', + valueKind: 'url', + optional: true, + placeholder: 'https://mcp.example.com', + restart: true, + }, + { + key: 'server_edition.session_cookie_secure', + label: 'Session cookie Secure attribute', + help: 'auto = Secure when the public URL is https or TLS is on (default). "false" cannot be combined with an https public URL or TLS.', + control: 'select', + defaultValue: 'auto', + options: [ + { value: 'auto', label: 'auto — follow the public URL / TLS (default)' }, + { value: 'true', label: 'true — always Secure' }, + { value: 'false', label: 'false — never Secure (plain-http development only)' }, + ], + restart: true, + }, ] // isBlankInstructions returns true when a saved instructions value is empty / @@ -544,8 +657,8 @@ export function aliasServerEdition(cfg: any): any { export function hydrateConfigState(cfg: any): { working: any; original: any; raw: any } { const clone = (v: any) => (v == null ? v : JSON.parse(JSON.stringify(v))) return { - working: normalizeFieldDefaults(aliasServerEdition(clone(cfg))), - original: normalizeFieldDefaults(aliasServerEdition(clone(cfg))), + working: normalizeListFields(normalizeFieldDefaults(aliasServerEdition(clone(cfg)))), + original: normalizeListFields(normalizeFieldDefaults(aliasServerEdition(clone(cfg)))), raw: clone(cfg), } } @@ -560,6 +673,47 @@ export function normalizeFieldDefaults(cfg: any): any { return cfg } +/** + * Render a []string value as the textarea text of a list-kind field. Anything + * that is not an array (already text, null) is returned unchanged. + */ +export function listToText(kind: ListKind, value: unknown): unknown { + if (!Array.isArray(value)) return value + const items = value.map((v) => String(v)) + return kind === 'lines' ? items.join('\n') : items.join(', ') +} + +/** + * Parse the textarea text of a list-kind field back into a trimmed []string. + * Commas and newlines both separate entries for either kind (an operator who + * pastes "a,b" into a one-per-line box still gets two entries); blank entries + * are dropped, so a cleared textarea is an empty list, never a string. An + * array is returned as-is. + */ +export function textToList(_kind: ListKind, value: unknown): string[] { + if (Array.isArray(value)) return value.map((v) => String(v)) + if (value == null) return [] + return String(value) + .split(/[\n,]/) + .map((v) => v.trim()) + .filter((v) => v !== '') +} + +/** + * Turn every list-kind field's array into its textarea text. Applied to BOTH + * the working copy and the last-saved snapshot (see hydrateConfigState) so the + * dirty comparison stays string-vs-string. Mutates and returns cfg. + */ +export function normalizeListFields(cfg: any): any { + if (cfg == null || typeof cfg !== 'object') return cfg + for (const f of allCatalogFields()) { + if (!f.listKind) continue + const cur = getPath(cfg, f.key) + if (Array.isArray(cur)) setPath(cfg, f.key, listToText(f.listKind, cur)) + } + return cfg +} + export function getPath(obj: any, path: string): any { return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj) } @@ -583,10 +737,15 @@ export function setPath(obj: any, path: string, value: any): void { // buildPartial assembles a nested object containing ONLY the given dot-path // keys, read from `source`. This is the partial payload sent to PATCH /config. +// A list-kind field (see ListKind) is converted from its textarea text back +// into the []string the Go side expects. export function buildPartial(source: any, dirtyKeys: string[]): Record { const out: Record = {} + const fields = allCatalogFields() for (const key of dirtyKeys) { - setPath(out, key, getPath(source, key)) + const f = fields.find((x) => x.key === key) + const val = getPath(source, key) + setPath(out, key, f?.listKind ? textToList(f.listKind, val) : val) } return out } diff --git a/frontend/src/views/teams/Login.vue b/frontend/src/views/teams/Login.vue index e7ad1de31..4c1cd3a28 100644 --- a/frontend/src/views/teams/Login.vue +++ b/frontend/src/views/teams/Login.vue @@ -20,12 +20,16 @@