From ee789e51fcfba5c26327adbcb15b3266c160870e Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Fri, 13 Feb 2026 13:56:07 +0000 Subject: [PATCH 1/5] error if scopes set explicitly with databricks-cli auth --- .../DatabricksCliCredentialsProvider.java | 8 ++++ .../databricks/sdk/core/DatabricksConfig.java | 11 +++++ .../DatabricksCliCredentialsProviderTest.java | 47 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java index 7fc505583..36b247957 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java @@ -15,6 +15,10 @@ public class DatabricksCliCredentialsProvider implements CredentialsProvider { public static final String DATABRICKS_CLI = "databricks-cli"; + static final String ERR_CUSTOM_SCOPES_NOT_SUPPORTED = + "custom scopes are not supported with databricks-cli auth; " + + "scopes are determined by what was last used when logging in with `databricks auth login`"; + @Override public String authType() { return DATABRICKS_CLI; @@ -74,6 +78,10 @@ public OAuthHeaderFactory configure(DatabricksConfig config) { return null; } + if (config.isScopesExplicitlySet()) { + throw new DatabricksException(ERR_CUSTOM_SCOPES_NOT_SUPPORTED); + } + CachedTokenSource cachedTokenSource = new CachedTokenSource.Builder(tokenSource) .setAsyncDisabled(config.getDisableAsyncTokenRefresh()) diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java index 84470959f..51fb6eb1c 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java @@ -50,6 +50,12 @@ public class DatabricksConfig { @ConfigAttribute(auth = "oauth") private List scopes; + // Temporary field to track if scopes were explicitly set by the user. + // This is used to ensure users don't set explicit scopes when using + // `databricks-cli` auth, as it does not respect the scopes. + // TODO: Remove this field once the `auth token` command supports scopes. + private boolean scopesExplicitlySet = false; + @ConfigAttribute(env = "DATABRICKS_REDIRECT_URL", auth = "oauth") private String redirectUrl; @@ -430,9 +436,14 @@ public List getScopes() { public DatabricksConfig setScopes(List scopes) { this.scopes = scopes; + this.scopesExplicitlySet = true; return this; } + public boolean isScopesExplicitlySet() { + return scopesExplicitlySet; + } + public String getProfile() { return profile; } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java index 478948d82..770a1ba66 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java @@ -1,5 +1,6 @@ package com.databricks.sdk.core; +import static com.databricks.sdk.core.DatabricksCliCredentialsProvider.ERR_CUSTOM_SCOPES_NOT_SUPPORTED; import static org.junit.jupiter.api.Assertions.*; import java.util.Arrays; @@ -139,4 +140,50 @@ void testBuildCliCommand_UnifiedHostFalse_WithAccountHost() { CLI_PATH, "auth", "token", "--host", ACCOUNT_HOST, "--account-id", ACCOUNT_ID), cmd); } + + @Test + void testConfigure_ErrorsWhenScopesExplicitlySet() { + DatabricksConfig config = + new DatabricksConfig() + .setHost(HOST) + .setDatabricksCliPath(CLI_PATH) + .setScopes(Arrays.asList("sql")); + + DatabricksException e = + assertThrows(DatabricksException.class, () -> provider.configure(config)); + assertEquals(ERR_CUSTOM_SCOPES_NOT_SUPPORTED, e.getMessage()); + } + + @Test + void testConfigure_SkipsWhenCliNotFoundEvenWithScopes() { + // When CLI is not available, the provider should return null (skip) + // rather than throwing an error about scopes. + DatabricksConfig config = + new DatabricksConfig() + .setHost(HOST) + .setScopes(Arrays.asList("sql")); + + assertNull(provider.configure(config)); + } + + @Test + void testConfigure_NoErrorWhenNoScopes() { + DatabricksConfig config = new DatabricksConfig().setHost(HOST); + + try { + provider.configure(config); + } catch (Exception e) { + // May fail for other reasons (CLI not found, env not set), but must not be the scope error + assertNotEquals(ERR_CUSTOM_SCOPES_NOT_SUPPORTED, e.getMessage()); + } + } + + @Test + void testScopesExplicitlySetFlag() { + DatabricksConfig config = new DatabricksConfig(); + assertFalse(config.isScopesExplicitlySet()); + + config.setScopes(Arrays.asList("sql", "clusters")); + assertTrue(config.isScopesExplicitlySet()); + } } From 2a1667e808344bdf35e073b82c011efb6f1c8281 Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Thu, 5 Mar 2026 06:23:38 +0000 Subject: [PATCH 2/5] Validate Databricks CLI token scopes against SDK configuration Detects when a cached Databricks CLI token was issued with different OAuth scopes than what the SDK configuration requires. Surfaces an actionable error telling the user how to re-authenticate instead of silently making requests with the wrong scopes. Co-Authored-By: Claude Opus 4.6 --- .../DatabricksCliCredentialsProvider.java | 127 +++++++++++++++-- .../databricks/sdk/core/DatabricksConfig.java | 14 +- .../DatabricksCliCredentialsProviderTest.java | 38 ----- .../DatabricksCliScopeValidationTest.java | 133 ++++++++++++++++++ 4 files changed, 258 insertions(+), 54 deletions(-) create mode 100644 databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java index e123fa46e..f5f03a7ad 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java @@ -2,8 +2,12 @@ import com.databricks.sdk.core.oauth.CachedTokenSource; import com.databricks.sdk.core.oauth.OAuthHeaderFactory; +import com.databricks.sdk.core.oauth.Token; import com.databricks.sdk.core.utils.OSUtils; import com.databricks.sdk.support.InternalApi; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; import java.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,9 +19,14 @@ public class DatabricksCliCredentialsProvider implements CredentialsProvider { public static final String DATABRICKS_CLI = "databricks-cli"; - static final String ERR_CUSTOM_SCOPES_NOT_SUPPORTED = - "custom scopes are not supported with databricks-cli auth; " - + "scopes are determined by what was last used when logging in with `databricks auth login`"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * offline_access controls whether the IdP issues a refresh token. It does not grant any API + * permissions, so its presence or absence should not cause a scope mismatch error. + */ + private static final Set SCOPES_IGNORED_FOR_COMPARISON = + Collections.singleton("offline_access"); @Override public String authType() { @@ -96,15 +105,16 @@ public OAuthHeaderFactory configure(DatabricksConfig config) { return null; } - if (config.isScopesExplicitlySet()) { - throw new DatabricksException(ERR_CUSTOM_SCOPES_NOT_SUPPORTED); - } - CachedTokenSource cachedTokenSource = new CachedTokenSource.Builder(tokenSource) .setAsyncDisabled(config.getDisableAsyncTokenRefresh()) .build(); - cachedTokenSource.getToken(); // We need this for checking if databricks CLI is installed. + Token token = + cachedTokenSource.getToken(); // We need this for checking if databricks CLI is installed. + + if (config.isScopesExplicitlySet()) { + validateTokenScopes(token, config.getScopes(), config.getHost()); + } return OAuthHeaderFactory.fromTokenSource(cachedTokenSource); } catch (DatabricksException e) { @@ -117,7 +127,108 @@ public OAuthHeaderFactory configure(DatabricksConfig config) { LOG.info("OAuth not configured or not available"); return null; } + // Scope validation failed. When the user explicitly selected databricks-cli auth, + // surface the mismatch immediately so they get an actionable error. When we're being + // tried as part of the default credential chain, step aside so other providers get + // a chance. + if (stderr.contains("do not match the configured scopes")) { + if (DATABRICKS_CLI.equals(config.getAuthType())) { + throw e; + } + LOG.warn("Databricks CLI token scope mismatch, skipping: {}", e.getMessage()); + return null; + } throw e; } } + + /** + * Validate that the token's scopes match the requested scopes from the config. + * + *

The {@code databricks auth token} command does not accept scopes yet. It returns whatever + * token was cached from the last {@code databricks auth login}. If a user configures specific + * scopes in the SDK config but their cached CLI token was issued with different scopes, requests + * will silently use the wrong scopes. This check surfaces that mismatch early with an actionable + * error telling the user how to re-authenticate with the correct scopes. + */ + static void validateTokenScopes(Token token, List requestedScopes, String host) { + Map claims = getJwtClaims(token.getAccessToken()); + if (claims == null) { + LOG.debug("Could not decode token as JWT to validate scopes"); + return; + } + + Object tokenScopesRaw = claims.get("scope"); + if (tokenScopesRaw == null) { + LOG.debug("Token does not contain 'scope' claim, skipping scope validation"); + return; + } + + Set tokenScopes = parseScopeClaim(tokenScopesRaw); + if (tokenScopes == null) { + LOG.debug("Unexpected 'scope' claim type: {}", tokenScopesRaw.getClass()); + return; + } + + tokenScopes.removeAll(SCOPES_IGNORED_FOR_COMPARISON); + Set requested = new HashSet<>(requestedScopes); + requested.removeAll(SCOPES_IGNORED_FOR_COMPARISON); + + if (!tokenScopes.equals(requested)) { + List sortedTokenScopes = new ArrayList<>(tokenScopes); + Collections.sort(sortedTokenScopes); + List sortedRequested = new ArrayList<>(requested); + Collections.sort(sortedRequested); + + // Build a re-auth command hint with scopes (excluding offline_access) + String scopesArg = String.join(",", sortedRequested); + + throw new DatabricksException( + String.format( + "Token issued by Databricks CLI has scopes %s which do not match " + + "the configured scopes %s. Please re-authenticate with the desired scopes " + + "by running `databricks auth login --host %s --scopes %s`.", + sortedTokenScopes, sortedRequested, host, scopesArg)); + } + } + + /** + * Decode a JWT access token and return its payload claims. Returns null if the token is not a + * valid JWT. + */ + private static Map getJwtClaims(String accessToken) { + try { + String[] parts = accessToken.split("\\."); + if (parts.length != 3) { + LOG.debug( + "Tried to decode access token as JWT, but failed: {} components", parts.length); + return null; + } + byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]); + String payloadJson = new String(payloadBytes, StandardCharsets.UTF_8); + @SuppressWarnings("unchecked") + Map claims = MAPPER.readValue(payloadJson, Map.class); + return claims; + } catch (Exception e) { + LOG.debug("Failed to decode JWT claims: {}", e.getMessage()); + return null; + } + } + + /** + * Parse the JWT "scope" claim, which can be either a space-delimited string or a JSON array. + * Returns null if the type is unexpected. + */ + private static Set parseScopeClaim(Object scopeClaim) { + if (scopeClaim instanceof String) { + return new HashSet<>(Arrays.asList(((String) scopeClaim).split("\\s+"))); + } else if (scopeClaim instanceof List) { + Set scopes = new HashSet<>(); + for (Object s : (List) scopeClaim) { + scopes.add(String.valueOf(s)); + } + return scopes; + } + return null; + } } diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java index d6b068dc9..f4d4494ea 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java @@ -51,12 +51,6 @@ public class DatabricksConfig { @ConfigAttribute(auth = "oauth") private List scopes; - // Temporary field to track if scopes were explicitly set by the user. - // This is used to ensure users don't set explicit scopes when using - // `databricks-cli` auth, as it does not respect the scopes. - // TODO: Remove this field once the `auth token` command supports scopes. - private boolean scopesExplicitlySet = false; - @ConfigAttribute(env = "DATABRICKS_REDIRECT_URL", auth = "oauth") private String redirectUrl; @@ -437,12 +431,16 @@ public List getScopes() { public DatabricksConfig setScopes(List scopes) { this.scopes = scopes; - this.scopesExplicitlySet = true; return this; } + /** + * Returns true if scopes were explicitly configured (either directly in code or loaded from a CLI + * profile/config file). When scopes are not set, getScopes() defaults to ["all-apis"], which + * would cause false-positive mismatches during scope validation. + */ public boolean isScopesExplicitlySet() { - return scopesExplicitlySet; + return scopes != null && !scopes.isEmpty(); } public String getProfile() { diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java index 3292db406..2144cd39c 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java @@ -1,6 +1,5 @@ package com.databricks.sdk.core; -import static com.databricks.sdk.core.DatabricksCliCredentialsProvider.ERR_CUSTOM_SCOPES_NOT_SUPPORTED; import static org.junit.jupiter.api.Assertions.*; import java.util.Arrays; @@ -141,43 +140,6 @@ void testBuildHostArgs_UnifiedHostFalse_WithAccountHost() { cmd); } - @Test - void testConfigure_ErrorsWhenScopesExplicitlySet() { - DatabricksConfig config = - new DatabricksConfig() - .setHost(HOST) - .setDatabricksCliPath(CLI_PATH) - .setScopes(Arrays.asList("sql")); - - DatabricksException e = - assertThrows(DatabricksException.class, () -> provider.configure(config)); - assertEquals(ERR_CUSTOM_SCOPES_NOT_SUPPORTED, e.getMessage()); - } - - @Test - void testConfigure_SkipsWhenCliNotFoundEvenWithScopes() { - // When CLI is not available, the provider should return null (skip) - // rather than throwing an error about scopes. - DatabricksConfig config = - new DatabricksConfig() - .setHost(HOST) - .setScopes(Arrays.asList("sql")); - - assertNull(provider.configure(config)); - } - - @Test - void testConfigure_NoErrorWhenNoScopes() { - DatabricksConfig config = new DatabricksConfig().setHost(HOST); - - try { - provider.configure(config); - } catch (Exception e) { - // May fail for other reasons (CLI not found, env not set), but must not be the scope error - assertNotEquals(ERR_CUSTOM_SCOPES_NOT_SUPPORTED, e.getMessage()); - } - } - @Test void testScopesExplicitlySetFlag() { DatabricksConfig config = new DatabricksConfig(); diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java new file mode 100644 index 000000000..ff20ae9c7 --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java @@ -0,0 +1,133 @@ +package com.databricks.sdk.core; + +import static org.junit.jupiter.api.Assertions.*; + +import com.databricks.sdk.core.oauth.Token; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class DatabricksCliScopeValidationTest { + + private static final String HOST = "https://my-workspace.cloud.databricks.com"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Builds a fake JWT (header.payload.signature) with the given claims. */ + private static String makeJwt(Map claims) { + try { + String header = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)); + String payload = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(MAPPER.writeValueAsBytes(claims)); + return header + "." + payload + ".sig"; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static Token makeToken(Map claims) { + return new Token(makeJwt(claims), "Bearer", Instant.now().plusSeconds(3600)); + } + + static List scopeValidationCases() { + return Arrays.asList( + // Exact match (offline_access filtered out). + Arguments.of( + Collections.singletonMap("scope", "sql offline_access"), + Collections.singletonList("sql"), + false, + "match"), + // Mismatch throws. + Arguments.of( + Collections.singletonMap("scope", "all-apis offline_access"), + Collections.singletonList("sql"), + true, + "mismatch"), + // offline_access on token only — still equivalent. + Arguments.of( + Collections.singletonMap("scope", "all-apis offline_access"), + Collections.singletonList("all-apis"), + false, + "offline_access_on_token_only"), + // offline_access in config only — still equivalent. + Arguments.of( + Collections.singletonMap("scope", "all-apis"), + Arrays.asList("all-apis", "offline_access"), + false, + "offline_access_in_config_only"), + // Scope claim as list instead of string. + Arguments.of( + new HashMap() { + { + put("scope", Arrays.asList("sql", "offline_access")); + } + }, + Collections.singletonList("sql"), + false, + "scope_as_list")); + } + + @ParameterizedTest(name = "{3}") + @MethodSource("scopeValidationCases") + void testScopeValidation( + Map tokenClaims, + List configuredScopes, + boolean expectError, + String testName) { + Token token = makeToken(tokenClaims); + + if (expectError) { + assertThrows( + DatabricksException.class, + () -> + DatabricksCliCredentialsProvider.validateTokenScopes( + token, configuredScopes, HOST)); + } else { + assertDoesNotThrow( + () -> + DatabricksCliCredentialsProvider.validateTokenScopes( + token, configuredScopes, HOST)); + } + } + + @Test + void testNoScopeClaimSkipsValidation() { + Token token = makeToken(Collections.singletonMap("sub", "user@example.com")); + assertDoesNotThrow( + () -> + DatabricksCliCredentialsProvider.validateTokenScopes( + token, Collections.singletonList("sql"), HOST)); + } + + @Test + void testNonJwtTokenSkipsValidation() { + Token token = new Token("opaque-token-string", "Bearer", Instant.now().plusSeconds(3600)); + assertDoesNotThrow( + () -> + DatabricksCliCredentialsProvider.validateTokenScopes( + token, Collections.singletonList("sql"), HOST)); + } + + @Test + void testErrorMessageContainsReauthCommand() { + Token token = makeToken(Collections.singletonMap("scope", "all-apis")); + DatabricksException e = + assertThrows( + DatabricksException.class, + () -> + DatabricksCliCredentialsProvider.validateTokenScopes( + token, Arrays.asList("sql", "offline_access"), HOST)); + assertTrue( + e.getMessage().contains("databricks auth login --host " + HOST + " --scopes sql"), + "Expected re-auth command in error message, got: " + e.getMessage()); + } +} From dd6ee4e643cbd1b061185bb997d3681085a2ab4e Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Thu, 5 Mar 2026 07:49:14 +0000 Subject: [PATCH 3/5] Fix formatting and add changelog entry Co-Authored-By: Claude Opus 4.6 --- NEXT_CHANGELOG.md | 1 + .../sdk/core/DatabricksCliCredentialsProvider.java | 4 +--- .../sdk/core/DatabricksCliScopeValidationTest.java | 10 +++------- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index f881abc99..0ae2af9f0 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,6 +5,7 @@ ### New Features and Improvements ### Bug Fixes +* Fixed Databricks CLI authentication to detect when the cached token's scopes don't match the SDK's configured scopes. Previously, a scope mismatch was silently ignored, causing requests to use wrong permissions. The SDK now raises an error with instructions to re-authenticate. ### Security Vulnerabilities diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java index f5f03a7ad..944f8cb15 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java @@ -5,7 +5,6 @@ import com.databricks.sdk.core.oauth.Token; import com.databricks.sdk.core.utils.OSUtils; import com.databricks.sdk.support.InternalApi; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.util.*; @@ -200,8 +199,7 @@ private static Map getJwtClaims(String accessToken) { try { String[] parts = accessToken.split("\\."); if (parts.length != 3) { - LOG.debug( - "Tried to decode access token as JWT, but failed: {} components", parts.length); + LOG.debug("Tried to decode access token as JWT, but failed: {} components", parts.length); return null; } byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]); diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java index ff20ae9c7..dd6ba0104 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java @@ -25,9 +25,7 @@ private static String makeJwt(Map claims) { .withoutPadding() .encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)); String payload = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(MAPPER.writeValueAsBytes(claims)); + Base64.getUrlEncoder().withoutPadding().encodeToString(MAPPER.writeValueAsBytes(claims)); return header + "." + payload + ".sig"; } catch (Exception e) { throw new RuntimeException(e); @@ -89,13 +87,11 @@ void testScopeValidation( assertThrows( DatabricksException.class, () -> - DatabricksCliCredentialsProvider.validateTokenScopes( - token, configuredScopes, HOST)); + DatabricksCliCredentialsProvider.validateTokenScopes(token, configuredScopes, HOST)); } else { assertDoesNotThrow( () -> - DatabricksCliCredentialsProvider.validateTokenScopes( - token, configuredScopes, HOST)); + DatabricksCliCredentialsProvider.validateTokenScopes(token, configuredScopes, HOST)); } } From 1cb43a3bd2065db205ba448817a114e3fa4e67b1 Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Fri, 6 Mar 2026 14:17:15 +0000 Subject: [PATCH 4/5] fixes / improvements --- .../DatabricksCliCredentialsProvider.java | 78 +++++++++++-------- .../databricks/sdk/core/DatabricksConfig.java | 6 +- .../DatabricksCliScopeValidationTest.java | 11 ++- 3 files changed, 57 insertions(+), 38 deletions(-) diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java index 944f8cb15..1f384f6ec 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java @@ -3,8 +3,10 @@ import com.databricks.sdk.core.oauth.CachedTokenSource; import com.databricks.sdk.core.oauth.OAuthHeaderFactory; import com.databricks.sdk.core.oauth.Token; +import com.databricks.sdk.core.oauth.TokenSource; import com.databricks.sdk.core.utils.OSUtils; import com.databricks.sdk.support.InternalApi; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.util.*; @@ -20,6 +22,13 @@ public class DatabricksCliCredentialsProvider implements CredentialsProvider { private static final ObjectMapper MAPPER = new ObjectMapper(); + /** Thrown when the cached CLI token's scopes don't match the SDK's configured scopes. */ + static class ScopeMismatchException extends DatabricksException { + ScopeMismatchException(String message) { + super(message); + } + } + /** * offline_access controls whether the IdP issues a refresh token. It does not grant any API * permissions, so its presence or absence should not cause a scope mismatch error. @@ -104,18 +113,38 @@ public OAuthHeaderFactory configure(DatabricksConfig config) { return null; } + // Wrap the token source with scope validation so that every token — both the + // initial fetch and subsequent refreshes — is checked against the configured scopes. + TokenSource effectiveSource; + if (config.isScopesExplicitlySet()) { + List scopes = config.getScopes(); + effectiveSource = + () -> { + Token t = tokenSource.getToken(); + validateTokenScopes(t, scopes, host); + return t; + }; + } else { + effectiveSource = tokenSource; + } + CachedTokenSource cachedTokenSource = - new CachedTokenSource.Builder(tokenSource) + new CachedTokenSource.Builder(effectiveSource) .setAsyncDisabled(config.getDisableAsyncTokenRefresh()) .build(); - Token token = - cachedTokenSource.getToken(); // We need this for checking if databricks CLI is installed. - - if (config.isScopesExplicitlySet()) { - validateTokenScopes(token, config.getScopes(), config.getHost()); - } + cachedTokenSource.getToken(); // We need this for checking if databricks CLI is installed. return OAuthHeaderFactory.fromTokenSource(cachedTokenSource); + } catch (ScopeMismatchException e) { + // Scope validation failed. When the user explicitly selected databricks-cli auth, + // surface the mismatch immediately so they get an actionable error. When we're being + // tried as part of the default credential chain, step aside so other providers get + // a chance. + if (DATABRICKS_CLI.equals(config.getAuthType())) { + throw e; + } + LOG.warn("Databricks CLI token scope mismatch, skipping: {}", e.getMessage()); + return null; } catch (DatabricksException e) { String stderr = e.getMessage(); if (stderr.contains("not found")) { @@ -126,17 +155,6 @@ public OAuthHeaderFactory configure(DatabricksConfig config) { LOG.info("OAuth not configured or not available"); return null; } - // Scope validation failed. When the user explicitly selected databricks-cli auth, - // surface the mismatch immediately so they get an actionable error. When we're being - // tried as part of the default credential chain, step aside so other providers get - // a chance. - if (stderr.contains("do not match the configured scopes")) { - if (DATABRICKS_CLI.equals(config.getAuthType())) { - throw e; - } - LOG.warn("Databricks CLI token scope mismatch, skipping: {}", e.getMessage()); - return null; - } throw e; } } @@ -179,15 +197,13 @@ static void validateTokenScopes(Token token, List requestedScopes, Strin List sortedRequested = new ArrayList<>(requested); Collections.sort(sortedRequested); - // Build a re-auth command hint with scopes (excluding offline_access) - String scopesArg = String.join(",", sortedRequested); - - throw new DatabricksException( + throw new ScopeMismatchException( String.format( "Token issued by Databricks CLI has scopes %s which do not match " - + "the configured scopes %s. Please re-authenticate with the desired scopes " - + "by running `databricks auth login --host %s --scopes %s`.", - sortedTokenScopes, sortedRequested, host, scopesArg)); + + "the configured scopes %s. Please re-authenticate " + + "with the desired scopes by running `databricks auth login` with the --scopes flag." + + "Scopes default to all-apis.", + sortedTokenScopes, sortedRequested)); } } @@ -196,18 +212,18 @@ static void validateTokenScopes(Token token, List requestedScopes, Strin * valid JWT. */ private static Map getJwtClaims(String accessToken) { + String[] parts = accessToken.split("\\."); + if (parts.length != 3) { + LOG.debug("Tried to decode access token as JWT, but failed: {} components", parts.length); + return null; + } try { - String[] parts = accessToken.split("\\."); - if (parts.length != 3) { - LOG.debug("Tried to decode access token as JWT, but failed: {} components", parts.length); - return null; - } byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]); String payloadJson = new String(payloadBytes, StandardCharsets.UTF_8); @SuppressWarnings("unchecked") Map claims = MAPPER.readValue(payloadJson, Map.class); return claims; - } catch (Exception e) { + } catch (IllegalArgumentException | JsonProcessingException e) { LOG.debug("Failed to decode JWT claims: {}", e.getMessage()); return null; } diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java index f4d4494ea..980af804b 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java @@ -435,11 +435,11 @@ public DatabricksConfig setScopes(List scopes) { } /** - * Returns true if scopes were explicitly configured (either directly in code or loaded from a CLI - * profile/config file). When scopes are not set, getScopes() defaults to ["all-apis"], which + * Returns true if scopes were explicitly configured (either directly in code or loaded from a + * config file). When scopes are not set, getScopes() defaults to ["all-apis"], which * would cause false-positive mismatches during scope validation. */ - public boolean isScopesExplicitlySet() { + boolean isScopesExplicitlySet() { return scopes != null && !scopes.isEmpty(); } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java index dd6ba0104..e3f038b23 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java @@ -85,7 +85,7 @@ void testScopeValidation( if (expectError) { assertThrows( - DatabricksException.class, + DatabricksCliCredentialsProvider.ScopeMismatchException.class, () -> DatabricksCliCredentialsProvider.validateTokenScopes(token, configuredScopes, HOST)); } else { @@ -116,14 +116,17 @@ void testNonJwtTokenSkipsValidation() { @Test void testErrorMessageContainsReauthCommand() { Token token = makeToken(Collections.singletonMap("scope", "all-apis")); - DatabricksException e = + DatabricksCliCredentialsProvider.ScopeMismatchException e = assertThrows( - DatabricksException.class, + DatabricksCliCredentialsProvider.ScopeMismatchException.class, () -> DatabricksCliCredentialsProvider.validateTokenScopes( token, Arrays.asList("sql", "offline_access"), HOST)); assertTrue( - e.getMessage().contains("databricks auth login --host " + HOST + " --scopes sql"), + e.getMessage().contains("databricks auth login"), "Expected re-auth command in error message, got: " + e.getMessage()); + assertTrue( + e.getMessage().contains("do not match the configured scopes"), + "Expected scope mismatch details in error message, got: " + e.getMessage()); } } From b19225ab2367cd6e11921463d3e7a6b1d864dbeb Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Fri, 6 Mar 2026 15:44:13 +0000 Subject: [PATCH 5/5] fix fmt --- .../sdk/core/DatabricksCliCredentialsProvider.java | 11 +++++++---- .../com/databricks/sdk/core/DatabricksConfig.java | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java index 1f384f6ec..220140a8c 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java @@ -119,10 +119,13 @@ public OAuthHeaderFactory configure(DatabricksConfig config) { if (config.isScopesExplicitlySet()) { List scopes = config.getScopes(); effectiveSource = - () -> { - Token t = tokenSource.getToken(); - validateTokenScopes(t, scopes, host); - return t; + new TokenSource() { + @Override + public Token getToken() { + Token t = tokenSource.getToken(); + validateTokenScopes(t, scopes, host); + return t; + } }; } else { effectiveSource = tokenSource; diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java index 980af804b..571827cd9 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java @@ -436,8 +436,8 @@ public DatabricksConfig setScopes(List scopes) { /** * Returns true if scopes were explicitly configured (either directly in code or loaded from a - * config file). When scopes are not set, getScopes() defaults to ["all-apis"], which - * would cause false-positive mismatches during scope validation. + * config file). When scopes are not set, getScopes() defaults to ["all-apis"], which would cause + * false-positive mismatches during scope validation. */ boolean isScopesExplicitlySet() { return scopes != null && !scopes.isEmpty();