registry/cmd/publisher/commands/login.go
Radoslav Dimitrov 90baa0528f
fix: store tokens in ~/.config/mcp-publisher/ instead of cwd (#1166)
## Summary

Fixes #663

- **Security fix**: `mcp-publisher login` no longer writes
`.mcpregistry_github_token` and `.mcpregistry_registry_token` to the
current working directory. GitHub tokens are kept in memory during the
login→exchange flow and cleared immediately after.
- **Token location**: Unified token moved from `~/.mcp_publisher_token`
to `~/.config/mcp-publisher/token.json` (dir 0700, file 0600), following
the convention used by `gh`, `docker`, and `kubectl`.
- **Dead code removed**: `NeedsLogin()` was on the `Provider` interface
but never called — removed from interface and all 4 implementations.
`forceLogin` field also removed.
- **Auth tests added**: 11 new tests for `GitHubATProvider` (package
previously had 0 tests).
- **Test isolation**: `SetupTestToken` now uses `t.TempDir()` +
`t.Setenv("HOME", ...)` instead of writing to real `$HOME`.

### Breaking change

Users must re-login after upgrading (`mcp-publisher login <method>`).
Existing tokens at `~/.mcp_publisher_token` are not auto-migrated.
`logout` cleans up both old and new locations.

## Test plan

- [x] `make check` passes (lint, unit tests, integration tests)
- [x] `mcp-publisher login none` stores token at
`~/.config/mcp-publisher/token.json`
- [x] No `.mcpregistry_*` files created in cwd after login (with
`--token`, `MCP_GITHUB_TOKEN`, or device flow)
- [x] `mcp-publisher publish` reads token from new location
- [x] `mcp-publisher validate` reads registry URL from new location
- [x] `mcp-publisher logout` cleans up new location, legacy
`~/.mcp_publisher_token`, and cwd `.mcpregistry_*` files
- [x] `mcp-publisher logout` when not logged in shows "Not logged in"
- [x] File permissions: dir 0700, file 0600

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:23:07 -04:00

285 lines
8.9 KiB
Go

package commands
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth/azurekeyvault"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth/googlekms"
)
const (
DefaultRegistryURL = "https://registry.modelcontextprotocol.io"
LegacyTokenFileName = ".mcp_publisher_token" //nolint:gosec // Not a credential, just a filename
MethodGitHub = "github"
MethodGitHubOIDC = "github-oidc"
MethodDNS = "dns"
MethodHTTP = "http"
MethodNone = "none"
)
// tokenFilePath returns the path to the token file in the user's config directory
// (~/.config/mcp-publisher/token.json). It does not create the directory.
func tokenFilePath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".config", "mcp-publisher", "token.json"), nil
}
// notAuthenticatedError returns an error guiding the user to log in,
// with a hint about the token location change for users upgrading.
func notAuthenticatedError() error {
_, _ = fmt.Fprintln(os.Stderr, "hint: token storage moved to ~/.config/mcp-publisher/. If you recently upgraded, please re-login.")
return errors.New("not authenticated, run 'mcp-publisher login <method>' first")
}
// ensureTokenDir creates the token directory (~/.config/mcp-publisher/) if needed.
func ensureTokenDir() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
dir := filepath.Join(homeDir, ".config", "mcp-publisher")
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
return nil
}
type CryptoAlgorithm auth.CryptoAlgorithm
type SignerType string
type LoginFlags struct {
Domain string
PrivateKey string
RegistryURL string
KvVault string
KvKeyName string
KmsResource string
Token Token
CryptoAlgorithm CryptoAlgorithm
SignerType SignerType
ArgOffset int
}
const (
InProcessSignerType SignerType = "in-process"
AzureKeyVaultSignerType SignerType = "azure-key-vault"
GoogleKMSSignerType SignerType = "google-kms"
NoSignerType SignerType = "none"
)
func (c *CryptoAlgorithm) String() string {
return string(*c)
}
func (c *CryptoAlgorithm) Set(v string) error {
switch v {
case string(auth.AlgorithmEd25519), string(auth.AlgorithmECDSAP384):
*c = CryptoAlgorithm(v)
return nil
}
return fmt.Errorf("invalid algorithm: %q (allowed: ed25519, ecdsap384)", v)
}
type Token string
func parseLoginFlags(method string, args []string) (LoginFlags, error) {
var flags LoginFlags
loginFlags := flag.NewFlagSet("login", flag.ExitOnError)
flags.CryptoAlgorithm = CryptoAlgorithm(auth.AlgorithmEd25519)
flags.SignerType = NoSignerType
flags.ArgOffset = 1
loginFlags.StringVar(&flags.RegistryURL, "registry", DefaultRegistryURL, "Registry URL")
// Add --token flag for GitHub authentication
var token string
if method == MethodGitHub {
loginFlags.StringVar(&token, "token", "", "GitHub Personal Access Token")
}
if method == "dns" || method == "http" {
loginFlags.StringVar(&flags.Domain, "domain", "", "Domain name")
if len(args) > 1 {
switch args[1] {
case string(AzureKeyVaultSignerType):
flags.SignerType = AzureKeyVaultSignerType
loginFlags.StringVar(&flags.KvVault, "vault", "", "The name of the Azure Key Vault resource")
loginFlags.StringVar(&flags.KvKeyName, "key", "", "Name of the signing key in the Azure Key Vault")
flags.ArgOffset = 2
case string(GoogleKMSSignerType):
flags.SignerType = GoogleKMSSignerType
loginFlags.StringVar(&flags.KmsResource, "resource", "", "Google Cloud KMS resource name (e.g. projects/lotr/locations/global/keyRings/fellowship/cryptoKeys/frodo/cryptoKeyVersions/1)")
flags.ArgOffset = 2
}
}
if flags.SignerType == NoSignerType {
flags.SignerType = InProcessSignerType
loginFlags.StringVar(&flags.PrivateKey, "private-key", "", "Private key (hex)")
loginFlags.Var(&flags.CryptoAlgorithm, "algorithm", "Cryptographic algorithm (ed25519, ecdsap384)")
}
}
err := loginFlags.Parse(args[flags.ArgOffset:])
if err == nil {
flags.RegistryURL = strings.TrimRight(flags.RegistryURL, "/")
}
// Store the token in flags if it was provided
if method == MethodGitHub {
flags.Token = Token(token)
}
return flags, err
}
func createSigner(flags LoginFlags) (auth.Signer, error) {
switch flags.SignerType {
case AzureKeyVaultSignerType:
return azurekeyvault.GetSignatureProvider(flags.KvVault, flags.KvKeyName)
case GoogleKMSSignerType:
return googlekms.GetSignatureProvider(flags.KmsResource)
case InProcessSignerType:
return auth.NewInProcessSigner(flags.PrivateKey, auth.CryptoAlgorithm(flags.CryptoAlgorithm))
case NoSignerType:
return nil, errors.New("no signing provider specified")
default:
return nil, errors.New("unknown signing provider specified")
}
}
func createAuthProvider(method, registryURL, domain string, token Token, signer auth.Signer) (auth.Provider, error) {
switch method {
case MethodGitHub:
return auth.NewGitHubATProvider(registryURL, string(token)), nil
case MethodGitHubOIDC:
return auth.NewGitHubOIDCProvider(registryURL), nil
case MethodDNS:
if domain == "" {
return nil, errors.New("dns authentication requires --domain")
}
return auth.NewDNSProvider(registryURL, domain, &signer), nil
case MethodHTTP:
if domain == "" {
return nil, errors.New("http authentication requires --domain")
}
return auth.NewHTTPProvider(registryURL, domain, &signer), nil
case MethodNone:
return auth.NewNoneProvider(registryURL), nil
default:
return nil, fmt.Errorf("unknown authentication method: %s\nFor a list of available methods, run: mcp-publisher login", method)
}
}
func LoginCommand(args []string) error {
if len(args) < 1 {
return errors.New(`authentication method required
Usage: mcp-publisher login <method> [<signing provider>]
Methods:
github Interactive GitHub authentication
github-oidc GitHub Actions OIDC authentication
dns DNS-based authentication (requires --domain)
http HTTP-based authentication (requires --domain)
none Anonymous authentication (for testing)
Signing providers:
azure-key-vault Sign using Azure Key Vault
google-kms Sign using Google Cloud KMS
The dns and http methods require a --private-key for in-process signing. For
out-of-process signing, use one of the supported signing providers. Signing is
needed for an authentication challenge with the registry.
The github and github-oidc methods do not support signing providers and
authenticate using the GitHub as an identity provider.
Examples:
# Interactive GitHub login, using device code flow
mcp-publisher login github
# Sign in using a specific Ed25519 private key for DNS authentication
mcp-publisher login dns -algorithm ed25519 -domain example.com -private-key <64 hex chars>
# Sign in using a specific ECDSA P-384 private key for DNS authentication
mcp-publisher login dns -algorithm ecdsap384 -domain example.com -private-key <96 hex chars>
# Sign in with gcloud CLI, use Google Cloud KMS for signing in DNS authentication
gcloud auth application-default login
mcp-publisher login dns google-kms -domain example.com -resource projects/lotr/locations/global/keyRings/fellowship/cryptoKeys/frodo/cryptoKeyVersions/1
# Sign in with az CLI, use Azure Key Vault for signing in HTTP authentication
az login
mcp-publisher login http azure-key-vault -domain example.com -vault myvault -key mysigningkey
`)
}
method := args[0]
flags, err := parseLoginFlags(method, args)
if err != nil {
return err
}
var signer auth.Signer
if flags.SignerType != NoSignerType {
signer, err = createSigner(flags)
if err != nil {
return err
}
}
authProvider, err := createAuthProvider(method, flags.RegistryURL, flags.Domain, flags.Token, signer)
if err != nil {
return err
}
ctx := context.Background()
_, _ = fmt.Fprintf(os.Stdout, "Logging in with %s...\n", method)
if err := authProvider.Login(ctx); err != nil {
return fmt.Errorf("login failed: %w", err)
}
token, err := authProvider.GetToken(ctx)
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}
if err := ensureTokenDir(); err != nil {
return err
}
tokenPath, err := tokenFilePath()
if err != nil {
return err
}
tokenData := map[string]string{
"token": token,
"method": method,
"registry": flags.RegistryURL,
}
jsonData, err := json.Marshal(tokenData)
if err != nil {
return fmt.Errorf("failed to marshal token data: %w", err)
}
if err := os.WriteFile(tokenPath, jsonData, 0600); err != nil {
return fmt.Errorf("failed to save token: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully logged in")
return nil
}