Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
75 changes: 75 additions & 0 deletions cmd/thv/app/config_registryauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"fmt"

"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/registry"
)

var (
authIssuer string
authClientID string
authAudience string
authScopes []string
)

var setRegistryAuthCmd = &cobra.Command{
Use: "set-registry-auth",
Short: "Configure OAuth/OIDC authentication for the registry",
Long: `Configure OAuth/OIDC authentication for the remote MCP server registry.
PKCE (S256) is always enforced for security.

The issuer URL is validated via OIDC discovery before saving.

Examples:
thv config set-registry-auth --issuer https://auth.company.com --client-id toolhive-cli
thv config set-registry-auth \
--issuer https://auth.company.com --client-id toolhive-cli \
--audience api://my-registry --scopes openid,profile`,
RunE: setRegistryAuthCmdFunc,
}

var unsetRegistryAuthCmd = &cobra.Command{
Use: "unset-registry-auth",
Short: "Remove registry authentication configuration",
Long: "Remove the OAuth/OIDC authentication configuration for the registry.",
RunE: unsetRegistryAuthCmdFunc,
}

func init() {
setRegistryAuthCmd.Flags().StringVar(&authIssuer, "issuer", "", "OIDC issuer URL (required)")
setRegistryAuthCmd.Flags().StringVar(&authClientID, "client-id", "", "OAuth client ID (required)")
setRegistryAuthCmd.Flags().StringVar(&authAudience, "audience", "", "OAuth audience parameter")
setRegistryAuthCmd.Flags().StringSliceVar(&authScopes, "scopes", []string{"openid"}, "OAuth scopes")

_ = setRegistryAuthCmd.MarkFlagRequired("issuer")
_ = setRegistryAuthCmd.MarkFlagRequired("client-id")

configCmd.AddCommand(setRegistryAuthCmd)
configCmd.AddCommand(unsetRegistryAuthCmd)
}

func setRegistryAuthCmdFunc(_ *cobra.Command, _ []string) error {
configurator := registry.NewAuthConfigurator()

if err := configurator.SetOAuthAuth(authIssuer, authClientID, authAudience, authScopes); err != nil {
return fmt.Errorf("failed to configure registry auth: %w", err)
}

return nil
}

func unsetRegistryAuthCmdFunc(_ *cobra.Command, _ []string) error {
configurator := registry.NewAuthConfigurator()

if err := configurator.UnsetAuth(); err != nil {
return fmt.Errorf("failed to remove registry auth: %w", err)
}

return nil
}
2 changes: 2 additions & 0 deletions docs/cli/thv_config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions docs/cli/thv_config_set-registry-auth.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions docs/cli/thv_config_unset-registry-auth.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,38 @@ type Config struct {
BuildEnvFromShell []string `yaml:"build_env_from_shell,omitempty"`
BuildAuthFiles map[string]string `yaml:"build_auth_files,omitempty"`
RuntimeConfigs map[string]*templates.RuntimeConfig `yaml:"runtime_configs,omitempty"`
RegistryAuth RegistryAuth `yaml:"registry_auth,omitempty"`
}

// RegistryAuthTypeOAuth is the auth type for OAuth/OIDC authentication.
const RegistryAuthTypeOAuth = "oauth"

// RegistryAuth holds authentication configuration for remote registries.
type RegistryAuth struct {
// Type is the authentication type: RegistryAuthTypeOAuth or "" (none).
Type string `yaml:"type,omitempty"`

// OAuth holds OAuth/OIDC authentication configuration.
OAuth *RegistryOAuthConfig `yaml:"oauth,omitempty"`
}

// RegistryOAuthConfig holds OAuth/OIDC configuration for registry authentication.
// PKCE (S256) is always enforced per OAuth 2.1 requirements for public clients.
type RegistryOAuthConfig struct {
Issuer string `yaml:"issuer"`
ClientID string `yaml:"client_id"`
Scopes []string `yaml:"scopes,omitempty"`
Audience string `yaml:"audience,omitempty"`

// ClientSecret is optional and only needed for confidential clients.
// For public clients (typical CLI usage), this should be empty.
// Note: This value is stored in the config file (0600 permissions), not in the secrets manager.
ClientSecret string `yaml:"client_secret,omitempty"`
CallbackPort int `yaml:"callback_port,omitempty"`

// Cached token references for session restoration across CLI invocations.
CachedRefreshTokenRef string `yaml:"cached_refresh_token_ref,omitempty"`
CachedTokenExpiry time.Time `yaml:"cached_token_expiry,omitempty"`
}

// Secrets contains the settings for secrets management.
Expand Down
10 changes: 8 additions & 2 deletions pkg/registry/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"gopkg.in/yaml.v3"

"github.com/stacklok/toolhive/pkg/networking"
"github.com/stacklok/toolhive/pkg/registry/auth"
"github.com/stacklok/toolhive/pkg/versions"
)

Expand Down Expand Up @@ -56,8 +57,10 @@ type mcpRegistryClient struct {
userAgent string
}

// NewClient creates a new MCP Registry API client
func NewClient(baseURL string, allowPrivateIp bool) (Client, error) {
// NewClient creates a new MCP Registry API client.
// If tokenSource is non-nil, the HTTP client transport will be wrapped to inject
// Bearer tokens into all requests.
func NewClient(baseURL string, allowPrivateIp bool, tokenSource auth.TokenSource) (Client, error) {
// Build HTTP client with security controls
// If private IPs are allowed, also allow HTTP (for localhost testing)
builder := networking.NewHttpClientBuilder().WithPrivateIPs(allowPrivateIp)
Expand All @@ -69,6 +72,9 @@ func NewClient(baseURL string, allowPrivateIp bool) (Client, error) {
return nil, fmt.Errorf("failed to build HTTP client: %w", err)
}

// Wrap transport with auth if token source is provided
httpClient.Transport = auth.WrapTransport(httpClient.Transport, tokenSource)

// Ensure base URL doesn't have trailing slash
if baseURL[len(baseURL)-1] == '/' {
baseURL = baseURL[:len(baseURL)-1]
Expand Down
57 changes: 57 additions & 0 deletions pkg/registry/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package auth provides authentication support for MCP server registries.
package auth

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"

"github.com/stacklok/toolhive/pkg/config"
"github.com/stacklok/toolhive/pkg/secrets"
)

// ErrRegistryAuthRequired is returned when registry authentication is required
// but no cached tokens are available in a non-interactive context.
var ErrRegistryAuthRequired = errors.New("registry authentication required: run 'thv registry login' to authenticate")

// TokenSource provides authentication tokens for registry HTTP requests.
type TokenSource interface {
// Token returns a valid access token string, or empty string if no auth.
// Implementations should handle token refresh transparently.
Token(ctx context.Context) (string, error)
}

// NewTokenSource creates a TokenSource from registry OAuth configuration.
// Returns nil, nil if oauth config is nil (no auth required).
// The registryURL is used to derive a unique secret key for token storage.
// The secrets provider may be nil if secret storage is not available.
// The interactive flag controls whether browser-based OAuth flows are allowed.
func NewTokenSource(
cfg *config.RegistryOAuthConfig,
registryURL string,
secretsProvider secrets.Provider,
interactive bool,
) (TokenSource, error) {
if cfg == nil {
return nil, nil
}

return &oauthTokenSource{
oauthCfg: cfg,
registryURL: registryURL,
secretsProvider: secretsProvider,
interactive: interactive,
}, nil
}

// DeriveSecretKey computes the secret key for storing a registry's refresh token.
// The key follows the formula: REGISTRY_OAUTH_<8 hex chars>
// where the hex is derived from sha256(registryURL + "\x00" + issuer)[:4].
func DeriveSecretKey(registryURL, issuer string) string {
h := sha256.Sum256([]byte(registryURL + "\x00" + issuer))
return "REGISTRY_OAUTH_" + hex.EncodeToString(h[:4])
}
Loading
Loading