Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the
* Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt
*/
package com.salesforce.datacloud.jdbc.auth;

import static com.salesforce.datacloud.jdbc.util.PropertyParsingUtils.takeOptional;
import static com.salesforce.datacloud.jdbc.util.PropertyParsingUtils.takeRequired;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.salesforce.datacloud.jdbc.auth.model.DataCloudTokenResponse;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
import java.util.Properties;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class DirectCdpTokenProcessor {
static final String CDP_TOKEN_KEY = "cdpToken";
static final String TENANT_URL_KEY = "tenantUrl";
static final String DATASPACE_KEY = "dataspace";
static final int FALLBACK_EXPIRES_IN_SECONDS = 3600;
private static final ObjectMapper JSON = new ObjectMapper();

private final String cdpToken;
private final String tenantUrl;
private final String dataspace;
private DataCloudToken cachedDataCloudToken;

private DirectCdpTokenProcessor(String cdpToken, String tenantUrl, String dataspace) {
this.cdpToken = cdpToken;
this.tenantUrl = tenantUrl;
this.dataspace = dataspace;
}

public static boolean hasCdpToken(Properties properties) {
return properties != null && properties.containsKey(CDP_TOKEN_KEY) && properties.containsKey(TENANT_URL_KEY);
}

public static DirectCdpTokenProcessor ofDestructive(Properties properties) throws SQLException {
try {
String cdpToken = takeRequired(properties, CDP_TOKEN_KEY);
String tenantUrl = validateTenantHost(takeRequired(properties, TENANT_URL_KEY));
String dataspace = takeOptional(properties, DATASPACE_KEY).orElse(null);
DirectCdpTokenProcessor processor = new DirectCdpTokenProcessor(cdpToken, tenantUrl, dataspace);
processor.validateToken();
return processor;
} catch (IllegalArgumentException ex) {
throw new SQLException(ex.getMessage(), "28000", ex);
}
}

/**
* tenantUrl must be a bare hostname — gRPC's {@code ManagedChannelBuilder.forAddress} requires it.
* Reject schemes, ports, paths, and whitespace so users get a clear error rather than a confusing
* connection failure later.
*/
static String validateTenantHost(String tenantUrl) {
if (tenantUrl.contains("://") || tenantUrl.contains("/") || tenantUrl.contains(":")) {
throw new IllegalArgumentException(
"tenantUrl must be a bare hostname (e.g. 'tenant.c360a.salesforce.com'), got: '" + tenantUrl + "'");
}
String trimmed = tenantUrl.trim();
if (trimmed.isEmpty() || trimmed.length() != tenantUrl.length()) {
throw new IllegalArgumentException(
"tenantUrl must be a non-empty hostname with no whitespace, got: '" + tenantUrl + "'");
}
return trimmed;
}

private void validateToken() throws SQLException {
try {
DataCloudToken token = buildDataCloudToken();
token.getTenantId();
cachedDataCloudToken = token;
} catch (Exception ex) {
throw new SQLException(
"Invalid CDP token: unable to parse JWT or extract tenant ID. " + ex.getMessage(), "28000", ex);
}
}

public DataCloudToken getDataCloudToken() throws SQLException {
if (cachedDataCloudToken == null || !cachedDataCloudToken.isAlive()) {
cachedDataCloudToken = buildDataCloudToken();
}
return cachedDataCloudToken;
}

public String getLakehouse() throws SQLException {
String tenantId = getDataCloudToken().getTenantId();
String response =
"lakehouse:" + tenantId + ";" + Optional.ofNullable(dataspace).orElse("");
log.info("Lakehouse: {}", response);
return response;
}

private DataCloudToken buildDataCloudToken() throws SQLException {
DataCloudTokenResponse response = new DataCloudTokenResponse();
response.setToken(cdpToken);
response.setInstanceUrl(tenantUrl);
response.setTokenType("Bearer");
response.setExpiresIn(secondsUntilJwtExpiry(cdpToken));
return DataCloudToken.of(response);
}

/**
* Returns seconds remaining until the JWT's `exp` claim, clamped to a minimum of 0.
* If the JWT cannot be parsed or has no `exp` claim, falls back to {@link #FALLBACK_EXPIRES_IN_SECONDS}
* so callers behave identically to the previous fixed-TTL behavior.
*/
static int secondsUntilJwtExpiry(String jwt) {
try {
String[] chunks = jwt.split("\\.", -1);
if (chunks.length < 2) {
return FALLBACK_EXPIRES_IN_SECONDS;
}
byte[] decoded = Base64.getUrlDecoder().decode(chunks[1]);
JsonNode payload = JSON.readTree(decoded);
JsonNode exp = payload.get("exp");
if (exp == null || !exp.canConvertToLong()) {
return FALLBACK_EXPIRES_IN_SECONDS;
}
long remaining = exp.asLong() - Instant.now().getEpochSecond();
return remaining > 0 ? (int) Math.min(remaining, Integer.MAX_VALUE) : 0;
} catch (Exception ex) {
log.debug("Could not derive exp from CDP token, using default TTL", ex);
return FALLBACK_EXPIRES_IN_SECONDS;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the
* Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt
*/
package com.salesforce.datacloud.jdbc.auth;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Base64;
import java.util.Properties;
import java.util.UUID;
import org.junit.jupiter.api.Test;

class DirectCdpTokenProcessorTest {

private static final String TENANT_HOST = "test.c360a.salesforce.com";
private static final String TENANT_ID = "a360/falcondev/a6d726a73f534327a6a8e2e0f3cc3840";

/**
* Builds an unsigned JWT with the given audienceTenantId and exp claim. The {@link DataCloudToken}
* decoder only base64-parses the payload — it does not verify the signature — so a fixed bogus
* signature is enough to exercise the production path.
*/
private static String jwtWithExp(long expEpochSeconds) {
try {
ObjectNode header = new ObjectMapper().createObjectNode();
header.put("alg", "ES256");
header.put("typ", "JWT");

ObjectNode payload = new ObjectMapper().createObjectNode();
payload.put("audienceTenantId", TENANT_ID);
payload.put("exp", expEpochSeconds);

Base64.Encoder enc = Base64.getUrlEncoder().withoutPadding();
String h = enc.encodeToString(header.toString().getBytes(StandardCharsets.UTF_8));
String p = enc.encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8));
return h + "." + p + ".sig";
} catch (Exception e) {
throw new RuntimeException(e);
}
}

private static String validJwt() {
return jwtWithExp(Instant.now().getEpochSecond() + 3600);
}

private static Properties propertiesForCdpToken(String cdpToken, String tenantUrl) {
Properties properties = new Properties();
properties.setProperty("cdpToken", cdpToken);
properties.setProperty("tenantUrl", tenantUrl);
return properties;
}

@Test
void getDataCloudTokenReturnsValidToken() throws SQLException {
String token = validJwt();
DirectCdpTokenProcessor processor =
DirectCdpTokenProcessor.ofDestructive(propertiesForCdpToken(token, TENANT_HOST));
DataCloudToken dcToken = processor.getDataCloudToken();

assertThat(dcToken.getAccessToken()).isEqualTo("Bearer " + token);
assertThat(dcToken.getTenantUrl()).isEqualTo(TENANT_HOST);
assertThat(dcToken.getTenantId()).isEqualTo(TENANT_ID);
assertThat(dcToken.isAlive()).isTrue();
}

@Test
void ofDestructiveRejectsTenantUrlWithScheme() {
for (String invalid : new String[] {
"https://" + TENANT_HOST, "http://" + TENANT_HOST, TENANT_HOST + ":443", TENANT_HOST + "/",
}) {
Properties props = propertiesForCdpToken(validJwt(), invalid);
SQLException ex = assertThrows(
SQLException.class,
() -> DirectCdpTokenProcessor.ofDestructive(props),
"Expected rejection of: " + invalid);
assertThat(ex.getMessage()).contains("bare hostname");
}
}

@Test
void ofDestructiveRejectsTenantUrlWithWhitespace() {
Properties props = propertiesForCdpToken(validJwt(), " " + TENANT_HOST + " ");
SQLException ex = assertThrows(SQLException.class, () -> DirectCdpTokenProcessor.ofDestructive(props));
assertThat(ex.getMessage()).contains("whitespace");
}

@Test
void getDataCloudTokenReturnsCachedToken() throws SQLException {
DirectCdpTokenProcessor processor =
DirectCdpTokenProcessor.ofDestructive(propertiesForCdpToken(validJwt(), TENANT_HOST));
DataCloudToken first = processor.getDataCloudToken();
DataCloudToken second = processor.getDataCloudToken();

assertThat(first).isSameAs(second);
}

@Test
void getLakehouseWithoutDataspace() throws SQLException {
DirectCdpTokenProcessor processor =
DirectCdpTokenProcessor.ofDestructive(propertiesForCdpToken(validJwt(), TENANT_HOST));
assertThat(processor.getLakehouse()).isEqualTo("lakehouse:" + TENANT_ID + ";");
}

@Test
void getLakehouseWithDataspace() throws SQLException {
String dataspace = UUID.randomUUID().toString();
Properties props = propertiesForCdpToken(validJwt(), TENANT_HOST);
props.setProperty("dataspace", dataspace);

DirectCdpTokenProcessor processor = DirectCdpTokenProcessor.ofDestructive(props);
assertThat(processor.getLakehouse()).isEqualTo("lakehouse:" + TENANT_ID + ";" + dataspace);
}

@Test
void ofDestructiveThrowsWhenCdpTokenMissing() {
Properties props = new Properties();
props.setProperty("tenantUrl", TENANT_HOST);

SQLException ex = assertThrows(SQLException.class, () -> DirectCdpTokenProcessor.ofDestructive(props));
assertThat(ex.getMessage()).contains("cdpToken");
}

@Test
void ofDestructiveThrowsWhenTenantUrlMissing() {
Properties props = new Properties();
props.setProperty("cdpToken", validJwt());

SQLException ex = assertThrows(SQLException.class, () -> DirectCdpTokenProcessor.ofDestructive(props));
assertThat(ex.getMessage()).contains("tenantUrl");
}

@Test
void ofDestructiveThrowsWhenCdpTokenIsInvalidJwt() {
Properties props = propertiesForCdpToken("not-a-valid-jwt", TENANT_HOST);

SQLException ex = assertThrows(SQLException.class, () -> DirectCdpTokenProcessor.ofDestructive(props));
assertThat(ex.getMessage()).contains("Invalid CDP token");
}

@Test
void ofDestructiveRemovesPropertiesFromInput() throws SQLException {
Properties props = propertiesForCdpToken(validJwt(), TENANT_HOST);
props.setProperty("dataspace", "myspace");

DirectCdpTokenProcessor.ofDestructive(props);

assertThat(props.containsKey("cdpToken")).isFalse();
assertThat(props.containsKey("tenantUrl")).isFalse();
assertThat(props.containsKey("dataspace")).isFalse();
}

@Test
void hasCdpTokenReturnsTrueWhenBothPresent() {
Properties props = propertiesForCdpToken(validJwt(), TENANT_HOST);
assertThat(DirectCdpTokenProcessor.hasCdpToken(props)).isTrue();
}

@Test
void hasCdpTokenReturnsFalseWhenMissing() {
assertThat(DirectCdpTokenProcessor.hasCdpToken(new Properties())).isFalse();
assertThat(DirectCdpTokenProcessor.hasCdpToken(null)).isFalse();

Properties onlyCdpToken = new Properties();
onlyCdpToken.setProperty("cdpToken", validJwt());
assertThat(DirectCdpTokenProcessor.hasCdpToken(onlyCdpToken)).isFalse();

Properties onlyTenantUrl = new Properties();
onlyTenantUrl.setProperty("tenantUrl", TENANT_HOST);
assertThat(DirectCdpTokenProcessor.hasCdpToken(onlyTenantUrl)).isFalse();
}

@Test
void secondsUntilJwtExpiryReturnsRemainingForValidJwt() {
long futureExp = Instant.now().getEpochSecond() + 1234;
int remaining = DirectCdpTokenProcessor.secondsUntilJwtExpiry(jwtWithExp(futureExp));
assertThat(remaining).isBetween(1230, 1234);
}

@Test
void secondsUntilJwtExpiryFallsBackWhenJwtSingleSegment() {
assertThat(DirectCdpTokenProcessor.secondsUntilJwtExpiry("only-one-segment"))
.isEqualTo(DirectCdpTokenProcessor.FALLBACK_EXPIRES_IN_SECONDS);
}

@Test
void secondsUntilJwtExpiryFallsBackWhenPayloadNotBase64() {
// Two segments but second one is not valid base64url
assertThat(DirectCdpTokenProcessor.secondsUntilJwtExpiry("header.@@not-base64@@.sig"))
.isEqualTo(DirectCdpTokenProcessor.FALLBACK_EXPIRES_IN_SECONDS);
}

@Test
void secondsUntilJwtExpiryFallsBackWhenExpClaimMissing() {
Base64.Encoder enc = Base64.getUrlEncoder().withoutPadding();
String header = enc.encodeToString("{\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8));
String payload = enc.encodeToString("{\"sub\":\"x\"}".getBytes(StandardCharsets.UTF_8));
assertThat(DirectCdpTokenProcessor.secondsUntilJwtExpiry(header + "." + payload + ".sig"))
.isEqualTo(DirectCdpTokenProcessor.FALLBACK_EXPIRES_IN_SECONDS);
}
}
Loading
Loading