diff --git a/command/acl_auth_method.go b/command/acl_auth_method.go index 1cfc32282..a5b0a24e6 100644 --- a/command/acl_auth_method.go +++ b/command/acl_auth_method.go @@ -85,6 +85,7 @@ func formatAuthMethodConfig(config *api.ACLAuthMethodConfig) []string { fmt.Sprintf("OIDC Discovery URL|%s", config.OIDCDiscoveryURL), fmt.Sprintf("OIDC Client ID|%s", config.OIDCClientID), fmt.Sprintf("OIDC Client Secret|%s", config.OIDCClientSecret), + fmt.Sprintf("OIDC Scopes|%s", strings.Join(config.OIDCScopes, ",")), fmt.Sprintf("Bound audiences|%s", strings.Join(config.BoundAudiences, ",")), fmt.Sprintf("Allowed redirects URIs|%s", strings.Join(config.AllowedRedirectURIs, ",")), fmt.Sprintf("Discovery CA pem|%s", strings.Join(config.DiscoveryCaPem, ",")), diff --git a/command/commands.go b/command/commands.go index 029293e43..8813204fd 100644 --- a/command/commands.go +++ b/command/commands.go @@ -510,6 +510,11 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "login": func() (cli.Command, error) { + return &LoginCommand{ + Meta: meta, + }, nil + }, "logs": func() (cli.Command, error) { return &AllocLogsCommand{ Meta: meta, diff --git a/command/login.go b/command/login.go new file mode 100644 index 000000000..15910c763 --- /dev/null +++ b/command/login.go @@ -0,0 +1,284 @@ +package command + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + + "github.com/hashicorp/cap/util" + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/lib/auth/oidc" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +// Ensure LoginCommand satisfies the cli.Command interface. +var _ cli.Command = &LoginCommand{} + +// LoginCommand implements cli.Command. +type LoginCommand struct { + Meta + + authMethodType string + authMethodName string + callbackAddr string + + template string + json bool +} + +// Help satisfies the cli.Command Help function. +func (l *LoginCommand) Help() string { + helpText := ` +Usage: nomad login [options] + + The login command will exchange the provided third party credentials with the + requested auth method for a newly minted Nomad ACL token. + +General Options: + + ` + generalOptionsUsage(usageOptsNoNamespace) + ` + +Login Options: + + -method + The name of the ACL auth method to login to. If the cluster administrator + has configured a default, this flag is optional. + + -type + Type of the auth method to login to. Defaults to "OIDC". + + -oidc-callback-addr + The address to use for the local OIDC callback server. This should be given + in the form of : and defaults to "127.0.0.1:4649". + + -json + Output the ACL token in JSON format. + + -t + Format and display the ACL token using a Go template. +` + return strings.TrimSpace(helpText) +} + +// Synopsis satisfies the cli.Command Synopsis function. +func (l *LoginCommand) Synopsis() string { + return "Login to Nomad using an auth method" +} + +func (l *LoginCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(l.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-method": complete.PredictAnything, + "-type": complete.PredictSet("OIDC"), + "-oidc-callback-addr": complete.PredictAnything, + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + }) +} + +// Name returns the name of this command. +func (l *LoginCommand) Name() string { return "login" } + +// Run satisfies the cli.Command Run function. +func (l *LoginCommand) Run(args []string) int { + + flags := l.Meta.FlagSet(l.Name(), FlagSetClient) + flags.Usage = func() { l.Ui.Output(l.Help()) } + flags.StringVar(&l.authMethodName, "method", "", "") + flags.StringVar(&l.authMethodType, "type", "OIDC", "") + flags.StringVar(&l.callbackAddr, "oidc-callback-addr", "127.0.0.1:4649", "") + flags.BoolVar(&l.json, "json", false, "") + flags.StringVar(&l.template, "t", "", "") + if err := flags.Parse(args); err != nil { + return 1 + } + args = flags.Args() + + if len(args) != 0 { + l.Ui.Error("This command takes no arguments") + l.Ui.Error(commandErrorText(l)) + return 1 + } + + // Auth method types are particular with their naming, so ensure we forgive + // any case mistakes here from the user. + sanitizedMethodType := strings.ToUpper(l.authMethodType) + + // Ensure we sanitize the method type so we do not pedantically return an + // error when the caller uses "oidc" rather than "OIDC". The flag default + // means an empty type is only possible is the caller specifies this + // explicitly. + switch sanitizedMethodType { + case "": + l.Ui.Error("Please supply an authentication type") + return 1 + case api.ACLAuthMethodTypeOIDC: + default: + l.Ui.Error(fmt.Sprintf("Unsupported authentication type %q", sanitizedMethodType)) + return 1 + } + + client, err := l.Meta.Client() + if err != nil { + l.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // If the caller did not supply and auth method name, attempt to lookup the + // default. This ensures a nice UX as clusters are expected to only have + // one method, and this avoids having to type the name during each login. + if l.authMethodName == "" { + + authMethodList, _, err := client.ACLAuthMethods().List(nil) + if err != nil { + l.Ui.Error(fmt.Sprintf("Error listing ACL auth methods: %s", err)) + return 1 + } + + for _, authMethod := range authMethodList { + if authMethod.Default { + l.authMethodName = authMethod.Name + } + } + + if l.authMethodName == "" { + l.Ui.Error("Must specify an auth method name, no default found") + return 1 + } + } + + // Each login type should implement a function which matches this signature + // for the specific login implementation. This allows the command to have + // reusable and generic handling of errors and outputs. + var authFn func(context.Context, *api.Client) (*api.ACLToken, error) + + switch sanitizedMethodType { + case api.ACLAuthMethodTypeOIDC: + authFn = l.loginOIDC + default: + l.Ui.Error(fmt.Sprintf("Unsupported authentication type %q", sanitizedMethodType)) + return 1 + } + + ctx, cancel := contextWithInterrupt() + defer cancel() + + token, err := authFn(ctx, client) + if err != nil { + l.Ui.Error(fmt.Sprintf("Error performing login: %v", err)) + return 1 + } + + if l.json || l.template != "" { + out, err := Format(l.json, l.template, token) + if err != nil { + l.Ui.Error(err.Error()) + return 1 + } + l.Ui.Output(out) + return 0 + } + + l.Ui.Output(fmt.Sprintf("Successfully logged in via %s and %s\n", sanitizedMethodType, l.authMethodName)) + outputACLToken(l.Ui, token) + return 0 +} + +func (l *LoginCommand) loginOIDC(ctx context.Context, client *api.Client) (*api.ACLToken, error) { + + callbackServer, err := oidc.NewCallbackServer(l.callbackAddr) + if err != nil { + return nil, err + } + defer callbackServer.Close() + + getAuthArgs := api.ACLOIDCAuthURLRequest{ + AuthMethodName: l.authMethodName, + RedirectURI: callbackServer.RedirectURI(), + ClientNonce: callbackServer.Nonce(), + } + + getAuthURLResp, _, err := client.ACLOIDC().GetAuthURL(&getAuthArgs, nil) + if err != nil { + return nil, err + } + + // Open the auth URL in the user browser or ask them to visit it. + // We purposely use fmt here and NOT c.ui because the ui will truncate + // our URL (a known bug). + if err := util.OpenURL(getAuthURLResp.AuthURL); err != nil { + l.Ui.Error(fmt.Sprintf("Error opening OIDC provider URL: %v\n", err)) + l.Ui.Output(fmt.Sprintf(strings.TrimSpace(oidcErrorVisitURLMsg)+"\n\n", getAuthURLResp.AuthURL)) + } + + // Wait. The login process can end to one of the following reasons: + // - the user interrupts the login process via CTRL-C + // - the login process returns an error via the callback server + // - the login process is successful as returned by the callback server + var req *api.ACLOIDCCompleteAuthRequest + select { + case <-ctx.Done(): + _ = callbackServer.Close() + return nil, ctx.Err() + case err := <-callbackServer.ErrorCh(): + return nil, err + case req = <-callbackServer.SuccessCh(): + } + + cbArgs := api.ACLOIDCCompleteAuthRequest{ + AuthMethodName: l.authMethodName, + RedirectURI: callbackServer.RedirectURI(), + ClientNonce: callbackServer.Nonce(), + Code: req.Code, + State: req.State, + } + + token, _, err := client.ACLOIDC().CompleteAuth(&cbArgs, nil) + return token, err +} + +const ( + // oidcErrorVisitURLMsg is a message to show users when opening the OIDC + // provider URL automatically fails. This type of message is otherwise not + // needed, as it just clutters the console without providing value. + oidcErrorVisitURLMsg = ` +Automatic opening of the OIDC provider for login has failed. To complete the +authentication, please visit your provider using the URL below: + +%s +` +) + +// contextWithInterrupt returns a context and cancel function that adheres to +// expected behaviour and also includes cancellation when the user interrupts +// the login process via CTRL-C. +func contextWithInterrupt() (context.Context, func()) { + + // Create the cancellable context that we'll use when we receive an + // interrupt. + ctx, cancel := context.WithCancel(context.Background()) + + // Create the signal channel and cancel the context when we get a signal. + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt) + + // Start a routine which waits for the signals. + go func() { + select { + case <-ch: + cancel() + case <-ctx.Done(): + return + } + }() + + // Return the context and a closer that cancels the context and also + // stops any signals from coming to our channel. + return ctx, func() { + signal.Stop(ch) + cancel() + } +} diff --git a/command/login_test.go b/command/login_test.go new file mode 100644 index 000000000..98b7af6bd --- /dev/null +++ b/command/login_test.go @@ -0,0 +1,57 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/shoenig/test/must" +) + +func TestLoginCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Build a test server with ACLs enabled. + srv, _, agentURL := testServer(t, false, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Wait for the server to start fully. + testutil.WaitForLeader(t, srv.Agent.RPC) + + ui := cli.NewMockUi() + cmd := &LoginCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: agentURL, + }, + } + + // Test the basic validation on the command. + must.Eq(t, 1, cmd.Run([]string{"-address=" + agentURL, "this-command-does-not-take-args"})) + must.StrContains(t, ui.ErrorWriter.String(), "This command takes no arguments") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Attempt to call it with an unsupported method type. + must.Eq(t, 1, cmd.Run([]string{"-address=" + agentURL, "-type=SAML"})) + must.StrContains(t, ui.ErrorWriter.String(), `Unsupported authentication type "SAML"`) + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Use a valid method type but with incorrect casing so we can ensure this + // is handled. + must.Eq(t, 1, cmd.Run([]string{"-address=" + agentURL, "-type=oIdC"})) + must.StrContains(t, ui.ErrorWriter.String(), "Must specify an auth method name, no default found") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // TODO(jrasell) find a way to test the full login flow from the CLI + // perspective. +}