CDAP-21261 : Implementing Refresh Token Rotation Oauth - #16206
CDAP-21261 : Implementing Refresh Token Rotation Oauth#16206sahusanket wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for OAuth 2.0 Refresh Token Rotation (RTR) by implementing distributed lease locking in OAuthStore and updating OAuthHandler to coordinate concurrent token refreshes. Feedback on these changes highlights a compilation failure in OAuthStoreTest due to mismatched method signatures for lease operations, a concurrency issue where InterruptedException is swallowed while waiting for a new token, and a potential logical bug in the RTR flow where a process should double-check for a newly published token after acquiring a lock to prevent redundant refreshes.
| // 4. Winner (either initial or fallback after timeout) executes token refresh and persistence | ||
| RefreshTokenResponse tokenResponse = executeTokenRefresh(oauthProvider, provider, credentialId); |
There was a problem hiding this comment.
When implementing Refresh Token Rotation (RTR), if a process times out waiting for the lock winner and subsequently acquires the lease lock itself, it should double-check if a valid access token has already been published in the store before executing executeTokenRefresh. Without this check, the process might perform a redundant token refresh. In RTR, redundant refreshes are not only wasteful but can also trigger security mechanisms on the authorization server (which detects reuse of rotated refresh tokens and invalidates all active tokens).
| // 4. Winner (either initial or fallback after timeout) executes token refresh and persistence | |
| RefreshTokenResponse tokenResponse = executeTokenRefresh(oauthProvider, provider, credentialId); | |
| // Double-check if a valid token was published while we were waiting or acquiring the lock | |
| Optional<OAuthAccessToken> currentToken = getAccessToken(provider, credentialId); | |
| if (currentToken.isPresent() && isAccessTokenValid(currentToken.get())) { | |
| return new GetAccessTokenResponse(currentToken.get().getAccessToken(), ""); | |
| } | |
| // 4. Winner (either initial or fallback after timeout) executes token refresh and persistence | |
| RefreshTokenResponse tokenResponse = executeTokenRefresh(oauthProvider, provider, credentialId); |
| import io.cdap.cdap.api.security.store.SecureStoreLease; | ||
| import io.cdap.cdap.datapipeline.oauth.OAuthAccessToken; | ||
| import io.cdap.cdap.datapipeline.oauth.OAuthProvider; | ||
| import io.cdap.cdap.datapipeline.oauth.AuthType; | ||
| import io.cdap.cdap.datapipeline.oauth.RefreshType; |
There was a problem hiding this comment.
Since SecureStoreLease is no longer used in the test after fixing the compilation error, we should remove its import.
| import io.cdap.cdap.api.security.store.SecureStoreLease; | |
| import io.cdap.cdap.datapipeline.oauth.OAuthAccessToken; | |
| import io.cdap.cdap.datapipeline.oauth.OAuthProvider; | |
| import io.cdap.cdap.datapipeline.oauth.AuthType; | |
| import io.cdap.cdap.datapipeline.oauth.RefreshType; | |
| import io.cdap.cdap.datapipeline.oauth.OAuthAccessToken; | |
| import io.cdap.cdap.datapipeline.oauth.OAuthProvider; | |
| import io.cdap.cdap.datapipeline.oauth.AuthType; | |
| import io.cdap.cdap.datapipeline.oauth.RefreshType; |
249faaf to
501504e
Compare
| } | ||
|
|
||
| public SecureStoreInfo getStoreInfo() throws OAuthStoreException { | ||
| if (secureStoreInfo == null) { |
There was a problem hiding this comment.
Use synchronized to initialize once.
There was a problem hiding this comment.
This locking till doesn't guarantee multiple initialization.
There was a problem hiding this comment.
This code would initialise only once, there is a doubt check of if (secureStoreInfo == null) { on line 94 .
|
|
||
| public AuthType getAuthType() { | ||
| return authType; | ||
| return authType == null ? AuthType.STANDARD : authType; |
There was a problem hiding this comment.
Can this be null? It is now handled in the constructor.
There was a problem hiding this comment.
GSON.fromJson(jsonString, PutOAuthProviderRequest.class) is used,
the value will be null . ( it by passes the constructor )
| } | ||
|
|
||
| public RefreshType getRefreshType() { | ||
| return refreshType == null ? RefreshType.STANDARD : refreshType; |
There was a problem hiding this comment.
Can this be null? It is now handled in the constructor.
There was a problem hiding this comment.
GSON.fromJson(jsonString, PutOAuthProviderRequest.class) is used,
the value will be null . ( it by passes the constructor )
| AuthType authType, | ||
| RefreshType refreshType) { |
There was a problem hiding this comment.
Add @Nullable annotations.
| @SerializedName("issued_at") | ||
| private final String issuedAt; | ||
| @SerializedName("expires_in") | ||
| private final long expiresIn; |
There was a problem hiding this comment.
What does this indicate? Epoch time in ms or seconds?
(Optional) You may consider changing this to String expiresAt similar to String issuedAt, if it makes sense.
There was a problem hiding this comment.
All the fields are either according to Oauth 2.0 standards or based on Salesforce Standard.
The expiresIn is in accordance to Oauth 2.0 : https://www.rfc-editor.org/info/rfc6749/#appendix-A.14
It is mandated to be in seconds.
There was a problem hiding this comment.
Good to mention in the comments or variable name to be clear.
|
|
||
| import io.cdap.cdap.api.security.store.SecureStore; | ||
| import io.cdap.cdap.api.security.store.SecureStoreManager; | ||
|
|
| } | ||
|
|
||
| @Test | ||
| public void testAcquireAndReleaseDatabaseLease() throws Exception { |
There was a problem hiding this comment.
Database lease or secret lease?
| </property> | ||
|
|
||
| <property> | ||
| <name>security.auth.oauth.rtr.access.token.safety.buffer.ms</name> |
There was a problem hiding this comment.
nit: Please use better property name.
Same for other properties below.
| try { | ||
| instanceName = java.net.InetAddress.getLocalHost().getHostName(); | ||
| } catch (java.net.UnknownHostException e) { | ||
| instanceName = "cdf"; |
| // If no long-lived access token was found, request a short-lived access token from the 3rd-party API using the | ||
| // stored refresh token | ||
| OAuthRefreshToken refreshToken = getRefreshToken(provider, credentialId); | ||
| private RefreshTokenResponse executeTokenRefresh(OAuthProvider oauthProvider, String provider, String credentialId) |
There was a problem hiding this comment.
Seems like newly added code in this file can be simplified. PTAL.
There was a problem hiding this comment.
I have tried to simply the code twice from the initial version. We can discuss over a meet to go over it.
1d5d428 to
24ec486
Compare
| public OAuthAccessToken(String accessToken) { | ||
| this(accessToken, 0L, null); | ||
| } | ||
|
|
||
| public OAuthAccessToken(String accessToken, long expiresAt) { | ||
| this(accessToken, expiresAt, null); | ||
| } | ||
|
|
||
| public OAuthAccessToken(String accessToken, long expiresAt, String identityUrl) { | ||
| this.accessToken = accessToken; | ||
| this.expiresAt = expiresAt; | ||
| this.identityUrl = identityUrl; | ||
| } |
There was a problem hiding this comment.
This class has Builder pattern for creation and multiple parameterized constructors, which is an anti-pattern.
| public OAuthProvider(String name, | ||
| String loginURL, | ||
| String tokenRefreshURL, | ||
| @Nullable OAuthClientCredentials clientCreds, | ||
| @Nullable CredentialEncodingStrategy strategy, | ||
| @Nullable String userAgent, | ||
| @Nullable AuthType authType) { | ||
| this(name, loginURL, tokenRefreshURL, clientCreds, strategy, userAgent, authType, RefreshType.STANDARD); | ||
| } | ||
|
|
||
| public OAuthProvider(String name, | ||
| String loginURL, | ||
| String tokenRefreshURL, | ||
| @Nullable OAuthClientCredentials clientCreds, | ||
| @Nullable CredentialEncodingStrategy strategy, | ||
| @Nullable String userAgent, | ||
| @Nullable AuthType authType, | ||
| @Nullable RefreshType refreshType) { |
There was a problem hiding this comment.
This class has Builder pattern for creation and multiple parameterized constructors, which is an anti-pattern.
|
|
||
| package io.cdap.cdap.datapipeline.oauth; | ||
|
|
||
| import javax.annotation.Nullable; |
There was a problem hiding this comment.
Missing new line after this line.
| RefreshType.STANDARD); | ||
| } | ||
|
|
||
| public PutOAuthProviderRequest( |
There was a problem hiding this comment.
Can this new parameterized constructor be merged with the previous constructor or Builder pattern be used?
There was a problem hiding this comment.
refactored to builder pattern
| } | ||
| } | ||
|
|
||
| private static String generateWorkerIdPrefix() { |
c30463e to
ffdb1aa
Compare
ffdb1aa to
e69ba08
Compare
This PR introduces support for Refresh Token Rotation (RTR) within the CDAP OAuth token management flow. To ensure safe token rotation in a distributed environment, it leverages distributed leasing/locking via
SecureStoreManagerto prevent race conditions where multiple pipeline workers might attempt to concurrently rotate the same refresh token.Key Changes:
RefreshType(withSTANDARDandRTRsupport) toOAuthProviderandPutOAuthProviderRequest.OAuthHandlerto utilize distributed leases (acquireLease/releaseLeaseviaOAuthStore) when fetching access tokens. This prevents concurrent token refreshes across multiple worker instances from invalidating each other.OAuthHandlerso instances blocked by a lock wait for the active worker to finish the refresh and then fetch the newly rotated access token.OAuthStoreto persist the newRefreshTypeand handle the new lease states.cdap-default.xmlfor fine-tuning RTR behavior:security.auth.oauth.rtr.access.token.safety.buffer.mssecurity.auth.oauth.rtr.wait.timeout.mssecurity.auth.oauth.rtr.wait.poll.interval.mssecurity.auth.oauth.rtr.lease.expiration.timeout.msOAuthStoreTestto mock and validate the new leasing implementation and token deserialization logic.