diff --git a/command/logout.go b/command/logout.go new file mode 100644 index 0000000000..b3325b24d3 --- /dev/null +++ b/command/logout.go @@ -0,0 +1,168 @@ +package command + +import ( + "fmt" + "path/filepath" + "strings" + + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform/command/cliconfig" + "github.com/hashicorp/terraform/tfdiags" +) + +// LogoutCommand is a Command implementation which removes stored credentials +// for a remote service host. +type LogoutCommand struct { + Meta +} + +// Run implements cli.Command. +func (c *LogoutCommand) Run(args []string) int { + args, err := c.Meta.process(args, false) + if err != nil { + return 1 + } + + cmdFlags := c.Meta.defaultFlagSet("logout") + cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + + args = cmdFlags.Args() + if len(args) > 1 { + c.Ui.Error( + "The logout command expects at most one argument: the host to log out of.") + cmdFlags.Usage() + return 1 + } + + var diags tfdiags.Diagnostics + + givenHostname := "app.terraform.io" + if len(args) != 0 { + givenHostname = args[0] + } + + hostname, err := svchost.ForComparison(givenHostname) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid hostname", + fmt.Sprintf("The given hostname %q is not valid: %s.", givenHostname, err.Error()), + )) + c.showDiagnostics(diags) + return 1 + } + + // From now on, since we've validated the given hostname, we should use + // dispHostname in the UI to ensure we're presenting it in the canonical + // form, in case that helps users with debugging when things aren't + // working as expected. (Perhaps the normalization is part of the cause.) + dispHostname := hostname.ForDisplay() + + creds := c.Services.CredentialsSource() + + // In normal use (i.e. without test mocks/fakes) creds will be an instance + // of the command/cliconfig.CredentialsSource type, which has some extra + // methods we can use to give the user better feedback about what we're + // going to do. credsCtx will be nil if it's any other implementation, + // though. + var credsCtx *loginCredentialsContext + if c, ok := creds.(*cliconfig.CredentialsSource); ok { + filename, _ := c.CredentialsFilePath() + credsCtx = &loginCredentialsContext{ + Location: c.HostCredentialsLocation(hostname), + LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user + HelperType: c.CredentialsHelperType(), + } + } + + if credsCtx.Location == cliconfig.CredentialsInOtherFile { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Credentials for %s are manually configured", dispHostname), + "The \"terraform logout\" command cannot log out because credentials for this host are manually configured in a CLI configuration file.\n\nTo log out, revoke the existing credentials and remove that block from the CLI configuration.", + )) + } + + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + // credsCtx might not be set if we're using a mock credentials source + // in a test, but it should always be set in normal use. + if credsCtx != nil { + switch credsCtx.Location { + case cliconfig.CredentialsViaHelper: + c.Ui.Output(fmt.Sprintf("Removing the stored credentials for %s from the configured\n%q credentials helper.\n", dispHostname, credsCtx.HelperType)) + case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable: + c.Ui.Output(fmt.Sprintf("Removing the stored credentials for %s from the following file:\n %s\n", dispHostname, credsCtx.LocalFilename)) + } + } + + err = creds.ForgetForHost(hostname) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to remove API token", + fmt.Sprintf("Unable to remove stored API token: %s", err), + )) + } + + c.showDiagnostics(diags) + if diags.HasErrors() { + return 1 + } + + c.Ui.Output( + fmt.Sprintf( + c.Colorize().Color(strings.TrimSpace(` +[green][bold]Success![reset] [bold]Terraform has removed the stored API token for %s.[reset] +`)), + dispHostname, + ) + "\n", + ) + + return 0 +} + +// Help implements cli.Command. +func (c *LogoutCommand) Help() string { + defaultFile := c.defaultOutputFile() + if defaultFile == "" { + // Because this is just for the help message and it's very unlikely + // that a user wouldn't have a functioning home directory anyway, + // we'll just use a placeholder here. The real command has some + // more complex behavior for this case. This result is not correct + // on all platforms, but given how unlikely we are to hit this case + // that seems okay. + defaultFile = "~/.terraform/credentials.tfrc.json" + } + + helpText := ` +Usage: terraform logout [hostname] + + Removes locally-stored credentials for specified hostname. + + Note: the API token is only removed from local storage, not destroyed on the + remote server, so it will remain valid until manually revoked. + + If no hostname is provided, the default hostname is app.terraform.io. + %s +` + return strings.TrimSpace(helpText) +} + +// Synopsis implements cli.Command. +func (c *LogoutCommand) Synopsis() string { + return "Remove locally-stored credentials for a remote host" +} + +func (c *LogoutCommand) defaultOutputFile() string { + if c.CLIConfigDir == "" { + return "" // no default available + } + return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json") +} diff --git a/command/logout_test.go b/command/logout_test.go new file mode 100644 index 0000000000..a4c17cf175 --- /dev/null +++ b/command/logout_test.go @@ -0,0 +1,81 @@ +package command + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/mitchellh/cli" + + svchost "github.com/hashicorp/terraform-svchost" + svcauth "github.com/hashicorp/terraform-svchost/auth" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/command/cliconfig" +) + +func TestLogout(t *testing.T) { + workDir, err := ioutil.TempDir("", "terraform-test-command-logout") + if err != nil { + t.Fatalf("cannot create temporary directory: %s", err) + } + defer os.RemoveAll(workDir) + + ui := cli.NewMockUi() + credsSrc := cliconfig.EmptyCredentialsSourceForTests(filepath.Join(workDir, "credentials.tfrc.json")) + + c := &LogoutCommand{ + Meta: Meta{ + Ui: ui, + Services: disco.NewWithCredentialsSource(credsSrc), + }, + } + + testCases := []struct { + // Hostname to associate a pre-stored token + hostname string + // Command-line arguments + args []string + // true iff the token at hostname should be removed by the command + shouldRemove bool + }{ + // If no command-line arguments given, should remove app.terraform.io token + {"app.terraform.io", []string{}, true}, + + // Can still specify app.terraform.io explicitly + {"app.terraform.io", []string{"app.terraform.io"}, true}, + + // Can remove tokens for other hostnames + {"tfe.example.com", []string{"tfe.example.com"}, true}, + + // Logout does not remove tokens for other hostnames + {"tfe.example.com", []string{"other-tfe.acme.com"}, false}, + } + for _, tc := range testCases { + host := svchost.Hostname(tc.hostname) + token := svcauth.HostCredentialsToken("some-token") + err = credsSrc.StoreForHost(host, token) + if err != nil { + t.Fatalf("unexpected error storing credentials: %s", err) + } + + status := c.Run(tc.args) + if status != 0 { + t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String()) + } + + creds, err := credsSrc.ForHost(host) + if err != nil { + t.Errorf("failed to retrieve credentials: %s", err) + } + if tc.shouldRemove { + if creds != nil { + t.Errorf("wrong token %q; should have no token", creds.Token()) + } + } else { + if got, want := creds.Token(), "some-token"; got != want { + t.Errorf("wrong token %q; want %q", got, want) + } + } + } +} diff --git a/commands.go b/commands.go index b908fc40c1..2e12ba92dc 100644 --- a/commands.go +++ b/commands.go @@ -190,6 +190,12 @@ func initCommands(config *cliconfig.Config, services *disco.Disco, providerSrc g }, nil }, + "logout": func() (cli.Command, error) { + return &command.LogoutCommand{ + Meta: meta, + }, nil + }, + "output": func() (cli.Command, error) { return &command.OutputCommand{ Meta: meta, diff --git a/website/docs/commands/logout.html.markdown b/website/docs/commands/logout.html.markdown new file mode 100644 index 0000000000..644ff5171b --- /dev/null +++ b/website/docs/commands/logout.html.markdown @@ -0,0 +1,30 @@ +--- +layout: "docs" +page_title: "Command: logout" +sidebar_current: "docs-commands-logout" +description: |- + The terraform logout command is used to remove credentials stored by terraform login. +--- + +# Command: logout + +The `terraform logout` command is used to remove credentials stored by +`terraform login`. These credentials are API tokens for Terraform Cloud, +Terraform Enterprise, or any other host that offers Terraform services. + +## Usage + +Usage: `terraform logout [hostname]` + +If you don't provide an explicit hostname, Terraform will assume you want to +log out of Terraform Cloud at `app.terraform.io`. + +-> **Note:** the API token is only removed from local storage, not destroyed on +the remote server, so it will remain valid until manually revoked. + +## Credentials Storage + +By default, Terraform will remove the token stored in plain text in a local CLI +configuration file called `credentials.tfrc.json`. If you have configured a +[credentials helper program](cli-config.html#credentials-helpers), Terraform +will use the helper's `forget` command to remove it. diff --git a/website/layouts/docs.erb b/website/layouts/docs.erb index a91e2183b6..38765f2baa 100644 --- a/website/layouts/docs.erb +++ b/website/layouts/docs.erb @@ -190,6 +190,10 @@ login + > + logout + + > output