Bug description
The embedded auth server's CIMD (Client ID Metadata Document) storage decorator rejects every CIMD client whose metadata document omits the optional scope field whenever the server's configured scopes_supported is not a superset of the hard-coded DefaultScopes (openid, profile, email, offline_access). The rejection is doubly silent: the OAuth client only ever receives the generic invalid_client / "The requested OAuth 2.0 Client does not exist." response, and the server logs nothing at any level, including debug. ChatGPT's MCP connector registers via CIMD with a document that omits scope, so any deployment that narrows scopes_supported below the four defaults breaks the ChatGPT connector out of the box, with no diagnosable trace on either side.
Mechanism (all file/line references at tag v0.41.0, commit d722304):
pkg/authserver/storage/cimd_decorator.go, fetch(): when the fetched document omits scope and the decorator has ScopesSupported configured, the omitted-scope branch (lines 179–190) calls registration.ValidateScopes(nil, d.scopesSupported) (line 181).
registration.ValidateScopes (pkg/authserver/server/registration/dcr.go:289): with no requested scopes it falls back to DefaultScopes (pkg/authserver/server/registration/client.go:88 — ["openid", "profile", "email", "offline_access"]) and returns a DCRError if any one of the four is missing from the allowed set (dcr.go:316–326, "default scope not supported by server: …").
- The decorator wraps that into
fosite.ErrInvalidClient with the hint scope field required (cimd_decorator.go:182–188).
- The client never sees the reason. The authorize handler passes the error through fosite's
NewAuthorizeRequest (pkg/authserver/server/handlers/authorize.go:48–52), and fosite discards the storage error wholesale: authorize_request_handler.go:360–363 in ory/fosite v0.49.0 replaces it with ErrInvalidClient.WithHint("The requested OAuth 2.0 Client does not exist.").WithWrap(err).WithDebug(err.Error()). The original reason survives only in the debug field, which fosite renders to clients only when SendDebugMessagesToClients is enabled — nothing in pkg/authserver enables it.
- The server never logs the rejection.
cimd_decorator.go contains no logging calls at all, and the authorize handler's error path (authorize.go:49–51) writes the fosite error and returns without logging.
Observation vs. interpretation, separated: the facts above are what the code does, verified by reading the source and by the reproduction below. As for intent, we understand this validation is designed behavior, not an accident — the code comment at cimd_decorator.go:158–169 documents the rule, and #4825 specifies that an omitted scope defaults to registration.DefaultScopes. What we are reporting is a real-world gap in that design: a compliant client (omitting an optional metadata field — ToolHive's own document validation accepts scope-less documents) meets a compliant, reasonable server configuration (a deliberately narrowed scopes_supported, e.g. {openid, email, offline_access} without profile), and the result is a hard failure that neither side can diagnose. The internal error says "the document must explicitly declare its required scopes", but a CIMD document is published globally by the client vendor and shared across every authorization server it talks to — a deployment operator cannot edit ChatGPT's https://chatgpt.com/oauth/<id>/client.json.
The diagnosability gap is what made this expensive for us. Our first live ChatGPT connector (user agent openai-mcp/1.0.0, MCP protocol 2025-11-25) failed authorization with only "The requested OAuth 2.0 Client does not exist", which is indistinguishable from a mistyped client_id, a CIMD fetch failure, or CIMD being disabled — and the server logs were empty even at debug level. We found the cause only by reading the decorator source and eliminating every other layer first. Notably, the DCR registration path uses the very same ValidateScopes fallback but returns the DCRError body to the client (pkg/authserver/server/handlers/dcr.go:68–72), so a DCR client in the analogous situation at least sees "default scope not supported by server: profile"; the CIMD path surfaces nothing anywhere.
Steps to reproduce
End to end: (1) run the embedded auth server with CIMD enabled and scopes_supported set to any list missing at least one of openid/profile/email/offline_access — for example ["openid", "email", "offline_access"]; (2) point a CIMD client whose document omits scope at it (the ChatGPT connector is such a client); (3) authorization fails with invalid_client and nothing is logged.
Standalone reproduction, driving the exact production code path (storage.NewCIMDStorageDecorator(...).GetClient(...)) with no source modifications: at tag v0.41.0, save the program below as cmd/repro-cimd-scopes/main.go and run go run ./cmd/repro-cimd-scopes. Serving the CIMD document over http://127.0.0.1 via httptest requires no SSRF-guard bypass: validateCIMDClientURL (pkg/oauthproto/cimd/fetch.go:150–158) has a documented http://localhost/loopback development exception — the same one the project's own cimd_decorator_test.go relies on.
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0
// Command repro-cimd-scopes is a minimal, standalone reproduction of a defect
// in the embedded auth server's CIMD (Client ID Metadata Document) storage
// decorator: when a CIMD document omits `scope` and the server's configured
// ScopesSupported does not happen to be a superset of the hard-coded
// DefaultScopes, GetClient silently rejects the client with a generic
// invalid_client error and logs nothing at any level.
//
// This program drives the exact same code path the embedded auth server uses
// in production: storage.NewCIMDStorageDecorator(...).GetClient(ctx, clientID).
// It runs three variants:
//
// Variant A: scopesSupported = [openid, email, offline_access] (no "profile"),
// CIMD document omits `scope` entirely -> expect rejection.
// Variant B: scopesSupported = [openid, profile, email, offline_access]
// (a superset of DefaultScopes), document omits `scope`
// -> expect success, resolved scopes = DefaultScopes.
// Variant C: same reduced scopesSupported as A, but the document declares
// `scope: "openid email"` explicitly -> expect success.
//
// The CIMD document is served over plain HTTP on 127.0.0.1 via
// net/http/httptest. This does NOT require bypassing any SSRF guard: the
// decorator's fetch client (pkg/oauthproto/cimd/fetch.go,
// validateCIMDClientURL) explicitly permits the http://localhost /
// http://127.0.0.1 scheme+host combination as a documented development
// exception, and the toolhive project's own cimd_decorator_test.go relies on
// exactly this exception to drive httptest servers through the real fetch
// path without a custom fetcher/client seam. No source file is modified to
// produce this reproduction.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"time"
"github.com/ory/fosite"
"github.com/stacklok/toolhive/pkg/authserver/storage"
"github.com/stacklok/toolhive/pkg/oauthproto/cimd"
)
// serveCIMDDoc starts an httptest.Server that serves a single CIMD client
// metadata document at /meta.json. scope is written verbatim into the
// document's "scope" field; pass "" to omit the field entirely (the
// ClientMetadataDocument.Scope field carries `json:"scope,omitempty"`, so an
// empty string is dropped from the encoded JSON, faithfully reproducing a
// CIMD document that omits `scope`, as ChatGPT's real document does).
func serveCIMDDoc(scope string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/meta.json" {
http.NotFound(w, r)
return
}
doc := cimd.ClientMetadataDocument{
ClientID: "http://" + r.Host + "/meta.json",
RedirectURIs: []string{"https://example.com/callback"},
GrantTypes: []string{"authorization_code"},
ResponseTypes: []string{"code"},
// TokenEndpointAuthMethod omitted -> decorator defaults to "none".
Scope: scope,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
}))
}
// runVariant builds a CIMDStorageDecorator with the given scopesSupported,
// serves a CIMD document with the given scope field, calls GetClient (the
// exact method the embedded auth server calls when resolving a client_id),
// and prints the outcome.
func runVariant(label, docScope string, scopesSupported []string) {
fmt.Printf("=== %s ===\n", label)
fmt.Printf("scopesSupported = %v\n", scopesSupported)
if docScope == "" {
fmt.Println("CIMD document scope field: OMITTED")
} else {
fmt.Printf("CIMD document scope field: %q\n", docScope)
}
srv := serveCIMDDoc(docScope)
defer srv.Close()
base := storage.NewMemoryStorage()
defer func() { _ = base.Close() }()
dec, err := storage.NewCIMDStorageDecorator(base, storage.CIMDDecoratorConfig{
Enabled: true,
CacheMaxSize: 10,
FallbackTTL: time.Minute,
ScopesSupported: scopesSupported,
})
if err != nil {
fmt.Printf("FATAL: NewCIMDStorageDecorator: %v\n", err)
os.Exit(1)
}
clientID := srv.URL + "/meta.json"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := dec.GetClient(ctx, clientID)
if err != nil {
fmt.Println("OUTCOME: REJECTED")
fmt.Printf("error (verbatim, %%v): %v\n", err)
fmt.Printf("error (verbatim, %%+v): %+v\n", err)
fmt.Printf("errors.Is(err, fosite.ErrInvalidClient) = %v\n", errors.Is(err, fosite.ErrInvalidClient))
fmt.Printf("errors.Is(err, fosite.ErrNotFound) = %v\n", errors.Is(err, fosite.ErrNotFound))
// Show the decorator-level error fields. NOTE: in production even the
// hint below never reaches the client, because fosite's
// NewAuthorizeRequest replaces this error wholesale with the generic
// "The requested OAuth 2.0 Client does not exist." (see issue text).
var rfcErr *fosite.RFC6749Error
if errors.As(err, &rfcErr) {
fmt.Printf("wire-visible error field: %q\n", rfcErr.ErrorField)
fmt.Printf("wire-visible error description: %q\n", rfcErr.GetDescription())
fmt.Printf("wire-visible hint (NOT sent): %q\n", rfcErr.HintField)
}
} else {
fmt.Println("OUTCOME: ACCEPTED")
fmt.Printf("resolved client ID: %s\n", client.GetID())
fmt.Printf("resolved client scopes: %v\n", []string(client.GetScopes()))
}
fmt.Println()
}
func main() {
fmt.Println("Reproduction: embedded auth server CIMD storage decorator silently")
fmt.Println("rejects scope-omitting CIMD clients when DefaultScopes is not a")
fmt.Println("subset of the configured ScopesSupported.")
fmt.Println()
fmt.Println("DefaultScopes (pkg/authserver/server/registration/client.go) = " +
"[openid profile email offline_access]")
fmt.Println()
// Variant A: reduced ScopesSupported missing "profile", scope omitted.
// DefaultScopes = {openid, profile, email, offline_access} is NOT a
// subset of {openid, email, offline_access} (missing "profile"), so
// registration.ValidateScopes(nil, scopesSupported) returns a DCRError
// and fetch() wraps it as fosite.ErrInvalidClient.
runVariant(
"VARIANT A: reduced scopes_supported, scope OMITTED -> expect REJECTED",
"",
[]string{"openid", "email", "offline_access"},
)
// Variant B: ScopesSupported is a superset of DefaultScopes, scope
// omitted. ValidateScopes(nil, scopesSupported) succeeds and returns
// DefaultScopes verbatim.
runVariant(
"VARIANT B: scopes_supported is a superset of DefaultScopes, scope OMITTED -> expect ACCEPTED",
"",
[]string{"openid", "profile", "email", "offline_access"},
)
// Variant C: same reduced ScopesSupported as A, but the document declares
// scope explicitly. ValidateScopes(["openid","email"], scopesSupported)
// succeeds because every declared scope is individually allowed.
runVariant(
"VARIANT C: reduced scopes_supported (same as A), scope EXPLICIT \"openid email\" -> expect ACCEPTED",
"openid email",
[]string{"openid", "email", "offline_access"},
)
fmt.Println("Reproduction complete.")
}
Output of a run at tag v0.41.0 on 2026-08-04 (ephemeral ports vary between runs):
Reproduction: embedded auth server CIMD storage decorator silently
rejects scope-omitting CIMD clients when DefaultScopes is not a
subset of the configured ScopesSupported.
DefaultScopes (pkg/authserver/server/registration/client.go) = [openid profile email offline_access]
=== VARIANT A: reduced scopes_supported, scope OMITTED -> expect REJECTED ===
scopesSupported = [openid email offline_access]
CIMD document scope field: OMITTED
OUTCOME: REJECTED
error (verbatim, %v): invalid_client: CIMD document at http://127.0.0.1:52572/meta.json omits scope but DefaultScopes are not a subset of this server's scopes_supported — the document must explicitly declare its required scopes
error (verbatim, %+v): invalid_client: CIMD document at http://127.0.0.1:52572/meta.json omits scope but DefaultScopes are not a subset of this server's scopes_supported — the document must explicitly declare its required scopes
errors.Is(err, fosite.ErrInvalidClient) = true
errors.Is(err, fosite.ErrNotFound) = false
wire-visible error field: "invalid_client"
wire-visible error description: "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). scope field required"
wire-visible hint (NOT sent): "scope field required"
=== VARIANT B: scopes_supported is a superset of DefaultScopes, scope OMITTED -> expect ACCEPTED ===
scopesSupported = [openid profile email offline_access]
CIMD document scope field: OMITTED
OUTCOME: ACCEPTED
resolved client ID: http://127.0.0.1:52574/meta.json
resolved client scopes: [openid profile email offline_access]
=== VARIANT C: reduced scopes_supported (same as A), scope EXPLICIT "openid email" -> expect ACCEPTED ===
scopesSupported = [openid email offline_access]
CIMD document scope field: "openid email"
OUTCOME: ACCEPTED
resolved client ID: http://127.0.0.1:52576/meta.json
resolved client scopes: [openid email]
Reproduction complete.
Variant A is the defect. Variant B shows the same scope-omitting document accepted when scopes_supported covers all four defaults. Variant C shows the same reduced server configuration accepting a document that explicitly declares openid email — which is what makes the intersection proposal below safe. One caveat on the harness labels: the "wire-visible" lines print the decorator-level error, which still carries the scope field required hint; in production even that hint never reaches the client, because fosite's NewAuthorizeRequest replaces the storage error wholesale (step 4 in the mechanism above), so the real client sees only "The requested OAuth 2.0 Client does not exist."
Expected behavior
A scope-omitting CIMD document on a server with a narrowed scopes_supported should be granted the intersection of DefaultScopes and scopes_supported (for the example configuration: openid email offline_access) — i.e., exactly what the same client would receive if its document declared that intersection explicitly, which Variant C shows the server already accepts. Intersecting therefore grants nothing the server does not already grant to the same client via an explicit declaration; it removes a hard failure that the client vendor cannot fix per-deployment (CIMD documents are global) and the operator cannot fix at all. Alternatively, or additionally, the omitted-scope fallback could be made configurable instead of hard-coding DefaultScopes.
At minimum, if the hard rejection is kept as designed: log the rejection at WARN, naming the CIMD client_id URL and the specific default scopes missing from scopes_supported. Given that fosite reduces every GetClient error to "The requested OAuth 2.0 Client does not exist." on the wire, a server-side log line is the only diagnostic channel an operator has.
Actual behavior
The client is rejected with invalid_client; over the wire it sees only "The requested OAuth 2.0 Client does not exist." No log line is produced at any level, including debug. The deployment behaves exactly as if the CIMD client had never existed, and every scope-omitting CIMD client — the ChatGPT connector included — is broken on any configuration whose scopes_supported does not include all four DefaultScopes.
Environment (if relevant)
- ToolHive v0.41.0 (tag commit d722304); all file/line references are at that tag, and the cited code is unchanged on current
main as of 2026-08-04.
- ory/fosite v0.49.0 (per
go.mod).
- Our deployment runs the embedded auth server via the Kubernetes operator (proxyrunner image), but the reproduction above is deployment-independent — a plain
go run against the repository.
Additional context
Related issues and PRs (none of these are duplicates of this report):
We are happy to provide more detail, test a candidate fix, or contribute a PR implementing the intersection-plus-logging behavior if maintainers agree on the direction.
Bug description
The embedded auth server's CIMD (Client ID Metadata Document) storage decorator rejects every CIMD client whose metadata document omits the optional
scopefield whenever the server's configuredscopes_supportedis not a superset of the hard-codedDefaultScopes(openid,profile,email,offline_access). The rejection is doubly silent: the OAuth client only ever receives the genericinvalid_client/ "The requested OAuth 2.0 Client does not exist." response, and the server logs nothing at any level, including debug. ChatGPT's MCP connector registers via CIMD with a document that omitsscope, so any deployment that narrowsscopes_supportedbelow the four defaults breaks the ChatGPT connector out of the box, with no diagnosable trace on either side.Mechanism (all file/line references at tag v0.41.0, commit d722304):
pkg/authserver/storage/cimd_decorator.go,fetch(): when the fetched document omitsscopeand the decorator hasScopesSupportedconfigured, the omitted-scope branch (lines 179–190) callsregistration.ValidateScopes(nil, d.scopesSupported)(line 181).registration.ValidateScopes(pkg/authserver/server/registration/dcr.go:289): with no requested scopes it falls back toDefaultScopes(pkg/authserver/server/registration/client.go:88—["openid", "profile", "email", "offline_access"]) and returns aDCRErrorif any one of the four is missing from the allowed set (dcr.go:316–326, "default scope not supported by server: …").fosite.ErrInvalidClientwith the hintscope field required(cimd_decorator.go:182–188).NewAuthorizeRequest(pkg/authserver/server/handlers/authorize.go:48–52), and fosite discards the storage error wholesale:authorize_request_handler.go:360–363in ory/fosite v0.49.0 replaces it withErrInvalidClient.WithHint("The requested OAuth 2.0 Client does not exist.").WithWrap(err).WithDebug(err.Error()). The original reason survives only in the debug field, which fosite renders to clients only whenSendDebugMessagesToClientsis enabled — nothing inpkg/authserverenables it.cimd_decorator.gocontains no logging calls at all, and the authorize handler's error path (authorize.go:49–51) writes the fosite error and returns without logging.Observation vs. interpretation, separated: the facts above are what the code does, verified by reading the source and by the reproduction below. As for intent, we understand this validation is designed behavior, not an accident — the code comment at cimd_decorator.go:158–169 documents the rule, and #4825 specifies that an omitted scope defaults to
registration.DefaultScopes. What we are reporting is a real-world gap in that design: a compliant client (omitting an optional metadata field — ToolHive's own document validation accepts scope-less documents) meets a compliant, reasonable server configuration (a deliberately narrowedscopes_supported, e.g.{openid, email, offline_access}withoutprofile), and the result is a hard failure that neither side can diagnose. The internal error says "the document must explicitly declare its required scopes", but a CIMD document is published globally by the client vendor and shared across every authorization server it talks to — a deployment operator cannot edit ChatGPT'shttps://chatgpt.com/oauth/<id>/client.json.The diagnosability gap is what made this expensive for us. Our first live ChatGPT connector (user agent
openai-mcp/1.0.0, MCP protocol 2025-11-25) failed authorization with only "The requested OAuth 2.0 Client does not exist", which is indistinguishable from a mistyped client_id, a CIMD fetch failure, or CIMD being disabled — and the server logs were empty even at debug level. We found the cause only by reading the decorator source and eliminating every other layer first. Notably, the DCR registration path uses the very sameValidateScopesfallback but returns theDCRErrorbody to the client (pkg/authserver/server/handlers/dcr.go:68–72), so a DCR client in the analogous situation at least sees "default scope not supported by server: profile"; the CIMD path surfaces nothing anywhere.Steps to reproduce
End to end: (1) run the embedded auth server with CIMD enabled and
scopes_supportedset to any list missing at least one ofopenid/profile/email/offline_access— for example["openid", "email", "offline_access"]; (2) point a CIMD client whose document omitsscopeat it (the ChatGPT connector is such a client); (3) authorization fails withinvalid_clientand nothing is logged.Standalone reproduction, driving the exact production code path (
storage.NewCIMDStorageDecorator(...).GetClient(...)) with no source modifications: at tag v0.41.0, save the program below ascmd/repro-cimd-scopes/main.goand rungo run ./cmd/repro-cimd-scopes. Serving the CIMD document overhttp://127.0.0.1viahttptestrequires no SSRF-guard bypass:validateCIMDClientURL(pkg/oauthproto/cimd/fetch.go:150–158) has a documentedhttp://localhost/loopback development exception — the same one the project's owncimd_decorator_test.gorelies on.Output of a run at tag v0.41.0 on 2026-08-04 (ephemeral ports vary between runs):
Variant A is the defect. Variant B shows the same scope-omitting document accepted when
scopes_supportedcovers all four defaults. Variant C shows the same reduced server configuration accepting a document that explicitly declaresopenid email— which is what makes the intersection proposal below safe. One caveat on the harness labels: the "wire-visible" lines print the decorator-level error, which still carries thescope field requiredhint; in production even that hint never reaches the client, because fosite'sNewAuthorizeRequestreplaces the storage error wholesale (step 4 in the mechanism above), so the real client sees only "The requested OAuth 2.0 Client does not exist."Expected behavior
A scope-omitting CIMD document on a server with a narrowed
scopes_supportedshould be granted the intersection ofDefaultScopesandscopes_supported(for the example configuration:openid email offline_access) — i.e., exactly what the same client would receive if its document declared that intersection explicitly, which Variant C shows the server already accepts. Intersecting therefore grants nothing the server does not already grant to the same client via an explicit declaration; it removes a hard failure that the client vendor cannot fix per-deployment (CIMD documents are global) and the operator cannot fix at all. Alternatively, or additionally, the omitted-scope fallback could be made configurable instead of hard-codingDefaultScopes.At minimum, if the hard rejection is kept as designed: log the rejection at WARN, naming the CIMD
client_idURL and the specific default scopes missing fromscopes_supported. Given that fosite reduces everyGetClienterror to "The requested OAuth 2.0 Client does not exist." on the wire, a server-side log line is the only diagnostic channel an operator has.Actual behavior
The client is rejected with
invalid_client; over the wire it sees only "The requested OAuth 2.0 Client does not exist." No log line is produced at any level, including debug. The deployment behaves exactly as if the CIMD client had never existed, and every scope-omitting CIMD client — the ChatGPT connector included — is broken on any configuration whosescopes_supporteddoes not include all fourDefaultScopes.Environment (if relevant)
mainas of 2026-08-04.go.mod).go runagainst the repository.Additional context
Related issues and PRs (none of these are duplicates of this report):
registration.DefaultScopes, but does not address thescopes_supported-subset interaction or the silent-rejection/no-logging failure mode. This report is a refinement request against that design.ValidateScopes(nil, scopesSupported)call path described above; it is the origin of the current behavior, not a report of it.token_endpoint_auth_methodhardcoded tonone) #5321 — same family (a real-world MCP client rejected by the embedded auth server; it explicitly flags ChatGPT as likely affected) but a distinct root cause: hard-codedtoken_endpoint_auth_method, not scope validation.scopes_supportedmismatch, in the opposite direction and on a different code path (DCR handler, not the CIMD decorator).requiredClientScopesto embedded auth server for DCR scope baseline #5224 — a DCR-side scope-baseline feature request; different client behavior and a different code path than the CIMD omitted-scope case.We are happy to provide more detail, test a candidate fix, or contribute a PR implementing the intersection-plus-logging behavior if maintainers agree on the direction.