Skip to content
Merged
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
100 changes: 93 additions & 7 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"

"github.com/golang-jwt/jwt/v5"
Expand Down Expand Up @@ -585,6 +587,26 @@ func prepareJWTToken(config *Config) (string, error) {
return tokenString, err
}

// Quality of life features for externalbrowser
var lastFail sync.Map // key -> time.Time (expiry)
const extBrowserBackoffWindow = 60 * time.Second

func normalizeHost(h string) string {
if strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://") {
if u, err := url.Parse(h); err == nil && u != nil && u.Host != "" {
h = u.Host
}
}
if hostOnly, _, err := net.SplitHostPort(h); err == nil {
h = hostOnly
}
return strings.ToLower(h)
}

func extBrowserBackoffKey(host, user string) string {
return normalizeHost(host) + "|" + strings.ToUpper(user)
}

// Authenticate with sc.cfg
func authenticateWithConfig(sc *snowflakeConn) error {
var authData *authResponseMain
Expand All @@ -599,6 +621,8 @@ func authenticateWithConfig(sc *snowflakeConn) error {
}
defer lease.Release()

key := extBrowserBackoffKey(sc.cfg.Host, sc.cfg.User)

if sc.cfg.Authenticator == AuthTypeExternalBrowser || sc.cfg.Authenticator == AuthTypeOAuthAuthorizationCode || sc.cfg.Authenticator == AuthTypeOAuthClientCredentials {
if isCacheSupportedGOOS(runtime.GOOS) && sc.cfg.ClientStoreTemporaryCredential == configBoolNotSet {
sc.cfg.ClientStoreTemporaryCredential = ConfigBoolTrue
Expand Down Expand Up @@ -630,9 +654,20 @@ func authenticateWithConfig(sc *snowflakeConn) error {
}

logger.WithContext(sc.ctx).Infof("Authenticating via %v", sc.cfg.Authenticator.String())

switch sc.cfg.Authenticator {
case AuthTypeExternalBrowser:
if sc.cfg.IDToken == "" {
if value, ok := lastFail.Load(key); ok {
if until := value.(time.Time); time.Now().Before(until) {
sc.cleanup()
return fmt.Errorf(
"External browser sign-in failed recently due to an unrecoverable authentication failure (e.g., IP restriction, IDP error)",
)
}
lastFail.Delete(key)
}

samlResponse, proofKey, err = authenticateByExternalBrowser(
sc.ctx,
lease,
Expand All @@ -657,16 +692,55 @@ func authenticateWithConfig(sc *snowflakeConn) error {
samlResponse,
proofKey)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
sc.cleanup()
return err // do not set backoff for context cancellations
}

var se *SnowflakeError
if errors.As(err, &se) && slices.Contains(refreshOAuthTokenErrorCodes, strconv.Itoa(se.Number)) {

switch {
// Case 1: cached ID token failed -> clear + try one interactive refresh
case sc.cfg.Authenticator == AuthTypeExternalBrowser && sc.cfg.IDToken != "":
credentialsStorage.deleteCredential(lease, newIDTokenSpec(sc.cfg.Host, sc.cfg.User))
sc.cfg.IDToken = ""

// NOTE on tabstorms:
// We intentionally do NOT pre-gate the cached-ID-token fallback with a LoadOrStore
// on lastFail. This leaves a narrow race where multiple goroutines that all
// fail auth with a now-bad cached ID token could concurrently enter the
// interactive External Browser flow before any backoff marker is set.
// In practice this requires near-simultaneous failures across threads and is
// considered low probability and low impact

// reenter interactive flow
samlResponse, proofKey, err = authenticateByExternalBrowser(
sc.ctx, lease, sc.rest, sc.cfg.Authenticator.String(),
sc.cfg.Application, sc.cfg.Account, sc.cfg.User, sc.cfg.Password,
sc.cfg.ExternalBrowserTimeout, sc.cfg.DisableConsoleLogin,
)
if err != nil {
// let SAML-phase failure remain retryable; do not set backoff
sc.cleanup()
return err
}

authData, err = authenticate(sc.ctx, lease, sc, samlResponse, proofKey)
if err == nil {
break
}
fallthrough

// Case 2: still failing, but could be an OAuth refreshable error
case errors.As(err, &se) && slices.Contains(refreshOAuthTokenErrorCodes, strconv.Itoa(se.Number)):
credentialsStorage.deleteCredential(lease, newOAuthAccessTokenSpec(sc.cfg.OauthTokenRequestURL, sc.cfg.User))

if sc.cfg.Authenticator == AuthTypeOAuthAuthorizationCode {
if oauthClient, err := newOauthClient(sc.ctx, sc.cfg); err != nil {
logger.Warnf("failed to create oauth client. %v", err)
if oauthClient, ocErr := newOauthClient(sc.ctx, sc.cfg); ocErr != nil {
logger.Warnf("failed to create oauth client. %v", ocErr)
} else {
if err = oauthClient.refreshToken(lease); err != nil {
logger.Warnf("cannot refresh token. %v", err)
if rfErr := oauthClient.refreshToken(lease); rfErr != nil {
logger.Warnf("cannot refresh token. %v", rfErr)
credentialsStorage.deleteCredential(lease, newOAuthRefreshTokenSpec(sc.cfg.OauthTokenRequestURL, sc.cfg.User))
}
}
Expand All @@ -675,12 +749,24 @@ func authenticateWithConfig(sc *snowflakeConn) error {
// if refreshing succeeds for authorization code, we will take a token from cache
// if it fails, we will just run the full flow
authData, err = authenticate(sc.ctx, lease, sc, nil, nil)
}
if err != nil {
if err == nil {
break
}
fallthrough

// no retry strategies saved the attempt -> record backoff + return
default:
// prevent unrelated auth modes from poisoning the EB backoff
if sc.cfg.Authenticator == AuthTypeExternalBrowser {
lastFail.Store(key, time.Now().Add(extBrowserBackoffWindow))
}
sc.cleanup()
return err
}
}
// success so clear any stale failure marker so future expiry refreshes are allowed
lastFail.Delete(key)

sc.populateSessionParameters(authData.Parameters)
sc.ctx = context.WithValue(sc.ctx, SFSessionIDKey, authData.SessionID)
return nil
Expand Down
Loading