github.com/snowflakedb/gosnowflake@v1.9.0/authexternalbrowser.go (about)

     1  // Copyright (c) 2019-2022 Snowflake Computing Inc. All rights reserved.
     2  
     3  package gosnowflake
     4  
     5  import (
     6  	"bytes"
     7  	"context"
     8  	"encoding/base64"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"log"
    14  	"net"
    15  	"net/http"
    16  	"net/url"
    17  	"strconv"
    18  	"strings"
    19  	"time"
    20  
    21  	"github.com/pkg/browser"
    22  )
    23  
    24  const (
    25  	successHTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"/>
    26  <title>SAML Response for Snowflake</title></head>
    27  <body>
    28  Your identity was confirmed and propagated to Snowflake %v.
    29  You can close this window now and go back where you started from.
    30  </body></html>`
    31  )
    32  
    33  const (
    34  	bufSize = 8192
    35  )
    36  
    37  // Builds a response to show to the user after successfully
    38  // getting a response from Snowflake.
    39  func buildResponse(application string) bytes.Buffer {
    40  	body := fmt.Sprintf(successHTML, application)
    41  	t := &http.Response{
    42  		Status:        "200 OK",
    43  		StatusCode:    200,
    44  		Proto:         "HTTP/1.1",
    45  		ProtoMajor:    1,
    46  		ProtoMinor:    1,
    47  		Body:          io.NopCloser(bytes.NewBufferString(body)),
    48  		ContentLength: int64(len(body)),
    49  		Request:       nil,
    50  		Header:        make(http.Header),
    51  	}
    52  	var b bytes.Buffer
    53  	t.Write(&b)
    54  	return b
    55  }
    56  
    57  // This opens a socket that listens on all available unicast
    58  // and any anycast IP addresses locally. By specifying "0", we are
    59  // able to bind to a free port.
    60  func createLocalTCPListener() (*net.TCPListener, error) {
    61  	l, err := net.Listen("tcp", "localhost:0")
    62  	if err != nil {
    63  		return nil, err
    64  	}
    65  
    66  	tcpListener, ok := l.(*net.TCPListener)
    67  	if !ok {
    68  		return nil, fmt.Errorf("failed to assert type as *net.TCPListener")
    69  	}
    70  
    71  	return tcpListener, nil
    72  }
    73  
    74  // Opens a browser window (or new tab) with the configured login Url.
    75  // This can / will fail if running inside a shell with no display, ie
    76  // ssh'ing into a box attempting to authenticate via external browser.
    77  func openBrowser(loginURL string) error {
    78  	err := browser.OpenURL(loginURL)
    79  	if err != nil {
    80  		logger.Infof("failed to open a browser. err: %v", err)
    81  		return err
    82  	}
    83  	return nil
    84  }
    85  
    86  // Gets the IDP Url and Proof Key from Snowflake.
    87  // Note: FuncPostAuthSaml will return a fully qualified error if
    88  // there is something wrong getting data from Snowflake.
    89  func getIdpURLProofKey(
    90  	ctx context.Context,
    91  	sr *snowflakeRestful,
    92  	authenticator string,
    93  	application string,
    94  	account string,
    95  	user string,
    96  	callbackPort int) (string, string, error) {
    97  
    98  	headers := make(map[string]string)
    99  	headers[httpHeaderContentType] = headerContentTypeApplicationJSON
   100  	headers[httpHeaderAccept] = headerContentTypeApplicationJSON
   101  	headers[httpHeaderUserAgent] = userAgent
   102  
   103  	clientEnvironment := authRequestClientEnvironment{
   104  		Application: application,
   105  		Os:          operatingSystem,
   106  		OsVersion:   platform,
   107  	}
   108  
   109  	requestMain := authRequestData{
   110  		ClientAppID:             clientType,
   111  		ClientAppVersion:        SnowflakeGoDriverVersion,
   112  		AccountName:             account,
   113  		LoginName:               user,
   114  		ClientEnvironment:       clientEnvironment,
   115  		Authenticator:           authenticator,
   116  		BrowserModeRedirectPort: strconv.Itoa(callbackPort),
   117  	}
   118  
   119  	authRequest := authRequest{
   120  		Data: requestMain,
   121  	}
   122  
   123  	jsonBody, err := json.Marshal(authRequest)
   124  	if err != nil {
   125  		logger.WithContext(ctx).Errorf("failed to serialize json. err: %v", err)
   126  		return "", "", err
   127  	}
   128  
   129  	respd, err := sr.FuncPostAuthSAML(ctx, sr, headers, jsonBody, sr.LoginTimeout)
   130  	if err != nil {
   131  		return "", "", err
   132  	}
   133  	if !respd.Success {
   134  		logger.Errorln("Authentication FAILED")
   135  		sr.TokenAccessor.SetTokens("", "", -1)
   136  		code, err := strconv.Atoi(respd.Code)
   137  		if err != nil {
   138  			code = -1
   139  			return "", "", err
   140  		}
   141  		return "", "", &SnowflakeError{
   142  			Number:   code,
   143  			SQLState: SQLStateConnectionRejected,
   144  			Message:  respd.Message,
   145  		}
   146  	}
   147  	return respd.Data.SSOURL, respd.Data.ProofKey, nil
   148  }
   149  
   150  // Gets the login URL for multiple SAML
   151  func getLoginURL(sr *snowflakeRestful, user string, callbackPort int) (string, string, error) {
   152  	proofKey := generateProofKey()
   153  
   154  	params := &url.Values{}
   155  	params.Add("login_name", user)
   156  	params.Add("browser_mode_redirect_port", strconv.Itoa(callbackPort))
   157  	params.Add("proof_key", proofKey)
   158  	url := sr.getFullURL(consoleLoginRequestPath, params)
   159  
   160  	return url.String(), proofKey, nil
   161  }
   162  
   163  func generateProofKey() string {
   164  	randomness := getSecureRandom(32)
   165  	return base64.StdEncoding.WithPadding(base64.StdPadding).EncodeToString(randomness)
   166  }
   167  
   168  // The response returned from Snowflake looks like so:
   169  // GET /?token=encodedSamlToken
   170  // Host: localhost:54001
   171  // Connection: keep-alive
   172  // Upgrade-Insecure-Requests: 1
   173  // User-Agent: userAgentStr
   174  // Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
   175  // Referer: https://myaccount.snowflakecomputing.com/fed/login
   176  // Accept-Encoding: gzip, deflate, br
   177  // Accept-Language: en-US,en;q=0.9
   178  // This extracts the token portion of the response.
   179  func getTokenFromResponse(response string) (string, error) {
   180  	start := "GET /?token="
   181  	arr := strings.Split(response, "\r\n")
   182  	if !strings.HasPrefix(arr[0], start) {
   183  		logger.Errorf("response is malformed. ")
   184  		return "", &SnowflakeError{
   185  			Number:      ErrFailedToParseResponse,
   186  			SQLState:    SQLStateConnectionRejected,
   187  			Message:     errMsgFailedToParseResponse,
   188  			MessageArgs: []interface{}{response},
   189  		}
   190  	}
   191  	token := strings.TrimPrefix(arr[0], start)
   192  	token = strings.Split(token, " ")[0]
   193  	return token, nil
   194  }
   195  
   196  type authenticateByExternalBrowserResult struct {
   197  	escapedSamlResponse []byte
   198  	proofKey            []byte
   199  	err                 error
   200  }
   201  
   202  func authenticateByExternalBrowser(
   203  	ctx context.Context,
   204  	sr *snowflakeRestful,
   205  	authenticator string,
   206  	application string,
   207  	account string,
   208  	user string,
   209  	password string,
   210  	externalBrowserTimeout time.Duration,
   211  	disableConsoleLogin ConfigBool,
   212  ) ([]byte, []byte, error) {
   213  	resultChan := make(chan authenticateByExternalBrowserResult, 1)
   214  	go func() {
   215  		resultChan <- doAuthenticateByExternalBrowser(ctx, sr, authenticator, application, account, user, password, disableConsoleLogin)
   216  	}()
   217  	select {
   218  	case <-time.After(externalBrowserTimeout):
   219  		return nil, nil, errors.New("authentication timed out")
   220  	case result := <-resultChan:
   221  		return result.escapedSamlResponse, result.proofKey, result.err
   222  	}
   223  }
   224  
   225  // Authentication by an external browser takes place via the following:
   226  //   - the golang snowflake driver communicates to Snowflake that the user wishes to
   227  //     authenticate via external browser
   228  //   - snowflake sends back the IDP Url configured at the Snowflake side for the
   229  //     provided account, or use the multiple SAML way via console login
   230  //   - the default browser is opened to that URL
   231  //   - user authenticates at the IDP, and is redirected to Snowflake
   232  //   - Snowflake directs the user back to the driver
   233  //   - authenticate is complete!
   234  func doAuthenticateByExternalBrowser(
   235  	ctx context.Context,
   236  	sr *snowflakeRestful,
   237  	authenticator string,
   238  	application string,
   239  	account string,
   240  	user string,
   241  	password string,
   242  	disableConsoleLogin ConfigBool,
   243  ) authenticateByExternalBrowserResult {
   244  	l, err := createLocalTCPListener()
   245  	if err != nil {
   246  		return authenticateByExternalBrowserResult{nil, nil, err}
   247  	}
   248  	defer l.Close()
   249  
   250  	callbackPort := l.Addr().(*net.TCPAddr).Port
   251  
   252  	var loginURL string
   253  	var proofKey string
   254  	if disableConsoleLogin == ConfigBoolTrue {
   255  		// Gets the IDP URL and Proof Key from Snowflake
   256  		loginURL, proofKey, err = getIdpURLProofKey(ctx, sr, authenticator, application, account, user, callbackPort)
   257  	} else {
   258  		// Multiple SAML way to do authentication via console login
   259  		loginURL, proofKey, err = getLoginURL(sr, user, callbackPort)
   260  	}
   261  
   262  	if err != nil {
   263  		return authenticateByExternalBrowserResult{nil, nil, err}
   264  	}
   265  
   266  	if err = openBrowser(loginURL); err != nil {
   267  		return authenticateByExternalBrowserResult{nil, nil, err}
   268  	}
   269  
   270  	encodedSamlResponseChan := make(chan string)
   271  	errChan := make(chan error)
   272  
   273  	var encodedSamlResponse string
   274  	var errFromGoroutine error
   275  	conn, err := l.Accept()
   276  	if err != nil {
   277  		logger.WithContext(ctx).Errorf("unable to accept connection. err: %v", err)
   278  		log.Fatal(err)
   279  	}
   280  	go func(c net.Conn) {
   281  		var buf bytes.Buffer
   282  		total := 0
   283  		encodedSamlResponse := ""
   284  		var errAccept error
   285  		for {
   286  			b := make([]byte, bufSize)
   287  			n, err := c.Read(b)
   288  			if err != nil {
   289  				if err != io.EOF {
   290  					logger.Infof("error reading from socket. err: %v", err)
   291  					errAccept = &SnowflakeError{
   292  						Number:      ErrFailedToGetExternalBrowserResponse,
   293  						SQLState:    SQLStateConnectionRejected,
   294  						Message:     errMsgFailedToGetExternalBrowserResponse,
   295  						MessageArgs: []interface{}{err},
   296  					}
   297  				}
   298  				break
   299  			}
   300  			total += n
   301  			buf.Write(b)
   302  			if n < bufSize {
   303  				// We successfully read all data
   304  				s := string(buf.Bytes()[:total])
   305  				encodedSamlResponse, errAccept = getTokenFromResponse(s)
   306  				break
   307  			}
   308  			buf.Grow(bufSize)
   309  		}
   310  		if encodedSamlResponse != "" {
   311  			httpResponse := buildResponse(application)
   312  			c.Write(httpResponse.Bytes())
   313  		}
   314  		c.Close()
   315  		encodedSamlResponseChan <- encodedSamlResponse
   316  		errChan <- errAccept
   317  	}(conn)
   318  
   319  	encodedSamlResponse = <-encodedSamlResponseChan
   320  	errFromGoroutine = <-errChan
   321  
   322  	if errFromGoroutine != nil {
   323  		return authenticateByExternalBrowserResult{nil, nil, errFromGoroutine}
   324  	}
   325  
   326  	escapedSamlResponse, err := url.QueryUnescape(encodedSamlResponse)
   327  	if err != nil {
   328  		logger.WithContext(ctx).Errorf("unable to unescape saml response. err: %v", err)
   329  		return authenticateByExternalBrowserResult{nil, nil, err}
   330  	}
   331  	return authenticateByExternalBrowserResult{[]byte(escapedSamlResponse), []byte(proofKey), nil}
   332  }