pentaho/pentaho-kettle · error · KettleDatabaseException

CmsTokenProvider: Keycloak response did not contain…

Error message

CmsTokenProvider: Keycloak response did not contain 'access_token'

What it means

CmsTokenProvider throws this when it successfully received an HTTP response from the Keycloak token endpoint but the parsed JSON body has no 'access_token' field. It means the server responded, but not with a usable OAuth2 token grant, so no credential can be cached or returned.

Solutions

  1. Verify the tokenUrl points to the exact OIDC token endpoint: <server>/realms/<realm>/protocol/openid-connect/token
  2. Check the client credentials (client_id/client_secret or username/password grant) are valid with: curl -d 'grant_type=...&client_id=...' <tokenUrl> and inspect the returned JSON for 'access_token' or an 'error' field
  3. Log/inspect the full response body and HTTP status to see what Keycloak actually returned
  4. Confirm no proxy or gateway is rewriting the response into an HTML error page

Example fix

// before (opaque failure)
Object tokenObj = responseBody.get( "access_token" );
// after (surface the server-side error)
Object tokenObj = responseBody.get( "access_token" );
if ( tokenObj == null ) {
  Object err = responseBody.get( "error" );
  throw new KettleDatabaseException( "Keycloak response missing access_token; server said: "
    + ( err != null ? err + ": " + responseBody.get( "error_description" ) : responseBody ) );
}
Defensive patterns

Strategy: validation

Validate before calling

// Pre-check via curl or Java: ensure the token endpoint returns a JSON grant
Map<String,Object> body = new ObjectMapper().readValue( httpClient.execute( tokenRequest ).getEntity().getContent(), Map.class );
if ( !body.containsKey( "access_token" ) ) {
  throw new IllegalStateException( "Token endpoint returned error: " + body.get( "error" ) );
}

Type guard

boolean hasToken( Map<String,Object> resp ) { return resp != null && resp.get( "access_token" ) instanceof String && !( (String) resp.get( "access_token" ) ).isEmpty(); }

Try / catch

try { token = CmsTokenProvider.getToken(); } catch ( KettleDatabaseException e ) { log.error( "Keycloak grant missing access_token; check client credentials and tokenUrl", e ); throw new AuthenticationException( e ); }

Prevention

When it happens

Trigger: fetchAndCache() POSTs credentials to the Keycloak token URL, parses the entity as a Map via Jackson, and throws when responseBody.get("access_token") is null — e.g. the endpoint returned an OAuth2 error body like {"error":"invalid_grant"}, an HTML login/error page, or an empty body with HTTP 200.

Common situations: Wrong client_id/client_secret or expired user credentials in the Keycloak grant; tokenUrl pointing at a non-token endpoint (e.g. the realm page instead of /protocol/openid-connect/token); a proxy or SSO gateway intercepting the request and returning HTML; Keycloak realm misconfiguration.

Related errors


AI-assisted analysis of pentaho/pentaho-kettle@f3058517a1 (2026-09-13). Data as JSON: /api/errors/e81394ac9913ecfc. Report an issue: GitHub.

Appendix: source

Thrown at core/src/main/java/org/pentaho/di/core/database/CmsTokenProvider.java:159

      request.setEntity( new StringEntity( body, StandardCharsets.UTF_8 ) );

      var response = client.execute( request );

      int status = response.getStatusLine().getStatusCode();
      if ( status != HttpURLConnection.HTTP_OK ) {
        throw new KettleDatabaseException(
          "CmsTokenProvider: Keycloak token request failed — HTTP " + status
            + " from " + tokenUrl );
      }

      Map<?, ?> responseBody;
      try ( java.io.InputStream is = response.getEntity().getContent() ) {
        responseBody = new ObjectMapper().readValue( is, Map.class );
      }

      Object tokenObj = responseBody.get( "access_token" );
      if ( tokenObj == null ) {
        throw new KettleDatabaseException(
          "CmsTokenProvider: Keycloak response did not contain 'access_token'" );
      }
      String accessToken = tokenObj.toString();

      long expiresInMs = 300_000L; // default 5 min if field is absent
      Object expiresInObj = responseBody.get( "expires_in" );
      if ( expiresInObj instanceof Number ) {
        expiresInMs = ( (Number) expiresInObj ).longValue() * 1000L;
      }
      long validUntilMs = System.currentTimeMillis() + expiresInMs - EXPIRY_BUFFER_MS;

      cached.set( new TokenEntry( accessToken, validUntilMs ) );
      log.logDebug( "CmsTokenProvider: token acquired, valid for ~" + ( expiresInMs / 1000 ) + "s" );
      return accessToken;

    } catch ( KettleDatabaseException e ) {
      throw e;
    } catch ( Exception e ) {

View on GitHub (pinned to f3058517a1)