Repository: snowflakedb/gosnowflake
Pull request #1338: SNOW-1825500 Add browser timeout for OAuth authorization code flow
Author: login: sfc-gh-pfus
Created: 2025-03-20T12:22:20Z | Merged: 2025-03-24T09:56:08Z

### Description

SNOW-1825500 Add browser timeout for OAuth authorization code flow

### Checklist
- [x] Created tests which fail without the change (if possible)
- [x] Extended the README / documentation, if necessary



Diff:
diff --git a/auth_oauth.go b/auth_oauth.go
index 2ba03fc0b..8417989c8 100644
--- a/auth_oauth.go
+++ b/auth_oauth.go
@@ -15,6 +15,7 @@ import (
 	"net/url"
 	"strconv"
 	"strings"
+	"time"
 )
 
 const (
@@ -69,7 +70,25 @@ func newOauthClient(ctx context.Context, cfg *Config) (*oauthClient, error) {
 	}, nil
 }
 
+type oauthBrowserResult struct {
+	accessToken string
+	err         error
+}
+
 func (oauthClient *oauthClient) authenticateByOAuthAuthorizationCode() (string, error) {
+	resultChan := make(chan oauthBrowserResult, 1)
+	go GoroutineWrapper(oauthClient.ctx, func() {
+		resultChan <- oauthClient.doAuthenticateByOAuthAuthorizationCode()
+	})
+	select {
+	case <-time.After(oauthClient.cfg.ExternalBrowserTimeout):
+		return "", errors.New("authentication via browser timed out")
+	case result := <-resultChan:
+		return result.accessToken, result.err
+	}
+}
+
+func (oauthClient *oauthClient) doAuthenticateByOAuthAuthorizationCode() oauthBrowserResult {
 	authCodeProvider := oauthClient.authorizationCodeProviderFactory()
 
 	successChan := make(chan []byte)
@@ -89,7 +108,7 @@ func (oauthClient *oauthClient) authenticateByOAuthAuthorizationCode() (string,
 	tcpListener, callbackPort, err := oauthClient.setupListener()
 	logger.Debugf("opening socket on port %v", callbackPort)
 	if err != nil {
-		return "", err
+		return oauthBrowserResult{"", err}
 	}
 	defer func(tcpListener *net.TCPListener) {
 		<-closeListenerChan
@@ -108,23 +127,24 @@ func (oauthClient *oauthClient) authenticateByOAuthAuthorizationCode() (string,
 	if err = authCodeProvider.run(authorizationURL); err != nil {
 		responseBodyChan <- err.Error()
 		closeListenerChan <- true
-		return "", err
+		return oauthBrowserResult{"", err}
 	}
 
 	err = <-errChan
 	if err != nil {
 		responseBodyChan <- err.Error()
-		return "", err
+		return oauthBrowserResult{"", err}
 	}
 	codeReqBytes := <-successChan
 
 	codeReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(codeReqBytes)))
 	if err != nil {
 		responseBodyChan <- err.Error()
-		return "", err
+		return oauthBrowserResult{"", err}
 	}
 
-	return oauthClient.exchangeAccessToken(codeReq, state, oauth2cfg, codeVerifier, responseBodyChan)
+	accessToken, err := oauthClient.exchangeAccessToken(codeReq, state, oauth2cfg, codeVerifier, responseBodyChan)
+	return oauthBrowserResult{accessToken, err}
 }
 
 func (oauthClient *oauthClient) setupListener() (*net.TCPListener, int, error) {
diff --git a/auth_oauth_test.go b/auth_oauth_test.go
index 7c688bf7b..51e0aaf50 100644
--- a/auth_oauth_test.go
+++ b/auth_oauth_test.go
@@ -13,12 +13,13 @@ import (
 
 func TestUnitOAuthAuthorizationCode(t *testing.T) {
 	client, err := newOauthClient(context.Background(), &Config{
-		Role:                  "ANALYST",
-		OauthClientID:         "testClientId",
-		OauthClientSecret:     "testClientSecret",
-		OauthAuthorizationURL: wiremock.baseURL() + "/oauth/authorize",
-		OauthTokenRequestURL:  wiremock.baseURL() + "/oauth/token",
-		OauthRedirectURI:      "http://localhost:1234/snowflake/oauth-redirect",
+		Role:                   "ANALYST",
+		OauthClientID:          "testClientId",
+		OauthClientSecret:      "testClientSecret",
+		OauthAuthorizationURL:  wiremock.baseURL() + "/oauth/authorize",
+		OauthTokenRequestURL:   wiremock.baseURL() + "/oauth/token",
+		OauthRedirectURI:       "http://localhost:1234/snowflake/oauth-redirect",
+		ExternalBrowserTimeout: defaultExternalBrowserTimeout,
 	})
 	assertNilF(t, err)
 
@@ -85,6 +86,20 @@ func TestUnitOAuthAuthorizationCode(t *testing.T) {
 		time.Sleep(100 * time.Millisecond)
 		authCodeProvider.assertResponseBodyContains("invalid_grant")
 	})
+
+	t.Run("timeout", func(t *testing.T) {
+		wiremock.registerMappings(t, newWiremockMapping("oauth2/authorization_code/successful_flow.json"))
+		client.cfg.ExternalBrowserTimeout = 2 * time.Second
+		authCodeProvider := &nonInteractiveAuthorizationCodeProvider{
+			sleepTime: 3 * time.Second,
+		}
+		client.authorizationCodeProviderFactory = func() authorizationCodeProvider {
+			return authCodeProvider
+		}
+		_, err = client.authenticateByOAuthAuthorizationCode()
+		assertNotNilE(t, err)
+		assertStringContainsE(t, err.Error(), "timed out")
+	})
 }
 
 type nonInteractiveAuthorizationCodeProvider struct {
@@ -93,9 +108,14 @@ type nonInteractiveAuthorizationCodeProvider struct {
 	triggerError    string
 	responseBody    string
 	mu              sync.Mutex
+	sleepTime       time.Duration
 }
 
 func (provider *nonInteractiveAuthorizationCodeProvider) run(authorizationURL string) error {
+	if provider.sleepTime != 0 {
+		time.Sleep(provider.sleepTime)
+		return errors.New("ignore me")
+	}
 	if provider.triggerError != "" {
 		return errors.New(provider.triggerError)
 	}


Reviews:
user: login: sfc-gh-pmotacki
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T13:38:50Z
user: login: sfc-gh-dheyman
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-24T09:12:34Z
user: login: sfc-gh-pfus
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-24T09:53:35Z
user: login: sfc-gh-dheyman
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-24T09:54:35Z

Review comments:
diff_hunk: @@ -69,7 +70,25 @@ func newOauthClient(ctx context.Context, cfg *Config) (*oauthClient, error) {
 	}, nil
 }
 
+type oauthBrowserResult struct {
path: auth_oauth.go
user: login: sfc-gh-dheyman
body: name suggests that it should contain authorization code, which is the actual browser result.
created_at: 2025-03-24T09:10:25Z
author_association: COLLABORATOR
diff_hunk: @@ -69,7 +70,25 @@ func newOauthClient(ctx context.Context, cfg *Config) (*oauthClient, error) {
 	}, nil
 }
 
+type oauthBrowserResult struct {
+	accessToken string
+	err         error
+}
+
 func (oauthClient *oauthClient) authenticateByOAuthAuthorizationCode() (string, error) {
+	resultChan := make(chan oauthBrowserResult, 1)
+	go GoroutineWrapper(oauthClient.ctx, func() {
+		resultChan <- oauthClient.doAuthenticateByOAuthAuthorizationCode()
+	})
+	select {
+	case <-time.After(oauthClient.cfg.ExternalBrowserTimeout):
+		return "", errors.New("authentication via browser timed out")
path: auth_oauth.go
user: login: sfc-gh-dheyman
body: I assume that the server stops in this case?
created_at: 2025-03-24T09:12:15Z
author_association: COLLABORATOR
diff_hunk: @@ -69,7 +70,25 @@ func newOauthClient(ctx context.Context, cfg *Config) (*oauthClient, error) {
 	}, nil
 }
 
+type oauthBrowserResult struct {
path: auth_oauth.go
user: login: sfc-gh-pfus
body: Discussed internally - the browser shows the final authentication result (after token exchange).
created_at: 2025-03-24T09:52:43Z
author_association: COLLABORATOR
diff_hunk: @@ -69,7 +70,25 @@ func newOauthClient(ctx context.Context, cfg *Config) (*oauthClient, error) {
 	}, nil
 }
 
+type oauthBrowserResult struct {
+	accessToken string
+	err         error
+}
+
 func (oauthClient *oauthClient) authenticateByOAuthAuthorizationCode() (string, error) {
+	resultChan := make(chan oauthBrowserResult, 1)
+	go GoroutineWrapper(oauthClient.ctx, func() {
+		resultChan <- oauthClient.doAuthenticateByOAuthAuthorizationCode()
+	})
+	select {
+	case <-time.After(oauthClient.cfg.ExternalBrowserTimeout):
+		return "", errors.New("authentication via browser timed out")
path: auth_oauth.go
user: login: sfc-gh-pfus
body: Yes, it does - it is handled in defer clause.
created_at: 2025-03-24T09:53:32Z
author_association: COLLABORATOR

PR comments:
user: login: sfc-gh-snowflakedb-snyk-sa
created_at: 2025-03-20T12:22:33Z
body: ![](https://res.cloudinary.com/snyk/image/upload/r-d/scm-platform/snyk-pull-requests/pr-banner-default.svg)

### :tada: **Snyk checks have passed. No issues have been found so far.**

:white_check_mark: **security/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/snowflakedb-sca-scanning-public-repo/pr-checks/85058d8a-a24e-4d32-8b2c-bc87e905a3e1?refs=76651fa712179c228e017e4348f53e3c159f5cf6&source=prChecksComment)

:white_check_mark: **license/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/snowflakedb-sca-scanning-public-repo/pr-checks/85058d8a-a24e-4d32-8b2c-bc87e905a3e1/license?refs=76651fa712179c228e017e4348f53e3c159f5cf6&source=prChecksComment)



author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-20T14:11:14Z
body: ## [Codecov](https://app.codecov.io/gh/snowflakedb/gosnowflake/pull/1338?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=snowflakedb) Report
Attention: Patch coverage is `81.25000%` with `3 lines` in your changes missing coverage. Please review.
> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`SNOW-1825500-oauth2@cb667ec`). [Learn more](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=snowflakedb#section-missing-base-commit) about missing BASE report.

| [Files with missing lines](https://app.codecov.io/gh/snowflakedb/gosnowflake/pull/1338?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=snowflakedb) | Patch % | Lines |
|---|---|---|
| [auth\_oauth.go](https://app.codecov.io/gh/snowflakedb/gosnowflake/pull/1338?src=pr&el=tree&filepath=auth_oauth.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=snowflakedb#diff-YXV0aF9vYXV0aC5nbw==) | 81.25% | [3 Missing :warning: ](https://app.codecov.io/gh/snowflakedb/gosnowflake/pull/1338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=snowflakedb) |

<details><summary>Additional details and impacted files</summary>


```diff
@@                  Coverage Diff                   @@
##             SNOW-1825500-oauth2    #1338   +/-   ##
======================================================
  Coverage                       ?   81.86%           
======================================================
  Files                          ?       56           
  Lines                          ?    14042           
  Branches                       ?        0           
======================================================
  Hits                           ?    11496           
  Misses                         ?     2546           
  Partials                       ?        0           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/snowflakedb/gosnowflake/pull/1338?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=snowflakedb).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=snowflakedb).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE