33import com .databricks .sdk .core .oauth .CachedTokenSource ;
44import com .databricks .sdk .core .oauth .OAuthHeaderFactory ;
55import com .databricks .sdk .core .oauth .Token ;
6+ import com .databricks .sdk .core .oauth .TokenSource ;
67import com .databricks .sdk .core .utils .OSUtils ;
78import com .databricks .sdk .support .InternalApi ;
9+ import com .fasterxml .jackson .core .JsonProcessingException ;
810import com .fasterxml .jackson .databind .ObjectMapper ;
911import java .nio .charset .StandardCharsets ;
1012import java .util .*;
@@ -20,6 +22,13 @@ public class DatabricksCliCredentialsProvider implements CredentialsProvider {
2022
2123 private static final ObjectMapper MAPPER = new ObjectMapper ();
2224
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+
2332 /**
2433 * offline_access controls whether the IdP issues a refresh token. It does not grant any API
2534 * permissions, so its presence or absence should not cause a scope mismatch error.
@@ -104,18 +113,38 @@ public OAuthHeaderFactory configure(DatabricksConfig config) {
104113 return null ;
105114 }
106115
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+ () -> {
123+ Token t = tokenSource .getToken ();
124+ validateTokenScopes (t , scopes , host );
125+ return t ;
126+ };
127+ } else {
128+ effectiveSource = tokenSource ;
129+ }
130+
107131 CachedTokenSource cachedTokenSource =
108- new CachedTokenSource .Builder (tokenSource )
132+ new CachedTokenSource .Builder (effectiveSource )
109133 .setAsyncDisabled (config .getDisableAsyncTokenRefresh ())
110134 .build ();
111- Token token =
112- cachedTokenSource .getToken (); // We need this for checking if databricks CLI is installed.
113-
114- if (config .isScopesExplicitlySet ()) {
115- validateTokenScopes (token , config .getScopes (), config .getHost ());
116- }
135+ cachedTokenSource .getToken (); // We need this for checking if databricks CLI is installed.
117136
118137 return OAuthHeaderFactory .fromTokenSource (cachedTokenSource );
138+ } catch (ScopeMismatchException e ) {
139+ // Scope validation failed. When the user explicitly selected databricks-cli auth,
140+ // surface the mismatch immediately so they get an actionable error. When we're being
141+ // tried as part of the default credential chain, step aside so other providers get
142+ // a chance.
143+ if (DATABRICKS_CLI .equals (config .getAuthType ())) {
144+ throw e ;
145+ }
146+ LOG .warn ("Databricks CLI token scope mismatch, skipping: {}" , e .getMessage ());
147+ return null ;
119148 } catch (DatabricksException e ) {
120149 String stderr = e .getMessage ();
121150 if (stderr .contains ("not found" )) {
@@ -126,17 +155,6 @@ public OAuthHeaderFactory configure(DatabricksConfig config) {
126155 LOG .info ("OAuth not configured or not available" );
127156 return null ;
128157 }
129- // Scope validation failed. When the user explicitly selected databricks-cli auth,
130- // surface the mismatch immediately so they get an actionable error. When we're being
131- // tried as part of the default credential chain, step aside so other providers get
132- // a chance.
133- if (stderr .contains ("do not match the configured scopes" )) {
134- if (DATABRICKS_CLI .equals (config .getAuthType ())) {
135- throw e ;
136- }
137- LOG .warn ("Databricks CLI token scope mismatch, skipping: {}" , e .getMessage ());
138- return null ;
139- }
140158 throw e ;
141159 }
142160 }
@@ -179,15 +197,13 @@ static void validateTokenScopes(Token token, List<String> requestedScopes, Strin
179197 List <String > sortedRequested = new ArrayList <>(requested );
180198 Collections .sort (sortedRequested );
181199
182- // Build a re-auth command hint with scopes (excluding offline_access)
183- String scopesArg = String .join ("," , sortedRequested );
184-
185- throw new DatabricksException (
200+ throw new ScopeMismatchException (
186201 String .format (
187202 "Token issued by Databricks CLI has scopes %s which do not match "
188- + "the configured scopes %s. Please re-authenticate with the desired scopes "
189- + "by running `databricks auth login --host %s --scopes %s`." ,
190- sortedTokenScopes , sortedRequested , host , scopesArg ));
203+ + "the configured scopes %s. Please re-authenticate "
204+ + "with the desired scopes by running `databricks auth login` with the --scopes flag."
205+ + "Scopes default to all-apis." ,
206+ sortedTokenScopes , sortedRequested ));
191207 }
192208 }
193209
@@ -196,18 +212,18 @@ static void validateTokenScopes(Token token, List<String> requestedScopes, Strin
196212 * valid JWT.
197213 */
198214 private static Map <String , Object > getJwtClaims (String accessToken ) {
215+ String [] parts = accessToken .split ("\\ ." );
216+ if (parts .length != 3 ) {
217+ LOG .debug ("Tried to decode access token as JWT, but failed: {} components" , parts .length );
218+ return null ;
219+ }
199220 try {
200- String [] parts = accessToken .split ("\\ ." );
201- if (parts .length != 3 ) {
202- LOG .debug ("Tried to decode access token as JWT, but failed: {} components" , parts .length );
203- return null ;
204- }
205221 byte [] payloadBytes = Base64 .getUrlDecoder ().decode (parts [1 ]);
206222 String payloadJson = new String (payloadBytes , StandardCharsets .UTF_8 );
207223 @ SuppressWarnings ("unchecked" )
208224 Map <String , Object > claims = MAPPER .readValue (payloadJson , Map .class );
209225 return claims ;
210- } catch (Exception e ) {
226+ } catch (IllegalArgumentException | JsonProcessingException e ) {
211227 LOG .debug ("Failed to decode JWT claims: {}" , e .getMessage ());
212228 return null ;
213229 }
0 commit comments