Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9a079e2
docs(specs): Spec 107 — server edition SSO front door hardened for re…
Dumbris Sep 16, 2026
9fdc58d
test(spec-107): PR-A red tests, server-tag lint job and release notice
Dumbris Sep 16, 2026
43d301d
refactor(serveredition): freeze the latent credential-injection chain…
Dumbris Sep 16, 2026
c255986
fix(storage): enforce the agent-token cap per owner, not per deployme…
Dumbris Sep 16, 2026
3bf4394
fix(spec-107): address adversarial review of PR-A
Dumbris Sep 16, 2026
36e0517
fix(spec-107): codex round 1 for PR-A
Dumbris Sep 16, 2026
e158121
fix(spec-107): codex round 2 for PR-A
Dumbris Sep 16, 2026
03ef19d
fix(spec-107): codex round 3 for PR-A
Dumbris Sep 16, 2026
7a4e8fe
fix(spec-107): codex round 4 for PR-A
Dumbris Sep 16, 2026
fb389e6
Merge origin/main (b1777e865) into 107-a-freeze-cut
Dumbris Sep 16, 2026
14611c9
docs(spec-107): PR-A verification and task ticks
Dumbris Sep 16, 2026
c741f82
docs(roadmap): link PR-A
Dumbris Sep 16, 2026
6cf5427
test(oauthserver): fake OIDC identity provider and server-edition dev…
Dumbris Sep 16, 2026
412dc3a
test(serveredition): red tests for the generic OIDC provider, subject…
Dumbris Sep 16, 2026
c74bfb2
feat(serveredition): generic OIDC provider with JWKS-verified ID toke…
Dumbris Sep 16, 2026
1d88938
test(serveredition): red tests for trusted proxies, public URL, cooki…
Dumbris Sep 16, 2026
07ff5c4
feat(serveredition): front door behind an ingress — public URL, trust…
Dumbris Sep 16, 2026
45d302a
feat(config,telemetry): hot-reload clauses, docs and OAS for the PR-B…
Dumbris Sep 16, 2026
4b8baa3
fix(spec-107): gate fixes for PR-B
Dumbris Sep 16, 2026
516606d
docs(spec-107): PR-B automated-check evidence
Dumbris Sep 16, 2026
fd27d27
fix(spec-107): cross-review round 1 for PR-B
Dumbris Sep 16, 2026
30a93bb
fix(spec-107): cross-review round 2 for PR-B
Dumbris Sep 16, 2026
732329c
fix(spec-107): cross-review round 3 for PR-B
Dumbris Sep 16, 2026
8666dab
fix(spec-107): cross-review round 3 for PR-B
Dumbris Sep 16, 2026
8f2e3d4
fix(spec-107): cross-review round 4 for PR-B
Dumbris Sep 16, 2026
1c976e7
fix(spec-107): cross-review round 5 for PR-B
Dumbris Sep 16, 2026
5ad3a08
fix(spec-107): cross-review round 6 for PR-B
Dumbris Sep 16, 2026
873145b
docs(spec-107): PR-B real-instance verification, gate re-run, task ticks
Dumbris Sep 16, 2026
f3103fd
docs(roadmap): link PR-B
Dumbris Sep 16, 2026
717e580
fix(ci): pull forward the Windows heap-corruption + Pass-2 race fix (…
Dumbris Sep 16, 2026
b4ab0f9
docs(spec-107): record the CI red/fix cycle for PR-B
Dumbris Sep 16, 2026
152f5d5
docs(spec-107): record PR-B CI green
Dumbris Sep 16, 2026
2c35085
fix(spec-107): cross-review round 7 for PR-B
Dumbris Sep 17, 2026
2fdb86b
fix(spec-107): cross-review round 8 for PR-B
Dumbris Sep 17, 2026
0dca9d2
Merge remote-tracking branch 'origin/main' into 107-b-oidc-front-door
Dumbris Sep 18, 2026
6e24117
fix(web-ui): update hard-reload auth-guard test for the FR-030 provid…
Dumbris Sep 18, 2026
4bd0dfa
fix(test): match TestLoadConfig_ListenFlag to loadConfig's 3-value si…
Dumbris Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/RELEASE_NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<public_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 `<issuer_url>/.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/).
14 changes: 8 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |

Expand Down Expand Up @@ -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/) | |
Expand Down Expand Up @@ -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%) |
2 changes: 1 addition & 1 deletion cmd/mcpproxy/listen_flag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
Loading
Loading