Skip to content
Open
78 changes: 78 additions & 0 deletions cmd/thv/app/config_registryauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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/config"
"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", "offline_access"}, "OAuth scopes",
)

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

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

func setRegistryAuthCmdFunc(_ *cobra.Command, _ []string) error {
authManager := registry.NewAuthManager(config.NewDefaultProvider())

if err := authManager.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 {
authManager := registry.NewAuthManager(config.NewDefaultProvider())

if err := authManager.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.

52 changes: 52 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.

27 changes: 27 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,33 @@ 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"`
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