Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion go/example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
164 changes: 164 additions & 0 deletions go/rtl/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions go/rtl/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
44 changes: 36 additions & 8 deletions go/rtl/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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) {
Expand All @@ -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

}

Expand Down Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions go/rtl/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading