diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index ddd5e0677a..d92a42daa0 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added connection property `OAuthWebServerTimeout` to configure the OAuth browser authentication timeout for U2M (user-to-machine) flows, and also updated hardcoded 1-hour timeout to default 120 seconds timeout. +- Added connection property `EnableOAuthSecretFromPwd` to allow reading the OAuth client secret from the `PWD`/`password` property instead of `OAuth2Secret`. This enables BI tools to mask the secret using their built-in password field handling. When enabled, it will only be read from `PWD`/`password`. ### Updated diff --git a/src/main/java/com/databricks/jdbc/api/impl/DatabricksConnectionContext.java b/src/main/java/com/databricks/jdbc/api/impl/DatabricksConnectionContext.java index c909acf40c..7cf1ca728f 100644 --- a/src/main/java/com/databricks/jdbc/api/impl/DatabricksConnectionContext.java +++ b/src/main/java/com/databricks/jdbc/api/impl/DatabricksConnectionContext.java @@ -340,9 +340,25 @@ public List getOAuthScopesForU2M() throws DatabricksParsingException { @Override public String getClientSecret() { + if (isOAuthSecretFromPwdEnabled()) { + String pwdSecret = + getParameter(DatabricksJdbcUrlParams.PWD, getParameter(DatabricksJdbcUrlParams.PASSWORD)); + if (pwdSecret == null) { + throw new DatabricksDriverException( + "EnableOAuthSecretFromPwd is enabled but no PWD/password property was provided." + + " Set the OAuth client secret via the PWD or password connection property.", + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR); + } + return pwdSecret; + } return getParameter(DatabricksJdbcUrlParams.CLIENT_SECRET); } + @Override + public boolean isOAuthSecretFromPwdEnabled() { + return getParameter(DatabricksJdbcUrlParams.ENABLE_OAUTH_SECRET_FROM_PWD).equals("1"); + } + @Override public String getGoogleServiceAccount() { return getParameter(DatabricksJdbcUrlParams.GOOGLE_SERVICE_ACCOUNT); diff --git a/src/main/java/com/databricks/jdbc/api/internal/IDatabricksConnectionContext.java b/src/main/java/com/databricks/jdbc/api/internal/IDatabricksConnectionContext.java index 5b0fa37585..b517b01042 100644 --- a/src/main/java/com/databricks/jdbc/api/internal/IDatabricksConnectionContext.java +++ b/src/main/java/com/databricks/jdbc/api/internal/IDatabricksConnectionContext.java @@ -468,4 +468,7 @@ public interface IDatabricksConnectionContext { * @return the link prefetch window size (default: 128) */ int getLinkPrefetchWindow(); + + /** Returns whether the driver should read OAuth secret from PWD/password property. */ + boolean isOAuthSecretFromPwdEnabled(); } diff --git a/src/main/java/com/databricks/jdbc/common/DatabricksJdbcUrlParams.java b/src/main/java/com/databricks/jdbc/common/DatabricksJdbcUrlParams.java index 0f043428bc..ac3df450ac 100644 --- a/src/main/java/com/databricks/jdbc/common/DatabricksJdbcUrlParams.java +++ b/src/main/java/com/databricks/jdbc/common/DatabricksJdbcUrlParams.java @@ -219,7 +219,9 @@ public enum DatabricksJdbcUrlParams { NON_ROWCOUNT_QUERY_PREFIXES( "NonRowcountQueryPrefixes", "Comma-separated list of query prefixes (like INSERT,UPDATE,DELETE) that should return result sets instead of row counts", - ""); + ""), + ENABLE_OAUTH_SECRET_FROM_PWD( + "EnableOAuthSecretFromPwd", "Read OAuth secret/token from PWD/password property", "0"); private final String paramName; private final String defaultValue; diff --git a/src/main/java/com/databricks/jdbc/common/util/DatabricksDriverPropertyUtil.java b/src/main/java/com/databricks/jdbc/common/util/DatabricksDriverPropertyUtil.java index 52fad67ca2..068a866883 100644 --- a/src/main/java/com/databricks/jdbc/common/util/DatabricksDriverPropertyUtil.java +++ b/src/main/java/com/databricks/jdbc/common/util/DatabricksDriverPropertyUtil.java @@ -132,7 +132,11 @@ public static List buildMissingPropertiesList( case TOKEN_PASSTHROUGH: if (connectionContext.getOAuthRefreshToken() != null) { addMissingProperty(missingPropertyInfos, connectionContext, CLIENT_ID, true); - addMissingProperty(missingPropertyInfos, connectionContext, CLIENT_SECRET, true); + if (!(connectionContext.isOAuthSecretFromPwdEnabled() + && (connectionContext.isPropertyPresent(PWD) + || connectionContext.isPropertyPresent(PASSWORD)))) { + addMissingProperty(missingPropertyInfos, connectionContext, CLIENT_SECRET, true); + } handleTokenEndpointAndDiscoveryMode(missingPropertyInfos, connectionContext); } else { addMissingProperty( @@ -149,7 +153,11 @@ public static List buildMissingPropertiesList( } else if (connectionContext.getCloud() == Cloud.AZURE) { addMissingProperty(missingPropertyInfos, connectionContext, AZURE_TENANT_ID, false); } - addMissingProperty(missingPropertyInfos, connectionContext, CLIENT_SECRET, true); + if (!(connectionContext.isOAuthSecretFromPwdEnabled() + && (connectionContext.isPropertyPresent(PWD) + || connectionContext.isPropertyPresent(PASSWORD)))) { + addMissingProperty(missingPropertyInfos, connectionContext, CLIENT_SECRET, true); + } addMissingProperty(missingPropertyInfos, connectionContext, CLIENT_ID, true); if (connectionContext.isPropertyPresent(USE_JWT_ASSERTION)) { 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 0aa31e366c..f85bfbfbcd 100644 --- a/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionContextTest.java +++ b/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionContextTest.java @@ -1357,4 +1357,173 @@ public void testOAuthWebServerTimeoutCustom() throws DatabricksSQLException { TestConstants.VALID_URL_1 + ";OAuthWebServerTimeout=300", properties); assertEquals(300, connectionContext.getOAuthWebServerTimeout()); } + + // ===== OAuth Secret from PWD Tests ===== + + private static final String OAUTH_M2M_BASE_URL = + "jdbc:databricks://sample-host.cloud.databricks.com:9999/default;AuthMech=11;Auth_Flow=1;" + + "httpPath=/sql/1.0/warehouses/9999999999999999"; + + @Test + public void testGetClientSecret_WithOAuthSecretFromPwd_ReadsFromPassword() + throws DatabricksSQLException { + String url = OAUTH_M2M_BASE_URL + ";EnableOAuthSecretFromPwd=1"; + Properties props = new Properties(); + props.setProperty("password", "my-oauth-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + assertEquals("my-oauth-secret", ctx.getClientSecret()); + assertTrue(ctx.isOAuthSecretFromPwdEnabled()); + } + + @Test + public void testGetClientSecret_WithOAuthSecretFromPwd_PwdWinsOverExplicitSecret() + throws DatabricksSQLException { + // When feature is enabled, PWD/password always takes precedence over OAuth2Secret + String url = OAUTH_M2M_BASE_URL + ";EnableOAuthSecretFromPwd=1"; + Properties props = new Properties(); + props.setProperty("password", "password-value"); + props.setProperty("OAuth2Secret", "explicit-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + assertEquals("password-value", ctx.getClientSecret()); + } + + @Test + public void testGetClientSecret_WithOAuthSecretFromPwd_PwdParamWinsOverExplicitSecret() + throws DatabricksSQLException { + // Same as above but with pwd param instead of password + String url = OAUTH_M2M_BASE_URL + ";EnableOAuthSecretFromPwd=1"; + Properties props = new Properties(); + props.setProperty("pwd", "pwd-value"); + props.setProperty("OAuth2Secret", "explicit-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + assertEquals("pwd-value", ctx.getClientSecret()); + } + + @Test + public void testGetClientSecret_WithoutFeatureFlag_DoesNotReadPwd() + throws DatabricksSQLException { + Properties props = new Properties(); + props.setProperty("password", "my-oauth-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(OAUTH_M2M_BASE_URL, props); + assertNull(ctx.getClientSecret()); + assertFalse(ctx.isOAuthSecretFromPwdEnabled()); + } + + @Test + public void testGetClientSecret_WithoutFeatureFlag_ReadsExplicitSecret() + throws DatabricksSQLException { + // When feature is disabled, OAuth2Secret is used as normal + Properties props = new Properties(); + props.setProperty("password", "password-value"); + props.setProperty("OAuth2Secret", "explicit-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(OAUTH_M2M_BASE_URL, props); + assertEquals("explicit-secret", ctx.getClientSecret()); + assertFalse(ctx.isOAuthSecretFromPwdEnabled()); + } + + @Test + public void testGetClientSecret_WithOAuthSecretFromPwd_ReadsFromPwdParam() + throws DatabricksSQLException { + String url = OAUTH_M2M_BASE_URL + ";EnableOAuthSecretFromPwd=1"; + Properties props = new Properties(); + props.setProperty("pwd", "pwd-value"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + assertEquals("pwd-value", ctx.getClientSecret()); + } + + @Test + public void testGetClientSecret_WithOAuthSecretFromPwd_NoPwdProvided_ThrowsError() + throws DatabricksSQLException { + // When feature is enabled but no PWD/password is provided, should throw error + String url = OAUTH_M2M_BASE_URL + ";EnableOAuthSecretFromPwd=1"; + Properties props = new Properties(); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + DatabricksDriverException ex = + assertThrows(DatabricksDriverException.class, ctx::getClientSecret); + assertTrue(ex.getMessage().contains("EnableOAuthSecretFromPwd is enabled")); + assertTrue(ex.getMessage().contains("PWD or password")); + } + + @Test + public void testGetClientSecret_WithOAuthSecretFromPwd_ExplicitSecretOnly_NoPwd_ThrowsError() + throws DatabricksSQLException { + // When feature is enabled, OAuth2Secret provided but no PWD — should throw error + // because the feature mandates reading from PWD + String url = OAUTH_M2M_BASE_URL + ";EnableOAuthSecretFromPwd=1"; + Properties props = new Properties(); + props.setProperty("OAuth2Secret", "explicit-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + DatabricksDriverException ex = + assertThrows(DatabricksDriverException.class, ctx::getClientSecret); + assertTrue(ex.getMessage().contains("EnableOAuthSecretFromPwd is enabled")); + } + + @Test + public void testGetClientSecret_FeatureDisabledExplicitly_DoesNotReadPwd() + throws DatabricksSQLException { + // Explicitly set EnableOAuthSecretFromPwd=0 + String url = OAUTH_M2M_BASE_URL + ";EnableOAuthSecretFromPwd=0"; + Properties props = new Properties(); + props.setProperty("password", "my-oauth-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + assertNull(ctx.getClientSecret()); + assertFalse(ctx.isOAuthSecretFromPwdEnabled()); + } + + @Test + public void testGetClientSecret_FeatureEnabled_PasswordInUrl() throws DatabricksSQLException { + // Password provided in the JDBC URL itself (not via Properties) + String url = OAUTH_M2M_BASE_URL + ";EnableOAuthSecretFromPwd=1;pwd=url-secret"; + Properties props = new Properties(); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + assertEquals("url-secret", ctx.getClientSecret()); + } + + @Test + public void testGetClientSecret_FeatureEnabled_BrowserBasedAuth() throws DatabricksSQLException { + // Browser-based auth (Auth_Flow=2) with EnableOAuthSecretFromPwd + String url = + "jdbc:databricks://sample-host.cloud.databricks.com:9999/default;AuthMech=11;Auth_Flow=2;" + + "httpPath=/sql/1.0/warehouses/9999999999999999;EnableOAuthSecretFromPwd=1"; + Properties props = new Properties(); + props.setProperty("password", "browser-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + assertEquals("browser-secret", ctx.getClientSecret()); + } + + @Test + public void testGetClientSecret_FeatureEnabled_RefreshTokenFlow() throws DatabricksSQLException { + // Refresh token flow (Auth_Flow=0) with EnableOAuthSecretFromPwd + String url = + "jdbc:databricks://sample-host.cloud.databricks.com:9999/default;AuthMech=11;Auth_Flow=0;" + + "httpPath=/sql/1.0/warehouses/9999999999999999;EnableOAuthSecretFromPwd=1"; + Properties props = new Properties(); + props.setProperty("password", "refresh-secret"); + + DatabricksConnectionContext ctx = + (DatabricksConnectionContext) DatabricksConnectionContext.parse(url, props); + assertEquals("refresh-secret", ctx.getClientSecret()); + } } diff --git a/src/test/java/com/databricks/jdbc/integration/IntegrationTestUtil.java b/src/test/java/com/databricks/jdbc/integration/IntegrationTestUtil.java index f0ba7dd482..ce701b17e9 100644 --- a/src/test/java/com/databricks/jdbc/integration/IntegrationTestUtil.java +++ b/src/test/java/com/databricks/jdbc/integration/IntegrationTestUtil.java @@ -140,6 +140,14 @@ public static Connection getValidM2MConnection() throws SQLException { return DriverManager.getConnection(getJdbcM2MUrl(), createM2MConnectionProperties()); } + public static Connection getValidM2MConnectionWithSecretFromPwd() throws SQLException { + String url = getJdbcM2MUrl() + ";EnableOAuthSecretFromPwd=1"; + Properties connProps = new Properties(); + connProps.put("OAuth2ClientId", System.getenv("DATABRICKS_JDBC_M2M_CLIENT_ID")); + connProps.put("password", System.getenv("DATABRICKS_JDBC_M2M_CLIENT_SECRET")); + return DriverManager.getConnection(url, connProps); + } + public static Connection getValidSPTokenFedConnection() throws SQLException { return DriverManager.getConnection(getSPTokenFedUrl(), createSPTokenFedConnectionProperties()); } diff --git a/src/test/java/com/databricks/jdbc/integration/e2e/OAuthTests.java b/src/test/java/com/databricks/jdbc/integration/e2e/OAuthTests.java index dd2fbaaec4..7b0ff93700 100644 --- a/src/test/java/com/databricks/jdbc/integration/e2e/OAuthTests.java +++ b/src/test/java/com/databricks/jdbc/integration/e2e/OAuthTests.java @@ -16,6 +16,12 @@ void testM2M() throws SQLException { assertDoesNotThrow(() -> connection.createStatement().execute("select 1")); } + @Test + void testM2MWithSecretFromPwd() throws SQLException { + Connection connection = getValidM2MConnectionWithSecretFromPwd(); + assertDoesNotThrow(() -> connection.createStatement().execute("select 1")); + } + @Test void testPAT() throws SQLException { Properties connectionProperties = new Properties(); diff --git a/src/test/java/com/databricks/jdbc/integration/fakeservice/tests/M2MAuthIntegrationTests.java b/src/test/java/com/databricks/jdbc/integration/fakeservice/tests/M2MAuthIntegrationTests.java index 61f662dc2b..bdd461e158 100644 --- a/src/test/java/com/databricks/jdbc/integration/fakeservice/tests/M2MAuthIntegrationTests.java +++ b/src/test/java/com/databricks/jdbc/integration/fakeservice/tests/M2MAuthIntegrationTests.java @@ -47,6 +47,64 @@ void testIncorrectCredentialsForM2M() { .contains("Connection failure while using the OSS Databricks JDBC driver."); } + @Test + void testSuccessfulM2MConnectionWithSecretFromPwd() throws SQLException { + String url = getFakeServiceM2MUrl() + "EnableOAuthSecretFromPwd=1;"; + Properties connProps = new Properties(); + connProps.put("OAuth2ClientId", TEST_CLIENT_ID); + connProps.put("password", TEST_CLIENT_SECRET); + connProps.put( + DatabricksJdbcUrlParams.CONN_CATALOG.getParamName(), + FakeServiceConfigLoader.getProperty(DatabricksJdbcUrlParams.CONN_CATALOG.getParamName())); + connProps.put( + DatabricksJdbcUrlParams.CONN_SCHEMA.getParamName(), + FakeServiceConfigLoader.getProperty(DatabricksJdbcUrlParams.CONN_SCHEMA.getParamName())); + + Connection conn = DriverManager.getConnection(url, connProps); + assertNotNull(conn); + assertFalse(conn.isClosed()); + conn.close(); + } + + @Test + void testM2MWithSecretFromPwd_PwdWinsOverExplicitSecret() throws SQLException { + // When EnableOAuthSecretFromPwd=1, PWD/password takes precedence over OAuth2Secret. + // Providing the correct secret in password and an invalid one in OAuth2Secret should succeed. + String url = getFakeServiceM2MUrl() + "EnableOAuthSecretFromPwd=1;"; + Properties connProps = new Properties(); + connProps.put("OAuth2ClientId", TEST_CLIENT_ID); + connProps.put("password", TEST_CLIENT_SECRET); + connProps.put("OAuth2Secret", "invalid-should-be-ignored"); + connProps.put( + DatabricksJdbcUrlParams.CONN_CATALOG.getParamName(), + FakeServiceConfigLoader.getProperty(DatabricksJdbcUrlParams.CONN_CATALOG.getParamName())); + connProps.put( + DatabricksJdbcUrlParams.CONN_SCHEMA.getParamName(), + FakeServiceConfigLoader.getProperty(DatabricksJdbcUrlParams.CONN_SCHEMA.getParamName())); + + Connection conn = DriverManager.getConnection(url, connProps); + assertNotNull(conn); + assertFalse(conn.isClosed()); + conn.close(); + } + + @Test + void testM2MWithSecretFromPwd_NoPwdProvided_ThrowsError() { + // EnableOAuthSecretFromPwd=1 but no PWD/password — should fail with validation error + String url = getFakeServiceM2MUrl() + "EnableOAuthSecretFromPwd=1;"; + Properties connProps = new Properties(); + connProps.put("OAuth2ClientId", TEST_CLIENT_ID); + connProps.put( + DatabricksJdbcUrlParams.CONN_CATALOG.getParamName(), + FakeServiceConfigLoader.getProperty(DatabricksJdbcUrlParams.CONN_CATALOG.getParamName())); + connProps.put( + DatabricksJdbcUrlParams.CONN_SCHEMA.getParamName(), + FakeServiceConfigLoader.getProperty(DatabricksJdbcUrlParams.CONN_SCHEMA.getParamName())); + + Exception e = assertThrows(Exception.class, () -> DriverManager.getConnection(url, connProps)); + assertTrue(e.getMessage().contains("EnableOAuthSecretFromPwd is enabled")); + } + private Connection getValidM2MConnection() throws SQLException { return DriverManager.getConnection( getFakeServiceM2MUrl(), createFakeServiceM2MConnectionProperties(TEST_CLIENT_SECRET));