Skip to content

Commit e6b19ed

Browse files
AAD Auth: Adds a fallback mechanism for AAD audience scope. (#46637)
* Update fallback mechanism * Code clean up * Update changelog. * Fix AAD scope tests. --------- Co-authored-by: Kushagra Thapar <[email protected]>
1 parent 3f64138 commit e6b19ed

File tree

4 files changed

+220
-63
lines changed

4 files changed

+220
-63
lines changed

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/AadAuthorizationTests.java

Lines changed: 161 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -199,74 +199,126 @@ public void createAadTokenCredential() throws InterruptedException {
199199
}
200200

201201
@Test(groups = { "long-emulator" }, timeOut = 10 * TIMEOUT)
202-
public void testAadScopeOverride() throws Exception {
203-
CosmosAsyncClient setupClient = null;
204-
CosmosAsyncClient aadClient = null;
205-
String containerName = UUID.randomUUID().toString();
206-
String overrideScope = "https://cosmos.azure.com/.default";
202+
public void overrideScope_only_noFallback_onSuccess() throws Exception {
203+
CosmosAsyncClient client = null;
204+
ScopeRecorder.clear();
205+
206+
java.net.URI ep = new java.net.URI(TestConfigurations.HOST);
207+
final String overrideScope = ep.getScheme() + "://" + ep.getHost() + "/.default";
208+
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, overrideScope);
207209

208210
try {
209-
setupClient = new CosmosClientBuilder()
211+
TokenCredential cred = new AadSimpleEmulatorTokenCredential(TestConfigurations.MASTER_KEY);
212+
213+
client = new CosmosClientBuilder()
210214
.endpoint(TestConfigurations.HOST)
211-
.key(TestConfigurations.MASTER_KEY)
215+
.credential(cred)
212216
.buildAsyncClient();
213217

214-
setupClient.createDatabase(databaseId).block();
215-
setupClient.getDatabase(databaseId).createContainer(containerName, PARTITION_KEY_PATH).block();
216-
} finally {
217-
if (setupClient != null) {
218-
safeClose(setupClient);
218+
client.readAllDatabases().byPage().blockFirst();
219+
220+
java.util.List<String> scopes = ScopeRecorder.all();
221+
assert scopes.size() >= 1 : "Expected at least one AAD call";
222+
for (String s : scopes) {
223+
assert overrideScope.equals(s) : "Expected only override scope; saw: " + scopes;
219224
}
225+
} finally {
226+
if (client != null) safeClose(client);
227+
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, Configs.DEFAULT_AAD_SCOPE_OVERRIDE);
220228
}
229+
}
221230

222-
Thread.sleep(TIMEOUT);
231+
@Test(groups = { "long-emulator"}, timeOut = 10 * TIMEOUT)
232+
public void overrideScope_authError_noFallback() throws Exception {
233+
CosmosAsyncClient client = null;
234+
ScopeRecorder.clear();
223235

236+
final String overrideScope = "https://my.custom.scope/.default";
224237
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, overrideScope);
225238

226-
TokenCredential emulatorCredential =
227-
new AadSimpleEmulatorTokenCredential(TestConfigurations.MASTER_KEY);
228-
229-
aadClient = new CosmosClientBuilder()
230-
.endpoint(TestConfigurations.HOST)
231-
.credential(emulatorCredential)
232-
.buildAsyncClient();
233-
234239
try {
235-
CosmosAsyncContainer container = aadClient
236-
.getDatabase(databaseId)
237-
.getContainer(containerName);
238-
239-
String itemId = UUID.randomUUID().toString();
240-
String pk = UUID.randomUUID().toString();
241-
ItemSample item = getDocumentDefinition(itemId, pk);
240+
TokenCredential cred = new AlwaysFail500011Credential();
242241

243-
container.createItem(item).block();
244-
245-
List<String> scopes = AadSimpleEmulatorTokenCredential.getLastScopes();
246-
assert scopes != null && scopes.size() == 1;
247-
assert overrideScope.equals(scopes.get(0));
248-
249-
container.deleteItem(item.id, new PartitionKey(item.mypk)).block();
250-
} finally {
251242
try {
252-
CosmosAsyncClient cleanupClient = new CosmosClientBuilder()
243+
client = new CosmosClientBuilder()
253244
.endpoint(TestConfigurations.HOST)
254-
.key(TestConfigurations.MASTER_KEY)
245+
.credential(cred)
255246
.buildAsyncClient();
256-
try {
257-
cleanupClient.getDatabase(databaseId).delete().block();
258-
} finally {
259-
safeClose(cleanupClient);
260-
}
261-
} finally {
262-
if (aadClient != null) {
263-
safeClose(aadClient);
247+
248+
client.readAllDatabases().byPage().blockFirst();
249+
assert false : "Expected an auth failure with override scope";
250+
} catch (Exception ex) {
251+
// Only the override scope should have been attempted; no fallback allowed
252+
java.util.List<String> scopes = ScopeRecorder.all();
253+
assert scopes.size() >= 1 : "Expected at least one scope attempt";
254+
for (String s : scopes) {
255+
assert overrideScope.equals(s) : "No fallback allowed in override mode; saw: " + scopes;
264256
}
265-
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, Configs.DEFAULT_AAD_SCOPE_OVERRIDE);
266257
}
258+
} finally {
259+
if (client != null) safeClose(client);
260+
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, Configs.DEFAULT_AAD_SCOPE_OVERRIDE);
267261
}
262+
}
268263

269-
Thread.sleep(SHUTDOWN_TIMEOUT);
264+
@Test(groups = { "long-emulator"}, timeOut = 10 * TIMEOUT)
265+
public void accountScope_only_whenNoOverride_andNoAuthFailure() throws Exception {
266+
CosmosAsyncClient client = null;
267+
ScopeRecorder.clear();
268+
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, Configs.DEFAULT_AAD_SCOPE_OVERRIDE);
269+
270+
java.net.URI ep = new java.net.URI(TestConfigurations.HOST);
271+
String accountScope = ep.getScheme() + "://" + ep.getHost() + "/.default";
272+
273+
try {
274+
TokenCredential cred = new AadSimpleEmulatorTokenCredential(TestConfigurations.MASTER_KEY);
275+
276+
client = new CosmosClientBuilder()
277+
.endpoint(TestConfigurations.HOST)
278+
.credential(cred)
279+
.buildAsyncClient();
280+
281+
client.readAllDatabases().byPage().blockFirst();
282+
283+
java.util.List<String> scopes = ScopeRecorder.all();
284+
assert scopes.size() >= 1 : "Expected at least one AAD call";
285+
for (String s : scopes) {
286+
assert accountScope.equals(s) : "Expected only account scope; saw: " + scopes;
287+
}
288+
} finally {
289+
if (client != null) safeClose(client);
290+
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, Configs.DEFAULT_AAD_SCOPE_OVERRIDE);
291+
}
292+
}
293+
294+
@Test(groups = { "long-emulator"}, timeOut = 10 * TIMEOUT)
295+
public void accountScope_fallbackToCosmosScope_onAadSts500011() throws Exception {
296+
CosmosAsyncClient client = null;
297+
ScopeRecorder.clear();
298+
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, Configs.DEFAULT_AAD_SCOPE_OVERRIDE);
299+
300+
java.net.URI ep = new java.net.URI(TestConfigurations.HOST);
301+
String accountScope = ep.getScheme() + "://" + ep.getHost() + "/.default";
302+
String fallbackScope = "https://cosmos.azure.com/.default";
303+
304+
try {
305+
// Fail on account scope with AADSTS500011
306+
TokenCredential cred = new AccountThenFallbackCredential(TestConfigurations.MASTER_KEY, accountScope);
307+
308+
client = new CosmosClientBuilder()
309+
.endpoint(TestConfigurations.HOST)
310+
.credential(cred)
311+
.buildAsyncClient();
312+
313+
client.readAllDatabases().byPage().blockFirst();
314+
315+
java.util.List<String> scopes = ScopeRecorder.all();
316+
assert scopes.contains(accountScope) : "Expected primary account scope attempt; saw: " + scopes;
317+
assert scopes.contains(fallbackScope) : "Expected fallback to cosmos public scope; saw: " + scopes;
318+
} finally {
319+
if (client != null) safeClose(client);
320+
setEnv(Configs.AAD_SCOPE_OVERRIDE_VARIABLE, Configs.DEFAULT_AAD_SCOPE_OVERRIDE);
321+
}
270322
}
271323

272324
@SuppressWarnings({"unchecked", "rawtypes"})
@@ -298,6 +350,65 @@ private static void setEnv(String key, String value) throws Exception {
298350
}
299351
}
300352

353+
// Records all scopes used during the test run (append-only).
354+
static final class ScopeRecorder {
355+
private static final java.util.concurrent.CopyOnWriteArrayList<String> SEEN = new java.util.concurrent.CopyOnWriteArrayList<>();
356+
static void clear() { SEEN.clear(); }
357+
static void record(TokenRequestContext ctx) {
358+
java.util.List<String> s = ctx.getScopes();
359+
if (s != null) SEEN.addAll(s);
360+
}
361+
static java.util.List<String> all() { return new java.util.ArrayList<>(SEEN); }
362+
}
363+
364+
/**
365+
* Always fails with an AADSTS500011 message for any scope.
366+
* Used to prove that the "override scope" path does NOT fallback.
367+
*/
368+
static class AlwaysFail500011Credential implements TokenCredential {
369+
@Override
370+
public Mono<AccessToken> getToken(TokenRequestContext tokenRequestContext) {
371+
ScopeRecorder.record(tokenRequestContext);
372+
return Mono.error(new RuntimeException("AADSTS500011: Application <id> was not found in the directory"));
373+
}
374+
}
375+
376+
static class AccountThenFallbackCredential implements TokenCredential {
377+
private final AadSimpleEmulatorTokenCredential emulatorIssuer;
378+
private final String accountScope;
379+
private final String cosmosPublicScope = "https://cosmos.azure.com/.default";
380+
private final java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(0);
381+
382+
AccountThenFallbackCredential(String emulatorKey, String accountScope) {
383+
this.emulatorIssuer = new AadSimpleEmulatorTokenCredential(emulatorKey);
384+
this.accountScope = accountScope;
385+
}
386+
387+
@Override
388+
public Mono<AccessToken> getToken(TokenRequestContext tokenRequestContext) {
389+
ScopeRecorder.record(tokenRequestContext);
390+
391+
String scope = tokenRequestContext.getScopes() != null && !tokenRequestContext.getScopes().isEmpty()
392+
? tokenRequestContext.getScopes().get(0)
393+
: "";
394+
395+
int n = calls.incrementAndGet();
396+
397+
// Fail on the first attempt if it is the account scope, to trigger fallback
398+
if (n == 1 && scope.equals(accountScope)) {
399+
return Mono.error(new RuntimeException("AADSTS500011: Application <id> was not found in the directory"));
400+
}
401+
402+
// When SDK retries with the cosmos public scope, succeed with a valid emulator token
403+
if (scope.equals(cosmosPublicScope)) {
404+
return emulatorIssuer.getToken(tokenRequestContext);
405+
}
406+
407+
// If anything unexpected happens, fail loudly so the test points to the issue
408+
return Mono.error(new IllegalStateException("Unexpected scope or call ordering. Scope=" + scope + " call=" + n));
409+
}
410+
}
411+
301412
private ItemSample getDocumentDefinition(String itemId, String partitionKeyValue) {
302413
ItemSample itemSample = new ItemSample();
303414
itemSample.id = itemId;
@@ -329,11 +440,6 @@ static class AadSimpleEmulatorTokenCredential implements TokenCredential {
329440
private final String AAD_HEADER_COSMOS_EMULATOR = "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"x5t\":\"CosmosEmulatorPrimaryMaster\",\"kid\":\"CosmosEmulatorPrimaryMaster\"}";
330441
private final String AAD_CLAIM_COSMOS_EMULATOR_FORMAT = "{\"aud\":\"https://localhost.localhost\",\"iss\":\"https://sts.fake-issuer.net/7b1999a1-dfd7-440e-8204-00170979b984\",\"iat\":%d,\"nbf\":%d,\"exp\":%d,\"aio\":\"\",\"appid\":\"localhost\",\"appidacr\":\"1\",\"idp\":\"https://localhost:8081/\",\"oid\":\"96313034-4739-43cb-93cd-74193adbe5b6\",\"rh\":\"\",\"sub\":\"localhost\",\"tid\":\"EmulatorFederation\",\"uti\":\"\",\"ver\":\"1.0\",\"scp\":\"user_impersonation\",\"groups\":[\"7ce1d003-4cb3-4879-b7c5-74062a35c66e\",\"e99ff30c-c229-4c67-ab29-30a6aebc3e58\",\"5549bb62-c77b-4305-bda9-9ec66b85d9e4\",\"c44fd685-5c58-452c-aaf7-13ce75184f65\",\"be895215-eab5-43b7-9536-9ef8fe130330\"]}";
331442

332-
private static volatile List<String> lastScopes = Collections.emptyList();
333-
334-
public static List<String> getLastScopes() {
335-
return lastScopes;
336-
}
337443
public AadSimpleEmulatorTokenCredential(String emulatorKey) {
338444
if (emulatorKey == null || emulatorKey.isEmpty()) {
339445
throw new IllegalArgumentException("emulatorKey");
@@ -344,10 +450,8 @@ public AadSimpleEmulatorTokenCredential(String emulatorKey) {
344450

345451
@Override
346452
public Mono<AccessToken> getToken(TokenRequestContext tokenRequestContext) {
347-
List<String> scopes = tokenRequestContext.getScopes(); // List<String>, not String[]
348-
lastScopes = (scopes != null && !scopes.isEmpty())
349-
? new ArrayList<>(scopes)
350-
: Collections.emptyList();
453+
// Record scopes for verification in tests
454+
AadAuthorizationTests.ScopeRecorder.record(tokenRequestContext);
351455

352456
String aadToken = emulatorKey_based_AAD_String();
353457
return Mono.just(new AccessToken(aadToken, OffsetDateTime.now().plusHours(2)));

sdk/cosmos/azure-cosmos/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
#### Features Added
1616
* Added `ThroughputBucket` support for throughput control. - [PR 46042](https://github.com/Azure/azure-sdk-for-java/pull/46042)
17+
* AAD Auth: Adds a fallback mechanism for AAD audience scope. - [PR 46637](https://github.com/Azure/azure-sdk-for-java/pull/46637)
1718

1819
#### Bugs Fixed
1920
* Fixed 404/1002 for query when container recreated with same name. - [PR 45930](https://github.com/Azure/azure-sdk-for-java/pull/45930)

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/Constants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,4 +292,5 @@ public static final class QueryExecutionContext {
292292
}
293293

294294
public static final int QUERYPLAN_CACHE_SIZE = 5000;
295+
public static final String AAD_DEFAULT_SCOPE = "https://cosmos.azure.com/.default";
295296
}

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -533,17 +533,68 @@ private RxDocumentClientImpl(URI serviceEndpoint,
533533
hasAuthKeyResourceToken = false;
534534
this.authorizationTokenType = AuthorizationTokenType.PrimaryMasterKey;
535535
this.authorizationTokenProvider = new BaseAuthorizationTokenProvider(this.credential);
536-
} else {
536+
}else {
537537
hasAuthKeyResourceToken = false;
538538
this.authorizationTokenProvider = null;
539539
if (tokenCredential != null) {
540540
String scopeOverride = Configs.getAadScopeOverride();
541-
String defaultScope = serviceEndpoint.getScheme() + "://" + serviceEndpoint.getHost() + "/.default";
542-
String scopeToUse = (scopeOverride != null && !scopeOverride.isEmpty()) ? scopeOverride : defaultScope;
541+
String accountScope = serviceEndpoint.getScheme() + "://" + serviceEndpoint.getHost() + "/.default";
542+
543+
if (scopeOverride != null && !scopeOverride.isEmpty()) {
544+
// Use only the override scope; no fallback.
545+
this.tokenCredentialScopes = new String[] { scopeOverride };
546+
547+
this.tokenCredentialCache = new SimpleTokenCache(() -> {
548+
final String primaryScope = this.tokenCredentialScopes[0];
549+
final TokenRequestContext ctx = new TokenRequestContext().addScopes(primaryScope);
550+
return this.tokenCredential.getToken(ctx)
551+
.doOnNext(t -> {
552+
if (logger.isInfoEnabled()) {
553+
logger.info("AAD token: acquired using override scope: {}", primaryScope);
554+
}
555+
});
556+
});
557+
} else {
558+
// Account scope with fallback to default scope on AADSTS500011 error
559+
this.tokenCredentialScopes = new String[] { accountScope, Constants.AAD_DEFAULT_SCOPE };
560+
561+
this.tokenCredentialCache = new SimpleTokenCache(() -> {
562+
final String primaryScope = this.tokenCredentialScopes[0];
563+
final String fallbackScope = this.tokenCredentialScopes[1];
543564

544-
this.tokenCredentialScopes = new String[] { scopeToUse };
545-
this.tokenCredentialCache = new SimpleTokenCache(() -> this.tokenCredential
546-
.getToken(new TokenRequestContext().addScopes(this.tokenCredentialScopes)));
565+
final TokenRequestContext primaryCtx = new TokenRequestContext().addScopes(primaryScope);
566+
567+
return this.tokenCredential.getToken(primaryCtx)
568+
.doOnNext(t -> {
569+
if (logger.isInfoEnabled()) {
570+
logger.info("AAD token: acquired using account scope: {}", primaryScope);
571+
}
572+
})
573+
.onErrorResume(error -> {
574+
final Throwable root = reactor.core.Exceptions.unwrap(error);
575+
final String messageText = (root.getMessage() != null) ? root.getMessage() : "";
576+
final boolean isAadAppNotFound = messageText.contains("AADSTS500011");
577+
578+
if (!isAadAppNotFound) {
579+
return Mono.error(error);
580+
}
581+
582+
if (logger.isWarnEnabled()) {
583+
logger.warn(
584+
"AAD token: account scope failed with AADSTS500011; retrying with fallback scope: {}",
585+
fallbackScope);
586+
}
587+
588+
final TokenRequestContext fallbackCtx = new TokenRequestContext().addScopes(fallbackScope);
589+
return this.tokenCredential.getToken(fallbackCtx)
590+
.doOnNext(t -> {
591+
if (logger.isInfoEnabled()) {
592+
logger.info("AAD token: acquired using fallback scope: {}", fallbackScope);
593+
}
594+
});
595+
});
596+
});
597+
}
547598
this.authorizationTokenType = AuthorizationTokenType.AadToken;
548599
}
549600
}

0 commit comments

Comments
 (0)