From df5c7200edc01dc7604bd17b872fcd548a4e429d Mon Sep 17 00:00:00 2001 From: Mike DeAngelo Date: Mon, 4 Aug 2025 20:41:58 +0000 Subject: [PATCH 1/4] feat: support for oauth via PKCE in browser --- go/rtl/auth.go | 161 ++++++++++++++++++++++++++++++++++++++++ go/rtl/constants.go | 3 + go/rtl/settings.go | 26 ++++++- go/rtl/settings_test.go | 6 ++ 4 files changed, 192 insertions(+), 4 deletions(-) diff --git a/go/rtl/auth.go b/go/rtl/auth.go index d993bec7f..2b6226756 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,76 @@ func NewAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper } } +func NewPkceAuthSession(config ApiSettings) *AuthSession { + 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 { + // 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 { + log.Fatalf("Failed to generate PKCE pair: %v", err) + } + + state, err := generateSecureRandomString(32) + 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 { + log.Fatalf("Authorization failed: %v", 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 { + log.Fatalf("Failed to exchange token: %v", 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}, + } +} + func (s *AuthSession) Do(result interface{}, method, ver, path string, reqPars map[string]interface{}, body interface{}, options *ApiSettings) error { // prepare URL @@ -237,3 +313,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 + } + }() + + openBrowser(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) { + 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") + } + if err != nil { + log.Printf("Failed to open browser: %v", 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..70d1cb62f 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,11 @@ 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", } func NewSettingsFromFile(file string, section *string) (ApiSettings, error) { @@ -71,6 +77,18 @@ 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 + } return settings, nil } diff --git a/go/rtl/settings_test.go b/go/rtl/settings_test.go index abcc9d52a..394ac7f6e 100644 --- a/go/rtl/settings_test.go +++ b/go/rtl/settings_test.go @@ -25,6 +25,8 @@ func TestNewSettingsFromFile(t *testing.T) { }, want: ApiSettings{ BaseUrl: "BaseUrlValue", + RedirectPort: 8080, + RedirectPath: "/callback", VerifySsl: false, Timeout: 160, AgentTag: "AgentTagValue", @@ -114,6 +116,8 @@ func TestNewSettingsFromEnv(t *testing.T) { }, want: ApiSettings{ BaseUrl: "url", + RedirectPort: 8080, + RedirectPath: "/callback", ApiVersion: "5.0", VerifySsl: false, Timeout: 360, @@ -130,6 +134,8 @@ func TestNewSettingsFromEnv(t *testing.T) { }, want: ApiSettings{ BaseUrl: "url", + RedirectPort: 8080, + RedirectPath: "/callback", ApiVersion: "4.0", VerifySsl: true, Timeout: 120, From d86d1067452186c71f1cd8f83c653bfdc40ebb66 Mon Sep 17 00:00:00 2001 From: Mike DeAngelo Date: Tue, 5 Aug 2025 14:43:07 +0000 Subject: [PATCH 2/4] fix: default the AuthUrl to BaseUrl + '/auth' --- go/rtl/auth.go | 25 ++++++++++++++----------- go/rtl/settings.go | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/go/rtl/auth.go b/go/rtl/auth.go index 2b6226756..39fd771c4 100644 --- a/go/rtl/auth.go +++ b/go/rtl/auth.go @@ -93,7 +93,7 @@ func NewAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper } } -func NewPkceAuthSession(config ApiSettings) *AuthSession { +func NewPkceAuthSession(config ApiSettings) (*AuthSession, error) { transport := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: !config.VerifySsl, @@ -104,7 +104,7 @@ func NewPkceAuthSession(config ApiSettings) *AuthSession { } // The transport parameter may override your VerifySSL setting -func NewPkceAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper) *AuthSession { +func NewPkceAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper) (*AuthSession, error) { // This transport (Roundtripper) sets // the "x-looker-appid" Header on requests appIdHeaderTransport := &transportWithHeaders{ @@ -124,17 +124,20 @@ func NewPkceAuthSessionWithTransport(config ApiSettings, transport http.RoundTri verifier, challenge, err := generatePKCEPair() if err != nil { - log.Fatalf("Failed to generate PKCE pair: %v", err) + 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 { - log.Fatalf("Authorization failed: %v", err) + return nil, fmt.Errorf("authorization failed: %w", err) } ctx := context.WithValue( @@ -147,7 +150,7 @@ func NewPkceAuthSessionWithTransport(config ApiSettings, transport http.RoundTri token, err := oauthConfig.Exchange(ctx, authCode, oauth2.SetAuthURLParam("code_verifier", verifier)) if err != nil { - log.Fatalf("Failed to exchange token: %v", err) + return nil, fmt.Errorf("token exchange failed: %w", err) } // Make use of oauth2 transport to handle token management @@ -160,7 +163,7 @@ func NewPkceAuthSessionWithTransport(config ApiSettings, transport http.RoundTri 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 { @@ -361,7 +364,9 @@ func startLocalServerAndWaitForCode(authURL string, redirectPort int64, redirect } }() - openBrowser(authURL) + if err := openBrowser(authURL); err != nil { + fmt.Fprintf(os.Stderr, "Unable to open browser: please go to %s", authURL) + } select { case code := <-codeChan: @@ -382,7 +387,7 @@ func generateSecureRandomString(length int) (string, error) { return base64.URLEncoding.EncodeToString(b), nil } -func openBrowser(url string) { +func openBrowser(url string) error { var err error switch runtime.GOOS { case "linux": @@ -394,7 +399,5 @@ func openBrowser(url string) { default: err = fmt.Errorf("unsupported platform") } - if err != nil { - log.Printf("Failed to open browser: %v", err) - } + return err } diff --git a/go/rtl/settings.go b/go/rtl/settings.go index 70d1cb62f..2ff7bc4d5 100644 --- a/go/rtl/settings.go +++ b/go/rtl/settings.go @@ -32,6 +32,8 @@ var defaultSettings ApiSettings = ApiSettings{ Timeout: 120, RedirectPort: 8080, RedirectPath: "/callback", + BaseUrl: "", + AuthUrl: "", } func NewSettingsFromFile(file string, section *string) (ApiSettings, error) { @@ -40,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(&settings) + if settings.AuthUrl == "" && settings.BaseUrl != "" { + settings.AuthUrl = settings.BaseUrl + "/auth" } - err = cfg.Section(*section).MapTo(&s) - return s, err + return settings, err } @@ -90,5 +96,9 @@ func NewSettingsFromEnv() (ApiSettings, error) { settings.RedirectPath = v } + if settings.AuthUrl == "" && settings.BaseUrl != "" { + settings.AuthUrl = settings.BaseUrl + "/auth" + } + return settings, nil } From a5701627e891e405770de93d76a7efa5bc53a17d Mon Sep 17 00:00:00 2001 From: Mike DeAngelo Date: Tue, 5 Aug 2025 14:55:13 +0000 Subject: [PATCH 3/4] fix: modify example to use PKCE flow --- go/example/main.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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) From c34a1c3c88c62604a2957501d50afd9ed7f3a5c8 Mon Sep 17 00:00:00 2001 From: Mike DeAngelo Date: Tue, 5 Aug 2025 15:00:22 +0000 Subject: [PATCH 4/4] chore: fix test --- go/rtl/settings_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/go/rtl/settings_test.go b/go/rtl/settings_test.go index 394ac7f6e..3dfbb0062 100644 --- a/go/rtl/settings_test.go +++ b/go/rtl/settings_test.go @@ -25,6 +25,7 @@ func TestNewSettingsFromFile(t *testing.T) { }, want: ApiSettings{ BaseUrl: "BaseUrlValue", + AuthUrl: "BaseUrlValue/auth", RedirectPort: 8080, RedirectPath: "/callback", VerifySsl: false, @@ -116,6 +117,7 @@ func TestNewSettingsFromEnv(t *testing.T) { }, want: ApiSettings{ BaseUrl: "url", + AuthUrl: "url/auth", RedirectPort: 8080, RedirectPath: "/callback", ApiVersion: "5.0", @@ -134,6 +136,7 @@ func TestNewSettingsFromEnv(t *testing.T) { }, want: ApiSettings{ BaseUrl: "url", + AuthUrl: "url/auth", RedirectPort: 8080, RedirectPath: "/callback", ApiVersion: "4.0",