From 842ca205963ba25b5ace772f09f49246652b7e8b Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 11:42:28 +0200 Subject: [PATCH 1/8] feat: add PKCE utilities (code generator, loopback callback, browser launcher) --- .../datacloud/jdbc/auth/BrowserLauncher.java | 52 +++++ .../jdbc/auth/LoopbackCallbackServer.java | 191 ++++++++++++++++++ .../datacloud/jdbc/auth/PkceCodes.java | 52 +++++ 3 files changed, 295 insertions(+) create mode 100644 jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/BrowserLauncher.java create mode 100644 jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServer.java create mode 100644 jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/PkceCodes.java diff --git a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/BrowserLauncher.java b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/BrowserLauncher.java new file mode 100644 index 00000000..de6bcbad --- /dev/null +++ b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/BrowserLauncher.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.awt.Desktop; +import java.io.IOException; +import java.net.URI; +import lombok.extern.slf4j.Slf4j; + +/** + * Opens the system browser at the OAuth authorization URL. Extracted as an + * interface so tests can drive the loopback callback without a real browser. + */ +public interface BrowserLauncher { + + /** + * Open the URL in the user's default browser. If launching the browser is + * not possible (e.g. headless servers, sandboxed CI), implementations should + * surface the URL through some other channel — the default implementation + * logs it to stderr so a human can paste it into a browser manually. + */ + void open(URI authorizationUrl) throws IOException; + + static BrowserLauncher defaultLauncher() { + return new DesktopBrowserLauncher(); + } + + @Slf4j + final class DesktopBrowserLauncher implements BrowserLauncher { + @Override + public void open(URI authorizationUrl) throws IOException { + if (Desktop.isDesktopSupported()) { + Desktop desktop = Desktop.getDesktop(); + if (desktop.isSupported(Desktop.Action.BROWSE)) { + try { + desktop.browse(authorizationUrl); + return; + } catch (Exception ex) { + // Fall through and print the URL so the user can complete the flow manually. + log.warn("Desktop.browse() failed, falling back to printing the URL", ex); + } + } + } + // Headless or restricted environment: surface the URL so a human can paste it. + String message = "Open the following URL in a browser to complete authentication:\n" + authorizationUrl; + log.info("{}", message); + System.err.println(message); + } + } +} 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..ea9d2617 --- /dev/null +++ b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServer.java @@ -0,0 +1,191 @@ +/** + * 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); + } + } +} From 55ff53b02fc2b2468674505abe3971aaca5ea9b1 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 11:44:43 +0200 Subject: [PATCH 2/8] feat: wire AUTH_CODE_PKCE mode into SalesforceAuthProperties --- .../jdbc/auth/SalesforceAuthProperties.java | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) 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..8925bc54 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,10 @@ 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 +158,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 +215,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; From 02594c23a2cd3555352145560929480a9495be9f Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 11:46:12 +0200 Subject: [PATCH 3/8] feat: wire AUTH_CODE_PKCE flow through DataCloudTokenProvider --- .../jdbc/auth/DataCloudTokenProvider.java | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProvider.java b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProvider.java index 8cec4dfe..5c6e4084 100644 --- a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProvider.java +++ b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProvider.java @@ -18,11 +18,18 @@ import dev.failsafe.RetryPolicy; import dev.failsafe.function.CheckedSupplier; import io.jsonwebtoken.Jwts; +import java.io.IOException; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; import java.sql.Date; import java.sql.SQLException; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Optional; import lombok.AccessLevel; import lombok.Builder; @@ -36,6 +43,7 @@ @Builder(access = AccessLevel.PRIVATE) public class DataCloudTokenProvider { private static final URI AUTHENTICATE_URL = URI.create("services/oauth2/token"); + private static final URI AUTHORIZE_URL = URI.create("services/oauth2/authorize"); private static final URI EXCHANGE_TOKEN_URL = URI.create("services/a360/token"); @Getter @@ -45,6 +53,16 @@ public class DataCloudTokenProvider { private DataCloudToken cachedDataCloudToken; private RetryPolicy exponentialBackOffPolicy; + @Builder.Default + private BrowserLauncher browserLauncher = BrowserLauncher.defaultLauncher(); + + /** + * Once we capture an authorization code via the browser, the resulting + * verifier needs to flow into {@link #buildAuthenticate()} on the very next + * call. The pair lives only for the duration of one OAuth dance. + */ + private PendingAuthCodeExchange pendingAuthCodeExchange; + public static DataCloudTokenProvider of( HttpClientProperties clientProperties, SalesforceAuthProperties authProperties) throws SQLException { val settings = authProperties; @@ -58,7 +76,28 @@ public static DataCloudTokenProvider of( .build(); } + /** Visible-for-test entry point that lets unit tests inject a fake browser. */ + static DataCloudTokenProvider ofForTest( + HttpClientProperties clientProperties, + SalesforceAuthProperties authProperties, + BrowserLauncher browserLauncher) + throws SQLException { + val client = clientProperties.buildOkHttpClient(); + val exponentialBackOffPolicy = buildExponentialBackoffRetryPolicy(clientProperties.getMaxRetries()); + return DataCloudTokenProvider.builder() + .client(client) + .exponentialBackOffPolicy(exponentialBackOffPolicy) + .settings(authProperties) + .browserLauncher(browserLauncher) + .build(); + } + private OAuthToken fetchOAuthToken() throws SQLException { + if (settings.getAuthenticationMode() == SalesforceAuthProperties.AuthenticationMode.AUTH_CODE_PKCE) { + // Drive the user through the browser-based authorization-code+PKCE dance, which + // populates `pendingAuthCodeExchange` so that buildAuthenticate() below can read it. + runAuthorizationCodeDance(); + } val command = buildAuthenticate(); val model = getWithRetry(() -> { val response = FormCommand.post(this.client, command, OAuthTokenResponse.class); @@ -67,6 +106,81 @@ private OAuthToken fetchOAuthToken() throws SQLException { return OAuthToken.of(model); } + private void runAuthorizationCodeDance() throws SQLException { + // PKCE verifier+challenge are generated fresh per dance; never reused across logins. + PkceCodes pkce = PkceCodes.generate(); + String state = randomState(); + + try (LoopbackCallbackServer callback = + LoopbackCallbackServer.start(settings.getRedirectPort())) { + URI redirectUri = callback.getRedirectUri(); + URI authorizeUrl = buildAuthorizeUrl(pkce.getChallenge(), state, redirectUri); + log.info("Opening browser for OAuth authorization at {}", authorizeUrl); + browserLauncher.open(authorizeUrl); + + long timeoutMs = (long) settings.getBrowserAuthTimeoutSeconds() * 1000L; + LoopbackCallbackServer.Result result = callback.awaitCallback(timeoutMs); + + if (!state.equals(result.getState())) { + // State mismatch is a CSRF indicator; reject before doing anything with the code. + throw new SQLException( + "OAuth state parameter did not match — possible CSRF, aborting authentication", "28000"); + } + this.pendingAuthCodeExchange = new PendingAuthCodeExchange(result.getCode(), pkce.getVerifier(), + redirectUri.toString()); + } catch (IOException ex) { + throw new SQLException("Failed to complete browser-based OAuth authorization: " + ex.getMessage(), + "28000", ex); + } + } + + private URI buildAuthorizeUrl(String codeChallenge, String state, URI redirectUri) { + Map params = new LinkedHashMap<>(); + params.put("response_type", "code"); + params.put("client_id", settings.getClientId()); + params.put("redirect_uri", redirectUri.toString()); + params.put("code_challenge", codeChallenge); + params.put("code_challenge_method", PkceCodes.CHALLENGE_METHOD_S256); + params.put("state", state); + params.put("scope", settings.getOauthScope()); + StringBuilder url = new StringBuilder() + .append(settings.getLoginUrl().toString()) + .append(settings.getLoginUrl().toString().endsWith("/") ? "" : "/") + .append(AUTHORIZE_URL.toString()) + .append('?'); + boolean first = true; + for (Map.Entry 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 +260,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(); From ba94d2985e80948de60ffc41f007ad9f87d266b7 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 11:50:42 +0200 Subject: [PATCH 4/8] test: add unit tests for PKCE codes, loopback server, auth-code mode parsing --- .../jdbc/auth/LoopbackCallbackServerTest.java | 123 ++++++++++++++++++ .../datacloud/jdbc/auth/PkceCodesTest.java | 54 ++++++++ .../auth/SalesforceAuthPropertiesTest.java | 65 ++++++++- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/LoopbackCallbackServerTest.java create mode 100644 jdbc-http/src/test/java/com/salesforce/datacloud/jdbc/auth/PkceCodesTest.java 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 From 7699e022800658af175c7ed70e262f4898b874ba Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 11:54:14 +0200 Subject: [PATCH 5/8] test: add end-to-end tests for AUTH_CODE_PKCE token exchange --- .../jdbc/auth/DataCloudTokenProviderTest.java | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) 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..48e8dd38 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,170 @@ 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) From 4e925a83e21bb37c2a98965484cef05fcb8fbcd8 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 11:55:20 +0200 Subject: [PATCH 6/8] docs: document AUTH_CODE_PKCE flow in README --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 47d2cbc9..8d524e80 100644 --- a/README.md +++ b/README.md @@ -64,18 +64,22 @@ Use `com.salesforce.datacloud.jdbc.DataCloudJDBCDriver` as the driver class name ### Authentication -We support three of the [OAuth authorization flows][oauth authorization flows] provided by Salesforce. -All of these flows require a connected app be configured for the driver to authenticate as, see the documentation here: [connected app overview][connected app overview]. +We support four of the [OAuth authorization flows][oauth authorization flows] provided by Salesforce. +All of these flows require a connected app or external client app be configured for the driver to authenticate as, see the documentation here: [connected app overview][connected app overview]. Set the following properties appropriately to establish a connection with your chosen OAuth authorization flow: -| Parameter | Description | -|--------------|----------------------------------------------------------------------------------------------------------------------| -| user | The login name of the user. | -| password | The password of the user. | -| clientId | The consumer key of the connected app. | -| clientSecret | The consumer secret of the connected app. | -| privateKey | The private key of the connected app. | -| refreshToken | Token obtained from the web server, user-agent, or hybrid app token flow. | +| Parameter | Description | +|----------------------------|----------------------------------------------------------------------------------------------------------------------| +| user | The login name of the user. | +| password | The password of the user. | +| clientId | The consumer key of the connected app. | +| clientSecret | The consumer secret of the connected app. | +| privateKey | The private key of the connected app. | +| refreshToken | Token obtained from the web server, user-agent, or hybrid app token flow. | +| authMode | Set to `AUTH_CODE_PKCE` to trigger the interactive browser-based authorization-code flow with PKCE. | +| oauthScope | (Optional, AUTH_CODE_PKCE only) Space-separated OAuth scopes; defaults to `cdp_query_api api refresh_token`. | +| redirectPort | (Optional, AUTH_CODE_PKCE only) Loopback port for the OAuth callback; default `0` (pick a free ephemeral port). | +| browserAuthTimeoutSeconds | (Optional, AUTH_CODE_PKCE only) How long to wait for the browser dance to complete; default `120`. | #### username and password authentication: @@ -116,6 +120,32 @@ properties.put("clientId", "${clientId}"); properties.put("clientSecret", "${clientSecret}"); ``` +#### interactive browser authentication (authorization code with PKCE): + +The driver can drive the [OAuth web server flow][web server flow] for you: it +opens the user's default browser at the Salesforce login screen, listens on a +loopback HTTP port for the redirect callback, and exchanges the resulting +authorization code for an access token using +[Proof Key for Code Exchange (RFC 7636)][rfc7636]. No long-lived secret needs +to be present in the configuration — the per-login PKCE verifier replaces a +`client_secret` for public clients. + +```java +Properties properties = new Properties(); +properties.put("authMode", "AUTH_CODE_PKCE"); +properties.put("clientId", "${clientId}"); +// Optional: properties.put("clientSecret", "${clientSecret}"); // for confidential ECAs +// Optional: properties.put("redirectPort", "8765"); // pin a port if your ECA expects one +``` + +When configuring the External Client App, register a redirect URI of the form +`http://127.0.0.1:/callback`. If you let the driver pick the port (the +default), register `http://127.0.0.1` and use the wildcard-port option that +ECAs offer, or pin `redirectPort` to a fixed value and register that exact +URI. Headless environments (servers without a display) are supported as a +fallback: the driver prints the authorization URL to stderr so a human can +paste it into a browser manually. + ### Connection settings See this page on available [connection settings][connection settings]. @@ -219,5 +249,7 @@ public static void executeQuery() throws ClassNotFoundException, SQLException { [username flow]: https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_username_password_flow.htm&type=5 [jwt flow]: https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm&type=5 [refresh token flow]: https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_refresh_token_flow.htm&type=5 +[web server flow]: https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_web_server_flow.htm&type=5 +[rfc7636]: https://datatracker.ietf.org/doc/html/rfc7636 [connection settings]: https://tableau.github.io/hyper-db/docs/hyper-api/connection#connection-settings [connected app overview]: https://help.salesforce.com/s/articleView?id=sf.connected_app_overview.htm&type=5 From 75083553d10cf7d0f9661a99c87b411998edc0dd Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 11:55:47 +0200 Subject: [PATCH 7/8] style: apply spotless formatting --- .../datacloud/jdbc/auth/DataCloudTokenProvider.java | 11 +++++------ .../datacloud/jdbc/auth/LoopbackCallbackServer.java | 5 +---- .../datacloud/jdbc/auth/SalesforceAuthProperties.java | 7 ++++--- .../jdbc/auth/DataCloudTokenProviderTest.java | 3 +-- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProvider.java b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProvider.java index 5c6e4084..8725943c 100644 --- a/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProvider.java +++ b/jdbc-http/src/main/java/com/salesforce/datacloud/jdbc/auth/DataCloudTokenProvider.java @@ -111,8 +111,7 @@ private void runAuthorizationCodeDance() throws SQLException { PkceCodes pkce = PkceCodes.generate(); String state = randomState(); - try (LoopbackCallbackServer callback = - LoopbackCallbackServer.start(settings.getRedirectPort())) { + try (LoopbackCallbackServer callback = LoopbackCallbackServer.start(settings.getRedirectPort())) { URI redirectUri = callback.getRedirectUri(); URI authorizeUrl = buildAuthorizeUrl(pkce.getChallenge(), state, redirectUri); log.info("Opening browser for OAuth authorization at {}", authorizeUrl); @@ -126,11 +125,11 @@ private void runAuthorizationCodeDance() throws SQLException { throw new SQLException( "OAuth state parameter did not match — possible CSRF, aborting authentication", "28000"); } - this.pendingAuthCodeExchange = new PendingAuthCodeExchange(result.getCode(), pkce.getVerifier(), - redirectUri.toString()); + this.pendingAuthCodeExchange = + new PendingAuthCodeExchange(result.getCode(), pkce.getVerifier(), redirectUri.toString()); } catch (IOException ex) { - throw new SQLException("Failed to complete browser-based OAuth authorization: " + ex.getMessage(), - "28000", ex); + throw new SQLException( + "Failed to complete browser-based OAuth authorization: " + ex.getMessage(), "28000", ex); } } 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 index ea9d2617..25c4b194 100644 --- 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 @@ -183,9 +183,6 @@ private static Map parseQuery(String rawQuery) { } private static String escapeHtml(String s) { - return s.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """); + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); } } 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 8925bc54..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 @@ -132,7 +132,8 @@ public static SalesforceAuthProperties ofDestructive(@NonNull URI loginUrl, Prop // 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)); + 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)) { @@ -164,8 +165,8 @@ public static SalesforceAuthProperties ofDestructive(@NonNull URI loginUrl, Prop 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); + 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"); } 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 48e8dd38..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 @@ -666,8 +666,7 @@ private static void callLoopback(String url) { conn.setReadTimeout(2_000); try { conn.getResponseCode(); - try (InputStream is = - conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream()) { + try (InputStream is = conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream()) { if (is != null) { byte[] buf = new byte[1024]; while (is.read(buf) >= 0) { From c60ecaa0167e2f325243ea7af8f6b2bdb46d2764 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 12:12:01 +0200 Subject: [PATCH 8/8] docs: document ECA setup for AUTH_CODE_PKCE flow --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8d524e80..21c98b58 100644 --- a/README.md +++ b/README.md @@ -134,18 +134,89 @@ to be present in the configuration — the per-login PKCE verifier replaces a Properties properties = new Properties(); properties.put("authMode", "AUTH_CODE_PKCE"); properties.put("clientId", "${clientId}"); +properties.put("redirectPort", "7171"); // must match the ECA's registered Callback URL // Optional: properties.put("clientSecret", "${clientSecret}"); // for confidential ECAs -// Optional: properties.put("redirectPort", "8765"); // pin a port if your ECA expects one ``` -When configuring the External Client App, register a redirect URI of the form -`http://127.0.0.1:/callback`. If you let the driver pick the port (the -default), register `http://127.0.0.1` and use the wildcard-port option that -ECAs offer, or pin `redirectPort` to a fixed value and register that exact -URI. Headless environments (servers without a display) are supported as a +**Pin the port.** Salesforce External Client Apps match the redirect URI as an +exact string — wildcard ports are *not* supported. Pick a fixed `redirectPort` +(e.g. `7171`) and register `http://127.0.0.1:7171/callback` in the ECA's +Callback URL field. If you don't pin the port, the driver picks a free +ephemeral one, which won't match anything the ECA has registered and +authentication will fail with `redirect_uri_mismatch`. + +Headless environments (servers without a display) are supported as a fallback: the driver prints the authorization URL to stderr so a human can paste it into a browser manually. +##### Setting up the External Client App for this flow + +The ECA needs the following settings. The fastest path is the **Setup UI**; +an automated path using anonymous Apex is described further below. + +| Setting | Value | Why | +| --- | --- | --- | +| **Enable OAuth** | checked | gates everything below | +| **Callback URL** | `http://127.0.0.1:7171/callback` (one line per port if multiple) | matched as an exact string by Salesforce | +| **OAuth Scopes** | `cdp_query_api`, `api`, `refresh_token` | Data Cloud Query API plus token refresh | +| **Require Proof Key for Code Exchange (PKCE) Extension for Supported Authorization Flows** | checked | rejects code-exchange requests that don't carry a verifier | +| **Require Secret for Web Server Flow** | unchecked | makes the ECA a *public client* — the driver doesn't ship a `client_secret` | +| **Require Secret for Refresh Token Flow** | unchecked | same reason as above | + +In the Setup UI: **Setup → External Client Apps → New External Client App**, +fill in Name / Contact Email / Distribution State, check **Enable OAuth**, +then set the fields above. Save and copy the generated **Consumer Key** — +that's the value you pass as `clientId` to the driver. + +##### Headless / scripted ECA creation + +There is no `sf eca create` CLI command. The realistic automated path is: + +1. Author SFDX-source XML for the three metadata types + ([`ExternalClientApplication`][eca-meta], [`ExtlClntAppOauthSettings`][eca-oauth-meta], + [`ExtlClntAppGlobalOauthSettings`][eca-global-oauth-meta]). +2. Deploy the `ExternalClientApplication` header with `sf project deploy start`. +3. Run anonymous Apex that calls `Metadata.Operations.enqueueDeployment` to + create the OAuth and Global-OAuth records — these two types cannot be + round-tripped by `sf project deploy` alone today. The + [flxbl-io ECA guide][flxbl-eca-guide] is the canonical reference and + includes a working `bootstrap-eca-oauth.apex` script you can adapt. + +Minimal `force-app/main/default/externalClientApps/DataCloudJDBC.eca-meta.xml`: + +```xml + + admin@example.com + Local + false + + +``` + +The OAuth/global-OAuth records the bootstrap Apex creates correspond to: + +```xml + + + CdpQueryApi, Api, RefreshToken + DataCloudJDBC + + + + + + http://127.0.0.1:7171/callback + DataCloudJDBC + true + true + false + + +``` + +`isConsumerSecretOptional=true` is the public-client toggle and +`isPkceRequired=true` is the matching server-side enforcement. + ### Connection settings See this page on available [connection settings][connection settings]. @@ -251,5 +322,9 @@ public static void executeQuery() throws ClassNotFoundException, SQLException { [refresh token flow]: https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_refresh_token_flow.htm&type=5 [web server flow]: https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_web_server_flow.htm&type=5 [rfc7636]: https://datatracker.ietf.org/doc/html/rfc7636 +[eca-meta]: https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_externalclientapplication.htm +[eca-oauth-meta]: https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_extlclntappoauthsettings.htm +[eca-global-oauth-meta]: https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_extlclntappglobaloauthsettings.htm +[flxbl-eca-guide]: https://github.com/flxbl-io/external-client-apps-guide [connection settings]: https://tableau.github.io/hyper-db/docs/hyper-api/connection#connection-settings [connected app overview]: https://help.salesforce.com/s/articleView?id=sf.connected_app_overview.htm&type=5