Skip to content

Commit e649b24

Browse files
authored
BearerTokenAuthenticationPolicy handles CAE challenges (#46277)
1 parent fe6df81 commit e649b24

File tree

5 files changed

+136
-4
lines changed

5 files changed

+136
-4
lines changed

sdk/core/Azure.Core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- `TokenRequestContext` added the `IsProofOfPossessionEnabled`, `ResourceRequestMethod`, and `ResourceRequestUri` properties to support Proof of Possession tokens ([45134](https://github.com/Azure/azure-sdk-for-net/pull/45134)).
88
- `AccessToken` added the `TokenType` property to support distinguishing Bearer tokens from Proof of Possession (PoP) tokens ([45134](https://github.com/Azure/azure-sdk-for-net/pull/45134)).
99
- Moved implementation of `Azure.AzureKeyCredential` into `System.ClientModel.ApiKeyCredential` and made `ApiKeyCredential` the base type for `AzureKeyCredential` ([#46128](https://github.com/Azure/azure-sdk-for-net/pull/46128)).
10+
- `BearerTokenAuthenticationPolicy` now will attempt to handle Continuous Access Evaluation (CAE) challenges, if present, by default ([#46277](https://github.com/Azure/azure-sdk-for-net/pull/46277)).
1011

1112
### Breaking Changes
1213

sdk/core/Azure.Core/src/Diagnostics/AzureCoreEventSource.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ internal sealed class AzureCoreEventSource : AzureEventSource
3535
private const int RequestRedirectBlockedEvent = 21;
3636
private const int RequestRedirectCountExceededEvent = 22;
3737
private const int PipelineTransportOptionsNotAppliedEvent = 23;
38+
private const int FailedToDecodeCaeChallengeClaimsEvent = 24;
3839

3940
private AzureCoreEventSource() : base(EventSourceName) { }
4041

@@ -317,5 +318,20 @@ private static string FormatHeaders(IEnumerable<HttpHeader> headers, HttpMessage
317318
}
318319
return stringBuilder.ToString();
319320
}
321+
322+
[NonEvent]
323+
public void FailedToDecodeCaeChallengeClaims(string? encodedClaims, Exception exception)
324+
{
325+
if (IsEnabled(EventLevel.Error, EventKeywords.None))
326+
{
327+
FailedToDecodeCaeChallengeClaims(encodedClaims, exception.ToString());
328+
}
329+
}
330+
331+
[Event(FailedToDecodeCaeChallengeClaimsEvent, Level = EventLevel.Error, Message = "BearerTokenAuthenticationPolicy Failed to decode CAE claims: '{0}'. Exception: {1}")]
332+
public void FailedToDecodeCaeChallengeClaims(string? encodedClaims, string exception)
333+
{
334+
WriteEvent(FailedToDecodeCaeChallengeClaimsEvent, encodedClaims, exception);
335+
}
320336
}
321337
}

sdk/core/Azure.Core/src/Pipeline/BearerTokenAuthenticationPolicy.cs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Linq;
77
using System.Net;
8+
using System.Text;
89
using System.Threading;
910
using System.Threading.Tasks;
1011
using Azure.Core.Diagnostics;
@@ -71,7 +72,7 @@ public override void Process(HttpMessage message, ReadOnlyMemory<HttpPipelinePol
7172
/// <returns>The <see cref="ValueTask"/> representing the asynchronous operation.</returns>
7273
protected virtual ValueTask AuthorizeRequestAsync(HttpMessage message)
7374
{
74-
var context = new TokenRequestContext(_scopes, message.Request.ClientRequestId);
75+
var context = new TokenRequestContext(_scopes, message.Request.ClientRequestId, isCaeEnabled: true);
7576
return AuthenticateAndAuthorizeRequestAsync(message, context);
7677
}
7778

@@ -84,32 +85,74 @@ protected virtual ValueTask AuthorizeRequestAsync(HttpMessage message)
8485
/// <param name="message">The <see cref="HttpMessage"/> this policy would be applied to.</param>
8586
protected virtual void AuthorizeRequest(HttpMessage message)
8687
{
87-
var context = new TokenRequestContext(_scopes, message.Request.ClientRequestId);
88+
var context = new TokenRequestContext(_scopes, message.Request.ClientRequestId, isCaeEnabled: true);
8889
AuthenticateAndAuthorizeRequest(message, context);
8990
}
9091

9192
/// <summary>
9293
/// Executed in the event a 401 response with a WWW-Authenticate authentication challenge header is received after the initial request.
94+
/// The default implementation will attempt to handle Continuous Access Evaluation (CAE) claims challenges.
9395
/// </summary>
9496
/// <remarks>Service client libraries may override this to handle service specific authentication challenges.</remarks>
9597
/// <param name="message">The <see cref="HttpMessage"/> to be authenticated.</param>
9698
/// <returns>A boolean indicating whether the request was successfully authenticated and should be sent to the transport.</returns>
97-
protected virtual ValueTask<bool> AuthorizeRequestOnChallengeAsync(HttpMessage message)
99+
protected virtual async ValueTask<bool> AuthorizeRequestOnChallengeAsync(HttpMessage message)
98100
{
101+
if (AuthorizationChallengeParser.IsCaeClaimsChallenge(message.Response) &&
102+
TryGetTokenRequestContextForCaeChallenge(message, out var tokenRequestContext))
103+
{
104+
await AuthenticateAndAuthorizeRequestAsync(message, tokenRequestContext).ConfigureAwait(false);
105+
return true;
106+
}
107+
99108
return default;
100109
}
101110

102111
/// <summary>
103112
/// Executed in the event a 401 response with a WWW-Authenticate authentication challenge header is received after the initial request.
113+
/// The default implementation will attempt to handle Continuous Access Evaluation (CAE) claims challenges.
104114
/// </summary>
105115
/// <remarks>Service client libraries may override this to handle service specific authentication challenges.</remarks>
106116
/// <param name="message">The <see cref="HttpMessage"/> to be authenticated.</param>
107117
/// <returns>A boolean indicating whether the request was successfully authenticated and should be sent to the transport.</returns>
108118
protected virtual bool AuthorizeRequestOnChallenge(HttpMessage message)
109119
{
120+
if (AuthorizationChallengeParser.IsCaeClaimsChallenge(message.Response) &&
121+
TryGetTokenRequestContextForCaeChallenge(message, out var tokenRequestContext))
122+
{
123+
AuthenticateAndAuthorizeRequest(message, tokenRequestContext);
124+
return true;
125+
}
110126
return false;
111127
}
112128

129+
internal bool TryGetTokenRequestContextForCaeChallenge(HttpMessage message, out TokenRequestContext tokenRequestContext)
130+
{
131+
string? decodedClaims = null;
132+
string? encodedClaims = AuthorizationChallengeParser.GetChallengeParameterFromResponse(message.Response, "Bearer", "claims");
133+
try
134+
{
135+
decodedClaims = encodedClaims switch
136+
{
137+
null => null,
138+
{ Length: 0 } => null,
139+
string enc => Encoding.UTF8.GetString(Convert.FromBase64String(enc))
140+
};
141+
}
142+
catch (FormatException ex)
143+
{
144+
AzureCoreEventSource.Singleton.FailedToDecodeCaeChallengeClaims(encodedClaims, ex.ToString());
145+
}
146+
if (decodedClaims == null)
147+
{
148+
tokenRequestContext = default;
149+
return false;
150+
}
151+
152+
tokenRequestContext = new TokenRequestContext(_scopes, message.Request.ClientRequestId, decodedClaims, isCaeEnabled: true);
153+
return true;
154+
}
155+
113156
private async ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory<HttpPipelinePolicy> pipeline, bool async)
114157
{
115158
if (message.Request.Uri.Scheme != Uri.UriSchemeHttps)
@@ -385,7 +428,7 @@ public TokenRequestState(TokenRequestContext currentContext, TaskCompletionSourc
385428

386429
public bool IsCurrentContextMismatched(TokenRequestContext context) =>
387430
(context.Scopes != null && !context.Scopes.AsSpan().SequenceEqual(CurrentContext.Scopes.AsSpan())) ||
388-
(context.Claims != null && !string.Equals(context.Claims, CurrentContext.Claims)) ||
431+
!string.Equals(context.Claims, CurrentContext.Claims) ||
389432
(context.TenantId != null && !string.Equals(context.TenantId, CurrentContext.TenantId));
390433

391434
public bool IsBackgroundTokenAvailable(DateTimeOffset now) =>

sdk/core/Azure.Core/src/Shared/AuthorizationChallengeParser.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ namespace Azure.Core
1313
/// </summary>
1414
internal static class AuthorizationChallengeParser
1515
{
16+
/// <summary>
17+
/// Determines if the specified <see cref="HttpMessage"/> is a CAE claims challenge.
18+
/// </summary>
19+
/// <param name="response">The response containing a WWW-Authenticate header.</param>
20+
/// <returns>True</returns>
21+
public static bool IsCaeClaimsChallenge(Response response){
22+
string? error = GetChallengeParameterFromResponse(response, "Bearer", "error");
23+
return error == "insufficient_claims";
24+
}
25+
1626
/// <summary>
1727
/// Parses the specified parameter from a challenge hearder found in the specified <see cref="Response"/>.
1828
/// </summary>

sdk/core/Azure.Core/tests/BearerTokenAuthenticationPolicyTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,68 @@ public async Task TokenCacheCurrentTcsIsCancelledAndBackgroundTcsInitialized()
940940
await cache.GetAuthHeaderValueAsync(msg, ctx, IsAsync);
941941
}
942942

943+
[Test]
944+
[TestCaseSource(nameof(CaeTestDetails))]
945+
public async Task BearerTokenAuthenticationPolicy_CAE_TokenRevocation(string description, string challenge, int expectedResponseCode, string expectedClaims, string encodedClaims)
946+
{
947+
string claims = null;
948+
int callCount = 0;
949+
950+
var transport = CreateMockTransport(req =>
951+
{
952+
if (callCount <= 1)
953+
{
954+
return challenge == null ? new(200) : new MockResponse(401).WithHeader("WWW-Authenticate", challenge);
955+
}
956+
else
957+
{
958+
return new(200);
959+
}
960+
});
961+
962+
var credential = new TokenCredentialStub((r, c) =>
963+
{
964+
claims = r.Claims;
965+
Interlocked.Increment(ref callCount);
966+
Assert.AreEqual(true, r.IsCaeEnabled);
967+
968+
return new(callCount.ToString(), DateTimeOffset.Now.AddHours(2));
969+
}, IsAsync);
970+
var policy = new BearerTokenAuthenticationPolicy(credential, "scope");
971+
972+
using AzureEventSourceListener listener = new((args, text) =>
973+
{
974+
TestContext.WriteLine(text);
975+
if (args.EventName == "FailedToDecodeCaeChallengeClaims")
976+
{
977+
Assert.That(text, Does.Contain($"'{encodedClaims}'"));
978+
}
979+
}, System.Diagnostics.Tracing.EventLevel.Error);
980+
981+
var response = await SendGetRequest(transport, policy, uri: new("https://example.com/1/Original"));
982+
Assert.AreEqual(expectedClaims, claims);
983+
Assert.AreEqual(expectedResponseCode, response.Status);
984+
985+
var response2 = await SendGetRequest(transport, policy, uri: new("https://example.com/1/Original"));
986+
if (expectedClaims != null)
987+
{
988+
Assert.IsNull(claims);
989+
}
990+
}
991+
992+
private static IEnumerable<object[]> CaeTestDetails()
993+
{
994+
yield return new object[] { "no challenge", null, 200, null, null };
995+
yield return new object[] { "unexpected error value", """Bearer authorization_uri="https://login.windows.net/", error="invalid_token", claims="ey==" """, 401, null, "ey==" };
996+
yield return new object[] { "unexpected error value", """Bearer authorization_uri="https://login.windows.net/", error="invalid_token", claims="ey==" """, 401, null, "ey==" };
997+
yield return new object[] { "parsing error", """Bearer claims="not base64", error="insufficient_claims" """, 401, null, "not base64" };
998+
yield return new object[] { "no padding", """Bearer error="insufficient_claims", authorization_uri="http://localhost", claims="ey" """, 401, null, "ey" };
999+
yield return new object[] { "more parameters, different order", """Bearer realm="", authorization_uri="http://localhost", client_id="00000003-0000-0000-c000-000000000000", error="insufficient_claims", claims="ey==" """, 200, "{", "ey==" };
1000+
yield return new object[] { "more parameters, different order", """Bearer realm="", authorization_uri="http://localhost", client_id="00000003-0000-0000-c000-000000000000", error="insufficient_claims", claims="ey==" """, 200, "{", "ey==" };
1001+
yield return new object[] { "standard", """Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ==" """, 200, """{"access_token":{"nbf":{"essential":true,"value":"1726077595"},"xms_caeerror":{"value":"10012"}}}""", "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ==" };
1002+
yield return new object[] { "multiple challenges", """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=" """, 200, """{"access_token":{"nbf":{"essential":true, "value":"1726258122"}}}""", "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTcyNjI1ODEyMiJ9fX0=" };
1003+
}
1004+
9431005
private class ChallengeBasedAuthenticationTestPolicy : BearerTokenAuthenticationPolicy
9441006
{
9451007
public string TenantId { get; private set; }

0 commit comments

Comments
 (0)