Skip to content

Commit 028a161

Browse files
authored
Improves token manager's refresh token handling (#541)
* Improves token manager's refresh token handling Signed-off-by: Mohammad Ghazanfar Ali Danish <[email protected]> * Adds relevant test Signed-off-by: Mohammad Ghazanfar Ali Danish <[email protected]> * Removes unnecessary class Signed-off-by: Mohammad Ghazanfar Ali Danish <[email protected]> * Addresses review remarks Signed-off-by: Mohammad Ghazanfar Ali Danish <[email protected]> * Re-adjusts the review remarks Signed-off-by: Mohammad Ghazanfar Ali Danish <[email protected]> * Refactors the code Signed-off-by: Mohammad Ghazanfar Ali Danish <[email protected]> * Adjusts the wait time in token retrieval tests Signed-off-by: Mohammad Ghazanfar Ali Danish <[email protected]> --------- Signed-off-by: Mohammad Ghazanfar Ali Danish <[email protected]>
1 parent d5f9b24 commit 028a161

File tree

3 files changed

+443
-55
lines changed

3 files changed

+443
-55
lines changed

basyx.common/basyx.client/src/main/java/org/eclipse/digitaltwin/basyx/client/internal/authorization/TokenManager.java

Lines changed: 149 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -26,30 +26,45 @@
2626
package org.eclipse.digitaltwin.basyx.client.internal.authorization;
2727

2828
import java.io.IOException;
29+
import java.text.ParseException;
2930

3031
import org.eclipse.digitaltwin.basyx.client.internal.authorization.grant.AccessTokenProvider;
3132
import org.eclipse.digitaltwin.basyx.core.exceptions.AccessTokenRetrievalException;
33+
import org.slf4j.Logger;
34+
import org.slf4j.LoggerFactory;
3235

3336
import com.nimbusds.oauth2.sdk.AccessTokenResponse;
3437
import com.nimbusds.oauth2.sdk.token.AccessToken;
3538
import com.nimbusds.oauth2.sdk.token.RefreshToken;
3639

40+
import net.minidev.json.JSONObject;
41+
import com.nimbusds.jwt.JWT;
42+
import com.nimbusds.jwt.JWTParser;
43+
import com.nimbusds.jwt.SignedJWT;
44+
45+
import java.util.Date;
46+
3747
/**
3848
* Requests and manages the Access Tokens and Refresh Tokens.
3949
*
40-
* @author danish
50+
* @author danish
4151
*/
52+
import java.time.Instant;
53+
4254
public class TokenManager {
43-
44-
private String tokenEndpoint;
45-
private AccessTokenProvider accessTokenProvider;
55+
56+
private static final Logger LOGGER = LoggerFactory.getLogger(TokenManager.class);
57+
58+
private static final String EXPIRES_IN = "expires_in";
59+
private static final String REFRESH_EXPIRES_IN = "refresh_expires_in";
60+
private final String tokenEndpoint;
61+
private final AccessTokenProvider accessTokenProvider;
4662
private String accessToken;
47-
private String refreshToken;
48-
private long accessTokenExpiryTime;
49-
private long refreshTokenExpiryTime;
50-
63+
private String refreshToken;
64+
private Instant accessTokenExpiryTime;
65+
private Instant refreshTokenExpiryTime;
66+
5167
public TokenManager(String tokenEndpoint, AccessTokenProvider accessTokenProvider) {
52-
super();
5368
this.tokenEndpoint = tokenEndpoint;
5469
this.accessTokenProvider = accessTokenProvider;
5570
}
@@ -61,46 +76,139 @@ public String getTokenEndpoint() {
6176
public AccessTokenProvider getAccessTokenProvider() {
6277
return this.accessTokenProvider;
6378
}
64-
79+
80+
/**
81+
* Provides the access token, refreshing it if necessary.
82+
*
83+
* @return the current valid access token
84+
* @throws IOException
85+
* if an error occurs while retrieving the token
86+
*/
87+
public String getAccessToken() throws IOException {
88+
Instant currentTime = Instant.now();
89+
90+
if (accessToken != null && currentTime.isBefore(accessTokenExpiryTime))
91+
return accessToken;
92+
93+
synchronized (this) {
94+
if (accessToken != null && currentTime.isBefore(accessTokenExpiryTime))
95+
return accessToken;
96+
97+
if (refreshToken != null && currentTime.isBefore(refreshTokenExpiryTime))
98+
return refreshAccessToken(currentTime);
99+
100+
return obtainNewAccessToken(currentTime);
101+
}
102+
}
103+
65104
/**
66-
* Provides access token
105+
* Updates the tokens and their expiry times.
67106
*
68-
* @return accessToken
107+
* @param accessTokenResponse
108+
* the response containing the new tokens
109+
* @param currentTime
110+
* the current timestamp for consistency
111+
* @return the new access token
69112
* @throws IOException
113+
* if an error occurs while processing the response
114+
*/
115+
private String updateTokens(AccessTokenResponse accessTokenResponse, Instant currentTime) throws IOException {
116+
AccessToken accessTokenObj = accessTokenResponse.getTokens().getAccessToken();
117+
accessToken = accessTokenObj.getValue();
118+
accessTokenExpiryTime = calculateExpiryTime(accessTokenObj, currentTime);
119+
120+
RefreshToken refreshTokenObj = accessTokenResponse.getTokens().getRefreshToken();
121+
122+
if (refreshTokenObj != null) {
123+
refreshToken = refreshTokenObj.getValue();
124+
refreshTokenExpiryTime = calculateRefreshExpiryTime(refreshTokenObj, accessTokenResponse, currentTime);
125+
}
126+
127+
return accessToken;
128+
}
129+
130+
/**
131+
* Calculates the expiry time for a JWT token. First checks the 'exp' field in
132+
* the JWT, falling back to 'expires_in'.
133+
*
134+
* @param tokenObj
135+
* the AccessToken or RefreshToken object
136+
* @param currentTime
137+
* the current timestamp
138+
* @return the calculated expiry time as Instant
139+
*/
140+
private Instant calculateExpiryTime(AccessToken tokenObj, Instant currentTime) {
141+
String tokenValue = tokenObj.getValue();
142+
Date expirationDate = extractExpirationTimeAsDateFromToken(tokenValue);
143+
144+
if (expirationDate != null)
145+
return expirationDate.toInstant();
146+
147+
LOGGER.info("Unable to find 'exp' claim inside Access Token! Falling back to the alternative, the '{}' field.", EXPIRES_IN);
148+
149+
return currentTime.plusSeconds(tokenObj.getLifetime());
150+
}
151+
152+
/**
153+
* Calculates the expiry time for a refresh token. First checks the 'exp' field
154+
* in the JWT refresh token, falling back to 'refresh_expires_in'.
155+
*
156+
* @param refreshTokenObj
157+
* the RefreshToken object
158+
* @param accessTokenResponse
159+
* the response containing the refresh token
160+
* @param currentTime
161+
* the current timestamp
162+
* @return the calculated expiry time as Instant
70163
*/
71-
public synchronized String getAccessToken() throws IOException {
164+
private Instant calculateRefreshExpiryTime(RefreshToken refreshTokenObj, AccessTokenResponse accessTokenResponse, Instant currentTime) {
165+
String tokenValue = refreshTokenObj.getValue();
166+
Date expirationDate = extractExpirationTimeAsDateFromToken(tokenValue);
167+
168+
if (expirationDate != null)
169+
return expirationDate.toInstant();
170+
171+
LOGGER.info("Unable to find 'exp' claim inside Refresh Token! Falling back to the alternative, the '{}' field", REFRESH_EXPIRES_IN);
72172

73-
if (accessToken != null && System.currentTimeMillis() < accessTokenExpiryTime)
74-
return accessToken;
173+
JSONObject jsonObject = accessTokenResponse.toJSONObject();
174+
Number refreshExpiresInSeconds = jsonObject.getAsNumber(REFRESH_EXPIRES_IN);
75175

76-
if (refreshToken != null && System.currentTimeMillis() < refreshTokenExpiryTime) {
77-
try {
78-
return requestAccessToken(accessTokenProvider.getAccessTokenResponse(tokenEndpoint, refreshToken));
79-
} catch (IOException e) {
80-
throw new AccessTokenRetrievalException("Error occurred while retrieving access token" + e.getMessage());
176+
if (refreshExpiresInSeconds == null)
177+
return Instant.EPOCH;
178+
179+
return currentTime.plusSeconds(refreshExpiresInSeconds.longValue());
180+
}
181+
182+
private Date extractExpirationTimeAsDateFromToken(String tokenValue) {
183+
try {
184+
JWT jwt = JWTParser.parse(tokenValue);
185+
186+
if (jwt instanceof SignedJWT) {
187+
SignedJWT signedJwt = (SignedJWT) jwt;
188+
return signedJwt.getJWTClaimsSet().getExpirationTime();
81189
}
82-
}
190+
} catch (ParseException e) {
191+
LOGGER.error("Failed to parse the token. Invalid JWT format: " + e.getMessage());
192+
} catch (Exception e) {
193+
LOGGER.error("Unexpected error occurred while extracting expiration time from the Token: " + e.getMessage());
194+
}
195+
196+
return null;
197+
}
198+
199+
private String obtainNewAccessToken(Instant currentTime) {
200+
try {
201+
return updateTokens(accessTokenProvider.getAccessTokenResponse(tokenEndpoint), currentTime);
202+
} catch (IOException e) {
203+
throw new AccessTokenRetrievalException("Error occurred while retrieving access token: " + e.getMessage());
204+
}
205+
}
83206

84-
try {
85-
return requestAccessToken(accessTokenProvider.getAccessTokenResponse(tokenEndpoint));
207+
private String refreshAccessToken(Instant currentTime) {
208+
try {
209+
return updateTokens(accessTokenProvider.getAccessTokenResponse(tokenEndpoint, refreshToken), currentTime);
86210
} catch (IOException e) {
87-
throw new AccessTokenRetrievalException("Error occurred while retrieving access token" + e.getMessage());
211+
throw new AccessTokenRetrievalException("Error occurred while retrieving access token: " + e.getMessage());
88212
}
89-
}
90-
91-
private String requestAccessToken(AccessTokenResponse accessTokenResponse) throws IOException {
92-
AccessToken accessTokenObj = accessTokenResponse.getTokens().getAccessToken();
93-
accessToken = accessTokenObj.getValue();
94-
accessTokenExpiryTime = accessTokenObj.getLifetime();
95-
96-
RefreshToken refreshTokenObj = accessTokenResponse.getTokens().getRefreshToken();
97-
98-
if (refreshTokenObj != null) {
99-
refreshToken = refreshTokenObj.getValue();
100-
refreshTokenExpiryTime = System.currentTimeMillis() + (30L * 24L * 60L * 60L * 1000L);
101-
}
102-
103-
return accessToken;
104-
}
105-
213+
}
106214
}

basyx.common/basyx.client/src/test/java/org/eclipse/digitaltwin/basyx/client/internal/TokenManagerTest.java

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,43 +25,106 @@
2525

2626
package org.eclipse.digitaltwin.basyx.client.internal;
2727

28+
import static org.junit.Assert.*;
2829
import org.eclipse.digitaltwin.basyx.client.internal.authorization.TokenManager;
29-
import org.eclipse.digitaltwin.basyx.client.internal.authorization.credential.ClientCredential;
30-
import org.eclipse.digitaltwin.basyx.client.internal.authorization.grant.ClientCredentialAccessTokenProvider;
3130
import org.junit.Before;
3231
import org.junit.Test;
32+
import org.eclipse.digitaltwin.basyx.client.internal.authorization.credential.ClientCredential;
33+
import org.eclipse.digitaltwin.basyx.client.internal.authorization.credential.PasswordCredential;
34+
import org.eclipse.digitaltwin.basyx.client.internal.authorization.grant.PasswordCredentialAccessTokenProvider;
3335

3436
import java.io.IOException;
3537

36-
import static org.junit.Assert.*;
37-
3838
/**
3939
* Tests the behaviour of {@link TokenManager}
4040
*
4141
* @author danish
4242
*/
4343
public class TokenManagerTest {
44-
44+
45+
private static final String TOKEN_ENDPOINT = "http://localhost:9096/realms/BaSyx/protocol/openid-connect/token";
4546
private TokenManager tokenManager;
4647

4748
@Before
4849
public void setUp() {
49-
tokenManager = new TokenManager("http://localhost:9096/realms/BaSyx/protocol/openid-connect/token", new ClientCredentialAccessTokenProvider(new ClientCredential("workstation-1", "nY0mjyECF60DGzNmQUjL81XurSl8etom")));
50+
tokenManager = new TokenManager(TOKEN_ENDPOINT, new PasswordCredentialAccessTokenProvider(new PasswordCredential("basyx.reader", "basyxreader"), new ClientCredential("max-sso", "8ccc227xJflxGgtFkwwssHRZUh99nAAc")));
5051
}
5152

5253
@Test
53-
public void testGetAccessToken_RetrievesNewTokenAfterExpiry() throws IOException, InterruptedException {
54-
54+
public void retrieveNewAccessTokenWhenExpired() throws IOException, InterruptedException {
5555
String initialAccessToken = tokenManager.getAccessToken();
5656
assertNotNull(initialAccessToken);
5757

58-
long tokenLifetime = 5000;
59-
Thread.sleep(tokenLifetime + 1000);
58+
long tokenLifetime = 1000;
59+
Thread.sleep(tokenLifetime + 5);
6060

6161
String newAccessToken = tokenManager.getAccessToken();
6262
assertNotNull(newAccessToken);
6363

6464
assertNotEquals(initialAccessToken, newAccessToken);
6565
}
6666

67+
@Test
68+
public void provideSameAccessTokenWhenNotExpired() throws IOException, InterruptedException {
69+
String initialAccessToken = tokenManager.getAccessToken();
70+
assertNotNull(initialAccessToken);
71+
72+
long tokenLifetime = 350;
73+
Thread.sleep(tokenLifetime);
74+
75+
String newAccessToken = tokenManager.getAccessToken();
76+
assertNotNull(newAccessToken);
77+
78+
assertEquals(initialAccessToken, newAccessToken);
79+
}
80+
81+
@Test
82+
public void retrieveNewAccessTokenUsingRefreshTokenWhenAccessTokenIsExpired() throws IOException, InterruptedException {
83+
String initialAccessToken = tokenManager.getAccessToken();
84+
assertNotNull(initialAccessToken);
85+
86+
long tokenLifetime = 1000;
87+
Thread.sleep(tokenLifetime + 15);
88+
89+
String newAccessToken = tokenManager.getAccessToken();
90+
assertNotNull(newAccessToken);
91+
92+
assertNotEquals(initialAccessToken, newAccessToken);
93+
}
94+
95+
@Test
96+
public void retrieveNewAccessTokenUsingRefreshTokenWhenExpired() throws IOException, InterruptedException {
97+
String initialAccessToken = tokenManager.getAccessToken();
98+
assertNotNull(initialAccessToken);
99+
100+
long tokenLifetime = 3000;
101+
Thread.sleep(tokenLifetime + 5);
102+
103+
String newAccessToken = tokenManager.getAccessToken();
104+
assertNotNull(newAccessToken);
105+
106+
assertNotEquals(initialAccessToken, newAccessToken);
107+
}
108+
109+
@Test
110+
// This test is for verifying the behavior described in the issue: https://github.com/eclipse-basyx/basyx-java-server-sdk/issues/530
111+
public void retrieveNewAccessTokenWhenSSOMaxReached() throws IOException, InterruptedException {
112+
String initialAccessToken = tokenManager.getAccessToken();
113+
assertNotNull(initialAccessToken);
114+
115+
long timeLimit = 4000;
116+
String intermediateAccessToken = null;
117+
118+
while (timeLimit > 0) {
119+
intermediateAccessToken = tokenManager.getAccessToken();
120+
121+
Thread.sleep(2000);
122+
123+
timeLimit -= 2000;
124+
}
125+
126+
assertNotNull(intermediateAccessToken);
127+
assertNotEquals(initialAccessToken, intermediateAccessToken);
128+
}
129+
67130
}

0 commit comments

Comments
 (0)