22
33import com .databricks .sdk .core .oauth .CachedTokenSource ;
44import com .databricks .sdk .core .oauth .OAuthHeaderFactory ;
5+ import com .databricks .sdk .core .oauth .Token ;
56import com .databricks .sdk .core .utils .OSUtils ;
67import 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 ;
711import java .util .*;
812import org .slf4j .Logger ;
913import 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}
0 commit comments