diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 8017d10a0..71f8b3a94 100755 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -10,6 +10,7 @@ * Fixed non-JSON error responses (e.g. plain-text "Invalid Token" with HTTP 403) producing `Unknown` instead of the correct typed exception (`PermissionDenied`, `Unauthenticated`, etc.). The error message no longer contains Jackson deserialization internals. * Added `X-Databricks-Org-Id` header to deprecated workspace SCIM APIs (Groups, ServicePrincipals, Users) for SPOG host compatibility. * 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. +* Fixed `NullPointerException` during OAuth token refresh when the token endpoint returns a response without `access_token` or `token_type`. The SDK now throws a `DatabricksException` with the endpoint URL for debuggability ([#335](https://github.com/databricks/databricks-sdk-java/issues/335)). ### Security Vulnerabilities diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/oauth/TokenEndpointClient.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/oauth/TokenEndpointClient.java index 8931ec162..fef018e97 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/oauth/TokenEndpointClient.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/oauth/TokenEndpointClient.java @@ -92,6 +92,16 @@ public static OAuthResponse requestToken( String.format( "Token request failed with error: %s - %s", response.getErrorCode(), errorSummary)); } + if (response.getAccessToken() == null || response.getAccessToken().isEmpty()) { + throw new DatabricksException( + String.format( + "Token request to %s returned a response with no access_token", tokenEndpointUrl)); + } + if (response.getTokenType() == null || response.getTokenType().isEmpty()) { + throw new DatabricksException( + String.format( + "Token request to %s returned a response with no token_type", tokenEndpointUrl)); + } LOG.debug("Successfully obtained token response from {}", tokenEndpointUrl); return response; } @@ -143,6 +153,15 @@ public static Token retrieveToken( if (resp.getErrorCode() != null) { throw new IllegalArgumentException(resp.getErrorCode() + ": " + resp.getErrorSummary()); } + if (resp.getAccessToken() == null || resp.getAccessToken().isEmpty()) { + throw new DatabricksException( + String.format( + "Token request to %s returned a response with no access_token", tokenUrl)); + } + if (resp.getTokenType() == null || resp.getTokenType().isEmpty()) { + throw new DatabricksException( + String.format("Token request to %s returned a response with no token_type", tokenUrl)); + } Instant expiry = Instant.now().plusSeconds(resp.getExpiresIn()); return new Token(resp.getAccessToken(), resp.getTokenType(), resp.getRefreshToken(), expiry); } catch (Exception e) { diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/EndpointTokenSourceTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/EndpointTokenSourceTest.java index 5fdac9f2d..fbcea4744 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/EndpointTokenSourceTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/EndpointTokenSourceTest.java @@ -45,6 +45,8 @@ private static Stream provideEndpointTokenScenarios() throws Exceptio + "\"error\":\"invalid_client\"," + "\"error_description\":\"Client authentication failed\"}"; + String noAccessTokenJson = "{" + "\"token_type\":\"Bearer\"," + "\"expires_in\":3600" + "}"; + String malformedJson = "{not valid json}"; // Mock DatabricksOAuthTokenSource for control plane token @@ -69,6 +71,12 @@ private static Stream provideEndpointTokenScenarios() throws Exceptio .thenReturn( new Response(malformedJson, 200, "OK", new URL("https://test.databricks.com/"))); + // Mock HttpClient for no access_token + HttpClient mockNoAccessTokenClient = mock(HttpClient.class); + when(mockNoAccessTokenClient.execute(any())) + .thenReturn( + new Response(noAccessTokenJson, 200, "OK", new URL("https://test.databricks.com/"))); + // Mock HttpClient for IOException HttpClient mockIOExceptionClient = mock(HttpClient.class); when(mockIOExceptionClient.execute(any())).thenThrow(new IOException("Network error")); @@ -183,6 +191,17 @@ private static Stream provideEndpointTokenScenarios() throws Exceptio null, null, null, + 0), + Arguments.of( + "Missing access_token in response", + mockCpTokenSource, + TEST_AUTH_DETAILS, + mockNoAccessTokenClient, + TEST_HOST, + DatabricksException.class, + null, + null, + null, 0)); } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/TokenEndpointClientTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/TokenEndpointClientTest.java index 581c90143..c914e508a 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/TokenEndpointClientTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/TokenEndpointClientTest.java @@ -34,6 +34,14 @@ private static Stream provideTokenScenarios() throws Exception { "{" + "\"error\":\"invalid_client\"," + "\"error_description\":\"Client authentication failed\"}"; + // Response with no access_token + String noAccessTokenJson = "{" + "\"token_type\":\"Bearer\"," + "\"expires_in\":3600" + "}"; + // Response with no token_type + String noTokenTypeJson = + "{" + "\"access_token\":\"test-access-token\"," + "\"expires_in\":3600" + "}"; + // Response with empty access_token + String emptyAccessTokenJson = + "{" + "\"access_token\":\"\"," + "\"token_type\":\"Bearer\"," + "\"expires_in\":3600" + "}"; // Malformed JSON String malformedJson = "{not valid json}"; @@ -54,6 +62,24 @@ private static Stream provideTokenScenarios() throws Exception { .thenReturn( new Response(malformedJson, 200, "OK", new URL("https://test.databricks.com/"))); + // Mock HttpClient for no access_token + HttpClient mockNoAccessTokenClient = mock(HttpClient.class); + when(mockNoAccessTokenClient.execute(any(FormRequest.class))) + .thenReturn( + new Response(noAccessTokenJson, 200, "OK", new URL("https://test.databricks.com/"))); + + // Mock HttpClient for no token_type + HttpClient mockNoTokenTypeClient = mock(HttpClient.class); + when(mockNoTokenTypeClient.execute(any(FormRequest.class))) + .thenReturn( + new Response(noTokenTypeJson, 200, "OK", new URL("https://test.databricks.com/"))); + + // Mock HttpClient for empty access_token + HttpClient mockEmptyAccessTokenClient = mock(HttpClient.class); + when(mockEmptyAccessTokenClient.execute(any(FormRequest.class))) + .thenReturn( + new Response(emptyAccessTokenJson, 200, "OK", new URL("https://test.databricks.com/"))); + // Mock HttpClient for IOException HttpClient mockIOExceptionClient = mock(HttpClient.class); when(mockIOExceptionClient.execute(any(FormRequest.class))) @@ -139,6 +165,36 @@ private static Stream provideTokenScenarios() throws Exception { null, null, 0, + null), + Arguments.of( + "Missing access_token in response", + mockNoAccessTokenClient, + TOKEN_ENDPOINT_URL, + PARAMS, + DatabricksException.class, + null, + null, + 0, + null), + Arguments.of( + "Missing token_type in response", + mockNoTokenTypeClient, + TOKEN_ENDPOINT_URL, + PARAMS, + DatabricksException.class, + null, + null, + 0, + null), + Arguments.of( + "Empty access_token in response", + mockEmptyAccessTokenClient, + TOKEN_ENDPOINT_URL, + PARAMS, + DatabricksException.class, + null, + null, + 0, null)); }