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 6cfbadc3d..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 @@ -2,8 +2,13 @@ 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.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,6 +20,22 @@ public class DatabricksCliCredentialsProvider implements CredentialsProvider { public static final String DATABRICKS_CLI = "databricks-cli"; + 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. + */ + private static final Set SCOPES_IGNORED_FOR_COMPARISON = + Collections.singleton("offline_access"); + @Override public String authType() { return DATABRICKS_CLI; @@ -92,13 +113,41 @@ 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 = + new TokenSource() { + @Override + public Token getToken() { + 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(); 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")) { @@ -112,4 +161,91 @@ public OAuthHeaderFactory configure(DatabricksConfig config) { 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); + + 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` with the --scopes flag." + + "Scopes default to all-apis.", + sortedTokenScopes, sortedRequested)); + } + } + + /** + * 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) { + 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 { + 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 (IllegalArgumentException | JsonProcessingException 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 627b0ab2f..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 @@ -434,6 +434,15 @@ public DatabricksConfig setScopes(List scopes) { return this; } + /** + * 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. + */ + boolean isScopesExplicitlySet() { + return scopes != null && !scopes.isEmpty(); + } + 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 bac4a766b..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 @@ -139,4 +139,13 @@ void testBuildHostArgs_UnifiedHostFalse_WithAccountHost() { CLI_PATH, "auth", "token", "--host", ACCOUNT_HOST, "--account-id", ACCOUNT_ID), cmd); } + + @Test + void testScopesExplicitlySetFlag() { + DatabricksConfig config = new DatabricksConfig(); + assertFalse(config.isScopesExplicitlySet()); + + config.setScopes(Arrays.asList("sql", "clusters")); + assertTrue(config.isScopesExplicitlySet()); + } } 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..e3f038b23 --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliScopeValidationTest.java @@ -0,0 +1,132 @@ +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( + DatabricksCliCredentialsProvider.ScopeMismatchException.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")); + DatabricksCliCredentialsProvider.ScopeMismatchException e = + assertThrows( + DatabricksCliCredentialsProvider.ScopeMismatchException.class, + () -> + DatabricksCliCredentialsProvider.validateTokenScopes( + token, Arrays.asList("sql", "offline_access"), HOST)); + assertTrue( + 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()); + } +}