// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0
// Command repro-redis-cimd is a minimal, throwaway reproduction of a defect in
// ToolHive's embedded OAuth 2.0 authorization server: RedisStorage rehydrates a
// persisted authorization-code session by calling GetClient on the BARE
// *RedisStorage (see unmarshalRequester in pkg/authserver/storage/redis.go),
// bypassing the CIMDStorageDecorator that the server wraps RedisStorage in
// (pkg/authserver/storage/cimd_decorator.go). A CIMD client (Client ID Metadata
// Document, resolved on the fly from a URL-shaped client_id) is never persisted
// to the client store, so the inner lookup fails and the token endpoint later
// reports invalid_grant for any client that authenticated via CIMD.
//
// This file is a disposable diagnostic harness. It is not part of the ToolHive
// build and is deleted after use.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/ory/fosite"
"github.com/redis/go-redis/v9"
"github.com/stacklok/toolhive/pkg/authserver/server/session"
"github.com/stacklok/toolhive/pkg/authserver/storage"
"github.com/stacklok/toolhive/pkg/oauthproto/cimd"
)
func main() {
fmt.Println("=== Repro: RedisStorage rehydration bypasses CIMDStorageDecorator ===")
fmt.Println()
if err := runCIMDVariant(); err != nil {
fmt.Printf("[CIMD variant] harness error: %v\n", err)
os.Exit(1)
}
fmt.Println()
if err := runRegularClientVariant(); err != nil {
fmt.Printf("[Contrast variant] harness error: %v\n", err)
os.Exit(1)
}
}
// newDecoratedRedisStorage starts miniredis, builds a *storage.RedisStorage
// exactly as pkg/authserver/storage/redis_test.go does (see newTestRedisStorage),
// then wraps it in a CIMDStorageDecorator exactly as the server does
// (see the NewCIMDStorageDecorator wiring in pkg/authserver/server_impl.go).
func newDecoratedRedisStorage() (storage.Storage, *storage.RedisStorage, func(), error) {
mr := miniredis.NewMiniRedis()
if err := mr.Start(); err != nil {
return nil, nil, nil, fmt.Errorf("failed to start miniredis: %w", err)
}
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
base := storage.NewRedisStorageWithClient(client, "repro:auth:")
cleanup := func() {
_ = base.Close()
mr.Close()
}
decorated, err := storage.NewCIMDStorageDecorator(base, storage.CIMDDecoratorConfig{
Enabled: true,
CacheMaxSize: 10,
FallbackTTL: time.Minute,
ScopesSupported: nil, // unconstrained AS: same shape as the decorator's own unit tests
BaselineClientScopes: nil,
})
if err != nil {
cleanup()
return nil, nil, nil, fmt.Errorf("failed to build CIMDStorageDecorator: %w", err)
}
return decorated, base, cleanup, nil
}
// serveCIMDDoc starts an httptest.Server serving a valid CIMD document at path,
// copying the construction pattern from
// pkg/authserver/storage/cimd_decorator_test.go's serveCIMDDoc helper. httptest
// servers bind to 127.0.0.1, which oauthproto.IsClientIDMetadataDocumentURL and
// cimd.FetchClientMetadataDocument both accept for loopback HTTP as a
// test-only affordance (see pkg/oauthproto/cimd.go).
func serveCIMDDoc(path string) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != path {
http.NotFound(w, r)
return
}
clientID := "http://" + r.Host + r.URL.Path
doc := cimd.ClientMetadataDocument{
ClientID: clientID,
RedirectURIs: []string{"https://example.com/callback"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
}))
return srv
}
// newRequestForClient builds a fosite.Request the same way
// pkg/authserver/storage/redis_test.go's newRedisTestRequester does, using the
// real session.Session type so JSON round-tripping through Redis works exactly
// as it does in the running server.
func newRequestForClient(id string, client fosite.Client) fosite.Requester {
return &fosite.Request{
ID: id,
RequestedAt: time.Now(),
Client: client,
RequestedScope: fosite.Arguments{"openid", "profile"},
GrantedScope: fosite.Arguments{"openid"},
RequestedAudience: fosite.Arguments{},
GrantedAudience: fosite.Arguments{},
Form: make(url.Values),
Session: session.New("test-subject", "", "", session.UserClaims{}),
}
}
// runCIMDVariant is the defect-reproducing path: a CIMD client is resolved
// through the decorator (succeeds), an authorization-code session referencing
// that client is created through the decorator (as the /oauth/authorize
// handler would), and then read back through the decorator (as the token
// handler would at the /oauth/token exchange). The read-back is expected to
// fail because unmarshalRequester resolves the client via the bare
// *RedisStorage, which never saw this client persisted.
func runCIMDVariant() error {
fmt.Println("--- Variant 1: CIMD client (e.g. ChatGPT connector) ---")
decorated, _, cleanup, err := newDecoratedRedisStorage()
if err != nil {
return err
}
defer cleanup()
srv := serveCIMDDoc("/client.json")
defer srv.Close()
documentURL := srv.URL + "/client.json"
ctx := context.Background()
// Step 1: resolve the CIMD client via the decorator, exactly as the
// /oauth/authorize handler does when a client presents a URL-shaped
// client_id.
cimdClient, err := decorated.GetClient(ctx, documentURL)
if err != nil {
return fmt.Errorf("unexpected failure resolving CIMD client via decorator: %w", err)
}
fmt.Printf("Step 1 OK: decorator.GetClient resolved CIMD client, id=%s\n", cimdClient.GetID())
// Step 2: create an authorization code session through the decorator,
// exactly as fosite's authorize endpoint does after the resource owner
// approves the request. CreateAuthorizeCodeSession is not overridden by
// CIMDStorageDecorator, so this delegates straight to RedisStorage.
code := "cimd-auth-code-123"
request := newRequestForClient("cimd-req-1", cimdClient)
if err := decorated.CreateAuthorizeCodeSession(ctx, code, request); err != nil {
return fmt.Errorf("unexpected failure creating authorize code session: %w", err)
}
fmt.Println("Step 2 OK: decorator.CreateAuthorizeCodeSession stored the session")
// Step 3: read the session back through the decorator, exactly as the
// /oauth/token handler does during authorization_code token exchange.
_, err = decorated.GetAuthorizeCodeSession(ctx, code, nil)
fmt.Println("Step 3 (token exchange read-back):")
if err == nil {
fmt.Println(" UNEXPECTED: no error — decorator.GetAuthorizeCodeSession succeeded")
return errors.New("expected the CIMD variant to fail with a client-lookup error, but it succeeded")
}
fmt.Printf(" VERBATIM ERROR: %v\n", err)
fmt.Println(" (fosite's token endpoint surfaces this as invalid_grant to the client)")
return nil
}
// runRegularClientVariant is the contrast path: a regular (DCR-style) client
// IS persisted in the client store via RegisterClient, the same store method
// the DCR handler uses. The same create/read-back round trip through the
// decorator succeeds because unmarshalRequester's bare-RedisStorage GetClient
// call finds the persisted client row.
func runRegularClientVariant() error {
fmt.Println("--- Variant 2 (contrast): regular DCR-registered client ---")
decorated, base, cleanup, err := newDecoratedRedisStorage()
if err != nil {
return err
}
defer cleanup()
ctx := context.Background()
regularClient := &fosite.DefaultClient{
ID: "dcr-client-abc",
RedirectURIs: []string{"https://example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
Scopes: []string{"openid", "profile"},
Public: true,
}
// Persist the client the way the DCR registration handler does: directly
// on the underlying storage via RegisterClient (storage.ClientRegistry).
if err := base.RegisterClient(ctx, regularClient); err != nil {
return fmt.Errorf("unexpected failure registering regular client: %w", err)
}
fmt.Printf("Step 1 OK: base.RegisterClient persisted client, id=%s\n", regularClient.ID)
resolved, err := decorated.GetClient(ctx, regularClient.ID)
if err != nil {
return fmt.Errorf("unexpected failure resolving regular client via decorator: %w", err)
}
fmt.Printf("Step 2 OK: decorator.GetClient resolved regular client, id=%s (delegated to base, opaque ID is not CIMD-shaped)\n", resolved.GetID())
code := "regular-auth-code-456"
request := newRequestForClient("regular-req-1", resolved)
if err := decorated.CreateAuthorizeCodeSession(ctx, code, request); err != nil {
return fmt.Errorf("unexpected failure creating authorize code session: %w", err)
}
fmt.Println("Step 3 OK: decorator.CreateAuthorizeCodeSession stored the session")
retrieved, err := decorated.GetAuthorizeCodeSession(ctx, code, nil)
fmt.Println("Step 4 (token exchange read-back):")
if err != nil {
fmt.Printf(" UNEXPECTED ERROR: %v\n", err)
return fmt.Errorf("expected the regular-client variant to succeed, got: %w", err)
}
fmt.Printf(" OK: decorator.GetAuthorizeCodeSession succeeded, retrieved request id=%s, client id=%s\n",
retrieved.GetID(), retrieved.GetClient().GetID())
return nil
}
Bug description
With the embedded OAuth 2.0 authorization server running on the Redis storage backend, any client registered via CIMD (Client ID Metadata Document, i.e. a URL-shaped
client_id) completes the authorization flow and receives an authorization code, but then every token exchange fails withinvalid_grant. The ChatGPT connector is a real-world client that hits this: it registers via CIMD withclient_id = https://chatgpt.com/oauth/<id>/client.json, signs in successfully, and then can never redeem its code.Facts observed in source (at tag
v0.41.0, commit d722304; all cited paths re-checked and unchanged on currentmainas of 2026-08-04):pkg/authserver/server_impl.go:234callsstorage.NewCIMDStorageDecorator(stor, ...).CIMDStorageDecorator.GetClient(pkg/authserver/storage/cimd_decorator.go:88-93) intercepts URL-shapedclient_idvalues (oauthproto.IsClientIDMetadataDocumentURL) and resolves them by fetching the metadata document, with an in-memory LRU cache. Nothing on this path ever persists the resolved client into the wrapped storage's client store — a CIMD client exists only as this on-the-fly resolution.unmarshalRequester(pkg/authserver/storage/redis.go:1854) resolves the stored session's client withclient, err := s.GetClient(ctx, stored.ClientID)on the bare*RedisStoragereceiver (line 1861), wrapping failures asfailed to get client for session: %w(line 1863). It is invoked fromGetAuthorizeCodeSession(line 332),GetAccessTokenSession(line 423),GetRefreshTokenSession(line 499), andGetPKCERequestSession(line 650).RedisStorage.GetClient(pkg/authserver/storage/redis.go:224) looks up the persisted client key and, for a client that was never registered, returnsErrNotFound(storage: item not found,pkg/authserver/storage/types.go:39) wrapped withfosite.ErrNotFound(line 230).MemoryStoragedoes not have this problem:GetAuthorizeCodeSession(pkg/authserver/storage/memory.go:435) returns the stored livefosite.Requester(entry.value, line 451) with its client pointer intact — the client is never re-resolved.slog.Error("failed to create access request", "error", err)(pkg/authserver/server/handlers/token.go:30), and the fosite error stringifies as justinvalid_grant. Neither theclient_idnor the wrapped storage error (failed to get client for session: ...) reaches the log.Interpretation: the authorize endpoint resolves the CIMD client through the decorated storage and succeeds, but the token endpoint's session read-back re-resolves the client through the inner
*RedisStorage, which has never seen it. The defect therefore exists only in the CIMD × Redis combination — which is exactly what a production multi-replica deployment runs — while the memory backend (what unit tests exercise) keeps the live requester and cannot surface it.Steps to reproduce
v0.41.0(commit d722304).cmd/repro-redis-cimd/main.gowith the harness below (self-contained; usesminiredis, a test-only dependency already present ingo.mod— nogo.mod/go.sumchanges needed).go run ./cmd/repro-redis-cimd. The run is deterministic; two consecutive runs produced identical results (modulo the ephemeral loopback port), exit code 0.The harness is modeled directly on the repo's own test construction patterns (
newTestRedisStorage/newRedisTestRequesterinpkg/authserver/storage/redis_test.go,serveCIMDDoc/newEnabledDecoratorinpkg/authserver/storage/cimd_decorator_test.go). It runs two variants through the identicalCIMDStorageDecorator+RedisStoragestack: (1) a CIMD client resolved via the decorator, which fails at the token-exchange read-back, and (2) a contrast variant with a regular client persisted viaRegisterClient(the same method the DCR handler uses), which succeeds.cmd/repro-redis-cimd/main.go (click to expand)
Real-world reproduction (how we actually hit it): run the embedded auth server with the Redis storage backend, add the server as a ChatGPT connector (registers via CIMD, user agent
openai-mcp/1.0.0, MCP protocol 2025-11-25), complete the sign-in. Authorization succeeds and the browser is redirected back with a code; the connector's token exchange then fails, and retries fail identically.Expected behavior
A CIMD client that just completed authorization can redeem its authorization code: session rehydration resolves the session's client through the same decorated storage the OAuth2 provider was configured with (or through some path that can see decorator-resolved CIMD clients), so both harness variants succeed.
Actual behavior
Variant 1 (CIMD client) fails at the token-exchange read-back; Variant 2 (persisted DCR-style client) succeeds through the identical stack, confirming the defect is specific to the CIMD × Redis combination and not a general storage bug. Verbatim harness output:
A second run was identical except for the ephemeral loopback port (
127.0.0.1:53045), exit code 0 both times.In the live deployment the operator-visible symptom is a single log line per failed exchange, with no client id and no underlying cause:
Environment (if relevant)
v0.41.0, commit d722304, and the cited code paths are unchanged on currentmainas of 2026-08-04).go run, no network beyond loopback).client_id = https://chatgpt.com/oauth/<id>/client.json).Additional context
Why the existing test suite cannot catch this:
MemoryStoragekeeps the livefosite.Requester(client pointer included) and never re-resolves the client on read-back (pkg/authserver/storage/memory.go:435-451), so any test running the CIMD flow against memory storage passes.redis_test.goround-trips sessions for clients that were persisted withRegisterClient, which is exactly the case that works. The gap is a round-trip test of a decorator-resolved CIMD client through the decorated Redis storage — the harness above is essentially that test, and it fails today.Proposed direction (either would fix it):
unmarshalRequesterreceive the outerfositestorage (afosite.ClientManager) instead of the bare*RedisStorage, so theCIMDStorageDecorator.GetClientinterception applies on rehydration too.Diagnosability request, independent of the fix: log the wrapped underlying error (and the client id) at the token endpoint. Today
pkg/authserver/server/handlers/token.go:30logs only the sanitized fosite error, so the operator seesfailed to create access request error=invalid_grantwhile the actionable cause (failed to get client for session: storage: item not found) is discarded — this made the failure a black-box hunt.Related (checked, not duplicates):
GetClient— distinct root cause, same family.thv runrestarts, predating both the Redis backend and the CIMD decorator. CIMD clients are resolved on the fly by design and were never meant to be persisted, so it is not the same defect — cited for lineage only.CIMDStorageDecorator.GetClientitself (merged); establishes where the decorator lives, but does not touchredis.go'sunmarshalRequesterclient-lookup path.storage.Storageimplementation (merged), which predates CIMD entirely — the origin ofunmarshalRequester, not a report of this bypass.