mirror of
https://github.com/kemko/nomad.git
synced 2026-01-03 08:55:43 +03:00
cli: add login command to allow OIDC provider SSO login.
This commit is contained in:
@@ -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, ",")),
|
||||
|
||||
@@ -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,
|
||||
|
||||
284
command/login.go
Normal file
284
command/login.go
Normal file
@@ -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 <IP>:<PORT> 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()
|
||||
}
|
||||
}
|
||||
57
command/login_test.go
Normal file
57
command/login_test.go
Normal file
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user