diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index bca95e04f..648500e47 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Added `ClientAssertionCredential` to enable applications to authenticate with custom client assertions. + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/identity/azure-identity/CMakeLists.txt b/sdk/identity/azure-identity/CMakeLists.txt index 709f212ba..2a3ffd8c4 100644 --- a/sdk/identity/azure-identity/CMakeLists.txt +++ b/sdk/identity/azure-identity/CMakeLists.txt @@ -50,6 +50,7 @@ set( inc/azure/identity/azure_cli_credential.hpp inc/azure/identity/azure_pipelines_credential.hpp inc/azure/identity/chained_token_credential.hpp + inc/azure/identity/client_assertion_credential.hpp inc/azure/identity/client_certificate_credential.hpp inc/azure/identity/client_secret_credential.hpp inc/azure/identity/default_azure_credential.hpp @@ -67,6 +68,7 @@ set( src/azure_cli_credential.cpp src/azure_pipelines_credential.cpp src/chained_token_credential.cpp + src/client_assertion_credential.cpp src/client_certificate_credential.cpp src/client_credential_core.cpp src/client_secret_credential.cpp diff --git a/sdk/identity/azure-identity/README.md b/sdk/identity/azure-identity/README.md index 57de82c28..de0574ca5 100644 --- a/sdk/identity/azure-identity/README.md +++ b/sdk/identity/azure-identity/README.md @@ -135,6 +135,7 @@ Configuration is attempted in the above order. For example, if values for a clie |Credential | Usage |-|- |`AzurePipelinesCredential`|Supports [Microsoft Entra Workload ID](https://learn.microsoft.com/azure/devops/pipelines/release/configure-workload-identity?view=azure-devops) on Azure Pipelines. +|`ClientAssertionCredential`|Authenticates a service principal using a signed client assertion. |`ClientSecretCredential`|Authenticates a service principal [using a secret](https://learn.microsoft.com/entra/identity-platform/app-objects-and-service-principals). |`ClientCertificateCredential`|Authenticates a service principal [using a certificate](https://learn.microsoft.com/entra/identity-platform/app-objects-and-service-principals). diff --git a/sdk/identity/azure-identity/inc/azure/identity.hpp b/sdk/identity/azure-identity/inc/azure/identity.hpp index d86546611..60d093061 100644 --- a/sdk/identity/azure-identity/inc/azure/identity.hpp +++ b/sdk/identity/azure-identity/inc/azure/identity.hpp @@ -11,6 +11,7 @@ #include "azure/identity/azure_cli_credential.hpp" #include "azure/identity/azure_pipelines_credential.hpp" #include "azure/identity/chained_token_credential.hpp" +#include "azure/identity/client_assertion_credential.hpp" #include "azure/identity/client_certificate_credential.hpp" #include "azure/identity/client_secret_credential.hpp" #include "azure/identity/default_azure_credential.hpp" diff --git a/sdk/identity/azure-identity/inc/azure/identity/client_assertion_credential.hpp b/sdk/identity/azure-identity/inc/azure/identity/client_assertion_credential.hpp new file mode 100644 index 000000000..e1fd87552 --- /dev/null +++ b/sdk/identity/azure-identity/inc/azure/identity/client_assertion_credential.hpp @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @file + * @brief Client Assertion Credential and options. + */ + +#pragma once + +#include "azure/identity/detail/client_credential_core.hpp" +#include "azure/identity/detail/token_cache.hpp" + +#include +#include + +#include +#include + +namespace Azure { namespace Identity { + namespace _detail { + class TokenCredentialImpl; + } // namespace _detail + + /** + * @brief Options used to configure the Client Assertion credential. + * + */ + struct ClientAssertionCredentialOptions final : public Core::Credentials::TokenCredentialOptions + { + /** + * @brief Authentication authority URL. + * @note Defaults to the value of the environment variable 'AZURE_AUTHORITY_HOST'. If that's not + * set, the default value is Microsoft Entra global authority + * (https://login.microsoftonline.com/). + * + * @note Example of an authority host string: "https://login.microsoftonline.us/". See national + * clouds' Microsoft Entra authentication endpoints: + * https://learn.microsoft.com/entra/identity-platform/authentication-national-cloud. + */ + std::string AuthorityHost = _detail::DefaultOptionValues::GetAuthorityHost(); + + /** + * @brief For multi-tenant applications, specifies additional tenants for which the credential + * may acquire tokens. Add the wildcard value `"*"` to allow the credential to acquire tokens + * for any tenant in which the application is installed. + */ + std::vector AdditionallyAllowedTenants; + }; + + /** + * @brief Credential which authenticates a Microsoft Entra service principal using a signed client + * assertion. + * + */ + class ClientAssertionCredential final : public Core::Credentials::TokenCredential { + private: + std::function m_assertionCallback; + _detail::ClientCredentialCore m_clientCredentialCore; + std::unique_ptr<_detail::TokenCredentialImpl> m_tokenCredentialImpl; + std::string m_requestBody; + _detail::TokenCache m_tokenCache; + + public: + /** + * @brief Creates an instance of the Client Assertion Credential with a callback that provides a + * signed client assertion to authenticate against Microsoft Entra ID. + * + * @param tenantId The Microsoft Entra tenant (directory) ID of the service principal. + * @param clientId The client (application) ID of the service principal. + * @param assertionCallback A callback returning a valid client assertion used to authenticate + * the service principal. + * @param options Options that allow to configure the management of the requests sent to + * Microsoft Entra ID for token retrieval. + */ + explicit ClientAssertionCredential( + std::string tenantId, + std::string clientId, + std::function assertionCallback, + ClientAssertionCredentialOptions const& options = {}); + + /** + * @brief Destructs `%ClientAssertionCredential`. + * + */ + ~ClientAssertionCredential() override; + + /** + * @brief Obtains an authentication token from Microsoft Entra ID, by calling the + * assertionCallback specified when constructing the credential to obtain a client assertion for + * authentication. + * + * @param tokenRequestContext A context to get the token in. + * @param context A context to control the request lifetime. + * + * @throw Azure::Core::Credentials::AuthenticationException Authentication error occurred. + */ + Core::Credentials::AccessToken GetToken( + Core::Credentials::TokenRequestContext const& tokenRequestContext, + Core::Context const& context) const override; + }; + +}} // namespace Azure::Identity diff --git a/sdk/identity/azure-identity/src/client_assertion_credential.cpp b/sdk/identity/azure-identity/src/client_assertion_credential.cpp new file mode 100644 index 000000000..596a06576 --- /dev/null +++ b/sdk/identity/azure-identity/src/client_assertion_credential.cpp @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "azure/identity/client_assertion_credential.hpp" + +#include "private/identity_log.hpp" +#include "private/package_version.hpp" +#include "private/tenant_id_resolver.hpp" +#include "private/token_credential_impl.hpp" + +#include + +using Azure::Identity::ClientAssertionCredential; +using Azure::Identity::ClientAssertionCredentialOptions; + +using Azure::Core::Context; +using Azure::Core::Url; +using Azure::Core::_internal::StringExtensions; +using Azure::Core::Credentials::AccessToken; +using Azure::Core::Credentials::AuthenticationException; +using Azure::Core::Credentials::TokenRequestContext; +using Azure::Core::Http::HttpMethod; +using Azure::Identity::_detail::IdentityLog; +using Azure::Identity::_detail::TenantIdResolver; +using Azure::Identity::_detail::TokenCredentialImpl; + +namespace { +bool IsValidTenantId(std::string const& tenantId) +{ + const std::string allowedChars = ".-"; + if (tenantId.empty()) + { + return false; + } + for (auto const c : tenantId) + { + if (allowedChars.find(c) != std::string::npos) + { + continue; + } + if (!StringExtensions::IsAlphaNumeric(c)) + { + return false; + } + } + return true; +} +} // namespace + +ClientAssertionCredential::ClientAssertionCredential( + std::string tenantId, + std::string clientId, + std::function assertionCallback, + ClientAssertionCredentialOptions const& options) + : TokenCredential("ClientAssertionCredential"), + m_assertionCallback(std::move(assertionCallback)), + m_clientCredentialCore(tenantId, options.AuthorityHost, options.AdditionallyAllowedTenants) +{ + bool isTenantIdValid = IsValidTenantId(tenantId); + if (!isTenantIdValid) + { + IdentityLog::Write( + IdentityLog::Level::Warning, + GetCredentialName() + + ": Invalid tenant ID provided. The tenant ID must be a non-empty string containing " + "only alphanumeric characters, periods, or hyphens. You can locate your tenant ID by " + "following the instructions listed here: " + "https://learn.microsoft.com/partner-center/find-ids-and-domain-names"); + } + if (clientId.empty()) + { + IdentityLog::Write( + IdentityLog::Level::Warning, GetCredentialName() + ": No client ID specified."); + } + if (!m_assertionCallback) + { + IdentityLog::Write( + IdentityLog::Level::Warning, + GetCredentialName() + + ": The assertionCallback must be a valid function that returns assertions."); + } + + if (isTenantIdValid && !clientId.empty() && m_assertionCallback) + { + m_tokenCredentialImpl = std::make_unique(options); + m_requestBody + = std::string( + "grant_type=client_credentials" + "&client_assertion_type=" + "urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer" // cspell:disable-line + "&client_id=") + + Url::Encode(clientId); + + IdentityLog::Write( + IdentityLog::Level::Informational, GetCredentialName() + " was created successfully."); + } + else + { + // Rather than throwing an exception in the ctor, following the pattern in existing credentials + // to log the errors, and defer throwing an exception to the first call of GetToken(). This is + // primarily needed for credentials that are part of the DefaultAzureCredential, which this + // credential is not intended for. + IdentityLog::Write( + IdentityLog::Level::Warning, GetCredentialName() + " was not initialized correctly."); + } +} + +ClientAssertionCredential::~ClientAssertionCredential() = default; + +AccessToken ClientAssertionCredential::GetToken( + TokenRequestContext const& tokenRequestContext, + Context const& context) const +{ + if (!m_tokenCredentialImpl) + { + auto const AuthUnavailable = GetCredentialName() + " authentication unavailable. "; + + IdentityLog::Write( + IdentityLog::Level::Warning, + AuthUnavailable + "See earlier " + GetCredentialName() + " log messages for details."); + + throw AuthenticationException(AuthUnavailable); + } + + auto const tenantId = TenantIdResolver::Resolve( + m_clientCredentialCore.GetTenantId(), + tokenRequestContext, + m_clientCredentialCore.GetAdditionallyAllowedTenants()); + + auto const scopesStr + = m_clientCredentialCore.GetScopesString(tenantId, tokenRequestContext.Scopes); + + // TokenCache::GetToken() and m_tokenCredentialImpl->GetToken() can only use the lambda + // argument when they are being executed. They are not supposed to keep a reference to lambda + // argument to call it later. Therefore, any capture made here will outlive the possible time + // frame when the lambda might get called. + return m_tokenCache.GetToken(scopesStr, tenantId, tokenRequestContext.MinimumExpiration, [&]() { + return m_tokenCredentialImpl->GetToken(context, false, [&]() { + auto body = m_requestBody; + if (!scopesStr.empty()) + { + body += "&scope=" + scopesStr; + } + + // Get the request url before calling m_assertionCallback to validate the authority host + // scheme (GetRequestUrl() will throw if validation fails). This is to avoid calling the + // assertion callback if the authority host scheme is invalid. + auto const requestUrl = m_clientCredentialCore.GetRequestUrl(tenantId); + + const std::string assertion = m_assertionCallback(context); + + body += "&client_assertion=" + Azure::Core::Url::Encode(assertion); + + auto request + = std::make_unique(HttpMethod::Post, requestUrl, body); + + request->HttpRequest.SetHeader("Host", requestUrl.GetHost()); + + return request; + }); + }); +} diff --git a/sdk/identity/azure-identity/test/ut/CMakeLists.txt b/sdk/identity/azure-identity/test/ut/CMakeLists.txt index 171655754..7efc6582e 100644 --- a/sdk/identity/azure-identity/test/ut/CMakeLists.txt +++ b/sdk/identity/azure-identity/test/ut/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable ( azure_cli_credential_test.cpp azure_pipelines_credential_test.cpp chained_token_credential_test.cpp + client_assertion_credential_test.cpp client_certificate_credential_test.cpp client_secret_credential_test.cpp credential_test_helper.cpp diff --git a/sdk/identity/azure-identity/test/ut/client_assertion_credential_test.cpp b/sdk/identity/azure-identity/test/ut/client_assertion_credential_test.cpp new file mode 100644 index 000000000..85ccf1c2a --- /dev/null +++ b/sdk/identity/azure-identity/test/ut/client_assertion_credential_test.cpp @@ -0,0 +1,406 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "azure/identity/client_assertion_credential.hpp" +#include "credential_test_helper.hpp" + +#include +#include + +#include + +using Azure::Core::_internal::Environment; +using Azure::Core::Credentials::AccessToken; +using Azure::Core::Credentials::AuthenticationException; +using Azure::Core::Credentials::TokenRequestContext; +using Azure::Core::Http::HttpMethod; +using Azure::Identity::ClientAssertionCredential; +using Azure::Identity::ClientAssertionCredentialOptions; +using Azure::Identity::Test::_detail::CredentialTestHelper; + +std::string GetAssertion_Throw(Azure::Core::Context const&) +{ + throw std::runtime_error( + "The test is not expected to call this function used for assertion callback."); +} + +std::string GetAssertion_Test(Azure::Core::Context const&) { return "sample-assertion"; } + +TEST(ClientAssertionCredential, GetCredentialName) +{ + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + std::string serviceConnectionId = "abc"; + std::string systemAccessToken = "123"; + + ClientAssertionCredential const cred(tenantId, clientId, GetAssertion_Throw); + + EXPECT_EQ(cred.GetCredentialName(), "ClientAssertionCredential"); +} + +TEST(ClientAssertionCredential, GetOptionsFromEnvironment) +{ + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + + { + std::map envVars = {{"AZURE_AUTHORITY_HOST", ""}}; + CredentialTestHelper::EnvironmentOverride const env(envVars); + + ClientAssertionCredentialOptions options; + ClientAssertionCredential const cred(tenantId, clientId, GetAssertion_Throw, options); + EXPECT_EQ(cred.GetCredentialName(), "ClientAssertionCredential"); + + EXPECT_EQ(options.AuthorityHost, "https://login.microsoftonline.com/"); + } + + { + std::map envVars = {{"AZURE_AUTHORITY_HOST", "foo"}}; + CredentialTestHelper::EnvironmentOverride const env(envVars); + + ClientAssertionCredentialOptions options; + options.AuthorityHost = "bar"; + EXPECT_EQ(options.AuthorityHost, "bar"); + } + + { + std::map envVars + = {{"AZURE_AUTHORITY_HOST", "https://microsoft.com/"}}; + CredentialTestHelper::EnvironmentOverride const env(envVars); + + ClientAssertionCredentialOptions options; + EXPECT_EQ(options.AuthorityHost, "https://microsoft.com/"); + } +} + +TEST(ClientAssertionCredential, InvalidArgs) +{ + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + + // Empty Tenant ID + { + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + ClientAssertionCredential const cred("", clientId, GetAssertion_Throw); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } + + // Invalid Tenant ID + { + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + ClientAssertionCredential const cred("!=invalidTenantId=!", clientId, GetAssertion_Throw); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } + + // Empty client ID + { + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + ClientAssertionCredential const cred(tenantId, "", GetAssertion_Throw); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } + + // Empty assertion callback + { + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + ClientAssertionCredential const cred(tenantId, clientId, nullptr); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } + { + std::function emptyCallBack; + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + ClientAssertionCredential const cred(tenantId, clientId, emptyCallBack); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } +} + +TEST(ClientAssertionCredential, Regular) +{ + auto const actual = CredentialTestHelper::SimulateTokenRequest( + [](auto transport) { + ClientAssertionCredentialOptions options; + options.Transport.Transport = transport; + + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + + return std::make_unique( + tenantId, clientId, GetAssertion_Test, options); + }, + {{{"https://azure.com/.default"}}, {{}}}, + std::vector{ + "{\"expires_in\":3600, \"access_token\":\"ACCESSTOKEN1\"}", + "{\"expires_in\":7200, \"access_token\":\"ACCESSTOKEN2\"}"}); + + EXPECT_EQ(actual.Requests.size(), 2U); + EXPECT_EQ(actual.Responses.size(), 2U); + + auto const& request0 = actual.Requests.at(0); + auto const& request1 = actual.Requests.at(1); + + auto const& response0 = actual.Responses.at(0); + auto const& response1 = actual.Responses.at(1); + + EXPECT_EQ(request0.HttpMethod, HttpMethod::Post); + EXPECT_EQ(request1.HttpMethod, HttpMethod::Post); + + EXPECT_EQ( + request0.AbsoluteUrl, + "https://login.microsoftonline.com/01234567-89ab-cdef-fedc-ba8976543210/oauth2/v2.0/token"); + + EXPECT_EQ( + request1.AbsoluteUrl, + "https://login.microsoftonline.com/01234567-89ab-cdef-fedc-ba8976543210/oauth2/v2.0/token"); + + { + constexpr char expectedBodyStart0[] // cspell:disable + = "grant_type=client_credentials" + "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer" + "&client_id=fedcba98-7654-3210-0123-456789abcdef" + "&scope=https%3A%2F%2Fazure.com%2F.default" + "&client_assertion=sample-assertion"; // cspell:enable + + constexpr char expectedBodyStart1[] // cspell:disable + = "grant_type=client_credentials" + "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer" + "&client_id=fedcba98-7654-3210-0123-456789abcdef" + "&client_assertion=sample-assertion"; // cspell:enable + + EXPECT_EQ(request0.Body.size(), (sizeof(expectedBodyStart0) - 1)); + EXPECT_EQ(request1.Body.size(), (sizeof(expectedBodyStart1) - 1)); + + EXPECT_EQ(request0.Body.substr(0, (sizeof(expectedBodyStart0) - 1)), expectedBodyStart0); + EXPECT_EQ(request1.Body.substr(0, (sizeof(expectedBodyStart1) - 1)), expectedBodyStart1); + + EXPECT_NE(request0.Headers.find("Content-Length"), request0.Headers.end()); + EXPECT_EQ( + std::stoi(request0.Headers.at("Content-Length")), + static_cast(sizeof(expectedBodyStart0) - 1)); + + EXPECT_NE(request1.Headers.find("Content-Length"), request1.Headers.end()); + EXPECT_EQ( + std::stoi(request1.Headers.at("Content-Length")), + static_cast(sizeof(expectedBodyStart1) - 1)); + } + + EXPECT_NE(request0.Headers.find("Content-Type"), request0.Headers.end()); + EXPECT_EQ(request0.Headers.at("Content-Type"), "application/x-www-form-urlencoded"); + + EXPECT_NE(request1.Headers.find("Content-Type"), request1.Headers.end()); + EXPECT_EQ(request1.Headers.at("Content-Type"), "application/x-www-form-urlencoded"); + + EXPECT_EQ(response0.AccessToken.Token, "ACCESSTOKEN1"); + EXPECT_EQ(response1.AccessToken.Token, "ACCESSTOKEN2"); + + using namespace std::chrono_literals; + EXPECT_GE(response0.AccessToken.ExpiresOn, response0.EarliestExpiration + 3600s); + EXPECT_LE(response0.AccessToken.ExpiresOn, response0.LatestExpiration + 3600s); + + EXPECT_GE(response1.AccessToken.ExpiresOn, response1.EarliestExpiration + 7200s); + EXPECT_LE(response1.AccessToken.ExpiresOn, response1.LatestExpiration + 7200s); +} + +TEST(ClientAssertionCredential, AzureStack) +{ + auto const actual = CredentialTestHelper::SimulateTokenRequest( + [](auto transport) { + ClientAssertionCredentialOptions options; + options.Transport.Transport = transport; + + std::string tenantId = "adfs"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + + return std::make_unique( + tenantId, clientId, GetAssertion_Test, options); + }, + {{{"https://azure.com/.default"}}, {{}}}, + std::vector{ + "{\"expires_in\":3600, \"access_token\":\"ACCESSTOKEN1\"}", + "{\"expires_in\":7200, \"access_token\":\"ACCESSTOKEN2\"}"}); + + EXPECT_EQ(actual.Requests.size(), 2U); + EXPECT_EQ(actual.Responses.size(), 2U); + + auto const& request0 = actual.Requests.at(0); + auto const& request1 = actual.Requests.at(1); + + auto const& response0 = actual.Responses.at(0); + auto const& response1 = actual.Responses.at(1); + + EXPECT_EQ(request0.HttpMethod, HttpMethod::Post); + EXPECT_EQ(request1.HttpMethod, HttpMethod::Post); + + EXPECT_EQ(request0.AbsoluteUrl, "https://login.microsoftonline.com/adfs/oauth2/token"); + + EXPECT_EQ(request1.AbsoluteUrl, "https://login.microsoftonline.com/adfs/oauth2/token"); + + { + constexpr char expectedBodyStart0[] // cspell:disable + = "grant_type=client_credentials" + "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer" + "&client_id=fedcba98-7654-3210-0123-456789abcdef" + "&scope=https%3A%2F%2Fazure.com" + "&client_assertion=sample-assertion"; // cspell:enable + + constexpr char expectedBodyStart1[] // cspell:disable + = "grant_type=client_credentials" + "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer" + "&client_id=fedcba98-7654-3210-0123-456789abcdef" + "&client_assertion=sample-assertion"; // cspell:enable + + EXPECT_EQ(request0.Body.size(), (sizeof(expectedBodyStart0) - 1)); + EXPECT_EQ(request1.Body.size(), (sizeof(expectedBodyStart1) - 1)); + + EXPECT_EQ(request0.Body.substr(0, (sizeof(expectedBodyStart0) - 1)), expectedBodyStart0); + EXPECT_EQ(request1.Body.substr(0, (sizeof(expectedBodyStart1) - 1)), expectedBodyStart1); + + EXPECT_NE(request0.Headers.find("Content-Length"), request0.Headers.end()); + EXPECT_EQ( + std::stoi(request0.Headers.at("Content-Length")), + static_cast(sizeof(expectedBodyStart0) - 1)); + + EXPECT_NE(request1.Headers.find("Content-Length"), request1.Headers.end()); + EXPECT_EQ( + std::stoi(request1.Headers.at("Content-Length")), + static_cast(sizeof(expectedBodyStart1) - 1)); + } + + EXPECT_NE(request0.Headers.find("Content-Type"), request0.Headers.end()); + EXPECT_EQ(request0.Headers.at("Content-Type"), "application/x-www-form-urlencoded"); + + EXPECT_NE(request1.Headers.find("Content-Type"), request1.Headers.end()); + EXPECT_EQ(request1.Headers.at("Content-Type"), "application/x-www-form-urlencoded"); + + EXPECT_EQ(response0.AccessToken.Token, "ACCESSTOKEN1"); + EXPECT_EQ(response1.AccessToken.Token, "ACCESSTOKEN2"); + + using namespace std::chrono_literals; + EXPECT_GE(response0.AccessToken.ExpiresOn, response0.EarliestExpiration + 3600s); + EXPECT_LE(response0.AccessToken.ExpiresOn, response0.LatestExpiration + 3600s); + + EXPECT_GE(response1.AccessToken.ExpiresOn, response1.EarliestExpiration + 7200s); + EXPECT_LE(response1.AccessToken.ExpiresOn, response1.LatestExpiration + 7200s); +} + +TEST(ClientAssertionCredential, Authority) +{ + auto const actual = CredentialTestHelper::SimulateTokenRequest( + [](auto transport) { + ClientAssertionCredentialOptions options; + options.Transport.Transport = transport; + + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + options.AuthorityHost = "https://microsoft.com/"; + + return std::make_unique( + tenantId, clientId, GetAssertion_Test, options); + }, + {{{"https://azure.com/.default"}}, {{}}}, + std::vector{ + "{\"expires_in\":3600, \"access_token\":\"ACCESSTOKEN1\"}", + "{\"expires_in\":7200, \"access_token\":\"ACCESSTOKEN2\"}"}); + + EXPECT_EQ(actual.Requests.size(), 2U); + EXPECT_EQ(actual.Responses.size(), 2U); + + auto const& request0 = actual.Requests.at(0); + auto const& request1 = actual.Requests.at(1); + + auto const& response0 = actual.Responses.at(0); + auto const& response1 = actual.Responses.at(1); + + EXPECT_EQ(request0.HttpMethod, HttpMethod::Post); + EXPECT_EQ(request1.HttpMethod, HttpMethod::Post); + + EXPECT_EQ( + request0.AbsoluteUrl, + "https://microsoft.com/01234567-89ab-cdef-fedc-ba8976543210/oauth2/v2.0/token"); + + EXPECT_EQ( + request1.AbsoluteUrl, + "https://microsoft.com/01234567-89ab-cdef-fedc-ba8976543210/oauth2/v2.0/token"); + + { + constexpr char expectedBodyStart0[] // cspell:disable + = "grant_type=client_credentials" + "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer" + "&client_id=fedcba98-7654-3210-0123-456789abcdef" + "&scope=https%3A%2F%2Fazure.com%2F.default" + "&client_assertion=sample-assertion"; // cspell:enable + + constexpr char expectedBodyStart1[] // cspell:disable + = "grant_type=client_credentials" + "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer" + "&client_id=fedcba98-7654-3210-0123-456789abcdef" + "&client_assertion=sample-assertion"; // cspell:enable + + EXPECT_EQ(request0.Body.size(), (sizeof(expectedBodyStart0) - 1)); + EXPECT_EQ(request1.Body.size(), (sizeof(expectedBodyStart1) - 1)); + + EXPECT_EQ(request0.Body.substr(0, (sizeof(expectedBodyStart0) - 1)), expectedBodyStart0); + EXPECT_EQ(request1.Body.substr(0, (sizeof(expectedBodyStart1) - 1)), expectedBodyStart1); + + EXPECT_NE(request0.Headers.find("Content-Length"), request0.Headers.end()); + EXPECT_EQ( + std::stoi(request0.Headers.at("Content-Length")), + static_cast(sizeof(expectedBodyStart0) - 1)); + + EXPECT_NE(request1.Headers.find("Content-Length"), request1.Headers.end()); + EXPECT_EQ( + std::stoi(request1.Headers.at("Content-Length")), + static_cast(sizeof(expectedBodyStart1) - 1)); + } + + EXPECT_NE(request0.Headers.find("Content-Type"), request0.Headers.end()); + EXPECT_EQ(request0.Headers.at("Content-Type"), "application/x-www-form-urlencoded"); + + EXPECT_NE(request1.Headers.find("Content-Type"), request1.Headers.end()); + EXPECT_EQ(request1.Headers.at("Content-Type"), "application/x-www-form-urlencoded"); + + EXPECT_EQ(response0.AccessToken.Token, "ACCESSTOKEN1"); + EXPECT_EQ(response1.AccessToken.Token, "ACCESSTOKEN2"); + + using namespace std::chrono_literals; + EXPECT_GE(response0.AccessToken.ExpiresOn, response0.EarliestExpiration + 3600s); + EXPECT_LE(response0.AccessToken.ExpiresOn, response0.LatestExpiration + 3600s); + + EXPECT_GE(response1.AccessToken.ExpiresOn, response1.EarliestExpiration + 7200s); + EXPECT_LE(response1.AccessToken.ExpiresOn, response1.LatestExpiration + 7200s); +} + +TEST(ClientAssertionCredential, HttpSchemeNotSupported) +{ + std::map envVars = {{"AZURE_AUTHORITY_HOST", "http://microsoft.com/"}}; + CredentialTestHelper::EnvironmentOverride const env(envVars); + + try + { + auto const actual = CredentialTestHelper::SimulateTokenRequest( + [](auto transport) { + ClientAssertionCredentialOptions options; + options.Transport.Transport = transport; + + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + + return std::make_unique( + tenantId, clientId, GetAssertion_Throw, options); + }, + {{{"https://azure.com/.default"}}}, + std::vector{"{\"expires_in\":3600, \"access_token\":\"ACCESSTOKEN1\"}"}); + } + catch (AuthenticationException const& e) + { + EXPECT_TRUE(std::string(e.what()).find("https") != std::string::npos) << e.what(); + } +}