Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
### Fixed

- Fixed `UserAgentEntry` attribution in Query History for SQL Execution API connections.
- Honor `Auth_Scope` for OAuth client-secret M2M; unify scope parsing with JWT-assertion M2M and U2M. (#1706)

---
*Note: When making changes, please add your change under the appropriate section
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ AuthMech=3;UID=token;PWD=<your_token>

Use `AuthMech=11` for OAuth2-based authentication. Several OAuth flows are supported:

For standard client-secret M2M, JWT-assertion M2M, and browser OAuth, set `Auth_Scope` as one
value containing space-separated scopes (for example, `Auth_Scope=sql jobs`).

##### Token Passthrough

Direct use of an existing OAuth token:
Expand All @@ -89,6 +92,8 @@ AuthMech=11;Auth_Flow=1;OAuth2ClientId=<client_id>;OAuth2Secret=<client_secret>
```

Optional parameters:
- `Auth_Scope`: Defaults to `all-apis` in the standard client-secret flow. JWT-assertion M2M
omits the scope parameter when this value is omitted or blank.
- `AzureTenantId`: Azure tenant ID for Azure Databricks (default: null). If enabled, the driver will include refreshed
Azure Active Directory (AAD) Service Principal OAuth tokens with every request.

Expand All @@ -101,6 +106,7 @@ AuthMech=11;Auth_Flow=2
```

Optional parameters:
- `Auth_Scope` - Defaults to `sql offline_access`.
- `OAuth2ClientId` - Client ID for OAuth2 (default: databricks-cli)
- `OAuth2RedirectUrlPort` - Ports for redirect URL (default: 8020)
- `EnableOIDCDiscovery` - Enable OIDC discovery (default: 1)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.databricks.jdbc.api.impl;

import static com.databricks.jdbc.common.DatabricksJdbcConstants.*;
import static com.databricks.jdbc.common.DatabricksJdbcUrlParams.AUTH_SCOPE;
import static com.databricks.jdbc.common.DatabricksJdbcUrlParams.DEFAULT_STRING_COLUMN_LENGTH;
import static com.databricks.jdbc.common.EnvironmentVariables.DEFAULT_ROW_LIMIT_PER_BLOCK;
import static com.databricks.jdbc.common.util.DatabricksAuthUtil.parseOAuthScopes;
import static com.databricks.jdbc.common.util.StringUtil.parseIntegerSet;
import static com.databricks.jdbc.common.util.UserAgentManager.USER_AGENT_SEA_CLIENT;
import static com.databricks.jdbc.common.util.UserAgentManager.USER_AGENT_THRIFT_CLIENT;
Expand Down Expand Up @@ -31,7 +31,6 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.util.*;
import java.util.Collections;
import java.util.regex.Matcher;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand Down Expand Up @@ -384,8 +383,9 @@ private boolean isM2MClientCredentialsMode() {

@Override
public List<String> getOAuthScopesForU2M() throws DatabricksParsingException {
if (getParameter(AUTH_SCOPE) != null) {
return Collections.singletonList(getAuthScope());
List<String> scopes = parseOAuthScopes(getAuthScope());
if (!scopes.isEmpty()) {
return scopes;
}
// Use uniform default scopes for all clouds: sql and offline_access
return Arrays.asList(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,10 @@ public interface IDatabricksConnectionContext {
/**
* Returns the OAuth scopes to request for the user-to-machine (U2M) authorization flow.
*
* <p>If an explicit auth scope is provided via connection parameters, this returns a singleton
* list containing that scope. On AWS and GCP, this returns the SQL scope and offline access
* scope. On Azure, this returns {@code null} because the default scope is set by the Databricks
* SDK.
* <p>If Auth_Scope is provided, this returns its whitespace-separated scopes. Otherwise, this
* returns the SQL and offline access scopes for all clouds.
*
* @return a list of OAuth scopes to request, or {@code null} on Azure to use the SDK default
* @return a list of OAuth scopes to request
* @throws DatabricksParsingException if connection parameters cannot be parsed
*/
List<String> getOAuthScopesForU2M() throws DatabricksParsingException;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ private Token generateToken() {
// Generate new JWT and retrieve token
Map<String, String> params = new HashMap<>();
params.put("grant_type", "client_credentials");
if (scopes != null) {
if (scopes != null && !scopes.isEmpty()) {
params.put("scope", String.join(" ", scopes));
}
params.put("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import com.databricks.sdk.core.HeaderFactory;
import com.databricks.sdk.core.oauth.Token;
import com.google.common.annotations.VisibleForTesting;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.apache.http.HttpHeaders;
Expand Down Expand Up @@ -43,7 +42,7 @@ JwtPrivateKeyClientCredentials getClientCredentialObject(DatabricksConfig config
.withJwtKeyPassphrase(connectionContext.getJWTPassphrase())
.withJwtAlgorithm(connectionContext.getJWTAlgorithm())
.withTokenUrl(tokenEndpoint)
.withScopes(Collections.singletonList(connectionContext.getAuthScope()))
.withScopes(DatabricksAuthUtil.parseOAuthScopes(connectionContext.getAuthScope()))
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,20 @@
import com.nimbusds.jwt.SignedJWT;
import java.io.IOException;
import java.text.ParseException;
import java.util.Arrays;
import java.util.List;

public class DatabricksAuthUtil {
private static final JdbcLogger LOGGER = JdbcLoggerFactory.getLogger(DatabricksAuthUtil.class);

/** Parses the space-separated Auth_Scope value, returning an empty list for blank values. */
public static List<String> parseOAuthScopes(String authScope) {
if (authScope == null || authScope.isBlank()) {
return List.of();
}
return Arrays.asList(authScope.strip().split("\\s+"));
}

public static String getTokenEndpoint(
DatabricksConfig databricksConfig, IDatabricksConnectionContext connectionContext) {
String userProvidedTokenEndpoint = connectionContext.getTokenEndpoint();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static com.databricks.jdbc.common.DatabricksJdbcConstants.*;
import static com.databricks.jdbc.common.util.DatabricksAuthUtil.initializeConfigWithToken;
import static com.databricks.jdbc.common.util.DatabricksAuthUtil.parseOAuthScopes;

import com.databricks.jdbc.api.internal.IDatabricksConnectionContext;
import com.databricks.jdbc.auth.*;
Expand Down Expand Up @@ -390,6 +391,10 @@ public void setupM2MConfig() throws DatabricksParsingException {
.setAuthType(jwtProvider.authType())
.setCredentialsProvider(wrapWithTokenFederationIfEnabled(jwtProvider));
} else {
List<String> scopes = parseOAuthScopes(connectionContext.getAuthScope());
Comment thread
vuanhphung marked this conversation as resolved.
if (!scopes.isEmpty()) {
databricksConfig.setScopes(new ArrayList<>(scopes));
}
CredentialsProvider m2mProvider = new OAuthM2MServicePrincipalCredentialsProvider();
databricksConfig
.setAuthType(DatabricksJdbcConstants.M2M_AUTH_TYPE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,28 @@ public void testParseValid() throws DatabricksSQLException {
assertEquals(M2M_AUTH_TYPE, connectionContext.getGcpAuthType());
}

@Test
public void testU2MOAuthScopesUseSpaceSeparatedAuthScope() throws DatabricksSQLException {
String jdbcUrl =
"jdbc:databricks://sample-host.cloud.databricks.com:443/default;SSL=1;AuthMech=11;"
+ "Auth_Flow=2;httpPath=/sql/1.0/warehouses/99999999;Auth_Scope= sql jobs ";
IDatabricksConnectionContext context =
DatabricksConnectionContext.parse(jdbcUrl, new Properties());

assertEquals(List.of("sql", "jobs"), context.getOAuthScopesForU2M());
}

@Test
public void testU2MBlankAuthScopeUsesDefault() throws DatabricksSQLException {
String jdbcUrl =
"jdbc:databricks://sample-host.cloud.databricks.com:443/default;SSL=1;AuthMech=11;"
+ "Auth_Flow=2;httpPath=/sql/1.0/warehouses/99999999;Auth_Scope= ";
IDatabricksConnectionContext context =
DatabricksConnectionContext.parse(jdbcUrl, new Properties());

assertEquals(List.of("sql", "offline_access"), context.getOAuthScopesForU2M());
}

@Test
public void testEmptySchemaConvertedToNull() throws DatabricksSQLException {
String urlWithEmptySchema =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@
import com.databricks.sdk.core.oauth.OpenIDConnectEndpoints;
import java.io.IOException;
import java.nio.file.Path;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.utils.URLEncodedUtils;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
Expand Down Expand Up @@ -200,23 +204,53 @@ void should_BuildClientCredentialsWithJWTAlgorithm() throws IOException {
}

@Test
void should_HandleNullScope() throws IOException {
void should_NormalizeMultipleScopesInTokenRequest() throws Exception {
assertTokenRequestScope(" sql jobs ", "sql jobs");
}

@Test
void should_OmitBlankScopeFromTokenRequest() throws Exception {
assertTokenRequestScope(" ", null);
}

@Test
void should_HandleNullScope() throws Exception {
assertTokenRequestScope(null, null);
}

private void assertTokenRequestScope(String configuredScope, String expectedScope)
throws Exception {
setup();
when(context.getAuthScope()).thenReturn(null);
when(context.getAuthScope()).thenReturn(configuredScope);

try (MockedStatic<DatabricksHttpClientFactory> factoryMocked =
mockStatic(DatabricksHttpClientFactory.class)) {
DatabricksHttpClientFactory mockFactory = mock(DatabricksHttpClientFactory.class);
factoryMocked.when(DatabricksHttpClientFactory::getInstance).thenReturn(mockFactory);
when(mockFactory.getClient(any())).thenReturn(httpClient);
when(config.getOidcEndpoints()).thenReturn(TEST_OIDC_ENDPOINTS);
org.apache.http.HttpEntity httpEntity = mock(org.apache.http.HttpEntity.class);
org.apache.http.client.methods.CloseableHttpResponse httpResponse =
mock(org.apache.http.client.methods.CloseableHttpResponse.class);
when(httpClient.execute(any())).thenReturn(httpResponse);
when(httpResponse.getEntity()).thenReturn(httpEntity);
when(httpEntity.getContent())
.thenReturn(new java.io.ByteArrayInputStream(TEST_OAUTH_RESPONSE.getBytes()));

PrivateKeyClientCredentialProvider provider =
new PrivateKeyClientCredentialProvider(context, config);

JwtPrivateKeyClientCredentials clientCredentials = provider.getClientCredentialObject(config);

assertEquals(TEST_TOKEN_URL, clientCredentials.getTokenEndpoint());
provider.configure(config).headers();

ArgumentCaptor<HttpUriRequest> requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class);
verify(httpClient).execute(requestCaptor.capture());
HttpPost request = (HttpPost) requestCaptor.getValue();
String actualScope =
URLEncodedUtils.parse(request.getEntity()).stream()
.filter(parameter -> "scope".equals(parameter.getName()))
.map(parameter -> parameter.getValue())
.findFirst()
.orElse(null);
assertEquals(expectedScope, actualScope);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,52 @@ void getWorkspaceClient_OAuthWithClientCredentials_AuthenticatesCorrectly()
assertEquals("client-id", config.getClientId());
assertEquals("client-secret", config.getClientSecret());
assertEquals(DatabricksJdbcConstants.M2M_AUTH_TYPE, config.getAuthType());
assertEquals(List.of("all-apis"), config.getScopes());
}

@Test
void getWorkspaceClient_OAuthWithClientCredentials_UsesConfiguredScope()
throws DatabricksSQLException {
String jdbcUrl =
"jdbc:databricks://sample-host.cloud.databricks.com:443/default;SSL=1;AuthMech=11;"
+ "Auth_Flow=1;httpPath=/sql/1.0/warehouses/99999999;"
+ "OAuth2ClientId=client-id;OAuth2Secret=client-secret;Auth_Scope= sql ";
IDatabricksConnectionContext connectionContext =
DatabricksConnectionContextFactory.create(jdbcUrl, new Properties());
assertEquals(" sql ", connectionContext.getAuthScope());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The new client-secret M2M scope tests assert only databricksConfig.getScopes() (the config field), not that the scope actually reaches the OAuth token request. The JWT-assertion path tests (assertTokenRequestScope) go further and capture the outbound HTTP request's scope parameter via URLEncodedUtils. For client-secret M2M the driver relies on the SDK's OAuthM2MServicePrincipalCredentialsProvider to translate config.setScopes(...) into the token request — which this PR does not exercise (the author notes a live scoped-secret connection was not tested). Consider adding an end-to-end-style assertion that the configured scope is sent on the wire for the client-secret path too, to guard against the SDK silently ignoring the field.

configurator = new ClientConfigurator(connectionContext);

assertEquals(List.of("sql"), configurator.getDatabricksConfig().getScopes());
}

@Test
void getWorkspaceClient_OAuthWithClientCredentials_BlankScopeUsesDefault()
throws DatabricksSQLException {
String jdbcUrl =
"jdbc:databricks://sample-host.cloud.databricks.com:443/default;SSL=1;AuthMech=11;"
+ "Auth_Flow=1;httpPath=/sql/1.0/warehouses/99999999;"
+ "OAuth2ClientId=client-id;OAuth2Secret=client-secret;Auth_Scope=";
IDatabricksConnectionContext connectionContext =
DatabricksConnectionContextFactory.create(jdbcUrl, new Properties());
assertEquals("", connectionContext.getAuthScope());
configurator = new ClientConfigurator(connectionContext);

assertEquals(List.of("all-apis"), configurator.getDatabricksConfig().getScopes());
}

@Test
void getWorkspaceClient_OAuthWithClientCredentials_UsesMultipleScopes()
throws DatabricksSQLException {
String jdbcUrl =
"jdbc:databricks://sample-host.cloud.databricks.com:443/default;SSL=1;AuthMech=11;"
+ "Auth_Flow=1;httpPath=/sql/1.0/warehouses/99999999;"
+ "OAuth2ClientId=client-id;OAuth2Secret=client-secret;Auth_Scope= sql jobs ";
IDatabricksConnectionContext connectionContext =
DatabricksConnectionContextFactory.create(jdbcUrl, new Properties());
assertEquals(" sql jobs ", connectionContext.getAuthScope());
configurator = new ClientConfigurator(connectionContext);

assertEquals(List.of("jobs", "sql"), configurator.getDatabricksConfig().getScopes());
}

@Test
Expand Down
Loading