Skip to content

Commit 6bdac20

Browse files
Validate Databricks CLI token scopes against SDK configuration (#689)
## Summary Detects when a cached Databricks CLI token was issued with different OAuth scopes than what the SDK configuration requires, and surfaces an actionable error telling the user how to re-authenticate instead of silently making requests with the wrong scopes. Mirrors Python SDK PR: databricks/databricks-sdk-py#1286 ## Why The `databricks auth token` CLI command does not accept scopes — it returns whatever token was cached from the last `databricks auth login`. If a user configures specific scopes in the SDK (e.g. `scopes=["sql"]`, either directly in code or loaded from a CLI profile), but their cached token was issued with different scopes (e.g. `all-apis`), every API request silently uses the wrong scopes. This is confusing to debug because authentication succeeds — it just grants the wrong permissions. This is especially likely now that the CLI writes scopes into profiles, meaning users who switch between different scope configurations will hit this without realizing it. ## What changed ### Behavioral changes - **Scope mismatch error** — When scopes are explicitly set in the SDK config and the cached CLI token's JWT `scope` claim doesn't match, a `DatabricksException` is raised with a message like: ``` Token issued by Databricks CLI has scopes [all-apis] which do not match the configured scopes [sql]. Please re-authenticate with the desired scopes by running `databricks auth login` with the --scopes flag. Scopes default to all-apis. ``` - **Credential chain fallthrough** — When `authType` is not explicitly set to `"databricks-cli"` (i.e. we're being tried as part of the default credential chain), a scope mismatch logs a warning and returns `null` so other providers get a chance. When `authType="databricks-cli"` is explicitly set, the error propagates immediately. - **`offline_access` is ignored during comparison** — This scope controls refresh token issuance, not API permissions. Its presence or absence on either side does not trigger a mismatch. - **Validation is skipped when scopes are not configured** — `getScopes()` defaults to `["all-apis"]` when nothing is set, which would cause false-positive mismatches. Validation only runs when scopes are explicitly set. ### Internal changes - **`DatabricksCliCredentialsProvider.validateTokenScopes()`** — Decodes the JWT `scope` claim (databricks uses space-delimited strings according to RFC 9068), filters out `offline_access`, and compares against configured scopes using strict set equality. - **`DatabricksCliCredentialsProvider.getJwtClaims()`** — Decodes JWT payload using `Base64.getUrlDecoder()` (URL-safe base64 per RFC 7519). - **`DatabricksConfig.isScopesExplicitlySet()`** — Package private method on Config that returns true when the raw `scopes` field is non-null and non-empty. ## How is this tested? - **Parametrized tests** covering: exact match, mismatch (error), `offline_access` filtering (both directions), non-JWT tokens (skip), no scope claim (skip), and error message format with re-auth command. - **Unit tests** for `isScopesExplicitlySet()` flag behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 680184f commit 6bdac20

File tree

4 files changed

+285
-1
lines changed

4 files changed

+285
-1
lines changed

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### New Features and Improvements
66

77
### Bug Fixes
8+
* 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.
89

910
### Security Vulnerabilities
1011

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
import com.databricks.sdk.core.oauth.CachedTokenSource;
44
import com.databricks.sdk.core.oauth.OAuthHeaderFactory;
5+
import com.databricks.sdk.core.oauth.Token;
6+
import com.databricks.sdk.core.oauth.TokenSource;
57
import com.databricks.sdk.core.utils.OSUtils;
68
import com.databricks.sdk.support.InternalApi;
9+
import com.fasterxml.jackson.core.JsonProcessingException;
10+
import com.fasterxml.jackson.databind.ObjectMapper;
11+
import java.nio.charset.StandardCharsets;
712
import java.util.*;
813
import org.slf4j.Logger;
914
import org.slf4j.LoggerFactory;
@@ -15,6 +20,22 @@ public class DatabricksCliCredentialsProvider implements CredentialsProvider {
1520

1621
public static final String DATABRICKS_CLI = "databricks-cli";
1722

23+
private static final ObjectMapper MAPPER = new ObjectMapper();
24+
25+
/** Thrown when the cached CLI token's scopes don't match the SDK's configured scopes. */
26+
static class ScopeMismatchException extends DatabricksException {
27+
ScopeMismatchException(String message) {
28+
super(message);
29+
}
30+
}
31+
32+
/**
33+
* offline_access controls whether the IdP issues a refresh token. It does not grant any API
34+
* permissions, so its presence or absence should not cause a scope mismatch error.
35+
*/
36+
private static final Set<String> SCOPES_IGNORED_FOR_COMPARISON =
37+
Collections.singleton("offline_access");
38+
1839
@Override
1940
public String authType() {
2041
return DATABRICKS_CLI;
@@ -92,13 +113,41 @@ public OAuthHeaderFactory configure(DatabricksConfig config) {
92113
return null;
93114
}
94115

116+
// Wrap the token source with scope validation so that every token — both the
117+
// initial fetch and subsequent refreshes — is checked against the configured scopes.
118+
TokenSource effectiveSource;
119+
if (config.isScopesExplicitlySet()) {
120+
List<String> scopes = config.getScopes();
121+
effectiveSource =
122+
new TokenSource() {
123+
@Override
124+
public Token getToken() {
125+
Token t = tokenSource.getToken();
126+
validateTokenScopes(t, scopes);
127+
return t;
128+
}
129+
};
130+
} else {
131+
effectiveSource = tokenSource;
132+
}
133+
95134
CachedTokenSource cachedTokenSource =
96-
new CachedTokenSource.Builder(tokenSource)
135+
new CachedTokenSource.Builder(effectiveSource)
97136
.setAsyncDisabled(config.getDisableAsyncTokenRefresh())
98137
.build();
99138
cachedTokenSource.getToken(); // We need this for checking if databricks CLI is installed.
100139

101140
return OAuthHeaderFactory.fromTokenSource(cachedTokenSource);
141+
} catch (ScopeMismatchException e) {
142+
// Scope validation failed. When the user explicitly selected databricks-cli auth,
143+
// surface the mismatch immediately so they get an actionable error. When we're being
144+
// tried as part of the default credential chain, step aside so other providers get
145+
// a chance.
146+
if (DATABRICKS_CLI.equals(config.getAuthType())) {
147+
throw e;
148+
}
149+
LOG.warn("Databricks CLI token scope mismatch, skipping: {}", e.getMessage());
150+
return null;
102151
} catch (DatabricksException e) {
103152
String stderr = e.getMessage();
104153
if (stderr.contains("not found")) {
@@ -112,4 +161,86 @@ public OAuthHeaderFactory configure(DatabricksConfig config) {
112161
throw e;
113162
}
114163
}
164+
165+
/**
166+
* Validate that the token's scopes match the requested scopes from the config.
167+
*
168+
* <p>The {@code databricks auth token} command does not accept scopes yet. It returns whatever
169+
* token was cached from the last {@code databricks auth login}. If a user configures specific
170+
* scopes in the SDK config but their cached CLI token was issued with different scopes, requests
171+
* will silently use the wrong scopes. This check surfaces that mismatch early with an actionable
172+
* error telling the user how to re-authenticate with the correct scopes.
173+
*/
174+
static void validateTokenScopes(Token token, List<String> requestedScopes) {
175+
Map<String, Object> claims = getJwtClaims(token.getAccessToken());
176+
if (claims == null) {
177+
LOG.debug("Could not decode token as JWT to validate scopes");
178+
return;
179+
}
180+
181+
Object tokenScopesRaw = claims.get("scope");
182+
if (tokenScopesRaw == null) {
183+
LOG.debug("Token does not contain 'scope' claim, skipping scope validation");
184+
return;
185+
}
186+
187+
Set<String> tokenScopes = parseScopeClaim(tokenScopesRaw);
188+
if (tokenScopes == null) {
189+
LOG.debug("Unexpected 'scope' claim type: {}", tokenScopesRaw.getClass());
190+
return;
191+
}
192+
193+
tokenScopes.removeAll(SCOPES_IGNORED_FOR_COMPARISON);
194+
Set<String> requested = new HashSet<>(requestedScopes);
195+
requested.removeAll(SCOPES_IGNORED_FOR_COMPARISON);
196+
197+
if (!tokenScopes.equals(requested)) {
198+
List<String> sortedTokenScopes = new ArrayList<>(tokenScopes);
199+
Collections.sort(sortedTokenScopes);
200+
List<String> sortedRequested = new ArrayList<>(requested);
201+
Collections.sort(sortedRequested);
202+
203+
throw new ScopeMismatchException(
204+
String.format(
205+
"Token issued by Databricks CLI has scopes %s which do not match "
206+
+ "the configured scopes %s. Please re-authenticate "
207+
+ "with the desired scopes by running `databricks auth login` with the --scopes flag. "
208+
+ "Scopes default to all-apis.",
209+
sortedTokenScopes, sortedRequested));
210+
}
211+
}
212+
213+
/**
214+
* Decode a JWT access token and return its payload claims. Returns null if the token is not a
215+
* valid JWT. No signature verification is performed — the token was already authenticated by the
216+
* CLI, and we only need to read the scope claim for comparison.
217+
*/
218+
private static Map<String, Object> getJwtClaims(String accessToken) {
219+
String[] parts = accessToken.split("\\.");
220+
if (parts.length != 3) {
221+
LOG.debug("Tried to decode access token as JWT, but failed: {} components", parts.length);
222+
return null;
223+
}
224+
try {
225+
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
226+
String payloadJson = new String(payloadBytes, StandardCharsets.UTF_8);
227+
@SuppressWarnings("unchecked")
228+
Map<String, Object> claims = MAPPER.readValue(payloadJson, Map.class);
229+
return claims;
230+
} catch (IllegalArgumentException | JsonProcessingException e) {
231+
LOG.debug("Failed to decode JWT claims: {}", e.getMessage());
232+
return null;
233+
}
234+
}
235+
236+
/**
237+
* Parse the JWT "scope" claim. Per RFC 9068, this is a space-delimited string. Returns null if
238+
* the type is unexpected.
239+
*/
240+
private static Set<String> parseScopeClaim(Object scopeClaim) {
241+
if (scopeClaim instanceof String) {
242+
return new HashSet<>(Arrays.asList(((String) scopeClaim).split("\\s+")));
243+
}
244+
return null;
245+
}
115246
}

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,15 @@ public DatabricksConfig setScopes(List<String> scopes) {
459459
return this;
460460
}
461461

462+
/**
463+
* Returns true if scopes were explicitly configured (either directly in code or loaded from a
464+
* config file). When scopes are not set, getScopes() defaults to ["all-apis"], which would cause
465+
* false-positive mismatches during scope validation.
466+
*/
467+
boolean isScopesExplicitlySet() {
468+
return scopes != null && !scopes.isEmpty();
469+
}
470+
462471
public String getProfile() {
463472
return profile;
464473
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.databricks.sdk.core;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import com.databricks.sdk.core.oauth.Token;
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import java.nio.charset.StandardCharsets;
8+
import java.time.Instant;
9+
import java.util.*;
10+
import org.junit.jupiter.api.Test;
11+
import org.junit.jupiter.params.ParameterizedTest;
12+
import org.junit.jupiter.params.provider.Arguments;
13+
import org.junit.jupiter.params.provider.MethodSource;
14+
15+
class DatabricksCliScopeValidationTest {
16+
17+
private static final ObjectMapper MAPPER = new ObjectMapper();
18+
19+
/** Builds a fake JWT (header.payload.signature) with the given claims. */
20+
private static String makeJwt(Map<String, Object> claims) {
21+
try {
22+
String header =
23+
Base64.getUrlEncoder()
24+
.withoutPadding()
25+
.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8));
26+
String payload =
27+
Base64.getUrlEncoder().withoutPadding().encodeToString(MAPPER.writeValueAsBytes(claims));
28+
return header + "." + payload + ".sig";
29+
} catch (Exception e) {
30+
throw new RuntimeException(e);
31+
}
32+
}
33+
34+
private static Token makeToken(Map<String, Object> claims) {
35+
return new Token(makeJwt(claims), "Bearer", Instant.now().plusSeconds(3600));
36+
}
37+
38+
static List<Arguments> scopeValidationCases() {
39+
return Arrays.asList(
40+
// Exact match (offline_access filtered out).
41+
Arguments.of(
42+
Collections.singletonMap("scope", "sql offline_access"),
43+
Collections.singletonList("sql"),
44+
false,
45+
"match"),
46+
// Mismatch throws.
47+
Arguments.of(
48+
Collections.singletonMap("scope", "all-apis offline_access"),
49+
Collections.singletonList("sql"),
50+
true,
51+
"mismatch"),
52+
// offline_access on token only — still equivalent.
53+
Arguments.of(
54+
Collections.singletonMap("scope", "all-apis offline_access"),
55+
Collections.singletonList("all-apis"),
56+
false,
57+
"offline_access_on_token_only"),
58+
// offline_access in config only — still equivalent.
59+
Arguments.of(
60+
Collections.singletonMap("scope", "all-apis"),
61+
Arrays.asList("all-apis", "offline_access"),
62+
false,
63+
"offline_access_in_config_only"),
64+
// Order should not matter.
65+
Arguments.of(
66+
Collections.singletonMap("scope", "clusters sql"),
67+
Arrays.asList("sql", "clusters"),
68+
false,
69+
"multiple_scopes_order_independent"),
70+
// Partial overlap is still a mismatch.
71+
Arguments.of(
72+
Collections.singletonMap("scope", "sql clusters"),
73+
Arrays.asList("sql", "compute"),
74+
true,
75+
"multiple_scopes_partial_overlap_mismatch"),
76+
// No scope claim in token — validation is skipped.
77+
Arguments.of(
78+
Collections.singletonMap("sub", "user@example.com"),
79+
Collections.singletonList("sql"),
80+
false,
81+
"no_scope_claim_skips_validation"));
82+
}
83+
84+
@ParameterizedTest(name = "{3}")
85+
@MethodSource("scopeValidationCases")
86+
void testScopeValidation(
87+
Map<String, Object> tokenClaims,
88+
List<String> configuredScopes,
89+
boolean expectError,
90+
String testName) {
91+
Token token = makeToken(tokenClaims);
92+
93+
if (expectError) {
94+
assertThrows(
95+
DatabricksCliCredentialsProvider.ScopeMismatchException.class,
96+
() -> DatabricksCliCredentialsProvider.validateTokenScopes(token, configuredScopes));
97+
} else {
98+
assertDoesNotThrow(
99+
() -> DatabricksCliCredentialsProvider.validateTokenScopes(token, configuredScopes));
100+
}
101+
}
102+
103+
@Test
104+
void testNonJwtTokenSkipsValidation() {
105+
Token token = new Token("opaque-token-string", "Bearer", Instant.now().plusSeconds(3600));
106+
assertDoesNotThrow(
107+
() ->
108+
DatabricksCliCredentialsProvider.validateTokenScopes(
109+
token, Collections.singletonList("sql")));
110+
}
111+
112+
@Test
113+
void testErrorMessageContainsReauthCommand() {
114+
Token token = makeToken(Collections.singletonMap("scope", "all-apis"));
115+
DatabricksCliCredentialsProvider.ScopeMismatchException e =
116+
assertThrows(
117+
DatabricksCliCredentialsProvider.ScopeMismatchException.class,
118+
() ->
119+
DatabricksCliCredentialsProvider.validateTokenScopes(
120+
token, Arrays.asList("sql", "offline_access")));
121+
assertTrue(
122+
e.getMessage().contains("databricks auth login"),
123+
"Expected re-auth command in error message, got: " + e.getMessage());
124+
assertTrue(
125+
e.getMessage().contains("do not match the configured scopes"),
126+
"Expected scope mismatch details in error message, got: " + e.getMessage());
127+
}
128+
129+
@Test
130+
void testScopesExplicitlySetFlag() {
131+
DatabricksConfig config = new DatabricksConfig();
132+
assertFalse(config.isScopesExplicitlySet());
133+
134+
config.setScopes(Arrays.asList("sql", "clusters"));
135+
assertTrue(config.isScopesExplicitlySet());
136+
137+
config.setScopes(Collections.emptyList());
138+
assertFalse(config.isScopesExplicitlySet(), "Empty list should not count as explicitly set");
139+
140+
config.setScopes(null);
141+
assertFalse(config.isScopesExplicitlySet(), "null should not count as explicitly set");
142+
}
143+
}

0 commit comments

Comments
 (0)