diff --git a/sdk/keyvault/Azure.Security.KeyVault.Administration/CHANGELOG.md b/sdk/keyvault/Azure.Security.KeyVault.Administration/CHANGELOG.md index 14c21894fcee2..033c93428657b 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Administration/CHANGELOG.md +++ b/sdk/keyvault/Azure.Security.KeyVault.Administration/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Support multi-tenant authentication against Managed HSM when using Azure.Identity 1.5.0 or newer. ([#18359](https://github.com/Azure/azure-sdk-for-net/issues/18359)) + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/keyvault/Azure.Security.KeyVault.Certificates/CHANGELOG.md b/sdk/keyvault/Azure.Security.KeyVault.Certificates/CHANGELOG.md index 9633f201915f7..9bb77ebc84b19 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Certificates/CHANGELOG.md +++ b/sdk/keyvault/Azure.Security.KeyVault.Certificates/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features Added - Added `KeyVaultCertificateIdentifier.TryCreate` to parse certificate URIs without throwing an exception when invalid. ([#23146](https://github.com/Azure/azure-sdk-for-net/issues/23146)) +- Support multi-tenant authentication against Key Vault and Managed HSM when using Azure.Identity 1.5.0 or newer. ([#18359](https://github.com/Azure/azure-sdk-for-net/issues/18359)) ### Breaking Changes diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md b/sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md index 790aa48aa514c..2903013934174 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md @@ -8,6 +8,7 @@ - Added `KeyClient.GetCryptographyClient` to get a `CryptographyClient` that uses the same options, policies, and pipeline as the `KeyClient` that created it. ([#23786](https://github.com/Azure/azure-sdk-for-net/issues/23786)) - Added `KeyRotationPolicy` class and new methods including `KeyClient.GetKeyRotationPolicy`, `KeyClient.RotateKey`, and `KeyClient.UpdateKeyRotationPolicy`. - Added `KeyVaultKeyIdentifier.TryCreate` to parse key URIs without throwing an exception when invalid. ([#23146](https://github.com/Azure/azure-sdk-for-net/issues/23146)) +- Support multi-tenant authentication against Key Vault and Managed HSM when using Azure.Identity 1.5.0 or newer. ([#18359](https://github.com/Azure/azure-sdk-for-net/issues/18359)) ### Breaking Changes diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets/CHANGELOG.md b/sdk/keyvault/Azure.Security.KeyVault.Secrets/CHANGELOG.md index 87d6f21a2bde2..db7510178ec5c 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Secrets/CHANGELOG.md +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features Added - Added `KeyVaultSecretIdentifier.TryCreate` to parse secret URIs without throwing an exception when invalid. ([#23146](https://github.com/Azure/azure-sdk-for-net/issues/23146)) +- Support multi-tenant authentication against Key Vault and Managed HSM when using Azure.Identity 1.5.0 or newer. ([#18359](https://github.com/Azure/azure-sdk-for-net/issues/18359)) ### Breaking Changes diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets/src/Azure.Security.KeyVault.Secrets.csproj b/sdk/keyvault/Azure.Security.KeyVault.Secrets/src/Azure.Security.KeyVault.Secrets.csproj index f02625d98045c..13679cd23cf54 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Secrets/src/Azure.Security.KeyVault.Secrets.csproj +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets/src/Azure.Security.KeyVault.Secrets.csproj @@ -25,6 +25,11 @@ + + + + + diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SecretClientLiveTests.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SecretClientLiveTests.cs index 7796faf5a1f34..67a33c342af28 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SecretClientLiveTests.cs +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SecretClientLiveTests.cs @@ -4,10 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; using NUnit.Framework; +using Azure.Core; using Azure.Core.TestFramework; -using System.Text; using NUnit.Framework.Constraints; namespace Azure.Security.KeyVault.Secrets.Tests @@ -460,5 +461,19 @@ public async Task GetDeletedSecrets() AssertSecretPropertiesEqual(deletedSecret.Properties, returnedSecret.Properties, compareId: false); } } + + [Test] + public async Task AuthenticateCrossTenant() + { + TokenCredential credential = GetCredential(Recording.Random.NewGuid().ToString()); + SecretClient client = GetClient(credential); + + string secretName = Recording.GenerateId(); + + Response response = await client.SetSecretAsync(secretName, "secret"); + RegisterForCleanup(secretName); + + Assert.AreEqual(200, response.GetRawResponse().Status); + } } } diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SecretsTestBase.cs b/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SecretsTestBase.cs index 5a4d94edc5b47..2f4f8722157dc 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SecretsTestBase.cs +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SecretsTestBase.cs @@ -5,7 +5,9 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading.Tasks; +using Azure.Core; using Azure.Core.TestFramework; +using Azure.Identity; using Azure.Security.KeyVault.Tests; using NUnit.Framework; @@ -41,12 +43,12 @@ protected SecretsTestBase(bool isAsync, SecretClientOptions.ServiceVersion servi _serviceVersion = serviceVersion; } - internal SecretClient GetClient() + internal SecretClient GetClient(TokenCredential credential = default) { return InstrumentClient (new SecretClient( new Uri(TestEnvironment.KeyVaultUrl), - TestEnvironment.Credential, + credential ?? TestEnvironment.Credential, InstrumentClientOptions( new SecretClientOptions(_serviceVersion) { @@ -256,5 +258,23 @@ protected Task WaitForSecret(string name) return TestRetryHelper.RetryAsync(async () => await Client.GetSecretAsync(name).ConfigureAwait(false), delay: PollingInterval); } } + + protected TokenCredential GetCredential(string tenantId) + { + if (Mode == RecordedTestMode.Playback) + { + return new MockCredential(); + } + + return new ClientSecretCredential( + tenantId ?? TestEnvironment.TenantId, + TestEnvironment.ClientId, + TestEnvironment.ClientSecret, + new ClientSecretCredentialOptions() + { + AuthorityHost = new Uri(TestEnvironment.AuthorityHostUrl), + } + ); + } } } diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SessionRecords/SecretClientLiveTests/AuthenticateCrossTenant.json b/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SessionRecords/SecretClientLiveTests/AuthenticateCrossTenant.json new file mode 100644 index 0000000000000..729025efd1731 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SessionRecords/SecretClientLiveTests/AuthenticateCrossTenant.json @@ -0,0 +1,98 @@ +{ + "Entries": [ + { + "RequestUri": "https://heathskeyvault.vault.azure.net/secrets/1976825381?api-version=7.3-preview", + "RequestMethod": "PUT", + "RequestHeaders": { + "Accept": "application/json", + "Content-Type": "application/json", + "traceparent": "00-4ccf898de50d824686a88d142ae3949b-942c9ef9409b7f48-00", + "User-Agent": [ + "azsdk-net-Security.KeyVault.Secrets/4.3.0-alpha.20211006.1", + "(.NET 5.0.10; Microsoft Windows 10.0.22000)" + ], + "x-ms-client-request-id": "3ffda9192896abb52c556d714ba4f0f5", + "x-ms-return-client-request-id": "true" + }, + "RequestBody": null, + "StatusCode": 401, + "ResponseHeaders": { + "Cache-Control": "no-cache", + "Content-Length": "97", + "Content-Type": "application/json; charset=utf-8", + "Date": "Thu, 07 Oct 2021 00:58:44 GMT", + "Expires": "-1", + "Pragma": "no-cache", + "Strict-Transport-Security": "max-age=31536000;includeSubDomains", + "WWW-Authenticate": "Bearer authorization=\u0022https://login.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47\u0022, resource=\u0022https://vault.azure.net\u0022", + "X-Content-Type-Options": "nosniff", + "x-ms-client-request-id": "3ffda9192896abb52c556d714ba4f0f5", + "x-ms-keyvault-network-info": "conn_type=Ipv4;addr=67.171.12.239;act_addr_fam=InterNetwork;", + "x-ms-keyvault-region": "westus2", + "x-ms-keyvault-service-version": "1.9.132.3", + "x-ms-request-id": "22a7e4d6-d334-4870-b379-ef40c2c01df4", + "X-Powered-By": "ASP.NET" + }, + "ResponseBody": { + "error": { + "code": "Unauthorized", + "message": "AKV10000: Request is missing a Bearer or PoP token." + } + } + }, + { + "RequestUri": "https://heathskeyvault.vault.azure.net/secrets/1976825381?api-version=7.3-preview", + "RequestMethod": "PUT", + "RequestHeaders": { + "Accept": "application/json", + "Authorization": "Sanitized", + "Content-Length": "18", + "Content-Type": "application/json", + "traceparent": "00-4ccf898de50d824686a88d142ae3949b-942c9ef9409b7f48-00", + "User-Agent": [ + "azsdk-net-Security.KeyVault.Secrets/4.3.0-alpha.20211006.1", + "(.NET 5.0.10; Microsoft Windows 10.0.22000)" + ], + "x-ms-client-request-id": "3ffda9192896abb52c556d714ba4f0f5", + "x-ms-return-client-request-id": "true" + }, + "RequestBody": { + "value": "secret" + }, + "StatusCode": 200, + "ResponseHeaders": { + "Cache-Control": "no-cache", + "Content-Length": "258", + "Content-Type": "application/json; charset=utf-8", + "Date": "Thu, 07 Oct 2021 00:58:47 GMT", + "Expires": "-1", + "Pragma": "no-cache", + "Strict-Transport-Security": "max-age=31536000;includeSubDomains", + "X-Content-Type-Options": "nosniff", + "x-ms-client-request-id": "3ffda9192896abb52c556d714ba4f0f5", + "x-ms-keyvault-network-info": "conn_type=Ipv4;addr=67.171.12.239;act_addr_fam=InterNetwork;", + "x-ms-keyvault-region": "westus2", + "x-ms-keyvault-service-version": "1.9.132.3", + "x-ms-request-id": "585c4eeb-c666-4760-9150-9a2d31a97bbf", + "X-Powered-By": "ASP.NET" + }, + "ResponseBody": { + "value": "secret", + "id": "https://heathskeyvault.vault.azure.net/secrets/1976825381/7f9f6a57d81f4997aa71365da5923a7c", + "attributes": { + "enabled": true, + "created": 1633568327, + "updated": 1633568327, + "recoveryLevel": "CustomizedRecoverable\u002BPurgeable", + "recoverableDays": 7 + } + } + } + ], + "Variables": { + "AZURE_AUTHORITY_HOST": "https://login.microsoftonline.com/", + "AZURE_KEYVAULT_URL": "https://heathskeyvault.vault.azure.net/", + "CLIENT_ID": "f9ab11db-b032-44b3-af0a-44713541cc40", + "RandomSeed": "914486277" + } +} \ No newline at end of file diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SessionRecords/SecretClientLiveTests/AuthenticateCrossTenantAsync.json b/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SessionRecords/SecretClientLiveTests/AuthenticateCrossTenantAsync.json new file mode 100644 index 0000000000000..9bf761bbb3fb0 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets/tests/SessionRecords/SecretClientLiveTests/AuthenticateCrossTenantAsync.json @@ -0,0 +1,98 @@ +{ + "Entries": [ + { + "RequestUri": "https://heathskeyvault.vault.azure.net/secrets/1361693861?api-version=7.3-preview", + "RequestMethod": "PUT", + "RequestHeaders": { + "Accept": "application/json", + "Content-Type": "application/json", + "traceparent": "00-68a7ae7bababd243a4177c95c8a17a84-adf102b50b9e4549-00", + "User-Agent": [ + "azsdk-net-Security.KeyVault.Secrets/4.3.0-alpha.20211006.1", + "(.NET 5.0.10; Microsoft Windows 10.0.22000)" + ], + "x-ms-client-request-id": "e871c4ae36cf4c9ba7851ef729df4ae2", + "x-ms-return-client-request-id": "true" + }, + "RequestBody": null, + "StatusCode": 401, + "ResponseHeaders": { + "Cache-Control": "no-cache", + "Content-Length": "97", + "Content-Type": "application/json; charset=utf-8", + "Date": "Thu, 07 Oct 2021 00:58:49 GMT", + "Expires": "-1", + "Pragma": "no-cache", + "Strict-Transport-Security": "max-age=31536000;includeSubDomains", + "WWW-Authenticate": "Bearer authorization=\u0022https://login.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47\u0022, resource=\u0022https://vault.azure.net\u0022", + "X-Content-Type-Options": "nosniff", + "x-ms-client-request-id": "e871c4ae36cf4c9ba7851ef729df4ae2", + "x-ms-keyvault-network-info": "conn_type=Ipv4;addr=67.171.12.239;act_addr_fam=InterNetwork;", + "x-ms-keyvault-region": "westus2", + "x-ms-keyvault-service-version": "1.9.132.3", + "x-ms-request-id": "459be702-5cea-4e8f-bf20-40a0ba829746", + "X-Powered-By": "ASP.NET" + }, + "ResponseBody": { + "error": { + "code": "Unauthorized", + "message": "AKV10000: Request is missing a Bearer or PoP token." + } + } + }, + { + "RequestUri": "https://heathskeyvault.vault.azure.net/secrets/1361693861?api-version=7.3-preview", + "RequestMethod": "PUT", + "RequestHeaders": { + "Accept": "application/json", + "Authorization": "Sanitized", + "Content-Length": "18", + "Content-Type": "application/json", + "traceparent": "00-68a7ae7bababd243a4177c95c8a17a84-adf102b50b9e4549-00", + "User-Agent": [ + "azsdk-net-Security.KeyVault.Secrets/4.3.0-alpha.20211006.1", + "(.NET 5.0.10; Microsoft Windows 10.0.22000)" + ], + "x-ms-client-request-id": "e871c4ae36cf4c9ba7851ef729df4ae2", + "x-ms-return-client-request-id": "true" + }, + "RequestBody": { + "value": "secret" + }, + "StatusCode": 200, + "ResponseHeaders": { + "Cache-Control": "no-cache", + "Content-Length": "258", + "Content-Type": "application/json; charset=utf-8", + "Date": "Thu, 07 Oct 2021 00:58:50 GMT", + "Expires": "-1", + "Pragma": "no-cache", + "Strict-Transport-Security": "max-age=31536000;includeSubDomains", + "X-Content-Type-Options": "nosniff", + "x-ms-client-request-id": "e871c4ae36cf4c9ba7851ef729df4ae2", + "x-ms-keyvault-network-info": "conn_type=Ipv4;addr=67.171.12.239;act_addr_fam=InterNetwork;", + "x-ms-keyvault-region": "westus2", + "x-ms-keyvault-service-version": "1.9.132.3", + "x-ms-request-id": "4413f9d6-d556-452e-b579-ca7a96b7c477", + "X-Powered-By": "ASP.NET" + }, + "ResponseBody": { + "value": "secret", + "id": "https://heathskeyvault.vault.azure.net/secrets/1361693861/2bbf7785b5444a07850ed04d2bbd19b1", + "attributes": { + "enabled": true, + "created": 1633568330, + "updated": 1633568330, + "recoveryLevel": "CustomizedRecoverable\u002BPurgeable", + "recoverableDays": 7 + } + } + } + ], + "Variables": { + "AZURE_AUTHORITY_HOST": "https://login.microsoftonline.com/", + "AZURE_KEYVAULT_URL": "https://heathskeyvault.vault.azure.net/", + "CLIENT_ID": "f9ab11db-b032-44b3-af0a-44713541cc40", + "RandomSeed": "1322165825" + } +} \ No newline at end of file diff --git a/sdk/keyvault/Azure.Security.KeyVault.Shared/src/ChallengeBasedAuthenticationPolicy.cs b/sdk/keyvault/Azure.Security.KeyVault.Shared/src/ChallengeBasedAuthenticationPolicy.cs index 59e584e22081d..3bfdb385e8a87 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Shared/src/ChallengeBasedAuthenticationPolicy.cs +++ b/sdk/keyvault/Azure.Security.KeyVault.Shared/src/ChallengeBasedAuthenticationPolicy.cs @@ -12,9 +12,13 @@ namespace Azure.Security.KeyVault { internal class ChallengeBasedAuthenticationPolicy : BearerTokenAuthenticationPolicy { - private static ConcurrentDictionary _scopeCache = new ConcurrentDictionary(); private const string KeyVaultStashedContentKey = "KeyVaultContent"; - private AuthorityScope _scope; + + /// + /// Challenges are cached using the Key Vault or Managed HSM endpoint URI authority as the key. + /// + private static readonly ConcurrentDictionary s_challengeCache = new(); + private ChallengeParameters _challenge; public ChallengeBasedAuthenticationPolicy(TokenCredential credential) : base(credential, Array.Empty()) { } @@ -34,17 +38,17 @@ private async ValueTask AuthorizeRequestInternal(HttpMessage message, bool async throw new InvalidOperationException("Bearer token authentication is not permitted for non TLS protected (https) endpoints."); } - // If this policy doesn't have _scope cached try to get it from the static challenge cache. - if (_scope == null) + // If this policy doesn't have challenge parameters cached try to get it from the static challenge cache. + if (_challenge == null) { string authority = GetRequestAuthority(message.Request); - _scopeCache.TryGetValue(authority, out _scope); + s_challengeCache.TryGetValue(authority, out _challenge); } - if (_scope != null) + if (_challenge != null) { - // We fetched the scope from the cache, but we have not initialized the Scopes in the base yet. - var context = new TokenRequestContext(_scope.Scopes, message.Request.ClientRequestId); + // 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); if (async) { await AuthenticateAndAuthorizeRequestAsync(message, context).ConfigureAwait(false); @@ -53,6 +57,7 @@ private async ValueTask AuthorizeRequestInternal(HttpMessage message, bool async { AuthenticateAndAuthorizeRequest(message, context); } + return; } @@ -86,7 +91,7 @@ private async ValueTask AuthorizeRequestOnChallengeAsyncInternal(HttpMessa string scope = AuthorizationChallengeParser.GetChallengeParameterFromResponse(message.Response, "Bearer", "resource"); if (scope != null) { - scope = scope + "/.default"; + scope += "/.default"; } else { @@ -95,18 +100,29 @@ private async ValueTask AuthorizeRequestOnChallengeAsyncInternal(HttpMessa if (scope is null) { - if (_scopeCache.TryGetValue(authority, out _scope)) + if (s_challengeCache.TryGetValue(authority, out _challenge)) { return false; } } else { - _scope = new AuthorityScope(authority, new string[] { scope }); - _scopeCache[authority] = _scope; + string authorization = AuthorizationChallengeParser.GetChallengeParameterFromResponse(message.Response, "Bearer", "authorization"); + if (authorization is null) + { + authorization = AuthorizationChallengeParser.GetChallengeParameterFromResponse(message.Response, "Bearer", "authorization_uri"); + } + + if (!Uri.TryCreate(authorization, UriKind.Absolute, out Uri authorizationUri)) + { + throw new UriFormatException($"The challenge authorization URI '{authorization}' is invalid."); + } + + _challenge = new ChallengeParameters(authorizationUri, new string[] { scope }); + s_challengeCache[authority] = _challenge; } - var context = new TokenRequestContext(_scope.Scopes, message.Request.ClientRequestId); + var context = new TokenRequestContext(_challenge.Scopes, parentRequestId: message.Request.ClientRequestId, tenantId: _challenge.TenantId); if (async) { await AuthenticateAndAuthorizeRequestAsync(message, context).ConfigureAwait(false); @@ -115,36 +131,53 @@ private async ValueTask AuthorizeRequestOnChallengeAsyncInternal(HttpMessa { AuthenticateAndAuthorizeRequest(message, context); } + return true; } - internal class AuthorityScope + internal class ChallengeParameters { - internal AuthorityScope(string authrority, string[] scopes) + internal ChallengeParameters(Uri authorizationUri, string[] scopes) { - Authority = authrority; + AuthorizationUri = authorizationUri; + TenantId = authorizationUri.Segments[1].Trim('/'); Scopes = scopes; } - public string Authority { get; } + /// + /// Gets the "authorization" or "authorization_uri" parameter from the challenge response. + /// + public Uri AuthorizationUri { get; } + /// + /// Gets the "resource" or "scope" parameter from the challenge response. This should end with "/.default". + /// public string[] Scopes { get; } + + /// + /// Gets the tenant ID from . + /// + public string TenantId { get; } } internal static void ClearCache() { - _scopeCache.Clear(); + s_challengeCache.Clear(); } + /// + /// Gets the host name and port of the Key Vault or Managed HSM endpoint. + /// + /// + /// private static string GetRequestAuthority(Request request) { Uri uri = request.Uri.ToUri(); string authority = uri.Authority; - if (!authority.Contains(":") && uri.Port > 0) { - // Append port for complete authority + // Append port for complete authority. authority = uri.Authority + ":" + uri.Port.ToString(CultureInfo.InvariantCulture); }