diff --git a/eng/versioning/version_client.txt b/eng/versioning/version_client.txt
index 1a206ffb4585..3d192ffd3ec9 100644
--- a/eng/versioning/version_client.txt
+++ b/eng/versioning/version_client.txt
@@ -567,6 +567,7 @@ io.clientcore:optional-dependency-tests;1.0.0-beta.1;1.0.0-beta.1
# In the pom, the version update tag after the version should name the unreleased package and the dependency version:
#
+unreleased_com.azure:azure-core;1.59.0-beta.1
unreleased_com.azure.v2:azure-core;2.0.0-beta.1
unreleased_com.azure.v2:azure-identity;2.0.0-beta.1
unreleased_com.azure.v2:azure-data-appconfiguration;2.0.0-beta.1
diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md
index a65de8736a2b..41f07fe5cf61 100644
--- a/sdk/core/azure-core/CHANGELOG.md
+++ b/sdk/core/azure-core/CHANGELOG.md
@@ -4,6 +4,8 @@
### Features Added
+- 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.
+
### Breaking Changes
### Bugs Fixed
diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/AccessTokenCache.java b/sdk/core/azure-core/src/main/java/com/azure/core/credential/AccessTokenCache.java
similarity index 71%
rename from sdk/core/azure-core/src/main/java/com/azure/core/implementation/AccessTokenCache.java
rename to sdk/core/azure-core/src/main/java/com/azure/core/credential/AccessTokenCache.java
index ba01c09e6b08..d1589ec260d2 100644
--- a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/AccessTokenCache.java
+++ b/sdk/core/azure-core/src/main/java/com/azure/core/credential/AccessTokenCache.java
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-package com.azure.core.implementation;
+package com.azure.core.credential;
-import com.azure.core.credential.AccessToken;
-import com.azure.core.credential.TokenCredential;
-import com.azure.core.credential.TokenRequestContext;
+import com.azure.core.implementation.AccessTokenCacheInfo;
import com.azure.core.util.logging.ClientLogger;
import com.azure.core.util.logging.LogLevel;
import com.azure.core.util.logging.LoggingEventBuilder;
@@ -25,7 +23,57 @@
import java.util.function.Supplier;
/**
- * A token cache that supports caching a token and refreshing it.
+ *
+ * {@code AccessTokenCache} is a thread-safe token cache that wraps a {@link TokenCredential} and manages proactive
+ * token refresh. It supports both asynchronous and synchronous token retrieval via
+ * {@link #getToken(TokenRequestContext, boolean)} and {@link #getTokenSync(TokenRequestContext, boolean)}.
+ *
+ *
+ *
+ * The cache maintains a single cached {@link AccessToken} per instance and proactively refreshes it before expiry
+ * (by default 5 minutes before the expiry time, or at the {@code refreshAt} time if provided by the credential).
+ * If a refresh fails while a non-expired token is still available, the cached token continues to be returned until
+ * it expires.
+ *
+ *
+ *
+ * When the {@code refreshOnContextChange} flag is {@code true}, the cache compares the incoming
+ * {@link TokenRequestContext} (scopes, tenant ID, claims) against the context used to acquire the current cached
+ * token. A mismatch causes an immediate token refresh regardless of expiry. This is the mechanism used to support
+ * Continuous Access Evaluation (CAE) claims challenges.
+ *
+ *
+ *
+ * Note: Each instance caches exactly one {@link AccessToken} associated with one
+ * {@link TokenRequestContext}. Do not share a single {@code AccessTokenCache} instance across calls that require
+ * different scopes or tenants simultaneously, as each new context will evict the previously cached token.
+ *
+ *
+ *
+ * This class is thread-safe. Multiple threads may call {@link #getToken(TokenRequestContext, boolean)} and
+ * {@link #getTokenSync(TokenRequestContext, boolean)} concurrently.
+ *
+ *
+ *
+ * Sample: Wrapping a TokenCredential with AccessTokenCache
+ *
+ *
+ *
+ *
+ * TokenCredential credential = new BasicAuthenticationCredential("username", "password");
+ * AccessTokenCache tokenCache = new AccessTokenCache(credential);
+ * TokenRequestContext requestContext = new TokenRequestContext().addScopes("https://management.azure.com/.default");
+ * // Async usage
+ * Mono<AccessToken> tokenMono = tokenCache.getToken(requestContext, false);
+ * // Sync usage
+ * AccessToken token = tokenCache.getTokenSync(requestContext, false);
+ *
+ *
+ *
+ * @see TokenCredential
+ * @see AccessToken
+ * @see TokenRequestContext
+ * @see SimpleTokenCache
*/
public final class AccessTokenCache {
// The delay after a refresh to attempt another token refresh
@@ -48,12 +96,12 @@ public final class AccessTokenCache {
private final Lock lock;
/**
- * Creates an instance of RefreshableTokenCredential with default scheme "Bearer".
+ * Creates an instance of {@code AccessTokenCache} that wraps the given {@link TokenCredential}.
*
* @param tokenCredential the token credential to be used to acquire the token.
*/
public AccessTokenCache(TokenCredential tokenCredential) {
- Objects.requireNonNull(tokenCredential, "The token credential cannot be null");
+ Objects.requireNonNull(tokenCredential, "'tokenCredential' cannot be null.");
this.wip = new AtomicReference<>();
this.tokenCredential = tokenCredential;
this.cacheInfo = new AtomicReference<>(new AccessTokenCacheInfo(null, OffsetDateTime.now()));
@@ -70,11 +118,15 @@ public AccessTokenCache(TokenCredential tokenCredential) {
* Asynchronously get a token from either the cache or replenish the cache with a new token.
*
* @param tokenRequestContext The request context for token acquisition.
- * @param checkToForceFetchToken The flag indicating whether to force fetch a new token or not.
- * @return The Publisher that emits an AccessToken
+ * @param refreshOnContextChange When {@code true}, compares the incoming {@link TokenRequestContext} against the
+ * one used to acquire the current cached token. If the scopes, tenant ID, or claims differ, a fresh token is
+ * fetched immediately regardless of expiry. Pass {@code false} to always use the cached token when it is
+ * still valid.
+ * @return a {@link Mono} that emits the cached or newly acquired {@link AccessToken}.
+ * @throws IllegalArgumentException if {@code tokenRequestContext} is {@code null}.
*/
- public Mono getToken(TokenRequestContext tokenRequestContext, boolean checkToForceFetchToken) {
- return Mono.defer(retrieveToken(tokenRequestContext, checkToForceFetchToken))
+ public Mono getToken(TokenRequestContext tokenRequestContext, boolean refreshOnContextChange) {
+ return Mono.defer(retrieveToken(tokenRequestContext, refreshOnContextChange))
// Keep resubscribing as long as Mono.defer [token acquisition] emits empty().
.repeatWhenEmpty((Flux longFlux) -> longFlux
.concatMap(ignored -> Flux.just(true).delayElements(Duration.ofMillis(500))));
@@ -84,25 +136,29 @@ public Mono getToken(TokenRequestContext tokenRequestContext, boole
* Synchronously get a token from either the cache or replenish the cache with a new token.
*
* @param tokenRequestContext The request context for token acquisition.
- * @param checkToForceFetchToken The flag indicating whether to force fetch a new token or not.
- * @return The Publisher that emits an AccessToken
+ * @param refreshOnContextChange When {@code true}, compares the incoming {@link TokenRequestContext} against the
+ * one used to acquire the current cached token. If the scopes, tenant ID, or claims differ, a fresh token is
+ * fetched immediately regardless of expiry. Pass {@code false} to always use the cached token when it is
+ * still valid.
+ * @return the cached or newly acquired {@link AccessToken}.
+ * @throws IllegalArgumentException if {@code tokenRequestContext} is {@code null}.
*/
- public AccessToken getTokenSync(TokenRequestContext tokenRequestContext, boolean checkToForceFetchToken) {
+ public AccessToken getTokenSync(TokenRequestContext tokenRequestContext, boolean refreshOnContextChange) {
lock.lock();
try {
- return retrieveTokenSync(tokenRequestContext, checkToForceFetchToken).get();
+ return retrieveTokenSync(tokenRequestContext, refreshOnContextChange).get();
} finally {
lock.unlock();
}
}
private Supplier> retrieveToken(TokenRequestContext tokenRequestContext,
- boolean checkToForceFetchToken) {
+ boolean refreshOnContextChange) {
return () -> {
try {
if (tokenRequestContext == null) {
- return Mono.error(LOGGER.logExceptionAsError(
- new IllegalArgumentException("The token request context input cannot be null.")));
+ return Mono.error(LOGGER
+ .logExceptionAsError(new IllegalArgumentException("'tokenRequestContext' cannot be null.")));
}
AccessTokenCacheInfo cache = this.cacheInfo.get();
@@ -115,9 +171,9 @@ private Supplier> retrieveToken(TokenRequestContext
Mono fallback;
// Check if the incoming token request context is different from the cached one. A different
- // token request context, requires to fetch a new token as the cached one won't work for the
+ // token request context requires fetching a new token as the cached one won't work for the
// passed in token request context.
- boolean forceRefresh = (checkToForceFetchToken && checkIfForceRefreshRequired(tokenRequestContext))
+ boolean forceRefresh = (refreshOnContextChange && checkIfForceRefreshRequired(tokenRequestContext))
|| this.tokenRequestContext == null;
if (forceRefresh) {
@@ -153,12 +209,12 @@ private Supplier> retrieveToken(TokenRequestContext
.flatMap(processTokenRefreshResult(sinksOne, now, fallback))
.doOnError(sinksOne::tryEmitError),
w -> w.set(null));
- } else if (cachedToken != null && !cachedToken.isExpired() && !checkToForceFetchToken) {
+ } else if (cachedToken != null && !cachedToken.isExpired() && !refreshOnContextChange) {
// another thread might be refreshing the token proactively, but the current token is still valid
return Mono.just(cachedToken);
} else {
- // if a force refresh is possible, then exit and retry.
- if (checkToForceFetchToken) {
+ // if a context-change refresh is pending, exit and retry.
+ if (refreshOnContextChange) {
return Mono.empty();
}
// another thread is definitely refreshing the expired token
@@ -178,11 +234,10 @@ private Supplier> retrieveToken(TokenRequestContext
}
private Supplier retrieveTokenSync(TokenRequestContext tokenRequestContext,
- boolean checkToForceFetchToken) {
+ boolean refreshOnContextChange) {
return () -> {
if (tokenRequestContext == null) {
- throw LOGGER.logExceptionAsError(
- new IllegalArgumentException("The token request context input cannot be null."));
+ throw LOGGER.logExceptionAsError(new IllegalArgumentException("'tokenRequestContext' cannot be null."));
}
AccessTokenCacheInfo cache = this.cacheInfo.get();
AccessToken cachedToken = cache.getCachedAccessToken();
@@ -192,9 +247,9 @@ private Supplier retrieveTokenSync(TokenRequestContext tokenRequest
AccessToken fallback;
// Check if the incoming token request context is different from the cached one. A different
- // token request context, requires to fetch a new token as the cached one won't work for the
+ // token request context requires fetching a new token as the cached one won't work for the
// passed in token request context.
- boolean forceRefresh = (checkToForceFetchToken && checkIfForceRefreshRequired(tokenRequestContext))
+ boolean forceRefresh = (refreshOnContextChange && checkIfForceRefreshRequired(tokenRequestContext))
|| this.tokenRequestContext == null;
if (forceRefresh) {
@@ -245,7 +300,10 @@ private Supplier retrieveTokenSync(TokenRequestContext tokenRequest
if (fallback != null) {
return fallback;
}
- throw error;
+ if (error instanceof RuntimeException) {
+ throw LOGGER.logExceptionAsError((RuntimeException) error);
+ }
+ throw LOGGER.logExceptionAsError(new RuntimeException(error));
}
};
}
diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/http/policy/BearerTokenAuthenticationPolicy.java b/sdk/core/azure-core/src/main/java/com/azure/core/http/policy/BearerTokenAuthenticationPolicy.java
index 01fa5f28cd6f..549cc18faa89 100644
--- a/sdk/core/azure-core/src/main/java/com/azure/core/http/policy/BearerTokenAuthenticationPolicy.java
+++ b/sdk/core/azure-core/src/main/java/com/azure/core/http/policy/BearerTokenAuthenticationPolicy.java
@@ -12,7 +12,7 @@
import com.azure.core.http.HttpPipelineNextPolicy;
import com.azure.core.http.HttpPipelineNextSyncPolicy;
import com.azure.core.http.HttpResponse;
-import com.azure.core.implementation.AccessTokenCache;
+import com.azure.core.credential.AccessTokenCache;
import com.azure.core.implementation.http.policy.AuthorizationChallengeParser;
import com.azure.core.util.CoreUtils;
import com.azure.core.util.logging.ClientLogger;
@@ -208,16 +208,16 @@ public void setAuthorizationHeaderSync(HttpPipelineCallContext context, TokenReq
}
private Mono setAuthorizationHeaderHelper(HttpPipelineCallContext context,
- TokenRequestContext tokenRequestContext, boolean checkToForceFetchToken) {
- return cache.getToken(tokenRequestContext, checkToForceFetchToken).flatMap(token -> {
+ TokenRequestContext tokenRequestContext, boolean refreshOnContextChange) {
+ return cache.getToken(tokenRequestContext, refreshOnContextChange).flatMap(token -> {
setAuthorizationHeader(context.getHttpRequest().getHeaders(), token.getToken());
return Mono.empty();
});
}
private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context,
- TokenRequestContext tokenRequestContext, boolean checkToForceFetchToken) {
- AccessToken token = cache.getTokenSync(tokenRequestContext, checkToForceFetchToken);
+ TokenRequestContext tokenRequestContext, boolean refreshOnContextChange) {
+ AccessToken token = cache.getTokenSync(tokenRequestContext, refreshOnContextChange);
setAuthorizationHeader(context.getHttpRequest().getHeaders(), token.getToken());
}
diff --git a/sdk/core/azure-core/src/samples/java/com/azure/core/credential/AccessTokenCacheJavadocCodeSnippets.java b/sdk/core/azure-core/src/samples/java/com/azure/core/credential/AccessTokenCacheJavadocCodeSnippets.java
new file mode 100644
index 000000000000..bbb20abf70e6
--- /dev/null
+++ b/sdk/core/azure-core/src/samples/java/com/azure/core/credential/AccessTokenCacheJavadocCodeSnippets.java
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.core.credential;
+
+import reactor.core.publisher.Mono;
+
+/**
+ * Codesnippets for {@link AccessTokenCache}.
+ */
+public class AccessTokenCacheJavadocCodeSnippets {
+
+ public void accessTokenCacheSnippet() {
+ // BEGIN: com.azure.core.credential.accessTokenCache
+ TokenCredential credential = new BasicAuthenticationCredential("username", "password");
+ AccessTokenCache tokenCache = new AccessTokenCache(credential);
+ TokenRequestContext requestContext = new TokenRequestContext().addScopes("https://management.azure.com/.default");
+ // Async usage
+ Mono tokenMono = tokenCache.getToken(requestContext, false);
+ // Sync usage
+ AccessToken token = tokenCache.getTokenSync(requestContext, false);
+ // END: com.azure.core.credential.accessTokenCache
+ }
+
+}
diff --git a/sdk/core/azure-core/src/test/java/com/azure/core/implementation/AccessTokenCacheTests.java b/sdk/core/azure-core/src/test/java/com/azure/core/credential/AccessTokenCacheTests.java
similarity index 97%
rename from sdk/core/azure-core/src/test/java/com/azure/core/implementation/AccessTokenCacheTests.java
rename to sdk/core/azure-core/src/test/java/com/azure/core/credential/AccessTokenCacheTests.java
index 07b45772c13f..a673a9ee3ae2 100644
--- a/sdk/core/azure-core/src/test/java/com/azure/core/implementation/AccessTokenCacheTests.java
+++ b/sdk/core/azure-core/src/test/java/com/azure/core/credential/AccessTokenCacheTests.java
@@ -1,11 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-package com.azure.core.implementation;
+package com.azure.core.credential;
-import com.azure.core.credential.AccessToken;
-import com.azure.core.credential.TokenCredential;
-import com.azure.core.credential.TokenRequestContext;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@@ -83,7 +80,7 @@ public void testTenantIdChangeTriggersForceRefresh() {
assertEquals(1, refreshCount.get());
- // Second request with same tenant should not trigger refresh (checkToForceFetchToken=false)
+ // Second request with same tenant should not trigger refresh (refreshOnContextChange=false)
StepVerifier.create(cache.getToken(context1, false)).assertNext(token -> {
assertNotNull(token);
assertEquals("token-1", token.getToken()); // Same token
@@ -91,7 +88,7 @@ public void testTenantIdChangeTriggersForceRefresh() {
assertEquals(1, refreshCount.get()); // No additional refresh
- // Third request with different tenant should trigger force refresh with checkToForceFetchToken=true
+ // Third request with different tenant should trigger force refresh with refreshOnContextChange=true
TokenRequestContext context2 = new TokenRequestContext().addScopes(SCOPE).setTenantId(TENANT_ID_2);
StepVerifier.create(cache.getToken(context2, true)).assertNext(token -> {
diff --git a/sdk/core/azure-core/src/test/java/com/azure/core/credential/TokenCacheTests.java b/sdk/core/azure-core/src/test/java/com/azure/core/credential/TokenCacheTests.java
index d1062efc7d7d..1e6238ee476d 100644
--- a/sdk/core/azure-core/src/test/java/com/azure/core/credential/TokenCacheTests.java
+++ b/sdk/core/azure-core/src/test/java/com/azure/core/credential/TokenCacheTests.java
@@ -3,7 +3,6 @@
package com.azure.core.credential;
-import com.azure.core.implementation.AccessTokenCache;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
diff --git a/sdk/identity/azure-identity-broker/src/test/java/com/azure/identity/broker/shr/resources/PopTokenAuthenticationPolicy.java b/sdk/identity/azure-identity-broker/src/test/java/com/azure/identity/broker/shr/resources/PopTokenAuthenticationPolicy.java
index 87f55ea0f004..5376a6cb148e 100644
--- a/sdk/identity/azure-identity-broker/src/test/java/com/azure/identity/broker/shr/resources/PopTokenAuthenticationPolicy.java
+++ b/sdk/identity/azure-identity-broker/src/test/java/com/azure/identity/broker/shr/resources/PopTokenAuthenticationPolicy.java
@@ -14,7 +14,7 @@
import com.azure.core.http.HttpPipelineNextSyncPolicy;
import com.azure.core.http.HttpResponse;
import com.azure.core.http.policy.HttpPipelinePolicy;
-import com.azure.core.implementation.AccessTokenCache;
+import com.azure.core.credential.AccessTokenCache;
import com.azure.core.util.CoreUtils;
import com.azure.core.util.logging.ClientLogger;
import reactor.core.publisher.Mono;
@@ -169,7 +169,7 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex
}
}
- private Mono setAuthorizationHeaderHelper(HttpPipelineCallContext context, boolean checkToForceFetchToken) {
+ private Mono setAuthorizationHeaderHelper(HttpPipelineCallContext context, boolean refreshOnContextChange) {
if (!"https".equals(context.getHttpRequest().getUrl().getProtocol())) {
throw LOGGER.logExceptionAsError(new RuntimeException(
"Proof of possession token authentication is not permitted for non TLS-protected (HTTPS) endpoints."));
@@ -181,7 +181,7 @@ private Mono setAuthorizationHeaderHelper(HttpPipelineCallContext context,
.setRequestUrl(context.getHttpRequest().getUrl()));
if (!CoreUtils.isNullOrEmpty(popNonce)) {
- return this.cache.getToken(popTokenRequestContext, checkToForceFetchToken).flatMap((token) -> {
+ return this.cache.getToken(popTokenRequestContext, refreshOnContextChange).flatMap((token) -> {
setAuthorizationHeader(context.getHttpRequest().getHeaders(), token.getToken());
return Mono.empty();
});
@@ -189,7 +189,7 @@ private Mono setAuthorizationHeaderHelper(HttpPipelineCallContext context,
return Mono.empty();
}
- private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context, boolean checkToForceFetchToken) {
+ private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context, boolean refreshOnContextChange) {
if (!"https".equals(context.getHttpRequest().getUrl().getProtocol())) {
throw LOGGER.logExceptionAsError(new RuntimeException(
"Proof of possession token authentication is not permitted for non TLS-protected (HTTPS) endpoints."));
@@ -199,7 +199,7 @@ private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context, b
.setRequestMethod(context.getHttpRequest().getHttpMethod())
.setRequestUrl(context.getHttpRequest().getUrl()));
- AccessToken token = this.cache.getTokenSync(popTokenRequestContext, checkToForceFetchToken);
+ AccessToken token = this.cache.getTokenSync(popTokenRequestContext, refreshOnContextChange);
setAuthorizationHeader(context.getHttpRequest().getHeaders(), token.getToken());
}
diff --git a/sdk/identity/azure-identity-perf/pom.xml b/sdk/identity/azure-identity-perf/pom.xml
index f50f3bdb06c4..7e71e6ae4c8c 100644
--- a/sdk/identity/azure-identity-perf/pom.xml
+++ b/sdk/identity/azure-identity-perf/pom.xml
@@ -36,7 +36,7 @@
com.azure
azure-core
- 1.58.1
+ 1.59.0-beta.1
diff --git a/sdk/identity/azure-identity/pom.xml b/sdk/identity/azure-identity/pom.xml
index 343865302e0d..9a49005d3214 100644
--- a/sdk/identity/azure-identity/pom.xml
+++ b/sdk/identity/azure-identity/pom.xml
@@ -35,7 +35,7 @@
com.azure
azure-core
- 1.58.1
+ 1.59.0-beta.1
diff --git a/sdk/identity/ci.yml b/sdk/identity/ci.yml
index 9dc5816b6e01..9fc5fe9bcd1d 100644
--- a/sdk/identity/ci.yml
+++ b/sdk/identity/ci.yml
@@ -74,6 +74,9 @@ extends:
# required by the above perf library
- name: perf-test-core
groupId: com.azure
+ # required by azure-identity-broker tests (PopTokenAuthenticationPolicy uses public AccessTokenCache)
+ - name: azure-core
+ groupId: com.azure
LiveTestStages:
- template: /eng/pipelines/templates/stages/archetype-sdk-tests-isolated.yml
parameters: