Skip to content

Commit 2a1667e

Browse files
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 <noreply@anthropic.com>
1 parent dd13d45 commit 2a1667e

4 files changed

Lines changed: 258 additions & 54 deletions

File tree

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

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
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;
56
import com.databricks.sdk.core.utils.OSUtils;
67
import com.databricks.sdk.support.InternalApi;
8+
import com.fasterxml.jackson.databind.JsonNode;
9+
import com.fasterxml.jackson.databind.ObjectMapper;
10+
import java.nio.charset.StandardCharsets;
711
import java.util.*;
812
import org.slf4j.Logger;
913
import org.slf4j.LoggerFactory;
@@ -15,9 +19,14 @@ public class DatabricksCliCredentialsProvider implements CredentialsProvider {
1519

1620
public static final String DATABRICKS_CLI = "databricks-cli";
1721

18-
static final String ERR_CUSTOM_SCOPES_NOT_SUPPORTED =
19-
"custom scopes are not supported with databricks-cli auth; "
20-
+ "scopes are determined by what was last used when logging in with `databricks auth login`";
22+
private static final ObjectMapper MAPPER = new ObjectMapper();
23+
24+
/**
25+
* offline_access controls whether the IdP issues a refresh token. It does not grant any API
26+
* permissions, so its presence or absence should not cause a scope mismatch error.
27+
*/
28+
private static final Set<String> SCOPES_IGNORED_FOR_COMPARISON =
29+
Collections.singleton("offline_access");
2130

2231
@Override
2332
public String authType() {
@@ -96,15 +105,16 @@ public OAuthHeaderFactory configure(DatabricksConfig config) {
96105
return null;
97106
}
98107

99-
if (config.isScopesExplicitlySet()) {
100-
throw new DatabricksException(ERR_CUSTOM_SCOPES_NOT_SUPPORTED);
101-
}
102-
103108
CachedTokenSource cachedTokenSource =
104109
new CachedTokenSource.Builder(tokenSource)
105110
.setAsyncDisabled(config.getDisableAsyncTokenRefresh())
106111
.build();
107-
cachedTokenSource.getToken(); // We need this for checking if databricks CLI is installed.
112+
Token token =
113+
cachedTokenSource.getToken(); // We need this for checking if databricks CLI is installed.
114+
115+
if (config.isScopesExplicitlySet()) {
116+
validateTokenScopes(token, config.getScopes(), config.getHost());
117+
}
108118

109119
return OAuthHeaderFactory.fromTokenSource(cachedTokenSource);
110120
} catch (DatabricksException e) {
@@ -117,7 +127,108 @@ public OAuthHeaderFactory configure(DatabricksConfig config) {
117127
LOG.info("OAuth not configured or not available");
118128
return null;
119129
}
130+
// Scope validation failed. When the user explicitly selected databricks-cli auth,
131+
// surface the mismatch immediately so they get an actionable error. When we're being
132+
// tried as part of the default credential chain, step aside so other providers get
133+
// a chance.
134+
if (stderr.contains("do not match the configured scopes")) {
135+
if (DATABRICKS_CLI.equals(config.getAuthType())) {
136+
throw e;
137+
}
138+
LOG.warn("Databricks CLI token scope mismatch, skipping: {}", e.getMessage());
139+
return null;
140+
}
120141
throw e;
121142
}
122143
}
144+
145+
/**
146+
* Validate that the token's scopes match the requested scopes from the config.
147+
*
148+
* <p>The {@code databricks auth token} command does not accept scopes yet. It returns whatever
149+
* token was cached from the last {@code databricks auth login}. If a user configures specific
150+
* scopes in the SDK config but their cached CLI token was issued with different scopes, requests
151+
* will silently use the wrong scopes. This check surfaces that mismatch early with an actionable
152+
* error telling the user how to re-authenticate with the correct scopes.
153+
*/
154+
static void validateTokenScopes(Token token, List<String> requestedScopes, String host) {
155+
Map<String, Object> claims = getJwtClaims(token.getAccessToken());
156+
if (claims == null) {
157+
LOG.debug("Could not decode token as JWT to validate scopes");
158+
return;
159+
}
160+
161+
Object tokenScopesRaw = claims.get("scope");
162+
if (tokenScopesRaw == null) {
163+
LOG.debug("Token does not contain 'scope' claim, skipping scope validation");
164+
return;
165+
}
166+
167+
Set<String> tokenScopes = parseScopeClaim(tokenScopesRaw);
168+
if (tokenScopes == null) {
169+
LOG.debug("Unexpected 'scope' claim type: {}", tokenScopesRaw.getClass());
170+
return;
171+
}
172+
173+
tokenScopes.removeAll(SCOPES_IGNORED_FOR_COMPARISON);
174+
Set<String> requested = new HashSet<>(requestedScopes);
175+
requested.removeAll(SCOPES_IGNORED_FOR_COMPARISON);
176+
177+
if (!tokenScopes.equals(requested)) {
178+
List<String> sortedTokenScopes = new ArrayList<>(tokenScopes);
179+
Collections.sort(sortedTokenScopes);
180+
List<String> sortedRequested = new ArrayList<>(requested);
181+
Collections.sort(sortedRequested);
182+
183+
// Build a re-auth command hint with scopes (excluding offline_access)
184+
String scopesArg = String.join(",", sortedRequested);
185+
186+
throw new DatabricksException(
187+
String.format(
188+
"Token issued by Databricks CLI has scopes %s which do not match "
189+
+ "the configured scopes %s. Please re-authenticate with the desired scopes "
190+
+ "by running `databricks auth login --host %s --scopes %s`.",
191+
sortedTokenScopes, sortedRequested, host, scopesArg));
192+
}
193+
}
194+
195+
/**
196+
* Decode a JWT access token and return its payload claims. Returns null if the token is not a
197+
* valid JWT.
198+
*/
199+
private static Map<String, Object> getJwtClaims(String accessToken) {
200+
try {
201+
String[] parts = accessToken.split("\\.");
202+
if (parts.length != 3) {
203+
LOG.debug(
204+
"Tried to decode access token as JWT, but failed: {} components", parts.length);
205+
return null;
206+
}
207+
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
208+
String payloadJson = new String(payloadBytes, StandardCharsets.UTF_8);
209+
@SuppressWarnings("unchecked")
210+
Map<String, Object> claims = MAPPER.readValue(payloadJson, Map.class);
211+
return claims;
212+
} catch (Exception e) {
213+
LOG.debug("Failed to decode JWT claims: {}", e.getMessage());
214+
return null;
215+
}
216+
}
217+
218+
/**
219+
* Parse the JWT "scope" claim, which can be either a space-delimited string or a JSON array.
220+
* Returns null if the type is unexpected.
221+
*/
222+
private static Set<String> parseScopeClaim(Object scopeClaim) {
223+
if (scopeClaim instanceof String) {
224+
return new HashSet<>(Arrays.asList(((String) scopeClaim).split("\\s+")));
225+
} else if (scopeClaim instanceof List) {
226+
Set<String> scopes = new HashSet<>();
227+
for (Object s : (List<?>) scopeClaim) {
228+
scopes.add(String.valueOf(s));
229+
}
230+
return scopes;
231+
}
232+
return null;
233+
}
123234
}

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,6 @@ public class DatabricksConfig {
5151
@ConfigAttribute(auth = "oauth")
5252
private List<String> scopes;
5353

54-
// Temporary field to track if scopes were explicitly set by the user.
55-
// This is used to ensure users don't set explicit scopes when using
56-
// `databricks-cli` auth, as it does not respect the scopes.
57-
// TODO: Remove this field once the `auth token` command supports scopes.
58-
private boolean scopesExplicitlySet = false;
59-
6054
@ConfigAttribute(env = "DATABRICKS_REDIRECT_URL", auth = "oauth")
6155
private String redirectUrl;
6256

@@ -437,12 +431,16 @@ public List<String> getScopes() {
437431

438432
public DatabricksConfig setScopes(List<String> scopes) {
439433
this.scopes = scopes;
440-
this.scopesExplicitlySet = true;
441434
return this;
442435
}
443436

437+
/**
438+
* Returns true if scopes were explicitly configured (either directly in code or loaded from a CLI
439+
* profile/config file). When scopes are not set, getScopes() defaults to ["all-apis"], which
440+
* would cause false-positive mismatches during scope validation.
441+
*/
444442
public boolean isScopesExplicitlySet() {
445-
return scopesExplicitlySet;
443+
return scopes != null && !scopes.isEmpty();
446444
}
447445

448446
public String getProfile() {

databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.databricks.sdk.core;
22

3-
import static com.databricks.sdk.core.DatabricksCliCredentialsProvider.ERR_CUSTOM_SCOPES_NOT_SUPPORTED;
43
import static org.junit.jupiter.api.Assertions.*;
54

65
import java.util.Arrays;
@@ -141,43 +140,6 @@ void testBuildHostArgs_UnifiedHostFalse_WithAccountHost() {
141140
cmd);
142141
}
143142

144-
@Test
145-
void testConfigure_ErrorsWhenScopesExplicitlySet() {
146-
DatabricksConfig config =
147-
new DatabricksConfig()
148-
.setHost(HOST)
149-
.setDatabricksCliPath(CLI_PATH)
150-
.setScopes(Arrays.asList("sql"));
151-
152-
DatabricksException e =
153-
assertThrows(DatabricksException.class, () -> provider.configure(config));
154-
assertEquals(ERR_CUSTOM_SCOPES_NOT_SUPPORTED, e.getMessage());
155-
}
156-
157-
@Test
158-
void testConfigure_SkipsWhenCliNotFoundEvenWithScopes() {
159-
// When CLI is not available, the provider should return null (skip)
160-
// rather than throwing an error about scopes.
161-
DatabricksConfig config =
162-
new DatabricksConfig()
163-
.setHost(HOST)
164-
.setScopes(Arrays.asList("sql"));
165-
166-
assertNull(provider.configure(config));
167-
}
168-
169-
@Test
170-
void testConfigure_NoErrorWhenNoScopes() {
171-
DatabricksConfig config = new DatabricksConfig().setHost(HOST);
172-
173-
try {
174-
provider.configure(config);
175-
} catch (Exception e) {
176-
// May fail for other reasons (CLI not found, env not set), but must not be the scope error
177-
assertNotEquals(ERR_CUSTOM_SCOPES_NOT_SUPPORTED, e.getMessage());
178-
}
179-
}
180-
181143
@Test
182144
void testScopesExplicitlySetFlag() {
183145
DatabricksConfig config = new DatabricksConfig();
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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 String HOST = "https://my-workspace.cloud.databricks.com";
18+
private static final ObjectMapper MAPPER = new ObjectMapper();
19+
20+
/** Builds a fake JWT (header.payload.signature) with the given claims. */
21+
private static String makeJwt(Map<String, Object> claims) {
22+
try {
23+
String header =
24+
Base64.getUrlEncoder()
25+
.withoutPadding()
26+
.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8));
27+
String payload =
28+
Base64.getUrlEncoder()
29+
.withoutPadding()
30+
.encodeToString(MAPPER.writeValueAsBytes(claims));
31+
return header + "." + payload + ".sig";
32+
} catch (Exception e) {
33+
throw new RuntimeException(e);
34+
}
35+
}
36+
37+
private static Token makeToken(Map<String, Object> claims) {
38+
return new Token(makeJwt(claims), "Bearer", Instant.now().plusSeconds(3600));
39+
}
40+
41+
static List<Arguments> scopeValidationCases() {
42+
return Arrays.asList(
43+
// Exact match (offline_access filtered out).
44+
Arguments.of(
45+
Collections.singletonMap("scope", "sql offline_access"),
46+
Collections.singletonList("sql"),
47+
false,
48+
"match"),
49+
// Mismatch throws.
50+
Arguments.of(
51+
Collections.singletonMap("scope", "all-apis offline_access"),
52+
Collections.singletonList("sql"),
53+
true,
54+
"mismatch"),
55+
// offline_access on token only — still equivalent.
56+
Arguments.of(
57+
Collections.singletonMap("scope", "all-apis offline_access"),
58+
Collections.singletonList("all-apis"),
59+
false,
60+
"offline_access_on_token_only"),
61+
// offline_access in config only — still equivalent.
62+
Arguments.of(
63+
Collections.singletonMap("scope", "all-apis"),
64+
Arrays.asList("all-apis", "offline_access"),
65+
false,
66+
"offline_access_in_config_only"),
67+
// Scope claim as list instead of string.
68+
Arguments.of(
69+
new HashMap<String, Object>() {
70+
{
71+
put("scope", Arrays.asList("sql", "offline_access"));
72+
}
73+
},
74+
Collections.singletonList("sql"),
75+
false,
76+
"scope_as_list"));
77+
}
78+
79+
@ParameterizedTest(name = "{3}")
80+
@MethodSource("scopeValidationCases")
81+
void testScopeValidation(
82+
Map<String, Object> tokenClaims,
83+
List<String> configuredScopes,
84+
boolean expectError,
85+
String testName) {
86+
Token token = makeToken(tokenClaims);
87+
88+
if (expectError) {
89+
assertThrows(
90+
DatabricksException.class,
91+
() ->
92+
DatabricksCliCredentialsProvider.validateTokenScopes(
93+
token, configuredScopes, HOST));
94+
} else {
95+
assertDoesNotThrow(
96+
() ->
97+
DatabricksCliCredentialsProvider.validateTokenScopes(
98+
token, configuredScopes, HOST));
99+
}
100+
}
101+
102+
@Test
103+
void testNoScopeClaimSkipsValidation() {
104+
Token token = makeToken(Collections.singletonMap("sub", "user@example.com"));
105+
assertDoesNotThrow(
106+
() ->
107+
DatabricksCliCredentialsProvider.validateTokenScopes(
108+
token, Collections.singletonList("sql"), HOST));
109+
}
110+
111+
@Test
112+
void testNonJwtTokenSkipsValidation() {
113+
Token token = new Token("opaque-token-string", "Bearer", Instant.now().plusSeconds(3600));
114+
assertDoesNotThrow(
115+
() ->
116+
DatabricksCliCredentialsProvider.validateTokenScopes(
117+
token, Collections.singletonList("sql"), HOST));
118+
}
119+
120+
@Test
121+
void testErrorMessageContainsReauthCommand() {
122+
Token token = makeToken(Collections.singletonMap("scope", "all-apis"));
123+
DatabricksException e =
124+
assertThrows(
125+
DatabricksException.class,
126+
() ->
127+
DatabricksCliCredentialsProvider.validateTokenScopes(
128+
token, Arrays.asList("sql", "offline_access"), HOST));
129+
assertTrue(
130+
e.getMessage().contains("databricks auth login --host " + HOST + " --scopes sql"),
131+
"Expected re-auth command in error message, got: " + e.getMessage());
132+
}
133+
}

0 commit comments

Comments
 (0)