Skip to content

CDAP-21261 : Implementing Refresh Token Rotation Oauth - #16206

Open
sahusanket wants to merge 2 commits into
developfrom
CDAP-21261_RTR_handling
Open

CDAP-21261 : Implementing Refresh Token Rotation Oauth#16206
sahusanket wants to merge 2 commits into
developfrom
CDAP-21261_RTR_handling

Conversation

@sahusanket

@sahusanket sahusanket commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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 SecureStoreManager to prevent race conditions where multiple pipeline workers might attempt to concurrently rotate the same refresh token.

Key Changes:

  • RTR Configuration: Introduced RefreshType (with STANDARD and RTR support) to OAuthProvider and PutOAuthProviderRequest.
  • Concurrency Control: Updated OAuthHandler to utilize distributed leases (acquireLease / releaseLease via OAuthStore) when fetching access tokens. This prevents concurrent token refreshes across multiple worker instances from invalidating each other.
  • Polling & Recovery: Added polling mechanisms to OAuthHandler so instances blocked by a lock wait for the active worker to finish the refresh and then fetch the newly rotated access token.
  • State Persistence: Updated OAuthStore to persist the new RefreshType and handle the new lease states.
  • Configurability: Added new properties to cdap-default.xml for fine-tuning RTR behavior:
    • security.auth.oauth.rtr.access.token.safety.buffer.ms
    • security.auth.oauth.rtr.wait.timeout.ms
    • security.auth.oauth.rtr.wait.poll.interval.ms
    • security.auth.oauth.rtr.lease.expiration.timeout.ms
  • Testing: Updated OAuthStoreTest to mock and validate the new leasing implementation and token deserialization logic.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +512 to +513
// 4. Winner (either initial or fallback after timeout) executes token refresh and persistence
RefreshTokenResponse tokenResponse = executeTokenRefresh(oauthProvider, provider, credentialId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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).

Suggested change
// 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);

Comment on lines +26 to +30
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since SecureStoreLease is no longer used in the test after fixing the compilation error, we should remove its import.

Suggested change
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;

@sahusanket
sahusanket force-pushed the CDAP-21261_RTR_handling branch 3 times, most recently from 249faaf to 501504e Compare August 24, 2026 20:05
}

public SecureStoreInfo getStoreInfo() throws OAuthStoreException {
if (secureStoreInfo == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use synchronized to initialize once.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This locking till doesn't guarantee multiple initialization.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be null? It is now handled in the constructor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be null? It is now handled in the constructor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GSON.fromJson(jsonString, PutOAuthProviderRequest.class) is used,
the value will be null . ( it by passes the constructor )

Comment on lines +52 to +53
AuthType authType,
RefreshType refreshType) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add @Nullable annotations.

@SerializedName("issued_at")
private final String issuedAt;
@SerializedName("expires_in")
private final long expiresIn;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove extra line

}

@Test
public void testAcquireAndReleaseDatabaseLease() throws Exception {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Database lease or secret lease?

</property>

<property>
<name>security.auth.oauth.rtr.access.token.safety.buffer.ms</name>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to cdap

// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like newly added code in this file can be simplified. PTAL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have tried to simply the code twice from the initial version. We can discuss over a meet to go over it.

@vsethi09 vsethi09 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests.

@sahusanket
sahusanket force-pushed the CDAP-21261_RTR_handling branch 2 times, most recently from 1d5d428 to 24ec486 Compare August 25, 2026 13:00
Comment on lines 27 to 39
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class has Builder pattern for creation and multiple parameterized constructors, which is an anti-pattern.

Comment on lines +42 to +59
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing new line after this line.

RefreshType.STANDARD);
}

public PutOAuthProviderRequest(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this new parameterized constructor be merged with the previous constructor or Builder pattern be used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactored to builder pattern

}
}

private static String generateWorkerIdPrefix() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use thread id instead.

@sahusanket
sahusanket force-pushed the CDAP-21261_RTR_handling branch 2 times, most recently from c30463e to ffdb1aa Compare August 28, 2026 09:23
@sahusanket
sahusanket force-pushed the CDAP-21261_RTR_handling branch from ffdb1aa to e69ba08 Compare August 28, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants