diff --git a/go/example/main.go b/go/example/main.go index 1a7cef431..6a8329ad6 100644 --- a/go/example/main.go +++ b/go/example/main.go @@ -65,7 +65,11 @@ func main() { check(err) // New instance of LookerSDK - sdk := v4.NewLookerSDK(rtl.NewAuthSession(cfg)) + authSession := rtl.NewAuthSession(cfg) + if cfg.ClientSecret == "" { + authSession, _ = rtl.NewPkceAuthSession(cfg) + } + sdk := v4.NewLookerSDK(authSession) printAllProjects(sdk) diff --git a/go/rtl/auth.go b/go/rtl/auth.go index d993bec7f..39fd771c4 100644 --- a/go/rtl/auth.go +++ b/go/rtl/auth.go @@ -3,13 +3,19 @@ package rtl import ( "bytes" "context" + "crypto/rand" + "crypto/sha256" "crypto/tls" + "encoding/base64" "fmt" "io/ioutil" + "log" "net/http" "net/url" "os" + "os/exec" "reflect" + "runtime" "time" "golang.org/x/oauth2" @@ -87,6 +93,79 @@ func NewAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper } } +func NewPkceAuthSession(config ApiSettings) (*AuthSession, error) { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !config.VerifySsl, + }, + } + + return NewPkceAuthSessionWithTransport(config, transport) +} + +// The transport parameter may override your VerifySSL setting +func NewPkceAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper) (*AuthSession, error) { + // This transport (Roundtripper) sets + // the "x-looker-appid" Header on requests + appIdHeaderTransport := &transportWithHeaders{ + Base: transport, + } + + oauthConfig := &oauth2.Config{ + ClientID: config.ClientId, + ClientSecret: "", // Public client, no secret + Scopes: []string{"cors_api"}, + Endpoint: oauth2.Endpoint{ + AuthURL: config.AuthUrl, + TokenURL: config.BaseUrl + "/api/token", + }, + RedirectURL: fmt.Sprintf("http://localhost:%d%s", config.RedirectPort, config.RedirectPath), + } + + verifier, challenge, err := generatePKCEPair() + if err != nil { + return nil, fmt.Errorf("failed to generate PKCE pair: %w", err) + } + + state, err := generateSecureRandomString(32) + if err != nil { + return nil, fmt.Errorf("failed to generate a secure random string: %w", err) + } + authURL := oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256")) + + authCode, err := startLocalServerAndWaitForCode(authURL, config.RedirectPort, config.RedirectPath) + if err != nil { + return nil, fmt.Errorf("authorization failed: %w", err) + } + + ctx := context.WithValue( + context.Background(), + oauth2.HTTPClient, + // Will set "x-looker-appid" Header on TokenURL requests + &http.Client{Transport: appIdHeaderTransport}, + ) + + token, err := oauthConfig.Exchange(ctx, authCode, + oauth2.SetAuthURLParam("code_verifier", verifier)) + if err != nil { + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + // Make use of oauth2 transport to handle token management + oauthTransport := &oauth2.Transport{ + Source: oauthConfig.TokenSource(ctx, token), + // Will set "x-looker-appid" Header on all other requests + Base: appIdHeaderTransport, + } + + return &AuthSession{ + Config: config, + Client: http.Client{Transport: oauthTransport}, + }, nil +} + func (s *AuthSession) Do(result interface{}, method, ver, path string, reqPars map[string]interface{}, body interface{}, options *ApiSettings) error { // prepare URL @@ -237,3 +316,88 @@ func setQuery(u *url.URL, pars map[string]interface{}) { } u.RawQuery = q.Encode() } + +func generatePKCEPair() (string, string, error) { + verifierBytes := make([]byte, 96) + _, err := rand.Read(verifierBytes) + if err != nil { + return "", "", err + } + verifier := base64.RawURLEncoding.EncodeToString(verifierBytes) + hasher := sha256.New() + hasher.Write([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) + return verifier, challenge, nil +} + +// --- Local HTTP Server for Redirect --- +func startLocalServerAndWaitForCode(authURL string, redirectPort int64, redirectPath string) (string, error) { + codeChan := make(chan string) + errChan := make(chan error) + + mux := http.NewServeMux() + server := &http.Server{Addr: fmt.Sprintf(":%d", redirectPort), Handler: mux} + + mux.HandleFunc(redirectPath, func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + errMsg := "authorization failed: no code received" + http.Error(w, errMsg, http.StatusBadRequest) + errChan <- fmt.Errorf(errMsg) + return + } + fmt.Fprintf(w, "Authorization successful! You can close this tab.") + codeChan <- code + go func() { + time.Sleep(1 * time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Printf("HTTP server Shutdown error: %v", err) + } + }() + }) + + go func() { + if err := server.ListenAndServe(); err != http.ErrServerClosed { + errChan <- err + } + }() + + if err := openBrowser(authURL); err != nil { + fmt.Fprintf(os.Stderr, "Unable to open browser: please go to %s", authURL) + } + + select { + case code := <-codeChan: + return code, nil + case err := <-errChan: + return "", err + case <-time.After(5 * time.Minute): + return "", fmt.Errorf("timed out waiting for authorization code") + } +} + +func generateSecureRandomString(length int) (string, error) { + b := make([]byte, length) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func openBrowser(url string) error { + var err error + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + return err +} diff --git a/go/rtl/constants.go b/go/rtl/constants.go index 9f8a8fc23..89534f799 100644 --- a/go/rtl/constants.go +++ b/go/rtl/constants.go @@ -7,4 +7,7 @@ const ( timeoutEnvKey = "LOOKERSDK_TIMEOUT" clientIdEnvKey = "LOOKERSDK_CLIENT_ID" clientSecretEnvKey = "LOOKERSDK_CLIENT_SECRET" + authUrlEnvKey = "LOOKERSDK_AUTH_URL" + redirectPortEnvKey = "LOOKERSDK_REDIRECT_PORT" + redirectPathEnvKey = "LOOKERSDK_REDIRECT_PATH" ) diff --git a/go/rtl/settings.go b/go/rtl/settings.go index f9d1334fa..2ff7bc4d5 100644 --- a/go/rtl/settings.go +++ b/go/rtl/settings.go @@ -2,16 +2,20 @@ package rtl import ( "fmt" - "gopkg.in/ini.v1" "os" "strconv" "strings" + + "gopkg.in/ini.v1" ) var defaultSectionName string = "Looker" type ApiSettings struct { BaseUrl string `ini:"base_url"` + AuthUrl string `ini:"auth_url"` + RedirectPort int64 `ini:"redirect_port"` + RedirectPath string `ini:"redirect_path"` VerifySsl bool `ini:"verify_ssl"` Timeout int32 `ini:"timeout"` AgentTag string `ini:"agent_tag"` @@ -23,9 +27,13 @@ type ApiSettings struct { } var defaultSettings ApiSettings = ApiSettings{ - VerifySsl: true, - ApiVersion: "4.0", - Timeout: 120, + VerifySsl: true, + ApiVersion: "4.0", + Timeout: 120, + RedirectPort: 8080, + RedirectPath: "/callback", + BaseUrl: "", + AuthUrl: "", } func NewSettingsFromFile(file string, section *string) (ApiSettings, error) { @@ -34,15 +42,19 @@ func NewSettingsFromFile(file string, section *string) (ApiSettings, error) { } // Default values - s := defaultSettings + settings := defaultSettings cfg, err := ini.Load(file) if err != nil { - return s, fmt.Errorf("error reading ini file: %w", err) + return settings, fmt.Errorf("error reading ini file: %w", err) } - err = cfg.Section(*section).MapTo(&s) - return s, err + err = cfg.Section(*section).MapTo(&settings) + if settings.AuthUrl == "" && settings.BaseUrl != "" { + settings.AuthUrl = settings.BaseUrl + "/auth" + } + + return settings, err } @@ -71,6 +83,22 @@ func NewSettingsFromEnv() (ApiSettings, error) { if v, present := os.LookupEnv(clientSecretEnvKey); present { settings.ClientSecret = v } + if v, present := os.LookupEnv(authUrlEnvKey); present { + settings.AuthUrl = v + } + if v, present := os.LookupEnv(redirectPortEnvKey); present { + redirectPort, err := strconv.ParseInt(v, 10, 64) + if err == nil { + settings.RedirectPort = int64(redirectPort) + } + } + if v, present := os.LookupEnv(redirectPathEnvKey); present { + settings.RedirectPath = v + } + + if settings.AuthUrl == "" && settings.BaseUrl != "" { + settings.AuthUrl = settings.BaseUrl + "/auth" + } return settings, nil } diff --git a/go/rtl/settings_test.go b/go/rtl/settings_test.go index abcc9d52a..3dfbb0062 100644 --- a/go/rtl/settings_test.go +++ b/go/rtl/settings_test.go @@ -25,6 +25,9 @@ func TestNewSettingsFromFile(t *testing.T) { }, want: ApiSettings{ BaseUrl: "BaseUrlValue", + AuthUrl: "BaseUrlValue/auth", + RedirectPort: 8080, + RedirectPath: "/callback", VerifySsl: false, Timeout: 160, AgentTag: "AgentTagValue", @@ -114,6 +117,9 @@ func TestNewSettingsFromEnv(t *testing.T) { }, want: ApiSettings{ BaseUrl: "url", + AuthUrl: "url/auth", + RedirectPort: 8080, + RedirectPath: "/callback", ApiVersion: "5.0", VerifySsl: false, Timeout: 360, @@ -130,6 +136,9 @@ func TestNewSettingsFromEnv(t *testing.T) { }, want: ApiSettings{ BaseUrl: "url", + AuthUrl: "url/auth", + RedirectPort: 8080, + RedirectPath: "/callback", ApiVersion: "4.0", VerifySsl: true, Timeout: 120,