Skip to content

Commit df5c720

Browse files
feat: support for oauth via PKCE in browser
1 parent 6e45409 commit df5c720

File tree

4 files changed

+192
-4
lines changed

4 files changed

+192
-4
lines changed

go/rtl/auth.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@ package rtl
33
import (
44
"bytes"
55
"context"
6+
"crypto/rand"
7+
"crypto/sha256"
68
"crypto/tls"
9+
"encoding/base64"
710
"fmt"
811
"io/ioutil"
12+
"log"
913
"net/http"
1014
"net/url"
1115
"os"
16+
"os/exec"
1217
"reflect"
18+
"runtime"
1319
"time"
1420

1521
"golang.org/x/oauth2"
@@ -87,6 +93,76 @@ func NewAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper
8793
}
8894
}
8995

96+
func NewPkceAuthSession(config ApiSettings) *AuthSession {
97+
transport := &http.Transport{
98+
TLSClientConfig: &tls.Config{
99+
InsecureSkipVerify: !config.VerifySsl,
100+
},
101+
}
102+
103+
return NewPkceAuthSessionWithTransport(config, transport)
104+
}
105+
106+
// The transport parameter may override your VerifySSL setting
107+
func NewPkceAuthSessionWithTransport(config ApiSettings, transport http.RoundTripper) *AuthSession {
108+
// This transport (Roundtripper) sets
109+
// the "x-looker-appid" Header on requests
110+
appIdHeaderTransport := &transportWithHeaders{
111+
Base: transport,
112+
}
113+
114+
oauthConfig := &oauth2.Config{
115+
ClientID: config.ClientId,
116+
ClientSecret: "", // Public client, no secret
117+
Scopes: []string{"cors_api"},
118+
Endpoint: oauth2.Endpoint{
119+
AuthURL: config.AuthUrl,
120+
TokenURL: config.BaseUrl + "/api/token",
121+
},
122+
RedirectURL: fmt.Sprintf("http://localhost:%d%s", config.RedirectPort, config.RedirectPath),
123+
}
124+
125+
verifier, challenge, err := generatePKCEPair()
126+
if err != nil {
127+
log.Fatalf("Failed to generate PKCE pair: %v", err)
128+
}
129+
130+
state, err := generateSecureRandomString(32)
131+
authURL := oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline,
132+
oauth2.SetAuthURLParam("code_challenge", challenge),
133+
oauth2.SetAuthURLParam("code_challenge_method", "S256"))
134+
135+
authCode, err := startLocalServerAndWaitForCode(authURL, config.RedirectPort, config.RedirectPath)
136+
if err != nil {
137+
log.Fatalf("Authorization failed: %v", err)
138+
}
139+
140+
ctx := context.WithValue(
141+
context.Background(),
142+
oauth2.HTTPClient,
143+
// Will set "x-looker-appid" Header on TokenURL requests
144+
&http.Client{Transport: appIdHeaderTransport},
145+
)
146+
147+
token, err := oauthConfig.Exchange(ctx, authCode,
148+
oauth2.SetAuthURLParam("code_verifier", verifier))
149+
if err != nil {
150+
log.Fatalf("Failed to exchange token: %v", err)
151+
}
152+
153+
// Make use of oauth2 transport to handle token management
154+
oauthTransport := &oauth2.Transport{
155+
Source: oauthConfig.TokenSource(ctx, token),
156+
// Will set "x-looker-appid" Header on all other requests
157+
Base: appIdHeaderTransport,
158+
}
159+
160+
return &AuthSession{
161+
Config: config,
162+
Client: http.Client{Transport: oauthTransport},
163+
}
164+
}
165+
90166
func (s *AuthSession) Do(result interface{}, method, ver, path string, reqPars map[string]interface{}, body interface{}, options *ApiSettings) error {
91167

92168
// prepare URL
@@ -237,3 +313,88 @@ func setQuery(u *url.URL, pars map[string]interface{}) {
237313
}
238314
u.RawQuery = q.Encode()
239315
}
316+
317+
func generatePKCEPair() (string, string, error) {
318+
verifierBytes := make([]byte, 96)
319+
_, err := rand.Read(verifierBytes)
320+
if err != nil {
321+
return "", "", err
322+
}
323+
verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
324+
hasher := sha256.New()
325+
hasher.Write([]byte(verifier))
326+
challenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
327+
return verifier, challenge, nil
328+
}
329+
330+
// --- Local HTTP Server for Redirect ---
331+
func startLocalServerAndWaitForCode(authURL string, redirectPort int64, redirectPath string) (string, error) {
332+
codeChan := make(chan string)
333+
errChan := make(chan error)
334+
335+
mux := http.NewServeMux()
336+
server := &http.Server{Addr: fmt.Sprintf(":%d", redirectPort), Handler: mux}
337+
338+
mux.HandleFunc(redirectPath, func(w http.ResponseWriter, r *http.Request) {
339+
code := r.URL.Query().Get("code")
340+
if code == "" {
341+
errMsg := "authorization failed: no code received"
342+
http.Error(w, errMsg, http.StatusBadRequest)
343+
errChan <- fmt.Errorf(errMsg)
344+
return
345+
}
346+
fmt.Fprintf(w, "Authorization successful! You can close this tab.")
347+
codeChan <- code
348+
go func() {
349+
time.Sleep(1 * time.Second)
350+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
351+
defer cancel()
352+
if err := server.Shutdown(ctx); err != nil {
353+
log.Printf("HTTP server Shutdown error: %v", err)
354+
}
355+
}()
356+
})
357+
358+
go func() {
359+
if err := server.ListenAndServe(); err != http.ErrServerClosed {
360+
errChan <- err
361+
}
362+
}()
363+
364+
openBrowser(authURL)
365+
366+
select {
367+
case code := <-codeChan:
368+
return code, nil
369+
case err := <-errChan:
370+
return "", err
371+
case <-time.After(5 * time.Minute):
372+
return "", fmt.Errorf("timed out waiting for authorization code")
373+
}
374+
}
375+
376+
func generateSecureRandomString(length int) (string, error) {
377+
b := make([]byte, length)
378+
_, err := rand.Read(b)
379+
if err != nil {
380+
return "", err
381+
}
382+
return base64.URLEncoding.EncodeToString(b), nil
383+
}
384+
385+
func openBrowser(url string) {
386+
var err error
387+
switch runtime.GOOS {
388+
case "linux":
389+
err = exec.Command("xdg-open", url).Start()
390+
case "windows":
391+
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
392+
case "darwin":
393+
err = exec.Command("open", url).Start()
394+
default:
395+
err = fmt.Errorf("unsupported platform")
396+
}
397+
if err != nil {
398+
log.Printf("Failed to open browser: %v", err)
399+
}
400+
}

go/rtl/constants.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,7 @@ const (
77
timeoutEnvKey = "LOOKERSDK_TIMEOUT"
88
clientIdEnvKey = "LOOKERSDK_CLIENT_ID"
99
clientSecretEnvKey = "LOOKERSDK_CLIENT_SECRET"
10+
authUrlEnvKey = "LOOKERSDK_AUTH_URL"
11+
redirectPortEnvKey = "LOOKERSDK_REDIRECT_PORT"
12+
redirectPathEnvKey = "LOOKERSDK_REDIRECT_PATH"
1013
)

go/rtl/settings.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ package rtl
22

33
import (
44
"fmt"
5-
"gopkg.in/ini.v1"
65
"os"
76
"strconv"
87
"strings"
8+
9+
"gopkg.in/ini.v1"
910
)
1011

1112
var defaultSectionName string = "Looker"
1213

1314
type ApiSettings struct {
1415
BaseUrl string `ini:"base_url"`
16+
AuthUrl string `ini:"auth_url"`
17+
RedirectPort int64 `ini:"redirect_port"`
18+
RedirectPath string `ini:"redirect_path"`
1519
VerifySsl bool `ini:"verify_ssl"`
1620
Timeout int32 `ini:"timeout"`
1721
AgentTag string `ini:"agent_tag"`
@@ -23,9 +27,11 @@ type ApiSettings struct {
2327
}
2428

2529
var defaultSettings ApiSettings = ApiSettings{
26-
VerifySsl: true,
27-
ApiVersion: "4.0",
28-
Timeout: 120,
30+
VerifySsl: true,
31+
ApiVersion: "4.0",
32+
Timeout: 120,
33+
RedirectPort: 8080,
34+
RedirectPath: "/callback",
2935
}
3036

3137
func NewSettingsFromFile(file string, section *string) (ApiSettings, error) {
@@ -71,6 +77,18 @@ func NewSettingsFromEnv() (ApiSettings, error) {
7177
if v, present := os.LookupEnv(clientSecretEnvKey); present {
7278
settings.ClientSecret = v
7379
}
80+
if v, present := os.LookupEnv(authUrlEnvKey); present {
81+
settings.AuthUrl = v
82+
}
83+
if v, present := os.LookupEnv(redirectPortEnvKey); present {
84+
redirectPort, err := strconv.ParseInt(v, 10, 64)
85+
if err == nil {
86+
settings.RedirectPort = int64(redirectPort)
87+
}
88+
}
89+
if v, present := os.LookupEnv(redirectPathEnvKey); present {
90+
settings.RedirectPath = v
91+
}
7492

7593
return settings, nil
7694
}

go/rtl/settings_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func TestNewSettingsFromFile(t *testing.T) {
2525
},
2626
want: ApiSettings{
2727
BaseUrl: "BaseUrlValue",
28+
RedirectPort: 8080,
29+
RedirectPath: "/callback",
2830
VerifySsl: false,
2931
Timeout: 160,
3032
AgentTag: "AgentTagValue",
@@ -114,6 +116,8 @@ func TestNewSettingsFromEnv(t *testing.T) {
114116
},
115117
want: ApiSettings{
116118
BaseUrl: "url",
119+
RedirectPort: 8080,
120+
RedirectPath: "/callback",
117121
ApiVersion: "5.0",
118122
VerifySsl: false,
119123
Timeout: 360,
@@ -130,6 +134,8 @@ func TestNewSettingsFromEnv(t *testing.T) {
130134
},
131135
want: ApiSettings{
132136
BaseUrl: "url",
137+
RedirectPort: 8080,
138+
RedirectPath: "/callback",
133139
ApiVersion: "4.0",
134140
VerifySsl: true,
135141
Timeout: 120,

0 commit comments

Comments
 (0)