This provider extends {@code RefreshableTokenSource} to handle token refreshing in a + * standardized way. When a token is obtained after successful authentication, it is encrypted and + * stored locally. On subsequent connection attempts, the provider will: + * + *
This approach minimizes the need for users to repeatedly authenticate through the browser,
+ * improving the user experience while maintaining security through encryption of the cached tokens.
+ */
+public class CachingExternalBrowserCredentialsProvider extends RefreshableTokenSource
+ implements CredentialsProvider {
+
+ private static final String AUTH_TYPE = "external-browser-with-cache";
+ private static final JdbcLogger LOGGER =
+ JdbcLoggerFactory.getLogger(CachingExternalBrowserCredentialsProvider.class);
+ private final TokenCache tokenCache;
+ private final DatabricksConfig config;
+ private final String tokenEndpoint;
+ private HttpClient hc;
+
+ /**
+ * Creates a new CachingExternalBrowserCredentialsProvider with the specified configuration,
+ * connection context, and token cache.
+ *
+ * @param config The Databricks configuration to use for authentication
+ * @param context The connection context containing OAuth configuration parameters
+ * @param tokenCache The token cache to use for storing and retrieving tokens
+ */
+ public CachingExternalBrowserCredentialsProvider(
+ DatabricksConfig config, IDatabricksConnectionContext context, TokenCache tokenCache) {
+ this.config = config;
+ this.tokenCache = tokenCache;
+ this.tokenEndpoint = DatabricksAuthUtil.getTokenEndpoint(config, context);
+ try {
+ // Initialize token from cache
+ this.token = tokenCache.load();
+ if (this.token == null) {
+ LOGGER.debug("No cached token found");
+ // Initialize with an expired token to force authentication on first use
+ this.token =
+ new Token(
+ DatabricksJdbcConstants.EMPTY_STRING,
+ DatabricksJdbcConstants.EMPTY_STRING,
+ null,
+ LocalDateTime.now().minusMinutes(1));
+ } else {
+ LOGGER.debug("Cached token found");
+ }
+ } catch (IOException e) {
+ LOGGER.debug("Failed to load token from cache", e);
+ // Initialize with an expired token to force authentication on first use
+ this.token =
+ new Token(
+ DatabricksJdbcConstants.EMPTY_STRING,
+ DatabricksJdbcConstants.EMPTY_STRING,
+ null,
+ LocalDateTime.now().minusMinutes(1));
+ }
+ }
+
+ /**
+ * Returns the authentication type identifier for this provider.
+ *
+ * @return The string "external-browser-with-cache"
+ */
+ @Override
+ public String authType() {
+ return AUTH_TYPE;
+ }
+
+ /**
+ * Configures the authentication by setting up the necessary headers for authenticated requests.
+ * This method implements the core OAuth flow with caching logic.
+ *
+ * @param config The Databricks configuration to use
+ * @return A HeaderFactory that adds the OAuth authentication header to requests, or null if the
+ * configuration is not valid for this provider
+ */
+ @Override
+ public HeaderFactory configure(DatabricksConfig config) {
+ if (config.getHost() == null || !config.getAuthType().equals(AUTH_TYPE)) {
+ return null;
+ }
+
+ if (this.hc == null) {
+ this.hc = config.getHttpClient();
+ }
+
+ return () -> {
+ Map This class provides functionality to securely store and retrieve OAuth tokens between
+ * application sessions. Tokens are encrypted using AES encryption with a key derived from the
+ * provided passphrase using PBKDF2 key derivation.
+ *
+ * The tokens are stored in a file within the system's temporary directory under a '.databricks'
+ * subdirectory. Each user has their own token cache file identified by the sanitized username.
+ */
+public class TokenCache {
+ private static final String CACHE_DIR = ".databricks";
+ private static final String CACHE_FILE_SUFFIX = ".databricks_jdbc_token_cache";
+ private static final String ALGORITHM = "AES";
+ private static final String SECRET_KEY_ALGORITHM = "PBKDF2WithHmacSHA256";
+ private static final byte[] SALT = "DatabricksTokenCache".getBytes(); // Fixed salt for simplicity
+ private static final int ITERATION_COUNT = 65536;
+ private static final int KEY_LENGTH = 256;
+
+ private final Path cacheFile;
+ private final String passphrase;
+ private final ObjectMapper mapper;
+
+ /**
+ * A serializable version of the Token class that can be serialized/deserialized by Jackson. This
+ * class extends the Token class from the SDK and adds JSON annotations for proper serialization.
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ static class SerializableToken extends Token {
+ public SerializableToken(String accessToken, String tokenType, LocalDateTime expiry) {
+ super(accessToken, tokenType, expiry);
+ }
+
+ public SerializableToken(
+ String accessToken, String tokenType, LocalDateTime expiry, ClockSupplier clockSupplier) {
+ super(accessToken, tokenType, expiry, clockSupplier);
+ }
+
+ @JsonCreator
+ public SerializableToken(
+ @JsonProperty("accessToken") String accessToken,
+ @JsonProperty("tokenType") String tokenType,
+ @JsonProperty("refreshToken") String refreshToken,
+ @JsonProperty("expiry") LocalDateTime expiry) {
+ super(accessToken, tokenType, refreshToken, expiry);
+ }
+
+ public SerializableToken(
+ String accessToken,
+ String tokenType,
+ String refreshToken,
+ LocalDateTime expiry,
+ ClockSupplier clockSupplier) {
+ super(accessToken, tokenType, refreshToken, expiry, clockSupplier);
+ }
+ }
+
+ /**
+ * Constructs a new TokenCache instance with encryption using the user's system username to
+ * identify the cache file.
+ *
+ * @param passphrase The passphrase used to encrypt/decrypt the token cache
+ * @throws IllegalArgumentException if the passphrase is null or empty
+ */
+ public TokenCache(String passphrase) {
+ this(
+ Paths.get(
+ System.getProperty("java.io.tmpdir"),
+ CACHE_DIR,
+ StringUtil.sanitizeUsernameForFile(System.getProperty("user.name"))
+ + CACHE_FILE_SUFFIX),
+ passphrase);
+ }
+
+ /**
+ * Constructs a new TokenCache instance with encryption using the cache file path provided.
+ *
+ * @param cacheFile The cache file path
+ * @param passphrase The passphrase used to encrypt/decrypt the token cache
+ */
+ @VisibleForTesting
+ public TokenCache(Path cacheFile, String passphrase) {
+ if (passphrase == null || passphrase.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Required setting TokenCachePassPhrase has not been provided in connection settings");
+ }
+ this.passphrase = passphrase;
+ this.cacheFile = cacheFile;
+ this.mapper = new ObjectMapper();
+ this.mapper.registerModule(new JavaTimeModule());
+ }
+
+ /**
+ * Saves a token to the cache file, encrypting it with the configured passphrase.
+ *
+ * @param token The token to save to the cache
+ * @throws IOException If an error occurs writing the token to the file or during encryption
+ */
+ public void save(Token token) throws IOException {
+ try {
+ Files.createDirectories(cacheFile.getParent());
+ String json = mapper.writeValueAsString(token);
+ byte[] encrypted = encrypt(json.getBytes());
+ Files.write(cacheFile, encrypted);
+ } catch (Exception e) {
+ throw new IOException("Failed to save token cache: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Loads a token from the cache file, decrypting it with the configured passphrase.
+ *
+ * @return The decrypted token from the cache or null if the cache file doesn't exist
+ * @throws IOException If an error occurs reading the token from the file or during decryption
+ */
+ public Token load() throws IOException {
+ try {
+ if (!Files.exists(cacheFile)) {
+ return null;
+ }
+ byte[] encrypted = Files.readAllBytes(cacheFile);
+ byte[] decrypted = decrypt(encrypted);
+ return mapper.readValue(decrypted, SerializableToken.class);
+ } catch (Exception e) {
+ throw new IOException("Failed to load token cache: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Generates a secret key from the passphrase using PBKDF2 with HMAC-SHA256.
+ *
+ * @return A SecretKey generated from the passphrase
+ * @throws Exception If an error occurs generating the key
+ */
+ private SecretKey generateSecretKey() throws Exception {
+ SecretKeyFactory factory = SecretKeyFactory.getInstance(SECRET_KEY_ALGORITHM);
+ KeySpec spec = new PBEKeySpec(passphrase.toCharArray(), SALT, ITERATION_COUNT, KEY_LENGTH);
+ return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), ALGORITHM);
+ }
+
+ /**
+ * Encrypts the given data using AES encryption with a key derived from the passphrase.
+ *
+ * @param data The data to encrypt
+ * @return The encrypted data, Base64 encoded
+ * @throws Exception If an error occurs during encryption
+ */
+ private byte[] encrypt(byte[] data) throws Exception {
+ Cipher cipher = Cipher.getInstance(ALGORITHM);
+ cipher.init(Cipher.ENCRYPT_MODE, generateSecretKey());
+ return Base64.getEncoder().encode(cipher.doFinal(data));
+ }
+
+ /**
+ * Decrypts the given encrypted data using AES decryption with a key derived from the passphrase.
+ *
+ * @param encryptedData The encrypted data, Base64 encoded
+ * @return The decrypted data
+ * @throws Exception If an error occurs during decryption
+ */
+ private byte[] decrypt(byte[] encryptedData) throws Exception {
+ Cipher cipher = Cipher.getInstance(ALGORITHM);
+ cipher.init(Cipher.DECRYPT_MODE, generateSecretKey());
+ return cipher.doFinal(Base64.getDecoder().decode(encryptedData));
+ }
+}
diff --git a/src/main/java/com/databricks/jdbc/common/DatabricksJdbcUrlParams.java b/src/main/java/com/databricks/jdbc/common/DatabricksJdbcUrlParams.java
index 5ee370514b..0174669a28 100644
--- a/src/main/java/com/databricks/jdbc/common/DatabricksJdbcUrlParams.java
+++ b/src/main/java/com/databricks/jdbc/common/DatabricksJdbcUrlParams.java
@@ -106,7 +106,9 @@ public enum DatabricksJdbcUrlParams {
DEFAULT_STRING_COLUMN_LENGTH(
"DefaultStringColumnLength",
"Maximum number of characters that can be contained in STRING columns",
- "255");
+ "255"),
+ TOKEN_CACHE_PASS_PHRASE("TokenCachePassPhrase", "Pass phrase to use for OAuth U2M Token Cache"),
+ ENABLE_TOKEN_CACHE("EnableTokenCache", "Enable caching OAuth tokens", "1");
private final String paramName;
private final String defaultValue;
diff --git a/src/main/java/com/databricks/jdbc/common/util/StringUtil.java b/src/main/java/com/databricks/jdbc/common/util/StringUtil.java
index e9b7f655d2..3fb1a0bf50 100644
--- a/src/main/java/com/databricks/jdbc/common/util/StringUtil.java
+++ b/src/main/java/com/databricks/jdbc/common/util/StringUtil.java
@@ -59,4 +59,13 @@ public static String getVolumePath(String catalog, String schema, String volume)
// We need to escape '' to prevent SQL injection
return escapeStringLiteral(String.format("/Volumes/%s/%s/%s/", catalog, schema, volume));
}
+
+ /**
+ * Sanitizes the given username so it can be safely used in a file name. Replaces all characters
+ * that are not a-z, A-Z, 0-9, or underscore (_) with an underscore.
+ */
+ public static String sanitizeUsernameForFile(String username) {
+ if (username == null) return "unknown_user";
+ return username.replaceAll("[^a-zA-Z0-9_]", "_");
+ }
}
diff --git a/src/main/java/com/databricks/jdbc/dbclient/impl/common/ClientConfigurator.java b/src/main/java/com/databricks/jdbc/dbclient/impl/common/ClientConfigurator.java
index a71895048f..a6fbb04c54 100644
--- a/src/main/java/com/databricks/jdbc/dbclient/impl/common/ClientConfigurator.java
+++ b/src/main/java/com/databricks/jdbc/dbclient/impl/common/ClientConfigurator.java
@@ -4,9 +4,7 @@
import static com.databricks.jdbc.common.util.DatabricksAuthUtil.initializeConfigWithToken;
import com.databricks.jdbc.api.IDatabricksConnectionContext;
-import com.databricks.jdbc.auth.AzureMSICredentialProvider;
-import com.databricks.jdbc.auth.OAuthRefreshCredentialsProvider;
-import com.databricks.jdbc.auth.PrivateKeyClientCredentialProvider;
+import com.databricks.jdbc.auth.*;
import com.databricks.jdbc.common.AuthMech;
import com.databricks.jdbc.common.DatabricksJdbcConstants;
import com.databricks.jdbc.common.util.DriverUtil;
@@ -20,6 +18,7 @@
import com.databricks.sdk.core.DatabricksException;
import com.databricks.sdk.core.ProxyConfig;
import com.databricks.sdk.core.commons.CommonsHttpClient;
+import com.databricks.sdk.core.oauth.ExternalBrowserCredentialsProvider;
import com.databricks.sdk.core.utils.Cloud;
import java.security.cert.*;
import java.util.Arrays;
@@ -130,7 +129,6 @@ public void setupOAuthConfig() throws DatabricksParsingException {
/** Setup the OAuth U2M authentication settings in the databricks config. */
public void setupU2MConfig() throws DatabricksParsingException {
databricksConfig
- .setAuthType(DatabricksJdbcConstants.U2M_AUTH_TYPE)
.setHost(connectionContext.getHostForOAuth())
.setClientId(connectionContext.getClientId())
.setClientSecret(connectionContext.getClientSecret())
@@ -138,6 +136,20 @@ public void setupU2MConfig() throws DatabricksParsingException {
if (!databricksConfig.isAzure()) {
databricksConfig.setScopes(connectionContext.getOAuthScopesForU2M());
}
+
+ CredentialsProvider provider;
+ if (connectionContext.isTokenCacheEnabled()) {
+ LOGGER.debug("Using CachingExternalBrowserCredentialsProvider as token caching is enabled");
+ TokenCache tokenCache = new TokenCache(connectionContext.getTokenCachePassPhrase());
+ provider =
+ new CachingExternalBrowserCredentialsProvider(
+ databricksConfig, connectionContext, tokenCache);
+ } else {
+ LOGGER.debug("Using ExternalBrowserCredentialsProvider as token caching is disabled");
+ provider = new ExternalBrowserCredentialsProvider();
+ }
+
+ databricksConfig.setCredentialsProvider(provider).setAuthType(provider.authType());
}
/** Setup the PAT authentication settings in the databricks config. */
@@ -162,14 +174,13 @@ public void resetAccessTokenInConfig(String newAccessToken) {
/** Setup the OAuth U2M refresh token authentication settings in the databricks config. */
public void setupU2MRefreshConfig() throws DatabricksParsingException {
- CredentialsProvider provider =
- new OAuthRefreshCredentialsProvider(connectionContext, databricksConfig);
databricksConfig
.setHost(connectionContext.getHostForOAuth())
- .setAuthType(provider.authType()) // oauth-refresh
- .setCredentialsProvider(provider)
.setClientId(connectionContext.getClientId())
.setClientSecret(connectionContext.getClientSecret());
+ CredentialsProvider provider =
+ new OAuthRefreshCredentialsProvider(connectionContext, databricksConfig);
+ databricksConfig.setAuthType(provider.authType()).setCredentialsProvider(provider);
}
/** Setup the OAuth M2M authentication settings in the databricks config. */
diff --git a/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionContextTest.java b/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionContextTest.java
index fb46338f37..9b99db53ba 100644
--- a/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionContextTest.java
+++ b/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionContextTest.java
@@ -441,13 +441,39 @@ public void testParsingOfUrlWithSpecifiedCatalogAndSchema() throws DatabricksSQL
@Test
void testLogLevels() {
- assertEquals(getLogLevel(123), LogLevel.OFF);
- assertEquals(getLogLevel(0), LogLevel.OFF);
- assertEquals(getLogLevel(1), LogLevel.FATAL);
- assertEquals(getLogLevel(2), LogLevel.ERROR);
- assertEquals(getLogLevel(3), LogLevel.WARN);
- assertEquals(getLogLevel(4), LogLevel.INFO);
- assertEquals(getLogLevel(5), LogLevel.DEBUG);
- assertEquals(getLogLevel(6), LogLevel.TRACE);
+ assertEquals(LogLevel.OFF, getLogLevel(0));
+ assertEquals(LogLevel.FATAL, getLogLevel(1));
+ assertEquals(LogLevel.ERROR, getLogLevel(2));
+ assertEquals(LogLevel.WARN, getLogLevel(3));
+ assertEquals(LogLevel.INFO, getLogLevel(4));
+ assertEquals(LogLevel.DEBUG, getLogLevel(5));
+ assertEquals(LogLevel.TRACE, getLogLevel(6));
+ assertEquals(LogLevel.OFF, getLogLevel(123));
+ }
+
+ @Test
+ public void testIsTokenCacheEnabled() throws DatabricksSQLException {
+ // Test with EnableTokenCache=1 (default)
+ Properties properties1 = new Properties();
+ DatabricksConnectionContext connectionContext1 =
+ (DatabricksConnectionContext)
+ DatabricksConnectionContext.parse(TestConstants.VALID_URL_1, properties1);
+ assertTrue(connectionContext1.isTokenCacheEnabled());
+
+ // Test with EnableTokenCache=0
+ Properties properties2 = new Properties();
+ properties2.setProperty("EnableTokenCache", "0");
+ DatabricksConnectionContext connectionContext2 =
+ (DatabricksConnectionContext)
+ DatabricksConnectionContext.parse(TestConstants.VALID_URL_1, properties2);
+ assertFalse(connectionContext2.isTokenCacheEnabled());
+
+ // Test with EnableTokenCache=1 explicitly set
+ Properties properties3 = new Properties();
+ properties3.setProperty("EnableTokenCache", "1");
+ DatabricksConnectionContext connectionContext3 =
+ (DatabricksConnectionContext)
+ DatabricksConnectionContext.parse(TestConstants.VALID_URL_1, properties3);
+ assertTrue(connectionContext3.isTokenCacheEnabled());
}
}
diff --git a/src/test/java/com/databricks/jdbc/auth/CachingExternalBrowserCredentialsProviderTest.java b/src/test/java/com/databricks/jdbc/auth/CachingExternalBrowserCredentialsProviderTest.java
new file mode 100644
index 0000000000..ead75942e5
--- /dev/null
+++ b/src/test/java/com/databricks/jdbc/auth/CachingExternalBrowserCredentialsProviderTest.java
@@ -0,0 +1,201 @@
+package com.databricks.jdbc.auth;
+
+import static com.databricks.jdbc.TestConstants.TEST_AUTH_URL;
+import static com.databricks.jdbc.TestConstants.TEST_TOKEN_URL;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import com.databricks.jdbc.api.IDatabricksConnectionContext;
+import com.databricks.jdbc.common.DatabricksJdbcConstants;
+import com.databricks.sdk.core.DatabricksConfig;
+import com.databricks.sdk.core.DatabricksException;
+import com.databricks.sdk.core.HeaderFactory;
+import com.databricks.sdk.core.oauth.OpenIDConnectEndpoints;
+import com.databricks.sdk.core.oauth.Token;
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.Map;
+import org.apache.http.HttpHeaders;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class CachingExternalBrowserCredentialsProviderTest {
+ private static final String TEST_HOST = "test-host";
+ private static final String AUTH_TYPE = "external-browser-with-cache";
+
+ @Mock private TokenCache tokenCache;
+ @Mock private IDatabricksConnectionContext connectionContext;
+ @Mock private DatabricksConfig config;
+
+ private CachingExternalBrowserCredentialsProvider provider;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ // Set up necessary mocks
+ doReturn(new OpenIDConnectEndpoints(TEST_TOKEN_URL, TEST_AUTH_URL))
+ .when(config)
+ .getOidcEndpoints();
+ }
+
+ @Test
+ void testAuthType() {
+ provider =
+ spy(new CachingExternalBrowserCredentialsProvider(config, connectionContext, tokenCache));
+ assertEquals(AUTH_TYPE, provider.authType());
+ }
+
+ @Test
+ void testConfigureWithInvalidConfig() {
+ DatabricksConfig invalidConfig = new DatabricksConfig().setAuthType("invalid-type");
+ provider =
+ spy(new CachingExternalBrowserCredentialsProvider(config, connectionContext, tokenCache));
+ assertNull(provider.configure(invalidConfig));
+ }
+
+ @Test
+ void testUseValidTokenFromCache() throws IOException {
+ when(config.getHost()).thenReturn(TEST_HOST);
+ when(config.getAuthType()).thenReturn(AUTH_TYPE);
+ // Setup valid token in cache
+ Token validToken =
+ new Token(
+ "cached-token", "Bearer", "cached-refresh-token", LocalDateTime.now().plusHours(1));
+ when(tokenCache.load()).thenReturn(validToken);
+
+ provider =
+ spy(new CachingExternalBrowserCredentialsProvider(config, connectionContext, tokenCache));
+
+ // Should use cached token without any refresh or browser auth
+ HeaderFactory headerFactory = provider.configure(config);
+ assertNotNull(headerFactory);
+
+ Map