Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Key Vault] Add CAE support #46013

Merged
merged 30 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
71190d0
Add flag and enable CAE to AuthorizeRequestInternal
JonathanCrd Sep 17, 2024
36643e2
Enable CAE for AuthorizeRequestOnChallenge
JonathanCrd Sep 17, 2024
02a805d
Add flag in SecretClientOption and SecretClient
JonathanCrd Sep 17, 2024
8bd442a
Revert "Add flag in SecretClientOption and SecretClient"
JonathanCrd Sep 17, 2024
c6a65da
Enable CAE by default
JonathanCrd Sep 17, 2024
8b4e44c
Removing unused parameter
JonathanCrd Sep 17, 2024
a8772d5
Remove saving the claims in the cache
JonathanCrd Sep 17, 2024
ee0b2c6
Update Changelog
JonathanCrd Sep 17, 2024
f645368
Update changelogs
JonathanCrd Sep 18, 2024
1a74490
Simplify error checking logic
JonathanCrd Sep 18, 2024
5df4002
Add test for base64 claims
JonathanCrd Sep 23, 2024
2451396
Override Process function to handle the first CAE Challenge after a s…
JonathanCrd Sep 25, 2024
1f9a73c
Add tests
JonathanCrd Sep 25, 2024
daa03ef
Separate credential and client transports and assert for a 401.
JonathanCrd Sep 25, 2024
f454e4d
Nest rety inside challenge if block
JonathanCrd Sep 27, 2024
5b09202
Merge remote-tracking branch 'upstream/main' into Enable-CAE-for-KeyV…
JonathanCrd Sep 30, 2024
de6d54d
Add test for claims in token
JonathanCrd Oct 1, 2024
72d98ef
Fix CI by removing extra test case parameter
JonathanCrd Oct 1, 2024
46909fe
Nit changes to tests
JonathanCrd Oct 3, 2024
15a5ab7
Simplify tests
JonathanCrd Oct 3, 2024
ee196ec
removing unnecessary mock responses
JonathanCrd Oct 3, 2024
0c33973
Refactor tests to test CAE in all projects
JonathanCrd Oct 7, 2024
a0de67f
Make tests non parallelizable
JonathanCrd Oct 7, 2024
0ffee52
Add setup method to CAE tests
JonathanCrd Oct 8, 2024
f03fe3b
Test for tokens obtained from cae challenges
JonathanCrd Oct 10, 2024
a9657ef
Merge remote-tracking branch 'upstream/main' into Enable-CAE-for-KeyV…
JonathanCrd Oct 10, 2024
8bd6cfc
Fix test / CI
JonathanCrd Oct 10, 2024
d3e535d
Merge remote-tracking branch 'upstream/main' into Enable-CAE-for-KeyV…
JonathanCrd Oct 10, 2024
8544ff6
Update dependency for System.ClientModel
JonathanCrd Oct 10, 2024
6ac9c91
Apply suggestions
JonathanCrd Oct 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Added support for service API version `7.6-preview.1`.
- Added new methods `StartPreRestoreAsync`, `StartPreRestore`, `StartPreBackupAsync`, and `StartPreBackupAsync` to the `KeyVaultBackupClient`.
- Support for Continuous Access Evaluation (CAE).

### Breaking Changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 4.7.0-beta.1 (Unreleased)

### Features Added
- Support for Continuous Access Evaluation (CAE).

### Breaking Changes

Expand Down
1 change: 1 addition & 0 deletions sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 4.7.0-beta.1 (Unreleased)

### Features Added
- Support for Continuous Access Evaluation (CAE).

### Breaking Changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 4.7.0-beta.1 (Unreleased)

### Features Added
- Support for Continuous Access Evaluation (CAE).

### Breaking Changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,150 @@ public async Task ReauthenticatesWhenTenantChanged()
Assert.AreEqual("secret-value", response.Value.Value);
}

[Test]
public void GetClaimsFromChallengeHeaders()
{
MockResponse response401WithClaims = new MockResponse(401)
.WithHeader("WWW-Authenticate", @"Bearer realm="""", authorization_uri=""https://login.microsoftonline.com/common/oauth2/authorize"", error=""insufficient_claims"", claims=""eyJhY2Nlc3NfdG9rZW4iOnsiYWNycyI6eyJlc3NlbnRpYWwiOnRydWUsInZhbHVlIjoiY3AxIn19fQ==""");
Assert.AreEqual(ChallengeBasedAuthenticationPolicy.getDecodedClaimsParameter("insufficient_claims", response401WithClaims), @"{""access_token"":{""acrs"":{""essential"":true,""value"":""cp1""}}}");

MockResponse response401 = new MockResponse(401)
.WithHeader("WWW-Authenticate", @"Bearer authorization=""https://login.windows.net/de763a21-49f7-4b08-a8e1-52c8fbc103b4"", resource=""https://vault.azure.net""");
Assert.IsNull(ChallengeBasedAuthenticationPolicy.getDecodedClaimsParameter(null, response401));
}

[Test]
public void HandlesCaeChallenges(){
MockTransport keyVaultTransport = new(new[]
{
// Initial scope challlenge
new MockResponse(401)
.WithHeader("WWW-Authenticate", @"Bearer authorization=""https://login.windows.net/de763a21-49f7-4b08-a8e1-52c8fbc103b4"", resource=""https://vault.azure.net"""),

// CAE Challenge
new MockResponse(401)
.WithHeader("WWW-Authenticate", @"Bearer realm="""", authorization_uri=""https://login.microsoftonline.com/common/oauth2/authorize"", error=""insufficient_claims"", claims=""eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ=="""),

new MockResponse(200)
{
ContentStream = new KeyVaultSecret("test-secret", "secret-value").ToStream(),
},
});

MockTransport credentialTransport = new(new[]
{
new MockResponse(200)
.WithJson("""
{
"token_type": "Bearer",
"expires_in": 3599,
"resource": "https://vault.azure.net",
"access_token": "ZGU3NjNhMjEtNDlmNy00YjA4LWE4ZTEtNTJjOGZiYzEwM2I0"
}
"""),

new MockResponse(200)
.WithJson("""
{
"token_type": "Bearer",
"expires_in": 3599,
"resource": "https://vault.azure.net",
"access_token": "NzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3"
}
"""),
});

SecretClient client = new(
VaultUri,
new MockCredential(credentialTransport),
new SecretClientOptions()
{
Transport = keyVaultTransport,
});

Response<KeyVaultSecret> response = client.GetSecret("test-secret");
Assert.AreEqual(200, response.GetRawResponse().Status);
Assert.AreEqual("secret-value", response.Value.Value);
}

[Test]
public void ThrowsWithTwoConsecutiveCaeChallenges()
{
MockTransport keyVaultTransport = new(new[]
{
// Initial scope challlenge
new MockResponse(401)
.WithHeader("WWW-Authenticate", @"Bearer authorization=""https://login.windows.net/de763a21-49f7-4b08-a8e1-52c8fbc103b4"", resource=""https://vault.azure.net"""),

// CAE Challenge
new MockResponse(401)
.WithHeader("WWW-Authenticate", @"Bearer realm="""", authorization_uri=""https://login.microsoftonline.com/common/oauth2/authorize"", error=""insufficient_claims"", claims=""eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ=="""),

// Second CAE Challenge
new MockResponse(401)
.WithHeader("WWW-Authenticate", @"Bearer realm="""", authorization_uri=""https://login.microsoftonline.com/common/oauth2/authorize"", error=""insufficient_claims"", claims=""eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ=="""),

new MockResponse(200)
{
ContentStream = new KeyVaultSecret("test-secret", "secret-value").ToStream(),
},
});

MockTransport credentialTransport = new(new[]
{
new MockResponse(200)
.WithJson("""
{
"token_type": "Bearer",
"expires_in": 3599,
"resource": "https://vault.azure.net",
"access_token": "ZGU3NjNhMjEtNDlmNy00YjA4LWE4ZTEtNTJjOGZiYzEwM2I0"
}
"""),

new MockResponse(200)
.WithJson("""
{
"token_type": "Bearer",
"expires_in": 3599,
"resource": "https://vault.azure.net",
"access_token": "NzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3"
}
"""),

new MockResponse(200)
.WithJson("""
{
"token_type": "Bearer",
"expires_in": 3599,
"resource": "https://vault.azure.net",
"access_token": GUID.NewGuid().ToString()
}
"""),
});

SecretClient client = new(
VaultUri,
new MockCredential(credentialTransport),
new SecretClientOptions()
{
Transport = keyVaultTransport,
});

try
{
client.GetSecret("test-secret");
}
catch (RequestFailedException ex)
{
Assert.AreEqual(401, ex.Status);
}
catch (Exception ex)
{
Assert.Fail($"Expected RequestFailedException, but got {ex.GetType()}");
}
}

private class MockTransportBuilder
{
private const string AuthorizationHeader = "Authorization";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Net;
using System.Threading.Tasks;

namespace Azure.Security.KeyVault
Expand Down Expand Up @@ -51,7 +52,7 @@ private async ValueTask AuthorizeRequestInternal(HttpMessage message, bool async
if (_challenge != null)
{
// We fetched the challenge from the cache, but we have not initialized the Scopes in the base yet.
var context = new TokenRequestContext(_challenge.Scopes, parentRequestId: message.Request.ClientRequestId, tenantId: _challenge.TenantId);
var context = new TokenRequestContext(_challenge.Scopes, parentRequestId: message.Request.ClientRequestId, tenantId: _challenge.TenantId, isCaeEnabled: true);
if (async)
{
await AuthenticateAndAuthorizeRequestAsync(message, context).ConfigureAwait(false);
Expand Down Expand Up @@ -84,15 +85,40 @@ protected override ValueTask<bool> AuthorizeRequestOnChallengeAsync(HttpMessage
protected override bool AuthorizeRequestOnChallenge(HttpMessage message)
=> AuthorizeRequestOnChallengeAsyncInternal(message, false).EnsureCompleted();

/// <summary>
/// Gets the claims parameter from the challenge response.
/// If there are no claims, returns null.
/// </summary>
/// <param name="error">The error message from the service.</param>
/// <param name="response">The response from the service which contains the headers.</param>
/// <returns>A string with the decoded claims if present, otherwise null</returns>
internal static string getDecodedClaimsParameter(string error, Response response)
{
// According to docs https://learn.microsoft.com/en-us/entra/identity-platform/claims-challenge?tabs=dotnet#claims-challenge-header-format,
// the error message must be "insufficient_claims" when a claims challenge should be generated.
if (error == "insufficient_claims")
{
return AuthorizationChallengeParser.GetChallengeParameterFromResponse(response, "Bearer", "claims") switch
{
{ Length: 0 } => null,
string enc => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(enc))
};
}

return null;
}

private async ValueTask<bool> AuthorizeRequestOnChallengeAsyncInternal(HttpMessage message, bool async)
{
if (message.Request.Content == null && message.TryGetProperty(KeyVaultStashedContentKey, out var content))
{
message.Request.Content = content as RequestContent;
}

string error = AuthorizationChallengeParser.GetChallengeParameterFromResponse(message.Response, "Bearer", "error");
string authority = GetRequestAuthority(message.Request);
string scope = AuthorizationChallengeParser.GetChallengeParameterFromResponse(message.Response, "Bearer", "resource");

if (scope != null)
{
scope += "/.default";
Expand All @@ -102,6 +128,15 @@ private async ValueTask<bool> AuthorizeRequestOnChallengeAsyncInternal(HttpMessa
scope = AuthorizationChallengeParser.GetChallengeParameterFromResponse(message.Response, "Bearer", "scope");
}

// Handle CAE Challenges
string claims = getDecodedClaimsParameter(error, message.Response);
if (claims != null)
{
// Get the scope from the cache
s_challengeCache.TryGetValue(authority, out _challenge);
scope = _challenge.Scopes[0];
}

if (scope is null)
{
if (s_challengeCache.TryGetValue(authority, out _challenge))
Expand Down Expand Up @@ -140,7 +175,7 @@ private async ValueTask<bool> AuthorizeRequestOnChallengeAsyncInternal(HttpMessa
s_challengeCache[authority] = _challenge;
}

var context = new TokenRequestContext(_challenge.Scopes, parentRequestId: message.Request.ClientRequestId, tenantId: _challenge.TenantId);
var context = new TokenRequestContext(_challenge.Scopes, parentRequestId: message.Request.ClientRequestId, tenantId: _challenge.TenantId, isCaeEnabled: true, claims: claims);
if (async)
{
await AuthenticateAndAuthorizeRequestAsync(message, context).ConfigureAwait(false);
Expand All @@ -153,6 +188,81 @@ private async ValueTask<bool> AuthorizeRequestOnChallengeAsyncInternal(HttpMessa
return true;
}

/// <inheritdoc />
public override ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory<HttpPipelinePolicy> pipeline)
{
return ProcessAsyncInternal(message, pipeline, true);
}

/// <inheritdoc />
public override void Process(HttpMessage message, ReadOnlyMemory<HttpPipelinePolicy> pipeline)
{
ProcessAsyncInternal(message, pipeline, false).EnsureCompleted();
}

private async ValueTask ProcessAsyncInternal(HttpMessage message, ReadOnlyMemory<HttpPipelinePolicy> pipeline, bool async)
{
if (message.Request.Uri.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException("Bearer token authentication is not permitted for non TLS protected (https) endpoints.");
}

if (async)
{
await AuthorizeRequestAsync(message).ConfigureAwait(false);
await ProcessNextAsync(message, pipeline).ConfigureAwait(false);
}
else
{
AuthorizeRequest(message);
ProcessNext(message, pipeline);
}

// Check if we have received a challenge or we have not yet issued the first request.
if (message.Response.Status == (int)HttpStatusCode.Unauthorized && message.Response.Headers.Contains(HttpHeader.Names.WwwAuthenticate))
{
// Attempt to get the TokenRequestContext based on the challenge.
// If we fail to get the context, the challenge was not present or invalid.
// If we succeed in getting the context, authenticate the request and pass it up the policy chain.
if (async)
{
if (await AuthorizeRequestOnChallengeAsync(message).ConfigureAwait(false))
{
await ProcessNextAsync(message, pipeline).ConfigureAwait(false);
}
}
else
{
if (AuthorizeRequestOnChallenge(message))
{
ProcessNext(message, pipeline);
}
}

// Handle the scenario in which we get a CAE challenge back.
if (message.Response.Status == (int)HttpStatusCode.Unauthorized
&& message.Response.Headers.Contains(HttpHeader.Names.WwwAuthenticate)
&& AuthorizationChallengeParser.GetChallengeParameterFromResponse(message.Response, "Bearer", "claims") != null)
{
if (async)
{
if (await AuthorizeRequestOnChallengeAsync(message).ConfigureAwait(false))
{
await ProcessNextAsync(message, pipeline).ConfigureAwait(false);
}
}
else
{
if (AuthorizeRequestOnChallenge(message))
{
ProcessNext(message, pipeline);
}
}
}
// If we get a second CAE challenge, an unlikely scenario, we do not attempt to re-authenticate.
}
}

internal class ChallengeParameters
{
internal ChallengeParameters(Uri authorizationUri, string[] scopes)
Expand Down