Skip to content

Commit

Permalink
TCE-807: Browser login (#112)
Browse files Browse the repository at this point in the history
* add oauth2Config to hcpConfig

* allow for configurable oauth2client ID

* add environment var for oauth2 client id

* add getTokenFromBrowser auth helper

* enable httpclient to get token from browser
when no client credentials are configured

* go mod tidy

* drop missing client creds invalid case

* update with idp defaults

* fix test

* add partial credentials validation
  • Loading branch information
bcmdarroch authored Aug 30, 2022
1 parent 50dbf4d commit ca31f5c
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 35 deletions.
121 changes: 103 additions & 18 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
12 changes: 10 additions & 2 deletions config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}

Expand Down
8 changes: 8 additions & 0 deletions config/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 14 additions & 3 deletions config/hcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 34 additions & 6 deletions config/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -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,

Expand All @@ -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)
Expand Down
5 changes: 0 additions & 5 deletions config/new_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
20 changes: 19 additions & 1 deletion config/with.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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
}
}
15 changes: 15 additions & 0 deletions config/with_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down

0 comments on commit ca31f5c

Please sign in to comment.