CDAP-21265 : Add support for PKCE - #16199
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for Proof Key for Code Exchange (PKCE) OAuth authentication by updating OAuthProvider, OAuthStore, and OAuthHandler to handle the PKCE flow, including generating, storing, and verifying code challenges and verifiers. The review feedback highlights several key improvements: removing a redundant @nullable annotation on the authType field, avoiding swallowing exceptions blindly when retrieving the code verifier, reusing a static SecureRandom instance for efficiency, and validating the user-supplied state parameter to prevent potential key injection or path traversal vulnerabilities.
| @Nullable | ||
| private final AuthType authType; |
There was a problem hiding this comment.
The authType field is annotated with @Nullable, but the constructor guarantees it will never be null by defaulting to AuthType.STANDARD if a null value is passed. To avoid confusion and accurately reflect the class invariants, the @Nullable annotation should be removed from the field.
| @Nullable | |
| private final AuthType authType; | |
| private final AuthType authType; |
| @javax.annotation.Nullable | ||
| public String getPKCECodeVerifier(String provider, String state) { | ||
| String key = getPKCEKey(provider, state); | ||
| try { | ||
| return new String(secureStore.getData(NamespaceId.SYSTEM.getNamespace(), key), java.nio.charset.StandardCharsets.UTF_8); | ||
| } catch (Exception e) { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
Catching Exception blindly and returning null swallows all potential errors, including critical secure store misconfigurations, network issues, or permission errors. This makes troubleshooting extremely difficult because any system failure will be silently treated as an 'invalid or expired state'. Consider logging the exception or catching a more specific 'not found' exception if available.
| private String generateCodeVerifier() { | ||
| SecureRandom sr = new SecureRandom(); | ||
| byte[] codeVerifierBytes = new byte[32]; | ||
| sr.nextBytes(codeVerifierBytes); | ||
| return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifierBytes); | ||
| } |
There was a problem hiding this comment.
Instantiating SecureRandom on every method call is inefficient and can lead to performance overhead or entropy source exhaustion. It is recommended to reuse a single, thread-safe SecureRandom instance as a static final class member.
| private String generateCodeVerifier() { | |
| SecureRandom sr = new SecureRandom(); | |
| byte[] codeVerifierBytes = new byte[32]; | |
| sr.nextBytes(codeVerifierBytes); | |
| return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifierBytes); | |
| } | |
| private static final SecureRandom SECURE_RANDOM = new SecureRandom(); | |
| private String generateCodeVerifier() { | |
| byte[] codeVerifierBytes = new byte[32]; | |
| SECURE_RANDOM.nextBytes(codeVerifierBytes); | |
| return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifierBytes); | |
| } |
| if (state == null || state.isEmpty()) { | ||
| throw new OAuthServiceException(java.net.HttpURLConnection.HTTP_BAD_REQUEST, "State is required for PKCE authentication"); | ||
| } |
There was a problem hiding this comment.
The state parameter is used directly to construct the secure store key. Since the state is user-supplied in the callback request, a malicious user could potentially pass a state containing path traversal characters (e.g., ../) or other special characters, which might cause unexpected behavior or key injection in the underlying secure store implementation. Since the state is generated as a UUID, you should validate that the incoming state only contains safe characters (e.g., alphanumeric and hyphens) before using it.
| if (state == null || state.isEmpty()) { | |
| throw new OAuthServiceException(java.net.HttpURLConnection.HTTP_BAD_REQUEST, "State is required for PKCE authentication"); | |
| } | |
| if (state == null || state.isEmpty() || !state.matches("^[a-zA-Z0-9-]+$")) { | |
| throw new OAuthServiceException(java.net.HttpURLConnection.HTTP_BAD_REQUEST, "State is required and must be valid for PKCE authentication"); | |
| } |
| return userAgent; | ||
| } | ||
|
|
||
| public enum AuthType { |
There was a problem hiding this comment.
Please add comments to explain what these Auth Types are.
| return fields; | ||
| } | ||
|
|
||
| public void storePKCECodeVerifier(String provider, String state, String codeVerifier) throws Exception { |
There was a problem hiding this comment.
supernit: use write or put, instead of store to keep the naming consistent with other methods.
| "PKCE Code Verifier", Collections.emptyMap(), codeVerifierTTL); | ||
| } | ||
|
|
||
| @javax.annotation.Nullable |
| public String getPKCECodeVerifier(String provider, String state) { | ||
| String key = getPKCEKey(provider, state); | ||
| try { | ||
| return new String(secureStore.getData(NamespaceId.SYSTEM.getNamespace(), key), java.nio.charset.StandardCharsets.UTF_8); |
| String key = getPKCEKey(provider, state); | ||
| try { | ||
| return new String(secureStore.getData(NamespaceId.SYSTEM.getNamespace(), key), java.nio.charset.StandardCharsets.UTF_8); | ||
| } catch (Exception e) { |
There was a problem hiding this comment.
Catch specific exception?
There was a problem hiding this comment.
Done
In interface > Exception : if the specified namespace or name does not exist
There was a problem hiding this comment.
nit: Can be simplified as
catch (IOException | Exception e) {
throw new OAuthStoreException("Failed to read PKCE code verifier from secure storage", e);
}There was a problem hiding this comment.
Actually we cannot : there's compilation issue : Types in multi-catch must be disjoint: 'java. io. IOException' is a subclass of 'java. lang. Exception'
| try { | ||
| secureStoreManager.delete(NamespaceId.SYSTEM.getNamespace(), key); | ||
| } catch (Exception e) { | ||
| // Ignore if not found |
There was a problem hiding this comment.
Check if the error was not found.
There was a problem hiding this comment.
The NotFoundException and SecureKeyNotFoundException both are a part of cdap-common which not a dependency in this module.
There was a problem hiding this comment.
Is it not safe to assume that any exception caught here would be of type NotFoundException or SecureKeyNotFoundException? There could be other kind of Exceptions here, like RuntimeException.
Is it safe to ignore the exception and not propagate it?
There was a problem hiding this comment.
For delete :
Yes , NotFoundException or SecureKeyNotFoundException is Safe to ignore here.
For any read operation error, we are catching IOexception.
But for any other exception yes we should catch ..
There is a UNCLEAN solution like :
catch (Exception e) {
if (e.getClass().getName().endsWith("NotFoundException")) {
// Ignore if already deleted
return;
}
throw new OAuthStoreException("Failed to delete PKCE code verifier from secure storage", e);
}
I don't think we should throw exception for NOT FOUND and break the flow.
Moreover , it's a delete op.
- the
PKCECodeVerifieranyway should be written with TTL , even if for some runtime it is missed, it will be cleaned up Eventually [ for GCP secret manager ]
Let me know your thoughts.
| try { | ||
| return new String(secureStore.getData(NamespaceId.SYSTEM.getNamespace(), key), java.nio.charset.StandardCharsets.UTF_8); | ||
| } catch (Exception e) { | ||
| return null; |
There was a problem hiding this comment.
Just curious, why is null returned here?
There was a problem hiding this comment.
Was done to handle it at OauthHandler level to have a better error message for the user but it's not clean .
Removed it.
| return userAgent; | ||
| } | ||
|
|
||
| public enum AuthType { |
There was a problem hiding this comment.
Can be moved to a separate file as the scope of this enum is not limited to this class. It is referenced in other java packages.
| Map<String, String> prefs = null; | ||
| try { | ||
| prefs = context.getPreferencesForNamespace(NamespaceId.SYSTEM.getNamespace(), true); | ||
| } catch (Exception e) { |
There was a problem hiding this comment.
Unreadability of Preferences should not stop the start up of Studio service.
But yes, added catch for the mentioned / known exceptions.
| this.oauthStore = new OAuthStore(context, context, context.getAdmin()); | ||
|
|
||
| this.oauthStore = new OAuthStore(context, context, context.getAdmin(), | ||
| getPrefOrDefault(prefs, PREF_PKCE_CODE_VERIFIER_TTL, 900)); |
There was a problem hiding this comment.
define const for default value for better visibility
| try { | ||
| oauthStore.storePKCECodeVerifier(provider, state, codeVerifier); | ||
| } catch (Exception e) { | ||
| throw new OAuthServiceException(java.net.HttpURLConnection.HTTP_INTERNAL_ERROR, "Failed to store PKCE code verifier", e); |
| } | ||
| } | ||
|
|
||
| private long getPrefOrDefault(java.util.Map<String, String> prefs, String key, long defaultValue) { |
|
|
||
| @Override | ||
| public void initialize(SystemHttpServiceContext context) throws Exception { | ||
| Map<String, String> prefs = null; |
There was a problem hiding this comment.
Initialise empty map, so that null checks are not needed at multiple places.
544b0a1 to
e964c6d
Compare
|
|
||
| private String generateCodeChallenge(String codeVerifier) throws OAuthServiceException { | ||
| try { | ||
| MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); |
e964c6d to
edf1662
Compare
|


Title:
feat: Add PKCE support for OAuth providers
Description:
This PR introduces Proof Key for Code Exchange (PKCE) support for OAuth providers in CDAP data pipelines.
Key Changes:
OAuthStoreschema to includeauthType(expanding the table spec to 6 columns) to properly identify and route PKCE-enabled providers.code_challengeand securely store thecode_verifierwith a configurable time-to-live (oauth.pkce.code.verifier.ttl.sec) via CDAP system preferences.TESTING:
Tested the following cases for a secure store as GCP SECRET MANAGER and the default CDAP secure store.
1. PKCE Flow (AuthType = PKCE)
Tests specifically targeting the Proof Key for Code Exchange logic and the new 10-minute Secure Store TTL.
PutOAuthProviderwithauthType = PKCE.code_challengeandcode_challenge_method=S256. Acode_verifieris generated internally.code_verifieris saved under the system namespace using thestateas the key, with a 10-minute TTL.code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))code_challengefrom step 2, andcode_verifierfrom step 3 and Verify.tokenRefreshURLcontainsgrant_type=authorization_code,code, and the retrievedcode_verifier.code_verifiershould be deleted.code_verifieris retrieved successfully and token exchange proceeds.2. Standard Flow (AuthType = STANDARD or default)
Tests to ensure existing integrations without PKCE continue to work perfectly.
PutOAuthProviderwithauthType = STANDARD(or missing).STANDARDprovider.code_challengeorcode_challenge_method.code_verifieris stored.tokenRefreshURLcontainsgrant_type=authorization_codeandcode, but nocode_verifier.