diff --git a/auth/auth.go b/auth/auth.go index 244100ce..a2cb344c 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -2,32 +2,117 @@ package auth import ( "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" "net/http" - "net/url" + "os" + "os/signal" + "time" - "golang.org/x/oauth2/clientcredentials" + "github.com/mitchellh/colorstring" + "github.com/skratchdot/open-golang/open" + "golang.org/x/oauth2" ) -var ( - tokenPath string = "/oauth2/token" -) +// callbackEndpoint exposes the confiugration for the callback server. +type callbackEndpoint struct { + server *http.Server + code string + shutdownSignal chan error +} + +// callbackEndpoint endpoint ServeHTTP confirms if an Authorization code was received from Auth0. +func (h *callbackEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { + + code := r.URL.Query().Get("code") + if code != "" { + h.code = code + fmt.Fprintln(w, "Login is successful. You may close the browser and return to the command line.") + colorstring.Println("[bold][green]Success!") + } else { + fmt.Fprintln(w, "Login is not successful. You may close the browser and try again.") + } + h.shutdownSignal <- nil +} + +// getTokenFromBrowser opens a browser window for the user to log in and handles the OAuth2 flow to obtain a token. +func GetTokenFromBrowser(ctx context.Context, conf *oauth2.Config) (*oauth2.Token, error) { + + // Launch a request to Auth0's authorization endpoint. + colorstring.Printf("[bold][yellow]The default web browser has been opened at %s. Please continue the login in the web browser.\n", conf.Endpoint.AuthURL) -// WithClientCredentials returns an http client with an access token obtained using the configured client credentials. -func WithClientCredentials(ctx context.Context, clientID, clientSecret, authURL string) (*http.Client, error) { - // The audience is the API identifier configured in the auth provider - // and must be provided when requesting an access token for the API. + // Prepare the /authorize request with randomly generated state, offline access option, and audience aud := "https://api.hashicorp.cloud" + opt := oauth2.SetAuthURLParam("audience", aud) + authzURL := conf.AuthCodeURL(generateRandomString(32), oauth2.AccessTypeOffline, opt) + + // Handle ctrl-c while waiting for the callback + sigintCh := make(chan os.Signal, 1) + signal.Notify(sigintCh, os.Interrupt) + defer signal.Stop(sigintCh) + + if err := open.Start(authzURL); err != nil { + return nil, fmt.Errorf("failed to open browser at URL %q: %w", authzURL, err) + } + + // Start callback server + callbackEndpoint := &callbackEndpoint{} + callbackEndpoint.shutdownSignal = make(chan error) + server := &http.Server{ + Addr: ":8443", + Handler: nil, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + } + callbackEndpoint.server = server + http.Handle("/oidc/callback", callbackEndpoint) + + go func() { + err := server.ListenAndServe() + if err != nil { + callbackEndpoint.shutdownSignal <- fmt.Errorf("failed to start callback server: %w", err) + } + }() + + // Wait for either the callback to finish, SIGINT to be received or up to 2 minutes + select { + case err := <-callbackEndpoint.shutdownSignal: - conf := &clientcredentials.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - TokenURL: authURL + tokenPath, - EndpointParams: url.Values{"audience": {aud}}, + if err != nil { + return nil, err + } + + err = callbackEndpoint.server.Shutdown(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to shutdown callback server: %w", err) + } + + // Exchange the code returned in the callback for a token. + tok, err := conf.Exchange(ctx, callbackEndpoint.code) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + return tok, nil + case <-sigintCh: + return nil, errors.New("interrupted") + case <-time.After(2 * time.Minute): + return nil, errors.New("timed out waiting for response from provider") } +} - // The http client is the same one attached to the context, - // only now it will be able to authenticate with the token obtained using the client credentials. - client := conf.Client(ctx) +// generateRandomString returns a URL-safe, base64 encoded +// securely generated random string. +func generateRandomString(n int) string { + b := make([]byte, n) + _, err := rand.Read(b) + // Note that err == nil only if we read len(b) bytes. + if err != nil { + panic(err) + } - return client, nil + return base64.RawURLEncoding.EncodeToString(b) } diff --git a/config/env.go b/config/env.go index 0cf778c7..81d9342f 100644 --- a/config/env.go +++ b/config/env.go @@ -10,7 +10,8 @@ import ( // The following constants contain the names of environment variables that can // be set to provide configuration values. const ( - envVarAuthURL = "HCP_AUTH_URL" + envVarAuthURL = "HCP_AUTH_URL" + envVarOAuth2ClientID = "HCP_OAUTH_CLIENT_ID" envVarClientID = "HCP_CLIENT_ID" envVarClientSecret = "HCP_CLIENT_SECRET" @@ -65,7 +66,14 @@ func FromEnv() HCPConfigOption { // Read auth URL from environment if authURL, ok := os.LookupEnv(envVarAuthURL); ok { if err := apply(config, WithAuth(authURL, nil)); err != nil { - return fmt.Errorf("failed to parse environment variable %s: %w", envVarPortalURL, err) + return fmt.Errorf("failed to parse environment variable %s: %w", envVarAuthURL, err) + } + } + + // Read oauth2ClientID from environment + if oauth2ClientID, ok := os.LookupEnv(envVarOAuth2ClientID); ok { + if err := apply(config, WithOAuth2ClientID(oauth2ClientID)); err != nil { + return fmt.Errorf("failed to parse environment variable %s: %w", envVarOAuth2ClientID, err) } } diff --git a/config/env_test.go b/config/env_test.go index f8d9e68c..78891bc8 100644 --- a/config/env_test.go +++ b/config/env_test.go @@ -38,6 +38,8 @@ func TestFromEnv_SimpleValues(t *testing.T) { require.NoError(os.Setenv(envVarPortalURL, "http://my-portal:2345")) + require.NoError(os.Setenv(envVarOAuth2ClientID, "1a2b3c4d")) + require.NoError(os.Setenv(envVarAPIAddress, "my-api:3456")) require.NoError(os.Setenv(envVarSCADAAddress, "my-scada:4567")) @@ -53,6 +55,11 @@ func TestFromEnv_SimpleValues(t *testing.T) { require.Equal("my-client-id", config.clientCredentialsConfig.ClientID) require.Equal("my-client-secret", config.clientCredentialsConfig.ClientSecret) + // Ensure the oauth2 config is set correctly + require.Equal("1a2b3c4d", config.oauth2Config.ClientID) + require.Equal("https://my-auth:1234/oauth2/auth", config.oauth2Config.Endpoint.AuthURL) + require.Equal("https://my-auth:1234/oauth2/token", config.oauth2Config.Endpoint.TokenURL) + // Ensure the portal URL is set correctly require.Equal("http", config.portalURL.Scheme) require.Equal("my-portal:2345", config.portalURL.Host) @@ -130,6 +137,7 @@ func clearEnv() { os.Unsetenv(envVarAuthURL) os.Unsetenv(envVarClientID) os.Unsetenv(envVarClientSecret) + os.Unsetenv(envVarOAuth2ClientID) os.Unsetenv(envVarPortalURL) os.Unsetenv(envVarAPIAddress) os.Unsetenv(envVarAPIHostnameLegacy) diff --git a/config/hcp.go b/config/hcp.go index 2cf2d44a..97a11854 100644 --- a/config/hcp.go +++ b/config/hcp.go @@ -50,6 +50,10 @@ type hcpConfig struct { // the token source. clientCredentialsConfig clientcredentials.Config + // oauth2Config is the configuration that will be used to create + // a browser-initiated token source when client credentials are not provided. + oauth2Config oauth2.Config + // authURL is the URL that will be used to authenticate. authURL *url.URL // authTLSConfig is the TLS configuration for the auth endpoint. TLS can not @@ -105,9 +109,16 @@ func (c *hcpConfig) SCADATLSConfig() *tls.Config { } func (c *hcpConfig) validate() error { - // Ensure client credentials have been provided - if c.clientCredentialsConfig.ClientID == "" && c.clientCredentialsConfig.ClientSecret == "" { - return fmt.Errorf("client credentials need to be provided") + + // Ensure both client credentials provided + if (c.clientCredentialsConfig.ClientID == "" && c.clientCredentialsConfig.ClientSecret != "") || + (c.clientCredentialsConfig.ClientID != "" && c.clientCredentialsConfig.ClientSecret == "") { + return fmt.Errorf("both client ID and secret must be provided") + } + + // Ensure at least one auth method configured + if c.clientCredentialsConfig.ClientID == "" && c.clientCredentialsConfig.ClientSecret == "" && c.oauth2Config.ClientID == "" { + return fmt.Errorf("either client credentials or oauth2 client ID must be provided") } // Ensure the auth URL is valid diff --git a/config/new.go b/config/new.go index d69300d7..552456e5 100644 --- a/config/new.go +++ b/config/new.go @@ -7,6 +7,7 @@ import ( "net/url" "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/hcp-sdk-go/auth" "golang.org/x/net/context" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" @@ -16,6 +17,9 @@ const ( // defaultAuthURL is the URL of the production auth endpoint. defaultAuthURL = "https://auth.idp.hashicorp.com" + // defaultOAuth2ClientID is the client ID of the production auth application. + defaultOAuth2ClientID = "21d86262-6f14-4a30-a90f-07e3fde8b23d" + // defaultPortalURL is the URL of the production portal. defaultPortalURL = "https://portal.cloud.hashicorp.com" @@ -60,6 +64,15 @@ func NewHCPConfig(opts ...HCPConfigOption) (HCPConfig, error) { authURL: authURL, authTLSConfig: &tls.Config{}, + oauth2Config: oauth2.Config{ + ClientID: defaultOAuth2ClientID, + Endpoint: oauth2.Endpoint{ + AuthURL: defaultAuthURL + "/oauth2/auth", + TokenURL: defaultAuthURL + "/oauth2/token", + }, + RedirectURL: "http://localhost:8443/oidc/callback", + Scopes: []string{"openid", "offline_access"}, + }, portalURL: portalURL, @@ -86,13 +99,28 @@ func NewHCPConfig(opts ...HCPConfigOption) (HCPConfig, error) { &http.Client{Transport: tokenTransport}, ) - // Set token URL based on auth URL - tokenURL := config.authURL - tokenURL.Path = tokenPath - config.clientCredentialsConfig.TokenURL = tokenURL.String() + // Set access token via configured client credentials. + if config.clientCredentialsConfig.ClientID != "" && config.clientCredentialsConfig.ClientSecret != "" { + // Set token URL based on auth URL + tokenURL := config.authURL + tokenURL.Path = tokenPath + config.clientCredentialsConfig.TokenURL = tokenURL.String() + + // Create token source from the client credentials configuration + config.tokenSource = config.clientCredentialsConfig.TokenSource(tokenContext) + + } else { // Set access token via browser login. - // Create token source from the client credentials configuration - config.tokenSource = config.clientCredentialsConfig.TokenSource(tokenContext) + // TODO: Right now we fetch a new token on every init of the client. We need to implement a library that will check for existing tokens in a well-known location. + // If no token is available or if the available token's max age has exceeded,then we get new token via browser login. + var tok *oauth2.Token + tok, err := auth.GetTokenFromBrowser(tokenContext, &config.oauth2Config) + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + + config.tokenSource = config.oauth2Config.TokenSource(tokenContext, tok) + } if err := config.validate(); err != nil { return nil, fmt.Errorf("the configuration is not valid: %w", err) diff --git a/config/new_test.go b/config/new_test.go index f792eaf5..5a7e6664 100644 --- a/config/new_test.go +++ b/config/new_test.go @@ -50,11 +50,6 @@ func TestNew_Invalid(t *testing.T) { options []HCPConfigOption expectedError string }{ - { - name: "missing credentials", - options: []HCPConfigOption{}, - expectedError: "the configuration is not valid: client credentials need to be provided", - }, { name: "empty portal URL", options: []HCPConfigOption{ diff --git a/config/with.go b/config/with.go index 61347255..d2970ca5 100644 --- a/config/with.go +++ b/config/with.go @@ -67,7 +67,7 @@ func WithPortalURL(portalURL string) HCPConfigOption { // WithAuth credentials is an option that can be used to provide a custom URL // for the auth endpoint. // -// An alternative TLS configuration can be provided, if non is provided the +// An alternative TLS configuration can be provided, if none is provided the // default TLS configuration will be used. It is not possible to disable TLS for // the auth endpoint. // @@ -88,6 +88,24 @@ func WithAuth(authURL string, tlsConfig *tls.Config) HCPConfigOption { config.authURL = parsedAuthURL config.authTLSConfig = cloneTLSConfig(tlsConfig) + // Ensure the OAuth2 endpoints are updated with the new auth URL + config.oauth2Config.Endpoint.AuthURL = authURL + "/oauth2/auth" + config.oauth2Config.Endpoint.TokenURL = authURL + "/oauth2/token" + + return nil + } +} + +// WithOAuth2ClientID credentials is an option that can be used to provide a custom OAuth2 Client ID. +// +// An alternative OAuth2 ClientID can be provided, if none is provided the +// default OAuth2 Client ID will be used. +// +// This should only be necessary for development purposes. +func WithOAuth2ClientID(oauth2ClientID string) HCPConfigOption { + return func(config *hcpConfig) error { + config.oauth2Config.ClientID = oauth2ClientID + return nil } } diff --git a/config/with_test.go b/config/with_test.go index 939738c1..46b4c500 100644 --- a/config/with_test.go +++ b/config/with_test.go @@ -64,6 +64,10 @@ func TestWith_Auth(t *testing.T) { // Ensure that the portal URL has been set require.Equal("http://my-auth:1234", config.authURL.String()) + // Ensure OAuth2 config is updated with custom auth URL + require.Equal("http://my-auth:1234/oauth2/auth", config.oauth2Config.Endpoint.AuthURL) + require.Equal("http://my-auth:1234/oauth2/token", config.oauth2Config.Endpoint.TokenURL) + // Ensure auth TLS is configured require.NotNil(config.authTLSConfig) } @@ -78,3 +82,14 @@ func TestWith_Auth_CustomTLSConfig(t *testing.T) { // Ensure auth TLS has custom configuration require.True(config.authTLSConfig.InsecureSkipVerify) } + +func TestWith_OAuth2ClientID(t *testing.T) { + require := requirepkg.New(t) + + // Exercise + config := &hcpConfig{} + require.NoError(apply(config, WithOAuth2ClientID("1a2b3c4d"))) + + // Ensure oauth2 config is configured with custom OAuth2 client ID + require.Equal("1a2b3c4d", config.oauth2Config.ClientID) +} diff --git a/go.mod b/go.mod index 81cc5122..7866b0f0 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( github.com/go-ozzo/ozzo-validation v3.6.0+incompatible github.com/hashicorp/go-cleanhttp v0.5.1 github.com/iancoleman/strcase v0.1.3 + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.7.0 golang.org/x/net v0.0.0-20210119194325-5f4716e94777 golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58 diff --git a/go.sum b/go.sum index fdb9ee87..377f0740 100644 --- a/go.sum +++ b/go.sum @@ -275,6 +275,8 @@ github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -300,6 +302,8 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=