Skip to content

Commit 4cf3d9f

Browse files
authored
Default CAE challenge handling in BearerTokenAuthenticationPolicy (#42471)
1 parent 35d00f8 commit 4cf3d9f

3 files changed

Lines changed: 302 additions & 2 deletions

File tree

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

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313
import com.azure.core.http.HttpPipelineNextSyncPolicy;
1414
import com.azure.core.http.HttpResponse;
1515
import com.azure.core.implementation.AccessTokenCache;
16+
import com.azure.core.implementation.http.policy.AuthorizationChallengeParser;
17+
import com.azure.core.util.CoreUtils;
1618
import com.azure.core.util.logging.ClientLogger;
1719
import reactor.core.publisher.Mono;
1820

21+
import java.nio.charset.StandardCharsets;
22+
import java.util.Base64;
1923
import java.util.Objects;
2024

2125
/**
@@ -75,7 +79,8 @@ public Mono<Void> authorizeRequest(HttpPipelineCallContext context) {
7579
if (this.scopes == null) {
7680
return Mono.empty();
7781
}
78-
return setAuthorizationHeaderHelper(context, new TokenRequestContext().addScopes(this.scopes), false);
82+
return setAuthorizationHeaderHelper(context,
83+
new TokenRequestContext().addScopes(this.scopes).setCaeEnabled(true), false);
7984
}
8085

8186
/**
@@ -84,32 +89,53 @@ public Mono<Void> authorizeRequest(HttpPipelineCallContext context) {
8489
* @param context The request context.
8590
*/
8691
public void authorizeRequestSync(HttpPipelineCallContext context) {
87-
setAuthorizationHeaderHelperSync(context, new TokenRequestContext().addScopes(scopes), false);
92+
setAuthorizationHeaderHelperSync(context, new TokenRequestContext().addScopes(scopes).setCaeEnabled(true),
93+
false);
8894
}
8995

9096
/**
9197
* Handles the authentication challenge in the event a 401 response with a WWW-Authenticate authentication challenge
9298
* header is received after the initial request and returns appropriate {@link TokenRequestContext} to be used for
9399
* re-authentication.
100+
* <p>
101+
* The default implementation will attempt to handle Continuous Access Evaluation (CAE) challenges.
102+
* </p>
94103
*
95104
* @param context The request context.
96105
* @param response The Http Response containing the authentication challenge header.
97106
* @return A {@link Mono} containing {@link TokenRequestContext}
98107
*/
99108
public Mono<Boolean> authorizeRequestOnChallenge(HttpPipelineCallContext context, HttpResponse response) {
109+
if (AuthorizationChallengeParser.isCaeClaimsChallenge(response)) {
110+
TokenRequestContext tokenRequestContext = getTokenRequestContextForCaeChallenge(response);
111+
if (tokenRequestContext != null) {
112+
return setAuthorizationHeader(context, tokenRequestContext).then(Mono.just(true));
113+
}
114+
}
100115
return Mono.just(false);
101116
}
102117

103118
/**
104119
* Handles the authentication challenge in the event a 401 response with a WWW-Authenticate authentication challenge
105120
* header is received after the initial request and returns appropriate {@link TokenRequestContext} to be used for
106121
* re-authentication.
122+
* <p>
123+
* The default implementation will attempt to handle Continuous Access Evaluation (CAE) challenges.
124+
* </p>
107125
*
108126
* @param context The request context.
109127
* @param response The Http Response containing the authentication challenge header.
110128
* @return A boolean indicating if containing the {@link TokenRequestContext} for re-authentication
111129
*/
112130
public boolean authorizeRequestOnChallengeSync(HttpPipelineCallContext context, HttpResponse response) {
131+
if (AuthorizationChallengeParser.isCaeClaimsChallenge(response)) {
132+
TokenRequestContext tokenRequestContext = getTokenRequestContextForCaeChallenge(response);
133+
if (tokenRequestContext != null) {
134+
setAuthorizationHeaderSync(context, tokenRequestContext);
135+
return true;
136+
}
137+
}
138+
113139
return false;
114140
}
115141

@@ -198,4 +224,25 @@ private void setAuthorizationHeaderHelperSync(HttpPipelineCallContext context,
198224
private static void setAuthorizationHeader(HttpHeaders headers, String token) {
199225
headers.set(HttpHeaderName.AUTHORIZATION, BEARER + " " + token);
200226
}
227+
228+
private TokenRequestContext getTokenRequestContextForCaeChallenge(HttpResponse response) {
229+
String decodedClaims = null;
230+
String encodedClaims
231+
= AuthorizationChallengeParser.getChallengeParameterFromResponse(response, "Bearer", "claims");
232+
233+
if (!CoreUtils.isNullOrEmpty(encodedClaims)) {
234+
try {
235+
decodedClaims = new String(Base64.getDecoder().decode(encodedClaims), StandardCharsets.UTF_8);
236+
} catch (IllegalArgumentException e) {
237+
// We don't want to throw here, but we want to log this for future incident investigation.
238+
LOGGER.warning("Failed to decode the claims from the CAE challenge. Encoded claims: " + encodedClaims);
239+
}
240+
}
241+
242+
if (decodedClaims == null) {
243+
return null;
244+
}
245+
246+
return new TokenRequestContext().setClaims(decodedClaims).addScopes(scopes).setCaeEnabled(true);
247+
}
201248
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.azure.core.implementation.http.policy;
5+
6+
import com.azure.core.http.HttpHeaderName;
7+
import com.azure.core.http.HttpResponse;
8+
import com.azure.core.util.CoreUtils;
9+
10+
/**
11+
* Parses Authorization challenges from the {@link HttpResponse}.
12+
*/
13+
public final class AuthorizationChallengeParser {
14+
15+
/**
16+
* Creates an instance of the AuthorizationChallengeParser.
17+
*/
18+
private AuthorizationChallengeParser() {
19+
}
20+
21+
/**
22+
* Examines a {@link HttpResponse} to see if it is a CAE challenge.
23+
* @param response The {@link HttpResponse} to examine.
24+
* @return True if the response is a CAE challenge, false otherwise.
25+
*/
26+
public static boolean isCaeClaimsChallenge(HttpResponse response) {
27+
String challenge = response.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE);
28+
29+
String parameters = getChallengeParametersForScheme(challenge, "Bearer");
30+
String error = getChallengeParameterValue(parameters, "error");
31+
String claims = getChallengeParameterValue(parameters, "claims");
32+
return !CoreUtils.isNullOrEmpty(claims) && "insufficient_claims".equals(error);
33+
}
34+
35+
/**
36+
* Gets the specified challenge parameter from the challenge response.
37+
*
38+
* @param response the Http response with auth challenge
39+
* @param challengeScheme the challenge scheme to be checked
40+
* @param parameter the challenge parameter value to get
41+
*
42+
* @return the extracted value of the challenge parameter
43+
*/
44+
public static String getChallengeParameterFromResponse(HttpResponse response, String challengeScheme,
45+
String parameter) {
46+
String challenge = response.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE);
47+
String parameters = getChallengeParametersForScheme(challenge, challengeScheme);
48+
return getChallengeParameterValue(parameters, parameter);
49+
}
50+
51+
/**
52+
* Gets the set of challenge parameters for the specified challenge scheme.
53+
* @param challenge The challenge to parse.
54+
* @param challengeScheme The challenge scheme to extract parameters for.
55+
* @return The extracted challenge parameters for the specified challenge scheme.
56+
*/
57+
private static String getChallengeParametersForScheme(String challenge, String challengeScheme) {
58+
if (CoreUtils.isNullOrEmpty(challenge)) {
59+
return null;
60+
}
61+
62+
int schemeIndex = -1;
63+
int length = challenge.length();
64+
int schemeLength = challengeScheme.length();
65+
66+
for (int i = 0; i <= length - schemeLength - 1; i++) {
67+
// Check if the scheme matches and is followed by a space
68+
if (challenge.startsWith(challengeScheme, i)
69+
&& (i + schemeLength < length)
70+
&& challenge.charAt(i + schemeLength) == ' ') {
71+
schemeIndex = i;
72+
break;
73+
}
74+
}
75+
76+
if (schemeIndex == -1) {
77+
return null; // Scheme not found
78+
}
79+
80+
int startIndex = schemeIndex + challengeScheme.length();
81+
int endIndex = challenge.length();
82+
83+
// Skip whitespace after the scheme to avoid unnecessary trim
84+
while (startIndex < endIndex && Character.isWhitespace(challenge.charAt(startIndex))) {
85+
startIndex++;
86+
}
87+
88+
// Skip trailing whitespace
89+
while (endIndex > startIndex && Character.isWhitespace(challenge.charAt(endIndex - 1))) {
90+
endIndex--;
91+
}
92+
93+
return startIndex < endIndex ? challenge.substring(startIndex, endIndex) : null;
94+
}
95+
96+
/**
97+
* Gets the specified challenge parameter from the challenge.
98+
* @param parameters The challenge parameters to parse.
99+
* @param parameter The parameter to extract.
100+
* @return The extracted value of the challenge parameter.
101+
*/
102+
private static String getChallengeParameterValue(String parameters, String parameter) {
103+
if (CoreUtils.isNullOrEmpty(parameters)) {
104+
return null;
105+
}
106+
107+
String[] paramPairs = parameters.split(",", -1);
108+
for (String pair : paramPairs) {
109+
int equalsIndex = pair.indexOf('=');
110+
if (equalsIndex != -1) {
111+
String key = pair.substring(0, equalsIndex).trim();
112+
113+
if (key.equals(parameter)) {
114+
String value = pair.substring(equalsIndex + 1).replace("\"", "").trim();
115+
return value;
116+
}
117+
}
118+
}
119+
return null;
120+
}
121+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.azure.core.http.policy;
5+
6+
import com.azure.core.credential.AccessToken;
7+
import com.azure.core.credential.TokenCredential;
8+
import com.azure.core.http.HttpClient;
9+
import com.azure.core.http.HttpHeaderName;
10+
import com.azure.core.http.HttpHeaders;
11+
import com.azure.core.http.HttpMethod;
12+
import com.azure.core.http.HttpPipeline;
13+
import com.azure.core.http.HttpPipelineBuilder;
14+
import com.azure.core.http.HttpRequest;
15+
import com.azure.core.http.HttpResponse;
16+
import com.azure.core.http.MockHttpResponse;
17+
import com.azure.core.implementation.http.policy.AuthorizationChallengeParser;
18+
import com.azure.core.util.Context;
19+
import org.junit.jupiter.params.ParameterizedTest;
20+
import org.junit.jupiter.params.provider.Arguments;
21+
import org.junit.jupiter.params.provider.MethodSource;
22+
import reactor.core.publisher.Mono;
23+
import reactor.test.StepVerifier;
24+
25+
import java.time.OffsetDateTime;
26+
import java.util.concurrent.atomic.AtomicInteger;
27+
import java.util.concurrent.atomic.AtomicReference;
28+
import java.util.stream.Stream;
29+
30+
import static org.junit.jupiter.api.Assertions.assertEquals;
31+
import static org.junit.jupiter.api.Assertions.assertTrue;
32+
33+
public class BearerTokenAuthenticationPolicyTests {
34+
35+
@ParameterizedTest
36+
@MethodSource("caeTestArguments")
37+
public void testDefaultCae(String challenge, int expectedStatusCode, String expectedClaims, String encodedClaims) {
38+
AtomicReference<String> claims = new AtomicReference<>();
39+
AtomicInteger callCount = new AtomicInteger();
40+
TokenCredential credential = getCaeTokenCredential(claims, callCount);
41+
BearerTokenAuthenticationPolicy policy = new BearerTokenAuthenticationPolicy(credential, "scope");
42+
HttpClient client = getCaeHttpClient(challenge, callCount);
43+
44+
HttpPipeline pipeline = new HttpPipelineBuilder().policies(policy).httpClient(client).build();
45+
StepVerifier.create(pipeline.send(new HttpRequest(HttpMethod.GET, "https://localhost")))
46+
.assertNext(response -> assertEquals(expectedStatusCode, response.getStatusCode()))
47+
.verifyComplete();
48+
assertEquals(expectedClaims, claims.get());
49+
50+
if (expectedClaims != null) {
51+
String actualEncodedClaims = AuthorizationChallengeParser.getChallengeParameterFromResponse(
52+
new MockHttpResponse(null, 401, new HttpHeaders().add(HttpHeaderName.WWW_AUTHENTICATE, challenge)),
53+
"Bearer", "claims");
54+
assertEquals(encodedClaims, actualEncodedClaims);
55+
}
56+
}
57+
58+
@ParameterizedTest
59+
@MethodSource("caeTestArguments")
60+
public void testDefaultCaeSync(String challenge, int expectedStatusCode, String expectedClaims,
61+
String encodedClaims) {
62+
AtomicReference<String> claims = new AtomicReference<>();
63+
AtomicInteger callCount = new AtomicInteger();
64+
65+
TokenCredential credential = getCaeTokenCredential(claims, callCount);
66+
BearerTokenAuthenticationPolicy policy = new BearerTokenAuthenticationPolicy(credential, "scope");
67+
HttpClient client = getCaeHttpClient(challenge, callCount);
68+
HttpPipeline pipeline = new HttpPipelineBuilder().policies(policy).httpClient(client).build();
69+
70+
try (HttpResponse response
71+
= pipeline.sendSync(new HttpRequest(HttpMethod.GET, "https://localhost"), Context.NONE)) {
72+
assertEquals(expectedStatusCode, response.getStatusCode());
73+
}
74+
assertEquals(expectedClaims, claims.get());
75+
76+
if (expectedClaims != null) {
77+
String actualEncodedClaims = AuthorizationChallengeParser.getChallengeParameterFromResponse(
78+
new MockHttpResponse(null, 401, new HttpHeaders().add(HttpHeaderName.WWW_AUTHENTICATE, challenge)),
79+
"Bearer", "claims");
80+
assertEquals(encodedClaims, actualEncodedClaims);
81+
}
82+
}
83+
84+
// A fake token credential that lets us keep track of what got parsed out of a CAE claim for assertion.
85+
private static TokenCredential getCaeTokenCredential(AtomicReference<String> claims, AtomicInteger callCount) {
86+
return request -> {
87+
claims.set(request.getClaims());
88+
assertTrue(request.isCaeEnabled());
89+
callCount.incrementAndGet();
90+
return Mono.just(new AccessToken("token", OffsetDateTime.now().plusHours(2)));
91+
};
92+
}
93+
94+
// This http client is effectively a state sentinel for how we progressed through the challenge.
95+
// If we had a challenge, and it is invalid, the policy stops and returns 401 all the way out.
96+
// If the CAE challenge parses properly we will end complete the policy normally and get 200.
97+
private static HttpClient getCaeHttpClient(String challenge, AtomicInteger callCount) {
98+
return request -> {
99+
if (callCount.get() <= 1) {
100+
if (challenge == null) {
101+
return Mono.just(new MockHttpResponse(request, 200));
102+
}
103+
return Mono.just(new MockHttpResponse(request, 401,
104+
new HttpHeaders().add(HttpHeaderName.WWW_AUTHENTICATE, challenge)));
105+
}
106+
return Mono.just(new MockHttpResponse(request, 200));
107+
};
108+
}
109+
110+
private static Stream<Arguments> caeTestArguments() {
111+
return Stream.of(Arguments.of(null, 200, null, null), // no challenge
112+
Arguments.of(
113+
"Bearer authorization_uri=\"https://login.windows.net/\", error=\"invalid_token\", claims=\"ey==\"",
114+
401, null, "ey=="), // unexpected error value
115+
Arguments.of("Bearer claims=\"not base64\", error=\"insufficient_claims\"", 401, null, "not base64"), // parsing error
116+
Arguments.of(
117+
"Bearer realm=\"\", authorization_uri=\"http://localhost\", client_id=\"00000003-0000-0000-c000-000000000000\", error=\"insufficient_claims\", claims=\"ey==\"",
118+
200, "{", "ey=="), // more parameters in a different order
119+
Arguments.of(
120+
"Bearer realm=\"\", authorization_uri=\"https://login.microsoftonline.com/common/oauth2/authorize\", error=\"insufficient_claims\", claims=\"eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ==\"",
121+
200,
122+
"{\"access_token\":{\"nbf\":{\"essential\":true,\"value\":\"1726077595\"},\"xms_caeerror\":{\"value\":\"10012\"}}}",
123+
"eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ=="), // standard request
124+
Arguments.of(
125+
"PoP realm=\"\", authorization_uri=\"https://login.microsoftonline.com/common/oauth2/authorize\", client_id=\"00000003-0000-0000-c000-000000000000\", nonce=\"ey==\", Bearer realm=\"\", authorization_uri=\"https://login.microsoftonline.com/common/oauth2/authorize\", client_id=\"00000003-0000-0000-c000-000000000000\", error_description=\"Continuous access evaluation resulted in challenge with result: InteractionRequired and code: TokenIssuedBeforeRevocationTimestamp\", error=\"insufficient_claims\", claims=\"eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTcyNjI1ODEyMiJ9fX0=\"",
126+
200, "{\"access_token\":{\"nbf\":{\"essential\":true, \"value\":\"1726258122\"}}}",
127+
"eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTcyNjI1ODEyMiJ9fX0="), // multiple challenges
128+
Arguments.of("Bearer claims=\"\" error=\"insufficient_claims\"", 401, null, ""), // empty claims
129+
Arguments.of("Bearer error=\"insufficient_claims\"", 401, null, "") // missing claims
130+
);
131+
}
132+
}

0 commit comments

Comments
 (0)