diff --git a/auth/oauth/tokencache.go b/auth/oauth/tokencache.go new file mode 100644 index 0000000..4491a12 --- /dev/null +++ b/auth/oauth/tokencache.go @@ -0,0 +1,133 @@ +package oauth + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" + "golang.org/x/oauth2" +) + +// TokenCache defines the interface for storing and retrieving OAuth tokens +type TokenCache interface { + // Save stores a token in the cache + Save(token *oauth2.Token) error + + // Load retrieves a token from the cache + Load() (*oauth2.Token, error) +} + +// FileTokenCache implements TokenCache using file-based storage +type FileTokenCache struct { + path string +} + +// NewFileTokenCache creates a new file-based token cache +func NewFileTokenCache(path string) *FileTokenCache { + return &FileTokenCache{path: path} +} + +// Save stores a token in the file cache +func (c *FileTokenCache) Save(token *oauth2.Token) error { + // Create parent directories if they don't exist + if err := os.MkdirAll(filepath.Dir(c.path), 0700); err != nil { + return fmt.Errorf("failed to create token cache directory: %w", err) + } + + // Serialize token to JSON + data, err := json.Marshal(token) + if err != nil { + return fmt.Errorf("failed to serialize token: %w", err) + } + + // Write to file with secure permissions + if err := os.WriteFile(c.path, data, 0600); err != nil { + return fmt.Errorf("failed to write token to cache: %w", err) + } + + log.Debug().Str("path", c.path).Msg("Token saved to cache") + return nil +} + +// Load retrieves a token from the file cache +func (c *FileTokenCache) Load() (*oauth2.Token, error) { + data, err := os.ReadFile(c.path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // Cache doesn't exist yet + } + return nil, fmt.Errorf("failed to read token from cache: %w", err) + } + + var token oauth2.Token + if err := json.Unmarshal(data, &token); err != nil { + return nil, fmt.Errorf("failed to deserialize token: %w", err) + } + + log.Debug().Str("path", c.path).Msg("Token loaded from cache") + return &token, nil +} + +// GetCacheFilePath returns the path to the token cache file +func GetCacheFilePath(host, clientID string, scopes []string) string { + // Create SHA-256 hash of host, client_id, and scopes + h := sha256.New() + h.Write([]byte(host)) + h.Write([]byte(clientID)) + h.Write([]byte(strings.Join(scopes, ","))) + hash := hex.EncodeToString(h.Sum(nil)) + + // Get user's home directory + homeDir, err := os.UserHomeDir() + if err != nil { + homeDir = "." + } + + return filepath.Join(homeDir, ".config", "databricks-sql-go", "oauth", hash) +} + +// CachingTokenSource wraps a TokenSource with a TokenCache +type CachingTokenSource struct { + src oauth2.TokenSource + cache TokenCache +} + +// NewCachingTokenSource creates a new TokenSource that caches tokens +func NewCachingTokenSource(src oauth2.TokenSource, cache TokenCache) oauth2.TokenSource { + return &CachingTokenSource{ + src: src, + cache: cache, + } +} + +// Token returns a valid token from either the cache or the underlying source +func (cts *CachingTokenSource) Token() (*oauth2.Token, error) { + // Try to get token from cache first + if cts.cache != nil { + token, err := cts.cache.Load() + if err == nil && token != nil && token.Valid() { + log.Debug().Msg("Using cached token") + return token, nil + } + } + + // Get a new token from the source + token, err := cts.src.Token() + if err != nil { + return nil, err + } + + // Save the token to cache + if cts.cache != nil { + if err := cts.cache.Save(token); err != nil { + log.Warn().Err(err).Msg("Failed to save token to cache") + } + } + + return token, nil +} \ No newline at end of file diff --git a/auth/oauth/tokencache_test.go b/auth/oauth/tokencache_test.go new file mode 100644 index 0000000..6d82665 --- /dev/null +++ b/auth/oauth/tokencache_test.go @@ -0,0 +1,155 @@ +package oauth + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestFileTokenCache(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "token-cache-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + cachePath := filepath.Join(tempDir, "token.json") + cache := NewFileTokenCache(cachePath) + + // Create a test token + expiry := time.Now().Add(1 * time.Hour) + token := &oauth2.Token{ + AccessToken: "test-access-token", + TokenType: "Bearer", + RefreshToken: "test-refresh-token", + Expiry: expiry, + } + + // Test saving the token + err = cache.Save(token) + require.NoError(t, err) + + // Verify the file exists + _, err = os.Stat(cachePath) + assert.NoError(t, err) + + // Test loading the token + loadedToken, err := cache.Load() + require.NoError(t, err) + assert.NotNil(t, loadedToken) + assert.Equal(t, token.AccessToken, loadedToken.AccessToken) + assert.Equal(t, token.TokenType, loadedToken.TokenType) + assert.Equal(t, token.RefreshToken, loadedToken.RefreshToken) + assert.WithinDuration(t, token.Expiry, loadedToken.Expiry, time.Second) + + // Test file permissions + fileInfo, err := os.Stat(cachePath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), fileInfo.Mode().Perm()) +} + +func TestCachingTokenSource(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "token-source-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + cachePath := filepath.Join(tempDir, "token.json") + cache := NewFileTokenCache(cachePath) + + // Create a mock token source + mockSource := &mockTokenSource{ + token: &oauth2.Token{ + AccessToken: "test-access-token", + TokenType: "Bearer", + RefreshToken: "test-refresh-token", + Expiry: time.Now().Add(1 * time.Hour), + }, + } + + // Create a caching token source + cachingSource := NewCachingTokenSource(mockSource, cache) + + // First call should use the mock source + token, err := cachingSource.Token() + require.NoError(t, err) + assert.Equal(t, mockSource.token.AccessToken, token.AccessToken) + assert.Equal(t, 1, mockSource.callCount) + + // Second call should use the cached token + token, err = cachingSource.Token() + require.NoError(t, err) + assert.Equal(t, mockSource.token.AccessToken, token.AccessToken) + assert.Equal(t, 1, mockSource.callCount) // Call count should still be 1 + + // Create a new caching token source with the same cache + cachingSource2 := NewCachingTokenSource(mockSource, cache) + + // This should use the cached token + token, err = cachingSource2.Token() + require.NoError(t, err) + assert.Equal(t, mockSource.token.AccessToken, token.AccessToken) + assert.Equal(t, 1, mockSource.callCount) // Call count should still be 1 + + // Create a token that's expired + mockSource.token = &oauth2.Token{ + AccessToken: "expired-token", + TokenType: "Bearer", + RefreshToken: "test-refresh-token", + Expiry: time.Now().Add(-1 * time.Hour), + } + + // Save the expired token to cache + err = cache.Save(mockSource.token) + require.NoError(t, err) + + // Create a new mock source with a fresh token + freshMockSource := &mockTokenSource{ + token: &oauth2.Token{ + AccessToken: "fresh-token", + TokenType: "Bearer", + RefreshToken: "test-refresh-token", + Expiry: time.Now().Add(1 * time.Hour), + }, + } + + // Create a new caching token source with the expired cache + cachingSource3 := NewCachingTokenSource(freshMockSource, cache) + + // This should detect the expired token and get a fresh one + token, err = cachingSource3.Token() + require.NoError(t, err) + assert.Equal(t, freshMockSource.token.AccessToken, token.AccessToken) + assert.Equal(t, 1, freshMockSource.callCount) // Should have called the fresh source +} + +func TestGetCacheFilePath(t *testing.T) { + host := "test-host.cloud.databricks.com" + clientID := "test-client-id" + scopes := []string{"scope1", "scope2"} + + path1 := GetCacheFilePath(host, clientID, scopes) + path2 := GetCacheFilePath(host, clientID, scopes) + + // Same inputs should produce the same path + assert.Equal(t, path1, path2) + + // Different inputs should produce different paths + path3 := GetCacheFilePath("different-host", clientID, scopes) + assert.NotEqual(t, path1, path3) +} + +// mockTokenSource is a simple implementation of oauth2.TokenSource for testing +type mockTokenSource struct { + token *oauth2.Token + callCount int +} + +func (m *mockTokenSource) Token() (*oauth2.Token, error) { + m.callCount++ + return m.token, nil +} \ No newline at end of file diff --git a/auth/oauth/u2m/authenticator.go b/auth/oauth/u2m/authenticator.go index ba9d4d7..3d9866b 100644 --- a/auth/oauth/u2m/authenticator.go +++ b/auth/oauth/u2m/authenticator.go @@ -35,7 +35,42 @@ const ( gcpRedirectURL = "localhost:8030" ) -func NewAuthenticator(hostName string, timeout time.Duration) (auth.Authenticator, error) { +// AuthOption is a function that configures an authenticator +type AuthOption func(*u2mAuthenticator) + +// WithTokenCache sets a custom token cache +func WithTokenCache(cache oauth.TokenCache) AuthOption { + return func(a *u2mAuthenticator) { + a.tokenCache = cache + } +} + +// WithScopes sets the OAuth scopes +func WithScopes(scopes []string) AuthOption { + return func(a *u2mAuthenticator) { + a.scopes = scopes + } +} + +// DisableTokenCache disables token caching +func DisableTokenCache() AuthOption { + return func(a *u2mAuthenticator) { + a.tokenCache = nil + } +} + +func NewAuthenticator(hostName string, timeout time.Duration, options ...AuthOption) (auth.Authenticator, error) { + // Create the authenticator with default values + auth := &u2mAuthenticator{ + hostName: hostName, + scopes: nil, + useTokenCache: true, + } + + // Apply any options + for _, option := range options { + option(auth) + } cloud := oauth.InferCloudFromHost(hostName) @@ -53,28 +88,52 @@ func NewAuthenticator(hostName string, timeout time.Duration) (auth.Authenticato return nil, errors.New("unhandled cloud type: " + cloud.String()) } + auth.clientID = clientID + + // If token caching is enabled but no cache is provided, create a default one + if auth.useTokenCache && auth.tokenCache == nil { + cachePath := oauth.GetCacheFilePath(hostName, clientID, auth.scopes) + auth.tokenCache = oauth.NewFileTokenCache(cachePath) + log.Debug().Str("path", cachePath).Msg("Created default token cache") + } + // Get an oauth2 config - config, err := GetConfig(context.Background(), hostName, clientID, "", redirectURL, nil) + config, err := GetConfig(context.Background(), hostName, clientID, "", redirectURL, auth.scopes) if err != nil { return nil, fmt.Errorf("unable to generate oauth2.Config: %w", err) } - tsp, err := GetTokenSourceProvider(context.Background(), config, timeout) + // Try to load token from cache first + if auth.tokenCache != nil { + token, err := auth.tokenCache.Load() + if err == nil && token != nil && token.Valid() { + // Create a token source from the cached token + auth.tokenSource = config.TokenSource(context.Background(), token) + log.Debug().Msg("Using cached token for authentication") + } + } + + // Only create token source provider if we don't have a valid token + if auth.tokenSource == nil { + tsp, err := GetTokenSourceProvider(context.Background(), config, timeout) + if err != nil { + return nil, err + } + auth.tsp = tsp + } - return &u2mAuthenticator{ - clientID: clientID, - hostName: hostName, - tsp: tsp, - }, err + return auth, nil } type u2mAuthenticator struct { - clientID string - hostName string - // scopes []string - tokenSource oauth2.TokenSource - tsp *tokenSourceProvider - mx sync.Mutex + clientID string + hostName string + scopes []string + tokenSource oauth2.TokenSource + tsp *tokenSourceProvider + tokenCache oauth.TokenCache + useTokenCache bool + mx sync.Mutex } // Auth will start the OAuth Authorization Flow to authenticate the cli client @@ -82,6 +141,8 @@ type u2mAuthenticator struct { func (c *u2mAuthenticator) Authenticate(r *http.Request) error { c.mx.Lock() defer c.mx.Unlock() + + // If we already have a token source, try to use it if c.tokenSource != nil { token, err := c.tokenSource.Token() if err == nil { @@ -90,24 +151,36 @@ func (c *u2mAuthenticator) Authenticate(r *http.Request) error { } else if !strings.Contains(err.Error(), "invalid_grant") { return err } + + // If we get here, the token is invalid and we need a new one + log.Warn().Err(err).Msg("Token is invalid, obtaining a new one") + } - token.SetAuthHeader(r) - return nil + // If we don't have a token source provider, we can't get a new token + if c.tsp == nil { + return errors.New("no token source provider available") } + // Get a new token source tokenSource, err := c.tsp.GetTokenSource() if err != nil { return fmt.Errorf("unable to get token source: %w", err) } + + // Wrap the token source with caching if enabled + if c.tokenCache != nil { + tokenSource = oauth.NewCachingTokenSource(tokenSource, c.tokenCache) + } + c.tokenSource = tokenSource - + + // Get a token and set the auth header token, err := tokenSource.Token() if err != nil { - return fmt.Errorf("unable to get token source: %w", err) + return fmt.Errorf("unable to get token: %w", err) } - + token.SetAuthHeader(r) - return nil } @@ -271,4 +344,4 @@ func randString(nByte int) (string, error) { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil -} +} \ No newline at end of file diff --git a/connector.go b/connector.go index 982e979..83c4924 100644 --- a/connector.go +++ b/connector.go @@ -10,7 +10,9 @@ import ( "time" "github.com/databricks/databricks-sql-go/auth" + "github.com/databricks/databricks-sql-go/auth/oauth" "github.com/databricks/databricks-sql-go/auth/oauth/m2m" + "github.com/databricks/databricks-sql-go/auth/oauth/u2m" "github.com/databricks/databricks-sql-go/auth/pat" "github.com/databricks/databricks-sql-go/driverctx" dbsqlerr "github.com/databricks/databricks-sql-go/errors" @@ -283,3 +285,48 @@ func WithClientCredentials(clientID, clientSecret string) ConnOption { } } } + +// WithUserAuthentication sets up OAuth User-to-Machine (U2M) authentication +func WithUserAuthentication(timeout time.Duration) ConnOption { + return func(c *config.Config) { + authr, err := u2m.NewAuthenticator(c.Host, timeout) + if err == nil { + c.Authenticator = authr + } else { + logger.Error().Err(err).Msg("Failed to create U2M authenticator") + } + } +} + +// WithUserAuthenticationOptions sets up OAuth User-to-Machine (U2M) authentication with options +func WithUserAuthenticationOptions(timeout time.Duration, options ...u2m.AuthOption) ConnOption { + return func(c *config.Config) { + authr, err := u2m.NewAuthenticator(c.Host, timeout, options...) + if err == nil { + c.Authenticator = authr + } else { + logger.Error().Err(err).Msg("Failed to create U2M authenticator with options") + } + } +} + +// WithTokenCache enables or disables token caching for OAuth authentication +func WithTokenCache(enabled bool) ConnOption { + return func(c *config.Config) { + c.UseTokenCache = enabled + } +} + +// WithTokenCachePath sets a custom path for the OAuth token cache +func WithTokenCachePath(path string) ConnOption { + return func(c *config.Config) { + c.TokenCachePath = path + } +} + +// WithOAuthScopes sets the OAuth scopes for authentication +func WithOAuthScopes(scopes []string) ConnOption { + return func(c *config.Config) { + c.OAuthScopes = scopes + } +} \ No newline at end of file diff --git a/docs/oauth_token_cache.md b/docs/oauth_token_cache.md new file mode 100644 index 0000000..65bb6b5 --- /dev/null +++ b/docs/oauth_token_cache.md @@ -0,0 +1,80 @@ +# OAuth Token Caching in Databricks SQL Go Driver + +This document explains how to use the OAuth token caching feature in the Databricks SQL Go Driver. + +## Overview + +The OAuth token caching feature allows the driver to store OAuth tokens locally, so that users don't need to go through the browser authentication flow every time they connect to Databricks SQL. This is especially useful for: + +- Command-line applications that are frequently run +- Applications that need to reconnect to Databricks SQL after a connection is closed +- Improving user experience by reducing the number of authentication prompts + +## How It Works + +1. When a user authenticates using OAuth U2M (User-to-Machine) authentication, the driver obtains an access token and a refresh token. +2. The tokens are securely stored in a local file in the user's home directory. +3. On subsequent connections, the driver checks if there's a valid cached token. +4. If a valid token exists, it's used directly without requiring browser authentication. +5. If the access token is expired but a refresh token is available, the driver automatically refreshes the access token. +6. If no valid token is found or the refresh fails, the driver falls back to the browser authentication flow. + +## Configuration Options + +### Using the Connector API + +```go +connector, err := dbsql.NewConnector( + // Basic connection parameters + dbsql.WithServerHostname("your-workspace.cloud.databricks.com"), + dbsql.WithPort(443), + dbsql.WithHTTPPath("/sql/1.0/warehouses/your-warehouse-id"), + + // Token cache options + dbsql.WithTokenCache(true), // Enable token caching (default is true) + dbsql.WithTokenCachePath("/path/to/custom/cache"), // Optional: specify a custom path + + // OAuth U2M authentication with options + dbsql.WithUserAuthenticationOptions( + 2*time.Minute, // Timeout for browser authentication + u2m.WithScopes([]string{"offline_access", "sql"}), // Optional: specify scopes + ), +) +``` + +### Using the DSN String + +```go +dsn := "https://your-workspace.cloud.databricks.com:443/sql/1.0/warehouses/your-warehouse-id?authType=oauthU2M&useTokenCache=true&tokenCachePath=/path/to/custom/cache&oauthScopes=offline_access,sql" +db, err := sql.Open("databricks", dsn) +``` + +## Token Cache Location + +By default, tokens are stored in: + +``` +$HOME/.config/databricks-sql-go/oauth/ +``` + +Where `` is a SHA-256 hash of the host, client ID, and scopes, ensuring that different configurations use different cache files. + +## Security Considerations + +- Token cache files are created with permissions that restrict access to the current user only (0600). +- The cache directory is created with permissions that restrict access to the current user only (0700). +- Token cache files contain sensitive information and should be protected accordingly. +- If you're concerned about storing tokens on disk, you can disable token caching with `WithTokenCache(false)`. + +## Troubleshooting + +If you're experiencing issues with token caching: + +1. Check if the token cache file exists in the expected location. +2. Ensure the user running the application has read/write permissions to the cache directory. +3. Try deleting the cache file to force a new authentication flow. +4. Enable debug logging to see more detailed information about token cache operations. + +## Example + +See the `examples/oauth_token_cache/main.go` file for a complete example of using token caching with the Databricks SQL Go Driver. \ No newline at end of file diff --git a/examples/oauth_token_cache/main.go b/examples/oauth_token_cache/main.go new file mode 100644 index 0000000..f5b96a7 --- /dev/null +++ b/examples/oauth_token_cache/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "time" + + dbsql "github.com/databricks/databricks-sql-go" + "github.com/databricks/databricks-sql-go/auth/oauth/u2m" +) + +func main() { + // Example of using the token cache with the Go driver + connector, err := dbsql.NewConnector( + dbsql.WithServerHostname("your-workspace.cloud.databricks.com"), + dbsql.WithPort(443), + dbsql.WithHTTPPath("/sql/1.0/warehouses/your-warehouse-id"), + // Enable token caching (enabled by default) + dbsql.WithTokenCache(true), + // Optional: Specify a custom path for the token cache + // dbsql.WithTokenCachePath("/path/to/custom/token/cache"), + // Set up OAuth U2M authentication with options + dbsql.WithUserAuthenticationOptions( + 2*time.Minute, // Timeout for browser authentication + // Optional: Specify custom scopes + u2m.WithScopes([]string{"offline_access", "sql"}), + ), + ) + if err != nil { + log.Fatal(err) + } + + // Create a database connection + db := sql.OpenDB(connector) + defer db.Close() + + // Test the connection + ctx := context.Background() + if err := db.PingContext(ctx); err != nil { + log.Fatal(err) + } + + // Run a simple query + rows, err := db.QueryContext(ctx, "SELECT current_date()") + if err != nil { + log.Fatal(err) + } + defer rows.Close() + + // Process the results + for rows.Next() { + var currentDate string + if err := rows.Scan(¤tDate); err != nil { + log.Fatal(err) + } + fmt.Printf("Current date: %s\n", currentDate) + } + + if err := rows.Err(); err != nil { + log.Fatal(err) + } + + fmt.Println("Connection successful! The token has been cached for future use.") +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index c6048c0..a59c9e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/databricks-sql-go/auth" "github.com/databricks/databricks-sql-go/auth/noop" + "github.com/databricks/databricks-sql-go/auth/oauth" "github.com/databricks/databricks-sql-go/auth/oauth/m2m" "github.com/databricks/databricks-sql-go/auth/oauth/u2m" "github.com/databricks/databricks-sql-go/auth/pat" @@ -101,6 +102,17 @@ type UserConfig struct { Transport http.RoundTripper UseLz4Compression bool CloudFetchConfig + OAuthConfig +} + +// OAuthConfig contains OAuth-specific configuration options +type OAuthConfig struct { + // UseTokenCache enables or disables token caching + UseTokenCache bool + // TokenCachePath specifies a custom path for the token cache + TokenCachePath string + // OAuthScopes defines the OAuth scopes to request + OAuthScopes []string } // DeepCopy returns a true deep copy of UserConfig @@ -122,6 +134,12 @@ func (ucfg UserConfig) DeepCopy() UserConfig { } + // Copy OAuth scopes + oauthScopes := make([]string, len(ucfg.OAuthScopes)) + if len(ucfg.OAuthScopes) > 0 { + copy(oauthScopes, ucfg.OAuthScopes) + } + return UserConfig{ Protocol: ucfg.Protocol, Host: ucfg.Host, @@ -142,6 +160,11 @@ func (ucfg UserConfig) DeepCopy() UserConfig { Transport: ucfg.Transport, UseLz4Compression: ucfg.UseLz4Compression, CloudFetchConfig: ucfg.CloudFetchConfig, + OAuthConfig: OAuthConfig{ + UseTokenCache: ucfg.UseTokenCache, + TokenCachePath: ucfg.TokenCachePath, + OAuthScopes: oauthScopes, + }, } } @@ -176,6 +199,12 @@ func (ucfg UserConfig) WithDefaults() UserConfig { } ucfg.UseLz4Compression = false ucfg.CloudFetchConfig = CloudFetchConfig{}.WithDefaults() + + // Set default OAuth config + if ucfg.OAuthScopes == nil { + ucfg.OAuthScopes = []string{} + } + ucfg.UseTokenCache = true return ucfg } @@ -257,6 +286,22 @@ func ParseDSN(dsn string) (UserConfig, error) { ucfg.Schema = schema } + // OAuth configuration parameters + if useTokenCache, ok, err := params.extractAsBool("useTokenCache"); ok { + if err != nil { + return UserConfig{}, err + } + ucfg.UseTokenCache = useTokenCache + } + + if tokenCachePath, ok := params.extract("tokenCachePath"); ok { + ucfg.TokenCachePath = tokenCachePath + } + + if oauthScopes, ok := params.extract("oauthScopes"); ok && oauthScopes != "" { + ucfg.OAuthScopes = strings.Split(oauthScopes, ",") + } + // Cloud Fetch parameters if useCloudFetch, ok, err := params.extractAsBool("useCloudFetch"); ok { if err != nil { @@ -363,7 +408,22 @@ func addOauthM2MAuthenticator(clientId, clientSecret string, config *UserConfig) } func addOauthU2MAuthenticator(config *UserConfig) error { - u2m, err := u2m.NewAuthenticator(config.Host, 0) + // Create options for the U2M authenticator + var options []u2m.AuthOption + + // Configure token caching + if !config.UseTokenCache { + options = append(options, u2m.DisableTokenCache()) + } else if config.TokenCachePath != "" { + options = append(options, u2m.WithTokenCache(oauth.NewFileTokenCache(config.TokenCachePath))) + } + + // Add scopes if specified + if len(config.OAuthScopes) > 0 { + options = append(options, u2m.WithScopes(config.OAuthScopes)) + } + + u2m, err := u2m.NewAuthenticator(config.Host, 0, options...) if err == nil { config.Authenticator = u2m } @@ -495,4 +555,4 @@ func (cfg CloudFetchConfig) DeepCopy() CloudFetchConfig { MaxFilesInMemory: cfg.MaxFilesInMemory, MinTimeToExpiry: cfg.MinTimeToExpiry, } -} +} \ No newline at end of file