Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
30 changes: 30 additions & 0 deletions docs/arch/11-auth-server-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,33 @@ When passing configuration across process boundaries (operator → proxy-runner)
- [Redis Storage Configuration Guide](../redis-storage.md) — User-facing setup guide
- [Operator Architecture](09-operator-architecture.md) — CRD and controller design
- [Core Concepts](02-core-concepts.md) — Platform terminology

## CIMD Storage Decorator

When `authServer.cimd.enabled: true` is set, the embedded authorization server wraps its storage backend in a `CIMDStorageDecorator` before passing it to fosite. This decorator enables MCP clients to present HTTPS URLs as `client_id` values without first calling `/oauth/register`.

### What it does

`CIMDStorageDecorator` embeds the full `storage.Storage` interface and overrides only `GetClient`. When fosite calls `GetClient("https://vscode.dev/oauth/client-metadata.json")` during an authorization request:

1. The decorator detects the HTTPS URL using `oauthproto.IsClientIDMetadataDocumentURL`
2. It fetches the Client ID Metadata Document from that URL via `pkg/oauthproto/cimd.FetchClientMetadataDocument` (with SSRF protection, 10 KB cap, 5-second timeout)
3. It builds a `fosite.Client` from the document fields, caches it with a configurable TTL, and returns it to fosite
4. Concurrent fetches for the same URL are deduplicated via `singleflight`

All other `Storage` methods (`RegisterClient`, token storage, upstream token storage, etc.) delegate to the underlying backend unchanged. DCR clients (opaque string IDs) continue to work exactly as before.

### Unwrap pattern

`CIMDStorageDecorator` implements `Unwrap() Storage` to expose the concrete backend through the decorator layer. Two call sites in `server_impl.go` depend on this:

- **`DCRCredentialStore` assertion** (`newServer`): The `DCRCredentialStore` interface is narrower than `Storage` and not embedded in it. The assertion `unwrapStorage(stor).(storage.DCRCredentialStore)` reaches the concrete backend through the decorator.
- **`RedisStorage` migration** (`runLegacyMigration`): A type assertion to `*storage.RedisStorage` is needed to run a one-shot data migration. Same `unwrapStorage` call.

Both call sites use the `unwrapStorage(stor)` helper rather than asserting directly on `stor`.

### Air-gapped environments

When the embedded authorization server is deployed in an environment that cannot reach `https://toolhive.dev/oauth/client-metadata.json` or any public CIMD metadata URL, set `authServer.cimd.enabled: false`. Clients will fall back to DCR (`/oauth/register`) which uses only the local storage backend and requires no outbound connectivity.

**Implementation:** `pkg/authserver/storage/cimd_decorator.go`
18 changes: 13 additions & 5 deletions pkg/authserver/server/registration/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ import (
// native apps that register redirect URIs like "http://localhost/callback" and then
// request authorization with dynamic ports like "http://localhost:57403/callback".
type LoopbackClient struct {
*fosite.DefaultClient
*fosite.DefaultOpenIDConnectClient
}

// NewLoopbackClient creates a new LoopbackClient wrapping the provided DefaultClient.
func NewLoopbackClient(client *fosite.DefaultClient) *LoopbackClient {
return &LoopbackClient{DefaultClient: client}
// NewLoopbackClient creates a new LoopbackClient wrapping the provided client.
// The wrapper preserves all OIDC fields (including TokenEndpointAuthMethod)
// while adding RFC 8252 §7.3 dynamic port matching for loopback redirect URIs.
func NewLoopbackClient(client *fosite.DefaultOpenIDConnectClient) *LoopbackClient {
return &LoopbackClient{DefaultOpenIDConnectClient: client}
}

// MatchRedirectURI checks if the given redirect URI matches one of the client's
Expand Down Expand Up @@ -167,8 +169,14 @@ func New(cfg Config) (fosite.Client, error) {

// Wrap public clients in LoopbackClient for RFC 8252 Section 7.3
// dynamic port matching for native app loopback redirect URIs.
// Use DefaultOpenIDConnectClient so TokenEndpointAuthMethod ("none" for
// public clients) is preserved through the LoopbackClient wrapper.
if cfg.Public {
return NewLoopbackClient(defaultClient), nil
oidcClient := &fosite.DefaultOpenIDConnectClient{
DefaultClient: defaultClient,
TokenEndpointAuthMethod: "none",
}
return NewLoopbackClient(oidcClient), nil
}

return defaultClient, nil
Expand Down
22 changes: 13 additions & 9 deletions pkg/authserver/server/registration/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func TestNewLoopbackClient(t *testing.T) {
Public: true,
}

client := NewLoopbackClient(defaultClient)
client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{DefaultClient: defaultClient})

assert.NotNil(t, client)
assert.Equal(t, "test-client", client.GetID())
Expand Down Expand Up @@ -196,10 +196,12 @@ func TestLoopbackClient_MatchRedirectURI(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

client := NewLoopbackClient(&fosite.DefaultClient{
ID: "test-client",
RedirectURIs: tt.registeredURIs,
Public: true,
client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: "test-client",
RedirectURIs: tt.registeredURIs,
Public: true,
},
})

result := client.MatchRedirectURI(tt.requestedURI)
Expand Down Expand Up @@ -247,10 +249,12 @@ func TestLoopbackClient_GetMatchingRedirectURI(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

client := NewLoopbackClient(&fosite.DefaultClient{
ID: "test-client",
RedirectURIs: tt.registeredURIs,
Public: true,
client := NewLoopbackClient(&fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: "test-client",
RedirectURIs: tt.registeredURIs,
Public: true,
},
})

result := client.GetMatchingRedirectURI(tt.requestedURI)
Expand Down
42 changes: 33 additions & 9 deletions pkg/authserver/server_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,10 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
// provably safe for the production backends; surfacing a bad backend as
// a constructor error keeps misconfiguration fail-loud at boot rather
// than at first DCR resolve.
dcrStore, ok := stor.(storage.DCRCredentialStore)
baseStore := unwrapStorage(stor)
dcrStore, ok := baseStore.(storage.DCRCredentialStore)
if !ok {
return nil, fmt.Errorf("storage backend %T does not implement storage.DCRCredentialStore", stor)
return nil, fmt.Errorf("storage backend %T does not implement storage.DCRCredentialStore", baseStore)
}

slog.Debug("creating OAuth2 configuration")
Expand Down Expand Up @@ -168,13 +169,8 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se

// Run one-shot bulk migration of legacy data before handler construction.
// TODO(migration): Remove once all deployments have upgraded past this version.
if rs, ok := stor.(*storage.RedisStorage); ok {
for i := range cfg.Upstreams {
upCfg := &cfg.Upstreams[i]
if err := rs.MigrateLegacyUpstreamData(ctx, upCfg.Name, string(upCfg.Type)); err != nil {
return nil, fmt.Errorf("legacy data migration failed for upstream %q: %w", upCfg.Name, err)
}
}
if err := runLegacyMigration(ctx, stor, cfg.Upstreams); err != nil {
return nil, err
}

handlerInstance, err := handlers.NewHandler(fositeProvider, authServerConfig, stor, upstreams)
Expand Down Expand Up @@ -294,3 +290,31 @@ func createProvider(authServerConfig *oauthserver.AuthorizationServerConfig, sto
compose.OAuth2PKCEFactory, // PKCE for public clients
)
}

// unwrapStorage peels off one decorator layer if the storage implements
// Unwrap(), returning the concrete backend. Both newServer (DCRCredentialStore
// assertion) and runLegacyMigration (RedisStorage type assertion) need this.
func unwrapStorage(stor storage.Storage) storage.Storage {
if unwrapper, ok := stor.(interface{ Unwrap() storage.Storage }); ok {
return unwrapper.Unwrap()
}
return stor
}

// runLegacyMigration runs one-shot Redis data migrations before handlers are
// constructed. It is a no-op for non-Redis backends and passes through any
// decorator wrapping so the concrete type can be reached.
func runLegacyMigration(ctx context.Context, stor storage.Storage, upstreams []UpstreamConfig) error {
base := unwrapStorage(stor)
rs, ok := base.(*storage.RedisStorage)
Comment thread
amirejaz marked this conversation as resolved.
if !ok {
return nil
}
for i := range upstreams {
upCfg := &upstreams[i]
if err := rs.MigrateLegacyUpstreamData(ctx, upCfg.Name, string(upCfg.Type)); err != nil {
return fmt.Errorf("legacy data migration failed for upstream %q: %w", upCfg.Name, err)
}
}
return nil
}
210 changes: 210 additions & 0 deletions pkg/authserver/storage/cimd_decorator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package storage

import (
"context"
"fmt"
"net/url"
"strings"
"time"

lru "github.com/hashicorp/golang-lru/v2"
"github.com/ory/fosite"
"golang.org/x/sync/singleflight"

"github.com/stacklok/toolhive/pkg/authserver/server/registration"
"github.com/stacklok/toolhive/pkg/oauthproto"
"github.com/stacklok/toolhive/pkg/oauthproto/cimd"
)

// CIMDStorageDecorator wraps storage.Storage and intercepts GetClient calls
// for HTTPS client_id values, fetching and caching the corresponding Client
// ID Metadata Document instead of requiring prior DCR registration.
//
// All other Storage methods delegate to the underlying storage unchanged.
// Only GetClient is overridden. DCR clients (opaque IDs) continue to work
// exactly as before.
type CIMDStorageDecorator struct {
Storage // embed full interface — all methods delegate
sf singleflight.Group // deduplicates concurrent fetches for the same URL
cache *lru.Cache[string, *cimdCacheEntry]
ttl time.Duration
}

type cimdCacheEntry struct {
client fosite.Client
expires time.Time
}

// NewCIMDStorageDecorator wraps base with CIMD client lookup. When enabled=false
// it returns base unchanged (no allocation). cacheMaxSize must be >= 1;
// fallbackTTL is the fixed TTL applied to every cache entry (Cache-Control
// header parsing is not yet implemented; all entries use this value).
func NewCIMDStorageDecorator(
base Storage,
enabled bool,
cacheMaxSize int,
fallbackTTL time.Duration,
) (Storage, error) {
Comment thread
amirejaz marked this conversation as resolved.
if !enabled {
return base, nil
}

if cacheMaxSize < 1 {
return nil, fmt.Errorf("CIMD storage decorator cacheMaxSize must be >= 1, got %d", cacheMaxSize)
}

c, err := lru.New[string, *cimdCacheEntry](cacheMaxSize)
if err != nil {
return nil, fmt.Errorf("failed to create CIMD LRU cache: %w", err)
}

return &CIMDStorageDecorator{
Storage: base,
cache: c,
ttl: fallbackTTL,
}, nil
}

// GetClient intercepts HTTPS client_id values to resolve them via CIMD.
// Opaque DCR-issued IDs are delegated to the underlying storage unchanged.
func (d *CIMDStorageDecorator) GetClient(ctx context.Context, id string) (fosite.Client, error) {
if !oauthproto.IsClientIDMetadataDocumentURL(id) {
return d.Storage.GetClient(ctx, id)
}
return d.fetchOrCached(ctx, id)
}

// Unwrap returns the underlying storage so that type assertions (e.g. for
// storage.DCRCredentialStore in server_impl.go) can reach the concrete type.
func (d *CIMDStorageDecorator) Unwrap() Storage {
return d.Storage
}

func (d *CIMDStorageDecorator) fetchOrCached(ctx context.Context, id string) (fosite.Client, error) {
// Check cache first (outside singleflight to avoid holding the group lock for cache hits)
if entry, ok := d.cache.Get(id); ok && time.Now().Before(entry.expires) {
return entry.client, nil
}

// Deduplicate concurrent fetches for the same URL. The shared fetch uses a
// context detached from the caller so that one caller cancelling does not
// abort the in-flight request for other waiters. The HTTP client inside
// FetchClientMetadataDocument enforces its own 5-second timeout.
fetchCtx := context.WithoutCancel(ctx)
result, err, _ := d.sf.Do(id, func() (interface{}, error) {
// Re-check cache inside singleflight (another goroutine may have populated it)
if entry, ok := d.cache.Get(id); ok && time.Now().Before(entry.expires) {
return entry.client, nil
}
return d.fetch(fetchCtx, id)
})
if err != nil {
return nil, err
}
client, ok := result.(fosite.Client)
if !ok {
return nil, fmt.Errorf("CIMD singleflight returned unexpected type %T", result)
}
return client, nil
}

func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Client, error) {
doc, err := cimd.FetchClientMetadataDocument(ctx, id)
if err != nil {
return nil, fmt.Errorf("%w: %w", fosite.ErrNotFound.WithHint("CIMD fetch failed"), err)
}

client := buildFositeClient(doc)

d.cache.Add(id, &cimdCacheEntry{
client: client,
expires: time.Now().Add(d.ttl),
})

return client, nil
}

// defaultCIMDGrantTypes are the OAuth 2.0 grant types applied when the CIMD
// document omits grant_types. CIMD clients are typically public native apps
// that use the authorization code flow with refresh token rotation.
var defaultCIMDGrantTypes = []string{"authorization_code", "refresh_token"}

// defaultCIMDResponseTypes are the OAuth 2.0 response types applied when the
// CIMD document omits response_types.
var defaultCIMDResponseTypes = []string{"code"}

// defaultCIMDTokenEndpointAuthMethod is the token endpoint authentication
// method applied when the CIMD document omits token_endpoint_auth_method.
// CIMD clients are always public — no pre-shared secret is established.
const defaultCIMDTokenEndpointAuthMethod = "none"

// buildFositeClient converts a ClientMetadataDocument into a fosite.Client.
// Redirect URIs containing http://localhost are wrapped in a LoopbackClient
// so that RFC 8252 §7.3 dynamic port matching applies.
func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client {
grantTypes := doc.GrantTypes
if len(grantTypes) == 0 {
grantTypes = defaultCIMDGrantTypes
}

responseTypes := doc.ResponseTypes
if len(responseTypes) == 0 {
responseTypes = defaultCIMDResponseTypes
}

// The embedded AS advertises only "none" in token_endpoint_auth_methods_supported.
// Override any other value from the document to avoid an inconsistent client
// configuration — CIMD clients are always public and have no pre-shared secret.
tokenEndpointAuthMethod := defaultCIMDTokenEndpointAuthMethod
Comment thread
amirejaz marked this conversation as resolved.
Outdated

var scopes []string
if doc.Scope != "" {
scopes = strings.Fields(doc.Scope)
}

defaultClient := &fosite.DefaultClient{
ID: doc.ClientID,
RedirectURIs: doc.RedirectURIs,
GrantTypes: grantTypes,
ResponseTypes: responseTypes,
Scopes: scopes,
// CIMD clients don't pre-declare audience; leave empty so the AS
// applies its own audience policy rather than rejecting all values.
Audience: nil,
Public: true,
}

openIDClient := &fosite.DefaultOpenIDConnectClient{
DefaultClient: defaultClient,
TokenEndpointAuthMethod: tokenEndpointAuthMethod,
}

// Wrap in LoopbackClient when any redirect URI targets localhost so that
// RFC 8252 §7.3 dynamic port matching works for native app clients.
// Pass openIDClient directly so TokenEndpointAuthMethod is preserved —
// LoopbackClient now embeds *fosite.DefaultOpenIDConnectClient.
if hasLoopbackRedirectURI(doc.RedirectURIs) {
return registration.NewLoopbackClient(openIDClient)
}

return openIDClient
Comment thread
amirejaz marked this conversation as resolved.
}

// hasLoopbackRedirectURI returns true when any of the redirect URIs in the
// list targets a loopback address over HTTP. The host is parsed from each URI
// to prevent bypass via hosts like "http://localhost.evil.com/".
func hasLoopbackRedirectURI(uris []string) bool {
for _, uri := range uris {
parsed, err := url.Parse(uri)
if err != nil {
continue
}
if parsed.Scheme == "http" && oauthproto.IsLoopbackHost(parsed.Hostname()) {
return true
}
}
return false
}
Loading
Loading