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 ;
97import com .azure .core .util .logging .ClientLogger ;
108import com .azure .core .util .logging .LogLevel ;
119import com .azure .core .util .logging .LoggingEventBuilder ;
2523import 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("username", "password");
64+ * AccessTokenCache tokenCache = new AccessTokenCache(credential);
65+ * TokenRequestContext requestContext = new TokenRequestContext().addScopes("https://management.azure.com/.default");
66+ * // Async usage
67+ * Mono<AccessToken> tokenMono = tokenCache.getToken(requestContext, false);
68+ * // Sync usage
69+ * AccessToken token = tokenCache.getTokenSync(requestContext, false);
70+ * </pre>
71+ * <!-- end com.azure.core.credential.accessTokenCache -->
72+ *
73+ * @see TokenCredential
74+ * @see AccessToken
75+ * @see TokenRequestContext
76+ * @see SimpleTokenCache
2977 */
3078public 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 }
0 commit comments