Skip to content

Commit ade4b70

Browse files
authored
Make Access token cache public (#49712)
1 parent 6074743 commit ade4b70

11 files changed

Lines changed: 133 additions & 48 deletions

File tree

eng/versioning/version_client.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ io.clientcore:optional-dependency-tests;1.0.0-beta.1;1.0.0-beta.1
570570
# In the pom, the version update tag after the version should name the unreleased package and the dependency version:
571571
# <!-- {x-version-update;unreleased_com.azure:azure-core;dependency} -->
572572

573+
unreleased_com.azure:azure-core;1.59.0-beta.1
573574
unreleased_com.azure.v2:azure-core;2.0.0-beta.1
574575
unreleased_com.azure.v2:azure-identity;2.0.0-beta.1
575576
unreleased_com.azure.v2:azure-data-appconfiguration;2.0.0-beta.1

sdk/core/azure-core/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- Promoted `AccessTokenCache` to a public API in the `com.azure.core.credential` package. This class provides a thread-safe, proactively refreshing token cache that wraps a `TokenCredential`, supporting both synchronous and asynchronous token retrieval.
8+
79
### Breaking Changes
810

911
### Bugs Fixed

sdk/core/azure-core/src/main/java/com/azure/core/implementation/AccessTokenCache.java renamed to sdk/core/azure-core/src/main/java/com/azure/core/credential/AccessTokenCache.java

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
package com.azure.core.implementation;
4+
package com.azure.core.credential;
55

6-
import com.azure.core.credential.AccessToken;
7-
import com.azure.core.credential.TokenCredential;
8-
import com.azure.core.credential.TokenRequestContext;
6+
import com.azure.core.implementation.AccessTokenCacheInfo;
97
import com.azure.core.util.logging.ClientLogger;
108
import com.azure.core.util.logging.LogLevel;
119
import com.azure.core.util.logging.LoggingEventBuilder;
@@ -25,7 +23,57 @@
2523
import java.util.function.Supplier;
2624

2725
/**
28-
* A token cache that supports caching a token and refreshing it.
26+
* <p>
27+
* {@code AccessTokenCache} is a thread-safe token cache that wraps a {@link TokenCredential} and manages proactive
28+
* token refresh. It supports both asynchronous and synchronous token retrieval via
29+
* {@link #getToken(TokenRequestContext, boolean)} and {@link #getTokenSync(TokenRequestContext, boolean)}.
30+
* </p>
31+
*
32+
* <p>
33+
* The cache maintains a single cached {@link AccessToken} per instance and proactively refreshes it before expiry
34+
* (by default 5 minutes before the expiry time, or at the {@code refreshAt} time if provided by the credential).
35+
* If a refresh fails while a non-expired token is still available, the cached token continues to be returned until
36+
* it expires.
37+
* </p>
38+
*
39+
* <p>
40+
* When the {@code refreshOnContextChange} flag is {@code true}, the cache compares the incoming
41+
* {@link TokenRequestContext} (scopes, tenant ID, claims) against the context used to acquire the current cached
42+
* token. A mismatch causes an immediate token refresh regardless of expiry. This is the mechanism used to support
43+
* Continuous Access Evaluation (CAE) claims challenges.
44+
* </p>
45+
*
46+
* <p>
47+
* <strong>Note:</strong> Each instance caches exactly one {@link AccessToken} associated with one
48+
* {@link TokenRequestContext}. Do not share a single {@code AccessTokenCache} instance across calls that require
49+
* different scopes or tenants simultaneously, as each new context will evict the previously cached token.
50+
* </p>
51+
*
52+
* <p>
53+
* This class is thread-safe. Multiple threads may call {@link #getToken(TokenRequestContext, boolean)} and
54+
* {@link #getTokenSync(TokenRequestContext, boolean)} concurrently.
55+
* </p>
56+
*
57+
* <p>
58+
* <strong>Sample: Wrapping a TokenCredential with AccessTokenCache</strong>
59+
* </p>
60+
*
61+
* <!-- src_embed com.azure.core.credential.accessTokenCache -->
62+
* <pre>
63+
* TokenCredential credential = new BasicAuthenticationCredential&#40;&quot;username&quot;, &quot;password&quot;&#41;;
64+
* AccessTokenCache tokenCache = new AccessTokenCache&#40;credential&#41;;
65+
* TokenRequestContext requestContext = new TokenRequestContext&#40;&#41;.addScopes&#40;&quot;https:&#47;&#47;management.azure.com&#47;.default&quot;&#41;;
66+
* &#47;&#47; Async usage
67+
* Mono&lt;AccessToken&gt; tokenMono = tokenCache.getToken&#40;requestContext, false&#41;;
68+
* &#47;&#47; Sync usage
69+
* AccessToken token = tokenCache.getTokenSync&#40;requestContext, false&#41;;
70+
* </pre>
71+
* <!-- end com.azure.core.credential.accessTokenCache -->
72+
*
73+
* @see TokenCredential
74+
* @see AccessToken
75+
* @see TokenRequestContext
76+
* @see SimpleTokenCache
2977
*/
3078
public final class AccessTokenCache {
3179
// The delay after a refresh to attempt another token refresh
@@ -48,12 +96,12 @@ public final class AccessTokenCache {
4896
private final Lock lock;
4997

5098
/**
51-
* Creates an instance of RefreshableTokenCredential with default scheme "Bearer".
99+
* Creates an instance of {@code AccessTokenCache} that wraps the given {@link TokenCredential}.
52100
*
53101
* @param tokenCredential the token credential to be used to acquire the token.
54102
*/
55103
public AccessTokenCache(TokenCredential tokenCredential) {
56-
Objects.requireNonNull(tokenCredential, "The token credential cannot be null");
104+
Objects.requireNonNull(tokenCredential, "'tokenCredential' cannot be null.");
57105
this.wip = new AtomicReference<>();
58106
this.tokenCredential = tokenCredential;
59107
this.cacheInfo = new AtomicReference<>(new AccessTokenCacheInfo(null, OffsetDateTime.now()));
@@ -70,11 +118,15 @@ public AccessTokenCache(TokenCredential tokenCredential) {
70118
* Asynchronously get a token from either the cache or replenish the cache with a new token.
71119
*
72120
* @param tokenRequestContext The request context for token acquisition.
73-
* @param checkToForceFetchToken The flag indicating whether to force fetch a new token or not.
74-
* @return The Publisher that emits an AccessToken
121+
* @param refreshOnContextChange When {@code true}, compares the incoming {@link TokenRequestContext} against the
122+
* one used to acquire the current cached token. If the scopes, tenant ID, or claims differ, a fresh token is
123+
* fetched immediately regardless of expiry. Pass {@code false} to always use the cached token when it is
124+
* still valid.
125+
* @return a {@link Mono} that emits the cached or newly acquired {@link AccessToken}.
126+
* @throws IllegalArgumentException if {@code tokenRequestContext} is {@code null}.
75127
*/
76-
public Mono<AccessToken> getToken(TokenRequestContext tokenRequestContext, boolean checkToForceFetchToken) {
77-
return Mono.defer(retrieveToken(tokenRequestContext, checkToForceFetchToken))
128+
public Mono<AccessToken> getToken(TokenRequestContext tokenRequestContext, boolean refreshOnContextChange) {
129+
return Mono.defer(retrieveToken(tokenRequestContext, refreshOnContextChange))
78130
// Keep resubscribing as long as Mono.defer [token acquisition] emits empty().
79131
.repeatWhenEmpty((Flux<Long> longFlux) -> longFlux
80132
.concatMap(ignored -> Flux.just(true).delayElements(Duration.ofMillis(500))));
@@ -84,25 +136,29 @@ public Mono<AccessToken> getToken(TokenRequestContext tokenRequestContext, boole
84136
* Synchronously get a token from either the cache or replenish the cache with a new token.
85137
*
86138
* @param tokenRequestContext The request context for token acquisition.
87-
* @param checkToForceFetchToken The flag indicating whether to force fetch a new token or not.
88-
* @return The Publisher that emits an AccessToken
139+
* @param refreshOnContextChange When {@code true}, compares the incoming {@link TokenRequestContext} against the
140+
* one used to acquire the current cached token. If the scopes, tenant ID, or claims differ, a fresh token is
141+
* fetched immediately regardless of expiry. Pass {@code false} to always use the cached token when it is
142+
* still valid.
143+
* @return the cached or newly acquired {@link AccessToken}.
144+
* @throws IllegalArgumentException if {@code tokenRequestContext} is {@code null}.
89145
*/
90-
public AccessToken getTokenSync(TokenRequestContext tokenRequestContext, boolean checkToForceFetchToken) {
146+
public AccessToken getTokenSync(TokenRequestContext tokenRequestContext, boolean refreshOnContextChange) {
91147
lock.lock();
92148
try {
93-
return retrieveTokenSync(tokenRequestContext, checkToForceFetchToken).get();
149+
return retrieveTokenSync(tokenRequestContext, refreshOnContextChange).get();
94150
} finally {
95151
lock.unlock();
96152
}
97153
}
98154

99155
private Supplier<Mono<? extends AccessToken>> retrieveToken(TokenRequestContext tokenRequestContext,
100-
boolean checkToForceFetchToken) {
156+
boolean refreshOnContextChange) {
101157
return () -> {
102158
try {
103159
if (tokenRequestContext == null) {
104-
return Mono.error(LOGGER.logExceptionAsError(
105-
new IllegalArgumentException("The token request context input cannot be null.")));
160+
return Mono.error(LOGGER
161+
.logExceptionAsError(new IllegalArgumentException("'tokenRequestContext' cannot be null.")));
106162
}
107163

108164
AccessTokenCacheInfo cache = this.cacheInfo.get();
@@ -115,9 +171,9 @@ private Supplier<Mono<? extends AccessToken>> retrieveToken(TokenRequestContext
115171
Mono<AccessToken> fallback;
116172

117173
// Check if the incoming token request context is different from the cached one. A different
118-
// token request context, requires to fetch a new token as the cached one won't work for the
174+
// token request context requires fetching a new token as the cached one won't work for the
119175
// passed in token request context.
120-
boolean forceRefresh = (checkToForceFetchToken && checkIfForceRefreshRequired(tokenRequestContext))
176+
boolean forceRefresh = (refreshOnContextChange && checkIfForceRefreshRequired(tokenRequestContext))
121177
|| this.tokenRequestContext == null;
122178

123179
if (forceRefresh) {
@@ -153,12 +209,12 @@ private Supplier<Mono<? extends AccessToken>> retrieveToken(TokenRequestContext
153209
.flatMap(processTokenRefreshResult(sinksOne, now, fallback))
154210
.doOnError(sinksOne::tryEmitError),
155211
w -> w.set(null));
156-
} else if (cachedToken != null && !cachedToken.isExpired() && !checkToForceFetchToken) {
212+
} else if (cachedToken != null && !cachedToken.isExpired() && !refreshOnContextChange) {
157213
// another thread might be refreshing the token proactively, but the current token is still valid
158214
return Mono.just(cachedToken);
159215
} else {
160-
// if a force refresh is possible, then exit and retry.
161-
if (checkToForceFetchToken) {
216+
// if a context-change refresh is pending, exit and retry.
217+
if (refreshOnContextChange) {
162218
return Mono.empty();
163219
}
164220
// another thread is definitely refreshing the expired token
@@ -178,11 +234,10 @@ private Supplier<Mono<? extends AccessToken>> retrieveToken(TokenRequestContext
178234
}
179235

180236
private Supplier<AccessToken> retrieveTokenSync(TokenRequestContext tokenRequestContext,
181-
boolean checkToForceFetchToken) {
237+
boolean refreshOnContextChange) {
182238
return () -> {
183239
if (tokenRequestContext == null) {
184-
throw LOGGER.logExceptionAsError(
185-
new IllegalArgumentException("The token request context input cannot be null."));
240+
throw LOGGER.logExceptionAsError(new IllegalArgumentException("'tokenRequestContext' cannot be null."));
186241
}
187242
AccessTokenCacheInfo cache = this.cacheInfo.get();
188243
AccessToken cachedToken = cache.getCachedAccessToken();
@@ -192,9 +247,9 @@ private Supplier<AccessToken> retrieveTokenSync(TokenRequestContext tokenRequest
192247
AccessToken fallback;
193248

194249
// Check if the incoming token request context is different from the cached one. A different
195-
// token request context, requires to fetch a new token as the cached one won't work for the
250+
// token request context requires fetching a new token as the cached one won't work for the
196251
// passed in token request context.
197-
boolean forceRefresh = (checkToForceFetchToken && checkIfForceRefreshRequired(tokenRequestContext))
252+
boolean forceRefresh = (refreshOnContextChange && checkIfForceRefreshRequired(tokenRequestContext))
198253
|| this.tokenRequestContext == null;
199254

200255
if (forceRefresh) {
@@ -245,7 +300,10 @@ private Supplier<AccessToken> retrieveTokenSync(TokenRequestContext tokenRequest
245300
if (fallback != null) {
246301
return fallback;
247302
}
248-
throw error;
303+
if (error instanceof RuntimeException) {
304+
throw LOGGER.logExceptionAsError((RuntimeException) error);
305+
}
306+
throw LOGGER.logExceptionAsError(new RuntimeException(error));
249307
}
250308
};
251309
}

sdk/core/azure-core/src/main/java/com/azure/core/http/policy/BearerTokenAuthenticationPolicy.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import com.azure.core.http.HttpPipelineNextPolicy;
1313
import com.azure.core.http.HttpPipelineNextSyncPolicy;
1414
import com.azure.core.http.HttpResponse;
15-
import com.azure.core.implementation.AccessTokenCache;
15+
import com.azure.core.credential.AccessTokenCache;
1616
import com.azure.core.implementation.http.policy.AuthorizationChallengeParser;
1717
import com.azure.core.util.CoreUtils;
1818
import com.azure.core.util.logging.ClientLogger;
@@ -208,16 +208,16 @@ public void setAuthorizationHeaderSync(HttpPipelineCallContext context, TokenReq
208208
}
209209

210210
private Mono<Void> setAuthorizationHeaderHelper(HttpPipelineCallContext context,
211-
TokenRequestContext tokenRequestContext, boolean checkToForceFetchToken) {
212-
return cache.getToken(tokenRequestContext, checkToForceFetchToken).flatMap(token -> {
211+
TokenRequestContext tokenRequestContext, boolean refreshOnContextChange) {
212+
return cache.getToken(tokenRequestContext, refreshOnContextChange).flatMap(token -> {
213213
setAuthorizationHeader(context.getHttpRequest().getHeaders(), token.getToken());
214214
return Mono.empty();
215215
});
216216
}
217217

218218
private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context,
219-
TokenRequestContext tokenRequestContext, boolean checkToForceFetchToken) {
220-
AccessToken token = cache.getTokenSync(tokenRequestContext, checkToForceFetchToken);
219+
TokenRequestContext tokenRequestContext, boolean refreshOnContextChange) {
220+
AccessToken token = cache.getTokenSync(tokenRequestContext, refreshOnContextChange);
221221
setAuthorizationHeader(context.getHttpRequest().getHeaders(), token.getToken());
222222
}
223223

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.azure.core.credential;
5+
6+
import reactor.core.publisher.Mono;
7+
8+
/**
9+
* Codesnippets for {@link AccessTokenCache}.
10+
*/
11+
public class AccessTokenCacheJavadocCodeSnippets {
12+
13+
public void accessTokenCacheSnippet() {
14+
// BEGIN: com.azure.core.credential.accessTokenCache
15+
TokenCredential credential = new BasicAuthenticationCredential("username", "password");
16+
AccessTokenCache tokenCache = new AccessTokenCache(credential);
17+
TokenRequestContext requestContext = new TokenRequestContext().addScopes("https://management.azure.com/.default");
18+
// Async usage
19+
Mono<AccessToken> tokenMono = tokenCache.getToken(requestContext, false);
20+
// Sync usage
21+
AccessToken token = tokenCache.getTokenSync(requestContext, false);
22+
// END: com.azure.core.credential.accessTokenCache
23+
}
24+
25+
}

sdk/core/azure-core/src/test/java/com/azure/core/implementation/AccessTokenCacheTests.java renamed to sdk/core/azure-core/src/test/java/com/azure/core/credential/AccessTokenCacheTests.java

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
package com.azure.core.implementation;
4+
package com.azure.core.credential;
55

6-
import com.azure.core.credential.AccessToken;
7-
import com.azure.core.credential.TokenCredential;
8-
import com.azure.core.credential.TokenRequestContext;
96
import org.junit.jupiter.api.Test;
107
import reactor.core.publisher.Mono;
118
import reactor.test.StepVerifier;
@@ -83,15 +80,15 @@ public void testTenantIdChangeTriggersForceRefresh() {
8380

8481
assertEquals(1, refreshCount.get());
8582

86-
// Second request with same tenant should not trigger refresh (checkToForceFetchToken=false)
83+
// Second request with same tenant should not trigger refresh (refreshOnContextChange=false)
8784
StepVerifier.create(cache.getToken(context1, false)).assertNext(token -> {
8885
assertNotNull(token);
8986
assertEquals("token-1", token.getToken()); // Same token
9087
}).verifyComplete();
9188

9289
assertEquals(1, refreshCount.get()); // No additional refresh
9390

94-
// Third request with different tenant should trigger force refresh with checkToForceFetchToken=true
91+
// Third request with different tenant should trigger force refresh with refreshOnContextChange=true
9592
TokenRequestContext context2 = new TokenRequestContext().addScopes(SCOPE).setTenantId(TENANT_ID_2);
9693

9794
StepVerifier.create(cache.getToken(context2, true)).assertNext(token -> {

sdk/core/azure-core/src/test/java/com/azure/core/credential/TokenCacheTests.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
package com.azure.core.credential;
55

6-
import com.azure.core.implementation.AccessTokenCache;
76
import org.junit.jupiter.api.Test;
87
import reactor.core.publisher.Flux;
98
import reactor.core.publisher.Mono;

sdk/identity/azure-identity-broker/src/test/java/com/azure/identity/broker/shr/resources/PopTokenAuthenticationPolicy.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import com.azure.core.http.HttpPipelineNextSyncPolicy;
1515
import com.azure.core.http.HttpResponse;
1616
import com.azure.core.http.policy.HttpPipelinePolicy;
17-
import com.azure.core.implementation.AccessTokenCache;
17+
import com.azure.core.credential.AccessTokenCache;
1818
import com.azure.core.util.CoreUtils;
1919
import com.azure.core.util.logging.ClientLogger;
2020
import reactor.core.publisher.Mono;
@@ -169,7 +169,7 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex
169169
}
170170
}
171171

172-
private Mono<Void> setAuthorizationHeaderHelper(HttpPipelineCallContext context, boolean checkToForceFetchToken) {
172+
private Mono<Void> setAuthorizationHeaderHelper(HttpPipelineCallContext context, boolean refreshOnContextChange) {
173173
if (!"https".equals(context.getHttpRequest().getUrl().getProtocol())) {
174174
throw LOGGER.logExceptionAsError(new RuntimeException(
175175
"Proof of possession token authentication is not permitted for non TLS-protected (HTTPS) endpoints."));
@@ -181,15 +181,15 @@ private Mono<Void> setAuthorizationHeaderHelper(HttpPipelineCallContext context,
181181
.setRequestUrl(context.getHttpRequest().getUrl()));
182182

183183
if (!CoreUtils.isNullOrEmpty(popNonce)) {
184-
return this.cache.getToken(popTokenRequestContext, checkToForceFetchToken).flatMap((token) -> {
184+
return this.cache.getToken(popTokenRequestContext, refreshOnContextChange).flatMap((token) -> {
185185
setAuthorizationHeader(context.getHttpRequest().getHeaders(), token.getToken());
186186
return Mono.empty();
187187
});
188188
}
189189
return Mono.empty();
190190
}
191191

192-
private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context, boolean checkToForceFetchToken) {
192+
private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context, boolean refreshOnContextChange) {
193193
if (!"https".equals(context.getHttpRequest().getUrl().getProtocol())) {
194194
throw LOGGER.logExceptionAsError(new RuntimeException(
195195
"Proof of possession token authentication is not permitted for non TLS-protected (HTTPS) endpoints."));
@@ -199,7 +199,7 @@ private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context, b
199199
.setRequestMethod(context.getHttpRequest().getHttpMethod())
200200
.setRequestUrl(context.getHttpRequest().getUrl()));
201201

202-
AccessToken token = this.cache.getTokenSync(popTokenRequestContext, checkToForceFetchToken);
202+
AccessToken token = this.cache.getTokenSync(popTokenRequestContext, refreshOnContextChange);
203203
setAuthorizationHeader(context.getHttpRequest().getHeaders(), token.getToken());
204204
}
205205

0 commit comments

Comments
 (0)