entry : params.entrySet()) {
+ if (!first) {
+ url.append('&');
+ }
+ first = false;
+ url.append(urlEncode(entry.getKey())).append('=').append(urlEncode(entry.getValue()));
+ }
+ return URI.create(url.toString());
+ }
+
+ private static String urlEncode(String value) {
+ try {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+ } catch (java.io.UnsupportedEncodingException ex) {
+ // UTF-8 is required by every JRE — this branch is unreachable in practice.
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static String randomState() {
+ byte[] buf = new byte[24];
+ new SecureRandom().nextBytes(buf);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
+ }
+
+ @lombok.Value
+ private static class PendingAuthCodeExchange {
+ String authorizationCode;
+ String codeVerifier;
+ String redirectUri;
+ }
+
private DataCloudToken exchangeOauthForDataCloudToken() throws SQLException {
val oauthToken = getOAuthToken();
@@ -146,6 +259,23 @@ private FormCommand buildAuthenticate() throws SQLException {
builder.bodyEntry("refresh_token", settings.getRefreshToken());
builder.bodyEntry("client_secret", settings.getClientSecret());
break;
+ case AUTH_CODE_PKCE:
+ // https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_web_server_flow.htm
+ if (pendingAuthCodeExchange == null) {
+ throw new SQLException(
+ "Authorization-code flow requested but no code was captured. "
+ + "Did the browser dance fail?",
+ "28000");
+ }
+ builder.bodyEntry("grant_type", "authorization_code");
+ builder.bodyEntry("code", pendingAuthCodeExchange.getAuthorizationCode());
+ builder.bodyEntry("redirect_uri", pendingAuthCodeExchange.getRedirectUri());
+ builder.bodyEntry("code_verifier", pendingAuthCodeExchange.getCodeVerifier());
+ if (StringUtils.isNotEmpty(settings.getClientSecret())) {
+ // Confidential ECAs still expect a client_secret alongside the verifier.
+ builder.bodyEntry("client_secret", settings.getClientSecret());
+ }
+ break;
}
return builder.build();
diff --git a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServer.java b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServer.java
new file mode 100644
index 00000000..25c4b194
--- /dev/null
+++ b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServer.java
@@ -0,0 +1,188 @@
+/**
+ * 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 com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import lombok.Value;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Single-request HTTP server bound to a loopback address that accepts the
+ * OAuth authorization-code callback from the user's browser.
+ *
+ * Per RFC 8252 (OAuth 2.0 for Native Apps) installed apps should redirect
+ * to {@code http://127.0.0.1:} and bind that port immediately
+ * before opening the browser. The server stops itself as soon as it has
+ * captured one valid response or an OAuth error.
+ *
+ * Closing the server is idempotent.
+ */
+@Slf4j
+public final class LoopbackCallbackServer implements Closeable {
+
+ public static final String CALLBACK_PATH = "/callback";
+
+ private static final String SUCCESS_PAGE = ""
+ + "
Authentication complete"
+ + ""
+ + "You can close this tab
"
+ + "The Data Cloud JDBC driver received the authorization code.
"
+ + "";
+
+ private static final String ERROR_PAGE_PREFIX = ""
+ + "Authentication failed"
+ + ""
+ + "Authentication failed
";
+
+ private static final String ERROR_PAGE_SUFFIX = "
";
+
+ private final HttpServer server;
+ private final CompletableFuture result = new CompletableFuture<>();
+
+ private LoopbackCallbackServer(HttpServer server) {
+ this.server = server;
+ }
+
+ /**
+ * Bind a loopback server on the requested port. Use {@code 0} to let the OS pick
+ * an ephemeral port (recommended). The bound port is available via {@link #getPort()}.
+ */
+ public static LoopbackCallbackServer start(int port) throws IOException {
+ // Bind to 127.0.0.1 explicitly — never expose the callback to the network.
+ InetSocketAddress address = new InetSocketAddress("127.0.0.1", port);
+ HttpServer http = HttpServer.create(address, 0);
+ LoopbackCallbackServer instance = new LoopbackCallbackServer(http);
+ http.createContext(CALLBACK_PATH, instance.new CallbackHandler());
+ // Default executor is fine; we only ever serve one useful request.
+ http.start();
+ log.debug("Loopback OAuth callback listening on http://127.0.0.1:{}{}", instance.getPort(), CALLBACK_PATH);
+ return instance;
+ }
+
+ public int getPort() {
+ return server.getAddress().getPort();
+ }
+
+ public URI getRedirectUri() {
+ return URI.create("http://127.0.0.1:" + getPort() + CALLBACK_PATH);
+ }
+
+ /**
+ * Block up to {@code timeoutMillis} for the browser callback. Throws on
+ * OAuth-level errors (the {@code error}/{@code error_description} query
+ * params) and on timeout.
+ */
+ public Result awaitCallback(long timeoutMillis) throws IOException {
+ try {
+ return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ throw new IOException("Timed out after " + timeoutMillis + " ms waiting for OAuth browser callback", ex);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while waiting for OAuth browser callback", ex);
+ } catch (Exception ex) {
+ // CompletionException / ExecutionException — unwrap so the caller sees the real cause.
+ Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
+ if (cause instanceof IOException) {
+ throw (IOException) cause;
+ }
+ throw new IOException(cause.getMessage(), cause);
+ }
+ }
+
+ @Override
+ public void close() {
+ // Stop quickly: in-flight handler has already completed when we get here.
+ server.stop(0);
+ }
+
+ @Value
+ public static class Result {
+ String code;
+ String state;
+ }
+
+ private final class CallbackHandler implements HttpHandler {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ try {
+ Map params = parseQuery(exchange.getRequestURI().getRawQuery());
+ String error = params.get("error");
+ if (error != null) {
+ String description = params.getOrDefault("error_description", "");
+ String body = ERROR_PAGE_PREFIX + escapeHtml(error + ": " + description) + ERROR_PAGE_SUFFIX;
+ respond(exchange, 400, body);
+ result.completeExceptionally(
+ new IOException("OAuth authorization failed: " + error + " - " + description));
+ return;
+ }
+
+ String code = params.get("code");
+ String state = params.get("state");
+ if (code == null || code.isEmpty()) {
+ String body = ERROR_PAGE_PREFIX + "Authorization response did not include a code parameter."
+ + ERROR_PAGE_SUFFIX;
+ respond(exchange, 400, body);
+ result.completeExceptionally(new IOException("OAuth callback was missing a code parameter"));
+ return;
+ }
+
+ respond(exchange, 200, SUCCESS_PAGE);
+ result.complete(new Result(code, state));
+ } catch (Exception ex) {
+ result.completeExceptionally(ex);
+ throw ex;
+ }
+ }
+ }
+
+ private static void respond(HttpExchange exchange, int status, String body) throws IOException {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
+ exchange.sendResponseHeaders(status, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ private static Map parseQuery(String rawQuery) {
+ Map result = new HashMap<>();
+ if (rawQuery == null || rawQuery.isEmpty()) {
+ return result;
+ }
+ for (String pair : rawQuery.split("&")) {
+ int eq = pair.indexOf('=');
+ String key = eq >= 0 ? pair.substring(0, eq) : pair;
+ String value = eq >= 0 ? pair.substring(eq + 1) : "";
+ try {
+ result.put(
+ URLDecoder.decode(key, StandardCharsets.UTF_8.name()),
+ URLDecoder.decode(value, StandardCharsets.UTF_8.name()));
+ } catch (Exception ex) {
+ // Malformed param: skip but don't fail the whole callback.
+ log.warn("Skipping malformed OAuth callback parameter `{}`", pair);
+ }
+ }
+ return result;
+ }
+
+ private static String escapeHtml(String s) {
+ return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
+ }
+}
diff --git a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/PkceCodes.java b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/PkceCodes.java
new file mode 100644
index 00000000..60943d97
--- /dev/null
+++ b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/PkceCodes.java
@@ -0,0 +1,52 @@
+/**
+ * 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 java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+import lombok.Value;
+
+/**
+ * Proof Key for Code Exchange (PKCE) verifier and challenge per RFC 7636.
+ *
+ * The verifier is a 32-byte cryptographically random string, base64url-encoded
+ * without padding (43 characters). The S256 challenge is the base64url-encoded
+ * SHA-256 of the verifier's ASCII bytes. Salesforce External Client Apps support
+ * S256 only.
+ */
+@Value
+public final class PkceCodes {
+
+ public static final String CHALLENGE_METHOD_S256 = "S256";
+
+ String verifier;
+ String challenge;
+
+ public static PkceCodes generate() {
+ return generate(new SecureRandom());
+ }
+
+ static PkceCodes generate(SecureRandom random) {
+ byte[] verifierBytes = new byte[32];
+ random.nextBytes(verifierBytes);
+ String verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(verifierBytes);
+ String challenge = s256Challenge(verifier);
+ return new PkceCodes(verifier, challenge);
+ }
+
+ static String s256Challenge(String verifier) {
+ try {
+ MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+ byte[] digest = sha256.digest(verifier.getBytes(StandardCharsets.US_ASCII));
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+ } catch (NoSuchAlgorithmException ex) {
+ // SHA-256 is required by every JRE — this branch is unreachable in practice.
+ throw new IllegalStateException("SHA-256 algorithm not available", ex);
+ }
+ }
+}
diff --git a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/SalesforceAuthProperties.java b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/SalesforceAuthProperties.java
index 3f65b01c..b3f51e95 100644
--- a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/SalesforceAuthProperties.java
+++ b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/SalesforceAuthProperties.java
@@ -5,6 +5,7 @@
package com.salesforce.datacloud.jdbc.auth;
import static com.salesforce.datacloud.jdbc.util.PropertyParsingUtils.takeOptional;
+import static com.salesforce.datacloud.jdbc.util.PropertyParsingUtils.takeOptionalInteger;
import static com.salesforce.datacloud.jdbc.util.PropertyParsingUtils.takeRequired;
import com.google.common.collect.ImmutableList;
@@ -37,10 +38,15 @@
* - userName: Username for password/private key authentication
* - password: Password for password authentication
* - privateKey: Private key for JWT authentication
- * - clientSecret: OAuth client secret (required for PASSWORD and REFRESH_TOKEN modes, not allowed for PRIVATE_KEY/JWT mode)
+ * - clientSecret: OAuth client secret (required for PASSWORD and REFRESH_TOKEN modes, not allowed for PRIVATE_KEY/JWT mode,
+ * optional for AUTH_CODE_PKCE mode for confidential clients)
* - clientId: OAuth client ID (required)
* - dataspace: Data space identifier, default is null
* - refreshToken: Refresh token for token-based authentication
+ * - authMode: Selects AUTH_CODE_PKCE explicitly when no other credentials are present
+ * - oauthScope: OAuth scope to request during the authorization-code flow (default: cdp_query_api api refresh_token)
+ * - redirectPort: Port for the loopback OAuth callback (default 0 = pick a free ephemeral port)
+ * - browserAuthTimeoutSeconds: How long to wait for the user to complete the browser flow (default 120)
*/
@Slf4j
@Getter
@@ -49,7 +55,8 @@ public class SalesforceAuthProperties {
public enum AuthenticationMode {
PASSWORD,
PRIVATE_KEY,
- REFRESH_TOKEN
+ REFRESH_TOKEN,
+ AUTH_CODE_PKCE
}
static final String AUTH_USER_NAME = "userName";
@@ -59,6 +66,13 @@ public enum AuthenticationMode {
static final String AUTH_CLIENT_SECRET = "clientSecret";
static final String AUTH_REFRESH_TOKEN = "refreshToken";
static final String AUTH_DATASPACE = "dataspace";
+ static final String AUTH_MODE = "authMode";
+ static final String AUTH_OAUTH_SCOPE = "oauthScope";
+ static final String AUTH_REDIRECT_PORT = "redirectPort";
+ static final String AUTH_BROWSER_TIMEOUT_SECONDS = "browserAuthTimeoutSeconds";
+
+ static final String DEFAULT_OAUTH_SCOPE = "cdp_query_api api refresh_token";
+ static final int DEFAULT_BROWSER_TIMEOUT_SECONDS = 120;
// Required fields
private final URI loginUrl;
@@ -81,6 +95,16 @@ public enum AuthenticationMode {
// For `AuthenticationMode.REFRESH_TOKEN`
private final String refreshToken;
+ // For `AuthenticationMode.AUTH_CODE_PKCE`
+ @Builder.Default
+ private final String oauthScope = DEFAULT_OAUTH_SCOPE;
+
+ @Builder.Default
+ private final int redirectPort = 0;
+
+ @Builder.Default
+ private final int browserAuthTimeoutSeconds = DEFAULT_BROWSER_TIMEOUT_SECONDS;
+
// Optional fields
@Builder.Default
private final String dataspace = null;
@@ -106,6 +130,11 @@ public static SalesforceAuthProperties ofDestructive(@NonNull URI loginUrl, Prop
// Optional fields
builder.dataspace(takeOptional(props, AUTH_DATASPACE).orElse(null));
+ // The caller can request the AUTH_CODE_PKCE flow explicitly, in case the connection
+ // properties are otherwise empty (no userName/password/privateKey/refreshToken).
+ boolean explicitPkce =
+ "AUTH_CODE_PKCE".equals(takeOptional(props, AUTH_MODE).orElse(null));
+
// Determine authentication mode and set credentials
if (props.containsKey(AUTH_USER_NAME) && props.containsKey(AUTH_PASSWORD)) {
builder.authenticationMode(AuthenticationMode.PASSWORD);
@@ -130,9 +159,22 @@ public static SalesforceAuthProperties ofDestructive(@NonNull URI loginUrl, Prop
// We still accept an optional userName. This might show up
// in the `DatabaseMetadata.getUserName` call.
builder.userName(takeOptional(props, AUTH_USER_NAME).orElse(null));
+ } else if (explicitPkce) {
+ builder.authenticationMode(AuthenticationMode.AUTH_CODE_PKCE);
+ // clientSecret is optional: ECAs may be configured as confidential or public clients.
+ builder.clientSecret(takeOptional(props, AUTH_CLIENT_SECRET).orElse(null));
+ builder.oauthScope(takeOptional(props, AUTH_OAUTH_SCOPE).orElse(DEFAULT_OAUTH_SCOPE));
+ builder.redirectPort(takeOptionalInteger(props, AUTH_REDIRECT_PORT).orElse(0));
+ int timeout =
+ takeOptionalInteger(props, AUTH_BROWSER_TIMEOUT_SECONDS).orElse(DEFAULT_BROWSER_TIMEOUT_SECONDS);
+ if (timeout <= 0) {
+ throw new SQLException(AUTH_BROWSER_TIMEOUT_SECONDS + " must be a positive number of seconds", "28000");
+ }
+ builder.browserAuthTimeoutSeconds(timeout);
} else {
throw new SQLException(
- "Properties must contain either (userName + password), (userName + privateKey), or refreshToken",
+ "Properties must contain either (userName + password), (userName + privateKey), refreshToken, "
+ + "or authMode=AUTH_CODE_PKCE",
"28000");
}
@@ -174,6 +216,21 @@ public Properties toProperties() {
props.setProperty(AUTH_REFRESH_TOKEN, refreshToken);
props.setProperty(AUTH_CLIENT_SECRET, clientSecret);
break;
+ case AUTH_CODE_PKCE:
+ props.setProperty(AUTH_MODE, AuthenticationMode.AUTH_CODE_PKCE.name());
+ if (clientSecret != null) {
+ props.setProperty(AUTH_CLIENT_SECRET, clientSecret);
+ }
+ if (!DEFAULT_OAUTH_SCOPE.equals(oauthScope)) {
+ props.setProperty(AUTH_OAUTH_SCOPE, oauthScope);
+ }
+ if (redirectPort != 0) {
+ props.setProperty(AUTH_REDIRECT_PORT, Integer.toString(redirectPort));
+ }
+ if (browserAuthTimeoutSeconds != DEFAULT_BROWSER_TIMEOUT_SECONDS) {
+ props.setProperty(AUTH_BROWSER_TIMEOUT_SECONDS, Integer.toString(browserAuthTimeoutSeconds));
+ }
+ break;
}
return props;
diff --git a/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProviderTest.java b/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProviderTest.java
index d9da2cfb..dc5f5ae4 100644
--- a/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProviderTest.java
+++ b/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProviderTest.java
@@ -14,13 +14,24 @@
import com.salesforce.datacloud.jdbc.auth.model.DataCloudTokenResponse;
import com.salesforce.datacloud.jdbc.auth.model.OAuthTokenResponse;
import com.salesforce.datacloud.jdbc.http.HttpClientProperties;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.Map;
import java.util.Properties;
import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
import lombok.SneakyThrows;
import lombok.val;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
import okhttp3.mockwebserver.SocketPolicy;
import org.assertj.core.api.AssertionsForClassTypes;
import org.assertj.core.api.SoftAssertions;
@@ -59,6 +70,13 @@ static Properties propertiesForRefreshToken(String refreshToken) {
return properties;
}
+ static Properties propertiesForPkce() {
+ val properties = new Properties();
+ properties.setProperty(SalesforceAuthProperties.AUTH_CLIENT_ID, "clientId");
+ properties.setProperty(SalesforceAuthProperties.AUTH_MODE, "AUTH_CODE_PKCE");
+ return properties;
+ }
+
// Valid RSA private key in PEM format for testing
private static final String FAKE_PRIVATE_KEY = SalesforceAuthPropertiesTest.FAKE_PRIVATE_KEY;
@@ -525,6 +543,169 @@ void getWithRetryHandlesIOExceptionDuringRequest() {
}
}
+ @SneakyThrows
+ @Test
+ void authCodePkceFlowExchangesCodeForToken() {
+ val mapper = new ObjectMapper();
+ val properties = propertiesForPkce();
+ val accessToken = UUID.randomUUID().toString();
+
+ try (val server = new MockWebServer()) {
+ server.start();
+ val oAuthTokenResponse = new OAuthTokenResponse();
+ oAuthTokenResponse.setToken(accessToken);
+ oAuthTokenResponse.setInstanceUrl(server.url("").toString());
+ server.enqueue(new MockResponse().setBody(mapper.writeValueAsString(oAuthTokenResponse)));
+
+ val loginUrl = server.url("").uri();
+ val clientProperties = HttpClientProperties.ofDestructive(properties);
+ val authProperties = SalesforceAuthProperties.ofDestructive(loginUrl, properties);
+
+ val provider = DataCloudTokenProvider.ofForTest(
+ clientProperties, authProperties, new ScriptedBrowserLauncher("auth-code-from-test"));
+ val token = provider.getOAuthToken();
+
+ assertThat(token.getToken()).as("access token").isEqualTo(accessToken);
+
+ // Inspect the request body sent to /services/oauth2/token
+ RecordedRequest tokenRequest = server.takeRequest();
+ assertThat(tokenRequest.getPath()).isEqualTo("/services/oauth2/token");
+ Map body = parseFormBody(tokenRequest.getBody().readUtf8());
+ softly.assertThat(body).containsEntry("grant_type", "authorization_code");
+ softly.assertThat(body).containsEntry("code", "auth-code-from-test");
+ softly.assertThat(body).containsEntry("client_id", "clientId");
+ softly.assertThat(body).containsKey("code_verifier");
+ softly.assertThat(body).containsKey("redirect_uri");
+ softly.assertThat(body.get("redirect_uri")).startsWith("http://127.0.0.1:");
+ // No client_secret was supplied; the public-client request must NOT include one.
+ softly.assertThat(body).doesNotContainKey("client_secret");
+ }
+ }
+
+ @SneakyThrows
+ @Test
+ void authCodePkceFlowIncludesClientSecretWhenConfigured() {
+ val mapper = new ObjectMapper();
+ val properties = propertiesForPkce();
+ properties.setProperty(SalesforceAuthProperties.AUTH_CLIENT_SECRET, "confidential-secret");
+
+ try (val server = new MockWebServer()) {
+ server.start();
+ val oAuthTokenResponse = new OAuthTokenResponse();
+ oAuthTokenResponse.setToken(UUID.randomUUID().toString());
+ oAuthTokenResponse.setInstanceUrl(server.url("").toString());
+ server.enqueue(new MockResponse().setBody(mapper.writeValueAsString(oAuthTokenResponse)));
+
+ val loginUrl = server.url("").uri();
+ val clientProperties = HttpClientProperties.ofDestructive(properties);
+ val authProperties = SalesforceAuthProperties.ofDestructive(loginUrl, properties);
+
+ val provider = DataCloudTokenProvider.ofForTest(
+ clientProperties, authProperties, new ScriptedBrowserLauncher("any-code"));
+ provider.getOAuthToken();
+
+ Map body =
+ parseFormBody(server.takeRequest().getBody().readUtf8());
+ assertThat(body).containsEntry("client_secret", "confidential-secret");
+ }
+ }
+
+ @SneakyThrows
+ @Test
+ void authCodePkceFlowRejectsMismatchedState() {
+ val properties = propertiesForPkce();
+ properties.setProperty(SalesforceAuthProperties.AUTH_BROWSER_TIMEOUT_SECONDS, "5");
+
+ try (val server = new MockWebServer()) {
+ server.start();
+ val loginUrl = server.url("").uri();
+ val clientProperties = HttpClientProperties.ofDestructive(properties);
+ val authProperties = SalesforceAuthProperties.ofDestructive(loginUrl, properties);
+
+ // Hand the browser a launcher that replies with a totally different state
+ BrowserLauncher tampered = uri -> {
+ Map params = parseQueryParams(uri.getRawQuery());
+ String redirect = params.get("redirect_uri");
+ String tamperedState = "tampered-" + UUID.randomUUID();
+ callLoopback(redirect + "?code=irrelevant&state=" + tamperedState);
+ };
+
+ val provider = DataCloudTokenProvider.ofForTest(clientProperties, authProperties, tampered);
+ val ex = assertThrows(SQLException.class, provider::getOAuthToken);
+ assertThat(ex.getMessage()).contains("state parameter did not match");
+ }
+ }
+
+ /** Fake browser that, given the authorize URL, immediately POSTs back to the loopback server. */
+ private static final class ScriptedBrowserLauncher implements BrowserLauncher {
+ private final String authorizationCode;
+
+ ScriptedBrowserLauncher(String authorizationCode) {
+ this.authorizationCode = authorizationCode;
+ }
+
+ @Override
+ public void open(URI authorizationUrl) {
+ CompletableFuture.runAsync(() -> {
+ try {
+ Map params = parseQueryParams(authorizationUrl.getRawQuery());
+ String redirect = params.get("redirect_uri");
+ String state = params.get("state");
+ callLoopback(redirect + "?code=" + authorizationCode + "&state=" + state);
+ } catch (Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ });
+ }
+ }
+
+ private static void callLoopback(String url) {
+ try {
+ HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(2_000);
+ conn.setReadTimeout(2_000);
+ try {
+ conn.getResponseCode();
+ try (InputStream is = conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream()) {
+ if (is != null) {
+ byte[] buf = new byte[1024];
+ while (is.read(buf) >= 0) {
+ // discard
+ }
+ }
+ }
+ } finally {
+ conn.disconnect();
+ }
+ } catch (IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ private static Map parseQueryParams(String rawQuery) {
+ Map result = new HashMap<>();
+ if (rawQuery == null || rawQuery.isEmpty()) {
+ return result;
+ }
+ for (String pair : rawQuery.split("&")) {
+ int eq = pair.indexOf('=');
+ String key = eq >= 0 ? pair.substring(0, eq) : pair;
+ String value = eq >= 0 ? pair.substring(eq + 1) : "";
+ try {
+ result.put(
+ URLDecoder.decode(key, StandardCharsets.UTF_8.name()),
+ URLDecoder.decode(value, StandardCharsets.UTF_8.name()));
+ } catch (Exception ignored) {
+ // skip malformed pair
+ }
+ }
+ return result;
+ }
+
+ private static Map parseFormBody(String body) {
+ return parseQueryParams(body);
+ }
+
private static void assertAuthorizationException(Throwable actual, CharSequence... messages) {
AssertionsForClassTypes.assertThat(actual)
.isInstanceOf(SQLException.class)
diff --git a/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServerTest.java b/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServerTest.java
new file mode 100644
index 00000000..59871caf
--- /dev/null
+++ b/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServerTest.java
@@ -0,0 +1,123 @@
+/**
+ * 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.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+class LoopbackCallbackServerTest {
+
+ @Test
+ void capturesCodeAndState() throws Exception {
+ try (LoopbackCallbackServer server = LoopbackCallbackServer.start(0)) {
+ URI redirect = server.getRedirectUri();
+ assertThat(redirect.getPort()).isPositive();
+ assertThat(redirect.getHost()).isEqualTo("127.0.0.1");
+
+ CompletableFuture async = CompletableFuture.supplyAsync(() -> {
+ try {
+ return server.awaitCallback(5_000);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ int status = httpGet(redirect.toString() + "?code=abc%20def&state=xyz");
+ assertThat(status).isEqualTo(200);
+
+ LoopbackCallbackServer.Result result = async.get(5, TimeUnit.SECONDS);
+ assertThat(result.getCode()).isEqualTo("abc def");
+ assertThat(result.getState()).isEqualTo("xyz");
+ }
+ }
+
+ @Test
+ void surfacesOAuthErrorParam() throws Exception {
+ try (LoopbackCallbackServer server = LoopbackCallbackServer.start(0)) {
+ URI redirect = server.getRedirectUri();
+
+ CompletableFuture async = CompletableFuture.runAsync(() -> {
+ try {
+ httpGet(redirect.toString() + "?error=access_denied&error_description=user%20cancelled");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ assertThatThrownBy(() -> server.awaitCallback(5_000))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("access_denied")
+ .hasMessageContaining("user cancelled");
+
+ async.get(5, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ void rejectsCallbackWithoutCode() throws Exception {
+ try (LoopbackCallbackServer server = LoopbackCallbackServer.start(0)) {
+ URI redirect = server.getRedirectUri();
+
+ CompletableFuture async = CompletableFuture.runAsync(() -> {
+ try {
+ httpGet(redirect.toString() + "?state=xyz");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ assertThatThrownBy(() -> server.awaitCallback(5_000))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("missing a code parameter");
+
+ async.get(5, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ void timesOutWhenNoCallback() throws Exception {
+ try (LoopbackCallbackServer server = LoopbackCallbackServer.start(0)) {
+ assertThatThrownBy(() -> server.awaitCallback(50))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Timed out");
+ }
+ }
+
+ /** Read the body to drain the response and return the status code. */
+ private static int httpGet(String url) throws IOException {
+ HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(2_000);
+ conn.setReadTimeout(2_000);
+ try {
+ int status = conn.getResponseCode();
+ // Drain the body so the server-side handler completes its sendResponseHeaders+write cycle.
+ try (java.io.InputStream is = status >= 400 ? conn.getErrorStream() : conn.getInputStream()) {
+ if (is != null) {
+ byte[] buf = new byte[1024];
+ while (is.read(buf) >= 0) {
+ // discard
+ }
+ }
+ }
+ return status;
+ } finally {
+ conn.disconnect();
+ }
+ }
+
+ @SuppressWarnings("unused") // kept for future Charset-aware decoding tests
+ private static String utf8(byte[] bytes) {
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+}
diff --git a/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/PkceCodesTest.java b/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/PkceCodesTest.java
new file mode 100644
index 00000000..214473ec
--- /dev/null
+++ b/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/PkceCodesTest.java
@@ -0,0 +1,54 @@
+/**
+ * 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 java.security.SecureRandom;
+import org.junit.jupiter.api.Test;
+
+class PkceCodesTest {
+
+ @Test
+ void verifierAndChallengeAreBase64UrlNoPad() {
+ PkceCodes codes = PkceCodes.generate();
+
+ assertThat(codes.getVerifier())
+ .as("verifier")
+ .matches("^[A-Za-z0-9_-]+$")
+ .hasSize(43);
+ assertThat(codes.getChallenge())
+ .as("challenge")
+ .matches("^[A-Za-z0-9_-]+$")
+ .hasSize(43);
+ }
+
+ @Test
+ void challengeMatchesSha256OfVerifier() {
+ PkceCodes codes = PkceCodes.generate();
+
+ assertThat(codes.getChallenge())
+ .as("challenge is base64url-no-pad sha256 of verifier")
+ .isEqualTo(PkceCodes.s256Challenge(codes.getVerifier()));
+ }
+
+ @Test
+ void s256ChallengeMatchesRfc7636AppendixBExample() {
+ // RFC 7636 Appendix B test vector:
+ // verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ // challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
+ String verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+ assertThat(PkceCodes.s256Challenge(verifier)).isEqualTo("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
+ }
+
+ @Test
+ void generateProducesDistinctVerifiersAcrossCalls() {
+ // Same SecureRandom seed shouldn't matter — every call still draws fresh entropy.
+ PkceCodes a = PkceCodes.generate(new SecureRandom());
+ PkceCodes b = PkceCodes.generate(new SecureRandom());
+
+ assertThat(a.getVerifier()).isNotEqualTo(b.getVerifier());
+ }
+}
diff --git a/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/SalesforceAuthPropertiesTest.java b/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/SalesforceAuthPropertiesTest.java
index cb722dd6..f4b53f7c 100644
--- a/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/SalesforceAuthPropertiesTest.java
+++ b/jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/SalesforceAuthPropertiesTest.java
@@ -209,7 +209,70 @@ void rejectsInvalidAuthenticationMode() {
assertThatThrownBy(() -> SalesforceAuthProperties.ofDestructive(TEST_LOGIN_URL, props))
.isInstanceOf(SQLException.class)
.hasMessageContaining(
- "Properties must contain either (userName + password), (userName + privateKey), or refreshToken");
+ "Properties must contain either (userName + password), (userName + privateKey), refreshToken");
+ }
+
+ @Test
+ void parsesAuthCodePkceMode() throws SQLException {
+ Properties props = new Properties();
+ props.setProperty("clientId", TEST_CLIENT_ID);
+ props.setProperty("authMode", "AUTH_CODE_PKCE");
+
+ SalesforceAuthProperties authProps = SalesforceAuthProperties.ofDestructive(TEST_LOGIN_URL, props);
+
+ assertThat(authProps.getAuthenticationMode())
+ .isEqualTo(SalesforceAuthProperties.AuthenticationMode.AUTH_CODE_PKCE);
+ assertThat(authProps.getClientId()).isEqualTo(TEST_CLIENT_ID);
+ assertThat(authProps.getClientSecret()).isNull();
+ assertThat(authProps.getOauthScope()).isEqualTo("cdp_query_api api refresh_token");
+ assertThat(authProps.getRedirectPort()).isZero();
+ assertThat(authProps.getBrowserAuthTimeoutSeconds()).isEqualTo(120);
+ }
+
+ @Test
+ void parsesAuthCodePkceWithOptionalFields() throws SQLException {
+ Properties props = new Properties();
+ props.setProperty("clientId", TEST_CLIENT_ID);
+ props.setProperty("clientSecret", TEST_CLIENT_SECRET);
+ props.setProperty("authMode", "AUTH_CODE_PKCE");
+ props.setProperty("oauthScope", "cdp_query_api");
+ props.setProperty("redirectPort", "8765");
+ props.setProperty("browserAuthTimeoutSeconds", "30");
+
+ SalesforceAuthProperties authProps = SalesforceAuthProperties.ofDestructive(TEST_LOGIN_URL, props);
+
+ assertThat(authProps.getAuthenticationMode())
+ .isEqualTo(SalesforceAuthProperties.AuthenticationMode.AUTH_CODE_PKCE);
+ assertThat(authProps.getClientSecret()).isEqualTo(TEST_CLIENT_SECRET);
+ assertThat(authProps.getOauthScope()).isEqualTo("cdp_query_api");
+ assertThat(authProps.getRedirectPort()).isEqualTo(8765);
+ assertThat(authProps.getBrowserAuthTimeoutSeconds()).isEqualTo(30);
+ }
+
+ @Test
+ void rejectsNonPositiveBrowserTimeout() {
+ Properties props = new Properties();
+ props.setProperty("clientId", TEST_CLIENT_ID);
+ props.setProperty("authMode", "AUTH_CODE_PKCE");
+ props.setProperty("browserAuthTimeoutSeconds", "0");
+
+ assertThatThrownBy(() -> SalesforceAuthProperties.ofDestructive(TEST_LOGIN_URL, props))
+ .isInstanceOf(SQLException.class)
+ .hasMessageContaining("browserAuthTimeoutSeconds");
+ }
+
+ @Test
+ void toPropertiesRoundtripAuthCodePkce() throws SQLException {
+ Properties props = new Properties();
+ props.setProperty("clientId", TEST_CLIENT_ID);
+ props.setProperty("authMode", "AUTH_CODE_PKCE");
+ props.setProperty("redirectPort", "8765");
+
+ Properties originalProps = (Properties) props.clone();
+ SalesforceAuthProperties authProps = SalesforceAuthProperties.ofDestructive(TEST_LOGIN_URL, props);
+ Properties roundtripProps = authProps.toProperties();
+
+ assertThat(roundtripProps).isEqualTo(originalProps);
}
@Test