diff --git a/plugins/npm/access_token.go b/plugins/npm/access_token.go new file mode 100644 index 00000000..6f6a9ada --- /dev/null +++ b/plugins/npm/access_token.go @@ -0,0 +1,232 @@ +package npm + +import ( + "bufio" + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/provision" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +const defaultRegistry = "https://registry.npmjs.org/" + +func AccessToken() schema.CredentialType { + return schema.CredentialType{ + Name: credname.AccessToken, + DocsURL: sdk.URL("https://docs.npmjs.com/about-access-tokens"), + ManagementURL: sdk.URL("https://www.npmjs.com/settings/~/tokens"), + Fields: []schema.CredentialField{ + { + Name: fieldname.Token, + MarkdownDescription: "Access token used to authenticate to an npm-compatible registry.", + Secret: true, + }, + { + Name: fieldname.Organization, + MarkdownDescription: "The package scope this registry should be used for, without the leading @.", + Optional: true, + }, + { + Name: fieldname.Host, + MarkdownDescription: "The npm-compatible registry host or URL that accepts this access token.", + Optional: true, + }, + }, + DefaultProvisioner: provision.TempFile( + npmConfigFile, + provision.Filename(".npmrc"), + provision.AddArgs("--userconfig", "{{ .Path }}"), + ), + Importer: importer.TryAll( + importer.TryAllEnvVars(fieldname.Token, "NPM_TOKEN", "NODE_AUTH_TOKEN"), + tryNPMRCFile("~/.npmrc"), + tryPNPMAuthFile(), + // pnpm 10 and earlier may have stored npm-compatible settings here. + tryNPMRCFile("~/.config/pnpm/rc"), + ), + } +} + +func pnpmProvisioner() sdk.Provisioner { + return provision.TempFile( + npmConfigFile, + provision.Filename(".npmrc"), + provision.SetPathAsEnvVar("NPM_CONFIG_USERCONFIG"), + ) +} + +func npmConfigFile(in sdk.ProvisionInput) ([]byte, error) { + registry, err := normalizeRegistry(in.ItemFields[fieldname.Host]) + if err != nil { + return nil, err + } + + scope := strings.TrimPrefix(strings.TrimSpace(in.ItemFields[fieldname.Organization]), "@") + var contents strings.Builder + if scope != "" { + fmt.Fprintf(&contents, "@%s:registry=%s\n", scope, registry.String()) + } else if strings.TrimSpace(in.ItemFields[fieldname.Host]) != "" { + fmt.Fprintf(&contents, "registry=%s\n", registry.String()) + } + + registryPath := registry.EscapedPath() + if !strings.HasSuffix(registryPath, "/") { + registryPath += "/" + } + fmt.Fprintf(&contents, "//%s%s:_authToken=%s\n", registry.Host, registryPath, in.ItemFields[fieldname.Token]) + + return []byte(contents.String()), nil +} + +func normalizeRegistry(value string) (*url.URL, error) { + value = strings.TrimSpace(value) + if value == "" { + value = defaultRegistry + } else if !strings.Contains(value, "://") { + value = "https://" + value + } + + registry, err := url.Parse(value) + if err != nil { + return nil, fmt.Errorf("parsing registry URL: %w", err) + } + if (registry.Scheme != "https" && registry.Scheme != "http") || registry.Host == "" { + return nil, fmt.Errorf("registry URL must use http or https and include a host") + } + if registry.User != nil || registry.RawQuery != "" || registry.Fragment != "" { + return nil, fmt.Errorf("registry URL must not contain credentials, a query, or a fragment") + } + if registry.Path == "" { + registry.Path = "/" + } else if !strings.HasSuffix(registry.Path, "/") { + registry.Path += "/" + } + + return registry, nil +} + +func tryPNPMAuthFile() sdk.Importer { + return func(ctx context.Context, in sdk.ImportInput, out *sdk.ImportOutput) { + var path string + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + path = filepath.Join(xdgConfigHome, "pnpm", "auth.ini") + } else { + switch in.OS { + case "darwin": + path = "~/Library/Preferences/pnpm/auth.ini" + case "linux": + path = "~/.config/pnpm/auth.ini" + default: + return + } + } + + tryNPMRCFile(path)(ctx, in, out) + } +} + +func tryNPMRCFile(path string) sdk.Importer { + return importer.TryFile(path, func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) { + lines := make(map[string]string) + scanner := bufio.NewScanner(strings.NewReader(string(contents))) + for scanner.Scan() { + key, value, found := strings.Cut(scanner.Text(), "=") + if !found { + continue + } + lines[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`) + } + if err := scanner.Err(); err != nil { + out.AddError(err) + return + } + + scopesByRegistry := make(map[string][]string) + registryURLs := make(map[string]string) + for key, value := range lines { + lowerKey := strings.ToLower(key) + if lowerKey != "registry" && (!strings.HasPrefix(key, "@") || !strings.HasSuffix(lowerKey, ":registry")) { + continue + } + registry, err := normalizeRegistry(value) + if err != nil { + continue + } + registryKey := registryAuthKey(registry) + registryURLs[registryKey] = registry.String() + if lowerKey == "registry" { + continue + } + scope := key[1 : len(key)-len(":registry")] + if scope != "" { + scopesByRegistry[registryKey] = append(scopesByRegistry[registryKey], scope) + } + } + + for key, token := range lines { + registryKey, explicitScope, ok := parseAuthKey(key) + if !ok || token == "" || strings.HasPrefix(token, "${") { + continue + } + + scopes := []string{explicitScope} + if explicitScope == "" { + if configuredScopes := scopesByRegistry[registryKey]; len(configuredScopes) > 0 { + scopes = configuredScopes + } + } + + for _, scope := range scopes { + host := registryKey + if configuredURL := registryURLs[registryKey]; configuredURL != "" { + host = configuredURL + } + fields := map[sdk.FieldName]string{ + fieldname.Token: token, + fieldname.Host: host, + } + if scope != "" { + fields[fieldname.Organization] = scope + } + out.AddCandidate(sdk.ImportCandidate{ + Fields: fields, + NameHint: importer.SanitizeNameHint(registryKey), + }) + } + } + }) +} + +func parseAuthKey(key string) (registry string, scope string, ok bool) { + const suffix = ":_authtoken" + lowerKey := strings.ToLower(key) + if !strings.HasPrefix(key, "//") || !strings.HasSuffix(lowerKey, suffix) { + return "", "", false + } + + registry = key[2 : len(key)-len(suffix)] + if scopeSeparator := strings.LastIndex(registry, ":@"); scopeSeparator >= 0 { + scope = strings.TrimPrefix(registry[scopeSeparator+1:], "@") + registry = registry[:scopeSeparator] + } + registry = strings.TrimSuffix(registry, ":") + registry = strings.TrimSuffix(registry, "/") + if registry == "" { + return "", "", false + } + return registry, scope, true +} + +func registryAuthKey(registry *url.URL) string { + path := strings.TrimSuffix(registry.EscapedPath(), "/") + return registry.Host + path +} diff --git a/plugins/npm/access_token_test.go b/plugins/npm/access_token_test.go new file mode 100644 index 00000000..64f02d69 --- /dev/null +++ b/plugins/npm/access_token_test.go @@ -0,0 +1,181 @@ +package npm + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestAccessTokenProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, AccessToken().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "default registry": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "npm_example123", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Files: map[string]sdk.OutputFile{ + "/tmp/.npmrc": {Contents: []byte("//registry.npmjs.org/:_authToken=npm_example123\n")}, + }, + CommandLine: []string{"--userconfig", "/tmp/.npmrc"}, + }, + }, + "custom default registry": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "custom_example123", + fieldname.Host: "registry.example.com/npm", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Files: map[string]sdk.OutputFile{ + "/tmp/.npmrc": {Contents: []byte("registry=https://registry.example.com/npm/\n//registry.example.com/npm/:_authToken=custom_example123\n")}, + }, + CommandLine: []string{"--userconfig", "/tmp/.npmrc"}, + }, + }, + "scoped custom registry": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "custom_example123", + fieldname.Host: "https://registry.example.com/npm/", + fieldname.Organization: "@acme", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Files: map[string]sdk.OutputFile{ + "/tmp/.npmrc": {Contents: []byte("@acme:registry=https://registry.example.com/npm/\n//registry.example.com/npm/:_authToken=custom_example123\n")}, + }, + CommandLine: []string{"--userconfig", "/tmp/.npmrc"}, + }, + }, + }) +} + +func TestPNPMProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, PNPMCLI().Uses[0].Provisioner, map[string]plugintest.ProvisionCase{ + "uses an environment variable instead of an unsupported CLI option": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Token: "npm_example123", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "NPM_CONFIG_USERCONFIG": "/tmp/.npmrc", + }, + Files: map[string]sdk.OutputFile{ + "/tmp/.npmrc": {Contents: []byte("//registry.npmjs.org/:_authToken=npm_example123\n")}, + }, + }, + }, + }) +} + +func TestAccessTokenImporter(t *testing.T) { + plugintest.TestImporter(t, AccessToken().Importer, map[string]plugintest.ImportCase{ + "NPM_TOKEN environment variable": { + Environment: map[string]string{"NPM_TOKEN": "npm_from_env"}, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{fieldname.Token: "npm_from_env"}, + }}, + }, + "NODE_AUTH_TOKEN environment variable": { + Environment: map[string]string{"NODE_AUTH_TOKEN": "npm_from_node_env"}, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{fieldname.Token: "npm_from_node_env"}, + }}, + }, + "npm user config": { + Files: map[string]string{ + "~/.npmrc": "//registry.npmjs.org/:_authToken=npm_from_npmrc\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: "npm_from_npmrc", + fieldname.Host: "registry.npmjs.org", + }, + NameHint: "registry.npmjs.org", + }}, + }, + "scoped custom registry": { + Files: map[string]string{ + "~/.npmrc": "@acme:registry=https://registry.example.com/npm/\n//registry.example.com/npm/:_authToken=custom_from_npmrc\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: "custom_from_npmrc", + fieldname.Host: "https://registry.example.com/npm/", + fieldname.Organization: "acme", + }, + NameHint: "registry.example.com/npm", + }}, + }, + "custom default registry preserves URL": { + Files: map[string]string{ + "~/.npmrc": "registry=http://localhost:4873/npm/\n//localhost:4873/npm/:_authToken=custom_from_npmrc\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: "custom_from_npmrc", + fieldname.Host: "http://localhost:4873/npm/", + }, + NameHint: "localhost:4873/npm", + }}, + }, + "pnpm scoped auth key": { + OS: "linux", + Files: map[string]string{ + "~/.config/pnpm/auth.ini": "//registry.example.com/:@acme:_authToken=custom_from_pnpm\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: "custom_from_pnpm", + fieldname.Host: "registry.example.com", + fieldname.Organization: "acme", + }, + NameHint: "registry.example.com", + }}, + }, + "pnpm macOS auth file": { + OS: "darwin", + Files: map[string]string{ + "~/Library/Preferences/pnpm/auth.ini": "//registry.npmjs.org/:_authToken=npm_from_pnpm\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: "npm_from_pnpm", + fieldname.Host: "registry.npmjs.org", + }, + NameHint: "registry.npmjs.org", + }}, + }, + "pnpm XDG auth file": { + OS: "linux", + Environment: map[string]string{"XDG_CONFIG_HOME": "/custom-config"}, + Files: map[string]string{ + "/custom-config/pnpm/auth.ini": "//registry.npmjs.org/:_authToken=npm_from_xdg\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: "npm_from_xdg", + fieldname.Host: "registry.npmjs.org", + }, + NameHint: "registry.npmjs.org", + }}, + }, + "legacy pnpm config": { + Files: map[string]string{ + "~/.config/pnpm/rc": "//registry.npmjs.org/:_authToken=npm_from_legacy_pnpm\n", + }, + ExpectedCandidates: []sdk.ImportCandidate{{ + Fields: map[sdk.FieldName]string{ + fieldname.Token: "npm_from_legacy_pnpm", + fieldname.Host: "registry.npmjs.org", + }, + NameHint: "registry.npmjs.org", + }}, + }, + "environment variable reference is not a secret": { + Files: map[string]string{ + "~/.npmrc": "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n", + }, + ExpectedCandidates: nil, + }, + }) +} diff --git a/plugins/npm/npm.go b/plugins/npm/npm.go new file mode 100644 index 00000000..73762d62 --- /dev/null +++ b/plugins/npm/npm.go @@ -0,0 +1,62 @@ +package npm + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/needsauth" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" +) + +func NPMCLI() schema.Executable { + return packageManagerCLI("npm CLI", "npm", "https://docs.npmjs.com/cli/") +} + +func PNPMCLI() schema.Executable { + executable := packageManagerCLI("pnpm CLI", "pnpm", "https://pnpm.io/cli") + executable.Uses[0].Provisioner = pnpmProvisioner() + return executable +} + +func packageManagerCLI(name, command, docsURL string) schema.Executable { + return schema.Executable{ + Name: name, + Runs: []string{command}, + DocsURL: sdk.URL(docsURL), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.IfAny( + needsauth.ForCommand("access"), + needsauth.ForCommand("add"), + needsauth.ForCommand("audit"), + needsauth.ForCommand("ci"), + needsauth.ForCommand("cit"), + needsauth.ForCommand("deprecate"), + needsauth.ForCommand("dist-tag"), + needsauth.ForCommand("fetch"), + needsauth.ForCommand("i"), + needsauth.ForCommand("exec"), + needsauth.ForCommand("install"), + needsauth.ForCommand("install-ci-test"), + needsauth.ForCommand("install-test"), + needsauth.ForCommand("it"), + needsauth.ForCommand("owner"), + needsauth.ForCommand("pack"), + needsauth.ForCommand("profile"), + needsauth.ForCommand("publish"), + needsauth.ForCommand("search"), + needsauth.ForCommand("stage"), + needsauth.ForCommand("team"), + needsauth.ForCommand("token"), + needsauth.ForCommand("unpublish"), + needsauth.ForCommand("update"), + needsauth.ForCommand("view"), + needsauth.ForCommand("whoami"), + ), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.AccessToken, + }, + }, + } +} diff --git a/plugins/npm/npm_test.go b/plugins/npm/npm_test.go new file mode 100644 index 00000000..4387574a --- /dev/null +++ b/plugins/npm/npm_test.go @@ -0,0 +1,36 @@ +package npm + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" +) + +func TestNPMCLINeedsAuth(t *testing.T) { + testPackageManagerNeedsAuth(t, NPMCLI().NeedsAuth) +} + +func TestPNPMCLINeedsAuth(t *testing.T) { + testPackageManagerNeedsAuth(t, PNPMCLI().NeedsAuth) +} + +func testPackageManagerNeedsAuth(t *testing.T, needsAuth sdk.NeedsAuthentication) { + t.Helper() + + var needsAuthCases = map[string]plugintest.NeedsAuthCase{ + "install may access private packages": {Args: []string{"install"}, ExpectedNeedsAuth: true}, + "install alias may access private packages": { + Args: []string{"i"}, + ExpectedNeedsAuth: true, + }, + "publish requires authentication": {Args: []string{"publish"}, ExpectedNeedsAuth: true}, + "whoami requires authentication": {Args: []string{"whoami"}, ExpectedNeedsAuth: true}, + "login establishes authentication": {Args: []string{"login"}, ExpectedNeedsAuth: false}, + "run is local": {Args: []string{"run", "test"}, ExpectedNeedsAuth: false}, + "help does not require auth": {Args: []string{"install", "--help"}, ExpectedNeedsAuth: false}, + "version does not require auth": {Args: []string{"--version"}, ExpectedNeedsAuth: false}, + } + + plugintest.TestNeedsAuth(t, needsAuth, needsAuthCases) +} diff --git a/plugins/npm/plugin.go b/plugins/npm/plugin.go new file mode 100644 index 00000000..c9c12aac --- /dev/null +++ b/plugins/npm/plugin.go @@ -0,0 +1,23 @@ +package npm + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "npm", + Platform: schema.PlatformInfo{ + Name: "npm", + Homepage: sdk.URL("https://www.npmjs.com"), + }, + Credentials: []schema.CredentialType{ + AccessToken(), + }, + Executables: []schema.Executable{ + NPMCLI(), + PNPMCLI(), + }, + } +}