diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 4a88b7b53..633cad9cb 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -188,7 +188,10 @@ "Oaep", "odata", "ofstad", + "oidc", + "Oidc", "OIDC", + "OIDCREQUESTURI", "okhttp", "opentelemetry", "Osterman", diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index c89c8380c..a919ee9d5 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Added `AzurePipelinesCredential` for authenticating an Azure Pipelines service connection with workload identity federation. + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/identity/azure-identity/CMakeLists.txt b/sdk/identity/azure-identity/CMakeLists.txt index 50df76415..709f212ba 100644 --- a/sdk/identity/azure-identity/CMakeLists.txt +++ b/sdk/identity/azure-identity/CMakeLists.txt @@ -48,6 +48,7 @@ set( AZURE_IDENTITY_HEADER inc/azure/identity.hpp 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_certificate_credential.hpp inc/azure/identity/client_secret_credential.hpp @@ -64,6 +65,7 @@ set( set( AZURE_IDENTITY_SOURCE src/azure_cli_credential.cpp + src/azure_pipelines_credential.cpp src/chained_token_credential.cpp src/client_certificate_credential.cpp src/client_credential_core.cpp diff --git a/sdk/identity/azure-identity/README.md b/sdk/identity/azure-identity/README.md index 6b925bf1f..57de82c28 100644 --- a/sdk/identity/azure-identity/README.md +++ b/sdk/identity/azure-identity/README.md @@ -134,6 +134,7 @@ Configuration is attempted in the above order. For example, if values for a clie ### Authenticate service principals |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. |`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 a258b213d..d86546611 100644 --- a/sdk/identity/azure-identity/inc/azure/identity.hpp +++ b/sdk/identity/azure-identity/inc/azure/identity.hpp @@ -9,6 +9,7 @@ #pragma once #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_certificate_credential.hpp" #include "azure/identity/client_secret_credential.hpp" diff --git a/sdk/identity/azure-identity/inc/azure/identity/azure_pipelines_credential.hpp b/sdk/identity/azure-identity/inc/azure/identity/azure_pipelines_credential.hpp new file mode 100644 index 000000000..0a96fc7b8 --- /dev/null +++ b/sdk/identity/azure-identity/inc/azure/identity/azure_pipelines_credential.hpp @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @file + * @brief Azure Pipelines Credential and options. + */ + +#pragma once + +#include "azure/identity/detail/client_credential_core.hpp" +#include "azure/identity/detail/token_cache.hpp" + +#include +#include +#include + +#include +#include + +namespace Azure { namespace Identity { + namespace _detail { + class TokenCredentialImpl; + } // namespace _detail + + /** + * @brief Options for Azure Pipelines credential. + * + */ + struct AzurePipelinesCredentialOptions 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 using an Azure Pipelines service connection. + * + */ + class AzurePipelinesCredential final : public Core::Credentials::TokenCredential { + private: + std::string m_serviceConnectionId; + std::string m_systemAccessToken; + _detail::ClientCredentialCore m_clientCredentialCore; + Azure::Core::Http::_internal::HttpPipeline m_httpPipeline; + std::string m_oidcRequestUrl; + std::unique_ptr<_detail::TokenCredentialImpl> m_tokenCredentialImpl; + std::string m_requestBody; + _detail::TokenCache m_tokenCache; + + std::string GetAssertion(Core::Context const& context) const; + Azure::Core::Http::Request CreateOidcRequestMessage() const; + std::string GetOidcTokenResponse( + std::unique_ptr const& response, + std::string responseBody) const; + + public: + /** + * @brief Constructs an Azure Pipelines Credential. + * + * @param tenantId The tenant ID for the service connection. + * @param clientId The client ID for the service connection. + * @param serviceConnectionId The service connection ID for the service connection associated + * with the pipeline. + * @param systemAccessToken The pipeline's System.AccessToken value. See + * https://learn.microsoft.com/azure/devops/pipelines/build/variables?view=azure-devops%26tabs=yaml#systemaccesstoken + * for more details. + * @param options Options for token retrieval. + */ + explicit AzurePipelinesCredential( + std::string tenantId, + std::string clientId, + std::string serviceConnectionId, + std::string systemAccessToken, + AzurePipelinesCredentialOptions const& options = {}); + + /** + * @brief Destructs `%AzurePipelinesCredential`. + * + */ + ~AzurePipelinesCredential() override; + + /** + * @brief Gets an authentication token. + * + * @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/inc/azure/identity/detail/client_credential_core.hpp b/sdk/identity/azure-identity/inc/azure/identity/detail/client_credential_core.hpp index cef582422..244cd86ab 100644 --- a/sdk/identity/azure-identity/inc/azure/identity/detail/client_credential_core.hpp +++ b/sdk/identity/azure-identity/inc/azure/identity/detail/client_credential_core.hpp @@ -17,6 +17,7 @@ namespace Azure { namespace Identity { namespace _detail { constexpr auto AzureTenantIdEnvVarName = "AZURE_TENANT_ID"; constexpr auto AzureClientIdEnvVarName = "AZURE_CLIENT_ID"; constexpr auto AzureFederatedTokenFileEnvVarName = "AZURE_FEDERATED_TOKEN_FILE"; + const std::string OidcRequestUrlEnvVarName = "SYSTEM_OIDCREQUESTURI"; const std::string AadGlobalAuthority = "https://login.microsoftonline.com/"; class DefaultOptionValues final { @@ -46,6 +47,11 @@ namespace Azure { namespace Identity { namespace _detail { { return Core::_internal::Environment::GetVariable(AzureFederatedTokenFileEnvVarName); } + + static std::string GetOidcRequestUrl() + { + return Core::_internal::Environment::GetVariable(OidcRequestUrlEnvVarName.c_str()); + } }; class ClientCredentialCore final { diff --git a/sdk/identity/azure-identity/src/azure_pipelines_credential.cpp b/sdk/identity/azure-identity/src/azure_pipelines_credential.cpp new file mode 100644 index 000000000..2fb8f0757 --- /dev/null +++ b/sdk/identity/azure-identity/src/azure_pipelines_credential.cpp @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "azure/identity/azure_pipelines_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::AzurePipelinesCredential; +using Azure::Identity::AzurePipelinesCredentialOptions; + +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::Core::Http::HttpStatusCode; +using Azure::Core::Http::RawResponse; +using Azure::Core::Http::Request; +using Azure::Core::Http::_internal::HttpPipeline; +using Azure::Core::Json::_internal::json; +using Azure::Identity::_detail::IdentityLog; +using Azure::Identity::_detail::PackageVersion; +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 + +AzurePipelinesCredential::AzurePipelinesCredential( + std::string tenantId, + std::string clientId, + std::string serviceConnectionId, + std::string systemAccessToken, + AzurePipelinesCredentialOptions const& options) + : TokenCredential("AzurePipelinesCredential"), m_serviceConnectionId(serviceConnectionId), + m_systemAccessToken(systemAccessToken), + m_clientCredentialCore(tenantId, options.AuthorityHost, options.AdditionallyAllowedTenants), + m_httpPipeline(HttpPipeline(options, "identity", PackageVersion::ToString(), {}, {})) +{ + m_oidcRequestUrl = _detail::DefaultOptionValues::GetOidcRequestUrl(); + + bool isTenantIdValid = IsValidTenantId(tenantId); + if (!isTenantIdValid) + { + IdentityLog::Write( + IdentityLog::Level::Warning, + "Invalid tenant ID provided for " + GetCredentialName() + + ". 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, "No client ID specified for " + GetCredentialName() + "."); + } + if (serviceConnectionId.empty()) + { + IdentityLog::Write( + IdentityLog::Level::Warning, + "No service connection ID specified for " + GetCredentialName() + "."); + } + if (systemAccessToken.empty()) + { + IdentityLog::Write( + IdentityLog::Level::Warning, + "No system access token specified for " + GetCredentialName() + "."); + } + if (m_oidcRequestUrl.empty()) + { + IdentityLog::Write( + IdentityLog::Level::Warning, + "No value for environment variable '" + Azure::Identity::_detail::OidcRequestUrlEnvVarName + + "' needed by " + GetCredentialName() + ". This should be set by Azure Pipelines."); + } + + if (isTenantIdValid && !clientId.empty() && !serviceConnectionId.empty() + && !systemAccessToken.empty() && !m_oidcRequestUrl.empty()) + { + 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, + "Azure Pipelines environment is not set up for the " + GetCredentialName() + + " credential to work."); + } +} + +Request AzurePipelinesCredential::CreateOidcRequestMessage() const +{ + const std::string oidcApiVersion = "7.1"; + + Url requestUrl = Url( + m_oidcRequestUrl + "?api-version=" + Url::Encode(oidcApiVersion) + + "&serviceConnectionId=" + Url::Encode(m_serviceConnectionId)); + Request request = Request(HttpMethod::Post, requestUrl); + request.SetHeader("content-type", "application/json"); + request.SetHeader("authorization", "Bearer " + m_systemAccessToken); + + return request; +} + +std::string AzurePipelinesCredential::GetOidcTokenResponse( + std::unique_ptr const& response, + std::string responseBody) const +{ + auto const statusCode = response->GetStatusCode(); + if (statusCode != HttpStatusCode::Ok) + { + // Include the response because its body, if any, probably contains an error message. + // OK responses aren't included with errors because they probably contain secrets. + + std::string message = GetCredentialName() + " : " + + std::to_string(static_cast::type>(statusCode)) + + " (" + response->GetReasonPhrase() + + ") response from the OIDC endpoint. Check service connection ID and Pipeline " + "configuration.\n\n" + + responseBody; + IdentityLog::Write(IdentityLog::Level::Verbose, message); + + throw AuthenticationException(message); + } + + json parsedJson; + try + { + parsedJson = Azure::Core::Json::_internal::json::parse(responseBody); + } + catch (json::exception const&) + { + std::string message = GetCredentialName() + " : Cannot parse the response string as JSON."; + IdentityLog::Write(IdentityLog::Level::Verbose, message); + + throw AuthenticationException(message); + } + + const std::string oidcTokenPropertyName = "oidcToken"; + if (!parsedJson.contains(oidcTokenPropertyName) || !parsedJson[oidcTokenPropertyName].is_string()) + { + std::string message = GetCredentialName() + + " : OIDC token not found in response. \nSee Azure::Core::Diagnostics::Logger for details " + "(https://aka.ms/azsdk/cpp/identity/troubleshooting)."; + IdentityLog::Write(IdentityLog::Level::Verbose, message); + throw AuthenticationException(message); + } + return parsedJson[oidcTokenPropertyName].get(); +} + +AzurePipelinesCredential::~AzurePipelinesCredential() = default; + +std::string AzurePipelinesCredential::GetAssertion(Context const& context) const +{ + Azure::Core::Http::Request oidcRequest = CreateOidcRequestMessage(); + std::unique_ptr response = m_httpPipeline.Send(oidcRequest, context); + + if (!response) + { + throw AuthenticationException( + GetCredentialName() + " couldn't send OIDC token request: null response."); + } + + auto const bodyStream = response->ExtractBodyStream(); + auto const bodyVec = bodyStream ? bodyStream->ReadToEnd(context) : response->GetBody(); + auto const responseBody + = std::string(reinterpret_cast(bodyVec.data()), bodyVec.size()); + + return GetOidcTokenResponse(response, responseBody); +} + +AccessToken AzurePipelinesCredential::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 + "Azure Pipelines environment is not set up correctly."); + } + + 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 GetAssertion to validate the authority host scheme. + // This is to avoid making a request to the OIDC endpoint if the authority host scheme is + // invalid. + auto const requestUrl = m_clientCredentialCore.GetRequestUrl(tenantId); + + const std::string assertion = GetAssertion(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/src/token_credential_impl.cpp b/sdk/identity/azure-identity/src/token_credential_impl.cpp index 516b7e1c0..00a6104e2 100644 --- a/sdk/identity/azure-identity/src/token_credential_impl.cpp +++ b/sdk/identity/azure-identity/src/token_credential_impl.cpp @@ -421,6 +421,7 @@ AccessToken TokenCredentialImpl::ParseToken( auto const tzOffsetStr = TimeZoneOffsetAsString(utcDiffSeconds); if (expiresOn.is_string()) { + bool successfulParse = false; auto const expiresOnAsString = expiresOn.get(); for (auto const& parse : { std::function([&](auto const& s) { @@ -448,13 +449,21 @@ AccessToken TokenCredentialImpl::ParseToken( try { accessToken.ExpiresOn = parse(expiresOnAsString); - return accessToken; + // Workaround for Warning C26800 - Use of a moved from object: 'accessToken' + // (lifetime.1) on MSVC version 14.40.33807+. + // Returning accessToken here directly causes the warning. + successfulParse = true; + break; } catch (std::exception const&) { // parse() has thrown, we may throw later. } } + if (successfulParse) + { + return accessToken; + } } } } diff --git a/sdk/identity/azure-identity/test/ut/CMakeLists.txt b/sdk/identity/azure-identity/test/ut/CMakeLists.txt index 2b8f17ca2..171655754 100644 --- a/sdk/identity/azure-identity/test/ut/CMakeLists.txt +++ b/sdk/identity/azure-identity/test/ut/CMakeLists.txt @@ -17,6 +17,7 @@ add_compile_definitions(AZURE_TEST_RECORDING_DIR="${CMAKE_CURRENT_LIST_DIR}") add_executable ( azure-identity-test azure_cli_credential_test.cpp + azure_pipelines_credential_test.cpp chained_token_credential_test.cpp client_certificate_credential_test.cpp client_secret_credential_test.cpp diff --git a/sdk/identity/azure-identity/test/ut/azure_pipelines_credential_test.cpp b/sdk/identity/azure-identity/test/ut/azure_pipelines_credential_test.cpp new file mode 100644 index 000000000..fac85e039 --- /dev/null +++ b/sdk/identity/azure-identity/test/ut/azure_pipelines_credential_test.cpp @@ -0,0 +1,494 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "azure/identity/azure_pipelines_credential.hpp" +#include "credential_test_helper.hpp" + +#include +#include + +#include + +using Azure::Core::Credentials::AuthenticationException; +using Azure::Core::Credentials::TokenRequestContext; +using Azure::Core::Http::HttpMethod; +using Azure::Identity::AzurePipelinesCredential; +using Azure::Identity::AzurePipelinesCredentialOptions; +using Azure::Identity::Test::_detail::CredentialTestHelper; + +TEST(AzurePipelinesCredential, 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"; + + AzurePipelinesCredential const cred(tenantId, clientId, serviceConnectionId, systemAccessToken); + + EXPECT_EQ(cred.GetCredentialName(), "AzurePipelinesCredential"); +} + +TEST(AzurePipelinesCredential, GetOptionsFromEnvironment) +{ + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + std::string serviceConnectionId = "abc"; + std::string systemAccessToken = "123"; + + { + std::map envVars = {{"AZURE_AUTHORITY_HOST", ""}}; + CredentialTestHelper::EnvironmentOverride const env(envVars); + + AzurePipelinesCredentialOptions options; + AzurePipelinesCredential const cred( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + EXPECT_EQ(cred.GetCredentialName(), "AzurePipelinesCredential"); + + EXPECT_EQ(options.AuthorityHost, "https://login.microsoftonline.com/"); + } + + { + std::map envVars = {{"AZURE_AUTHORITY_HOST", "foo"}}; + CredentialTestHelper::EnvironmentOverride const env(envVars); + + AzurePipelinesCredentialOptions options; + options.AuthorityHost = "bar"; + EXPECT_EQ(options.AuthorityHost, "bar"); + } + + { + std::map envVars + = {{"AZURE_AUTHORITY_HOST", "https://microsoft.com/"}}; + CredentialTestHelper::EnvironmentOverride const env(envVars); + + AzurePipelinesCredentialOptions options; + EXPECT_EQ(options.AuthorityHost, "https://microsoft.com/"); + } +} + +TEST(AzurePipelinesCredential, InvalidArgs) +{ + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + std::string serviceConnectionId = "abc"; + std::string systemAccessToken = "123"; + + std::map validEnvVars + = {{"SYSTEM_OIDCREQUESTURI", "https://localhost/instance"}}; + + // Empty Oidc Request Uri + { + std::map invalidEnvVars = {{"SYSTEM_OIDCREQUESTURI", ""}}; + CredentialTestHelper::EnvironmentOverride const env(invalidEnvVars); + + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + AzurePipelinesCredential const cred(tenantId, clientId, serviceConnectionId, systemAccessToken); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + AzurePipelinesCredentialOptions options; + AzurePipelinesCredential const credWithOptions( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + EXPECT_THROW(credWithOptions.GetToken(trc, {}), AuthenticationException); + } + + // Empty Tenant ID + { + CredentialTestHelper::EnvironmentOverride const env(validEnvVars); + + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + AzurePipelinesCredential const cred("", clientId, serviceConnectionId, systemAccessToken); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } + + // Invalid Tenant ID + { + CredentialTestHelper::EnvironmentOverride const env(validEnvVars); + + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + AzurePipelinesCredential const cred( + "!=invalidTenantId=!", clientId, serviceConnectionId, systemAccessToken); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } + + // Empty client ID + { + CredentialTestHelper::EnvironmentOverride const env(validEnvVars); + + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + AzurePipelinesCredential const cred(tenantId, "", serviceConnectionId, systemAccessToken); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } + + // Empty service connection ID + { + CredentialTestHelper::EnvironmentOverride const env(validEnvVars); + + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + AzurePipelinesCredential const cred(tenantId, clientId, "", systemAccessToken); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } + + // Empty system access token + { + CredentialTestHelper::EnvironmentOverride const env(validEnvVars); + + TokenRequestContext trc; + trc.Scopes.push_back("https://storage.azure.com/.default"); + + AzurePipelinesCredential const cred(tenantId, clientId, serviceConnectionId, ""); + EXPECT_THROW(cred.GetToken(trc, {}), AuthenticationException); + } +} + +TEST(AzurePipelinesCredential, Regular) +{ + std::map validEnvVars + = {{"SYSTEM_OIDCREQUESTURI", "https://localhost/instance"}}; + CredentialTestHelper::EnvironmentOverride const env(validEnvVars); + + auto const actual = CredentialTestHelper::SimulateTokenRequest( + [](auto transport) { + AzurePipelinesCredentialOptions options; + options.Transport.Transport = transport; + + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + std::string serviceConnectionId = "a/bc"; + std::string systemAccessToken = "123"; + + return std::make_unique( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + }, + {{{"https://azure.com/.default"}}}, + std::vector{ + "{\"oidcToken\":\"abc/d\"}", "{\"expires_in\":3600, \"access_token\":\"ACCESSTOKEN1\"}"}); + + EXPECT_EQ(actual.Requests.size(), 2U); + EXPECT_EQ(actual.Responses.size(), 1U); + + auto const& request0 = actual.Requests.at(0); + auto const& request1 = actual.Requests.at(1); + + auto const& response0 = actual.Responses.at(0); + + EXPECT_EQ(request0.HttpMethod, HttpMethod::Post); + EXPECT_EQ(request1.HttpMethod, HttpMethod::Post); + + EXPECT_EQ( + request0.AbsoluteUrl, + "https://localhost/instance?api-version=7.1&serviceConnectionId=a%2Fbc"); + + EXPECT_EQ( + request1.AbsoluteUrl, + "https://login.microsoftonline.com/01234567-89ab-cdef-fedc-ba8976543210/oauth2/v2.0/token"); + + { + 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" + "&scope=https%3A%2F%2Fazure.com%2F.default" + "&client_assertion=abc%2Fd"; // cspell:enable + + EXPECT_EQ(request0.Body.size(), 0); + EXPECT_EQ(request1.Body.size(), (sizeof(expectedBodyStart1) - 1)); + + EXPECT_EQ(request1.Body, expectedBodyStart1); + + EXPECT_EQ(request0.Headers.find("Content-Length"), request0.Headers.end()); + + 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/json"); + + 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"); + + using namespace std::chrono_literals; + EXPECT_GE(response0.AccessToken.ExpiresOn, response0.EarliestExpiration + 3600s); + EXPECT_LE(response0.AccessToken.ExpiresOn, response0.LatestExpiration + 3600s); +} + +TEST(AzurePipelinesCredential, AzureStack) +{ + std::map validEnvVars + = {{"SYSTEM_OIDCREQUESTURI", "https://localhost/instance"}}; + CredentialTestHelper::EnvironmentOverride const env(validEnvVars); + + auto const actual = CredentialTestHelper::SimulateTokenRequest( + [](auto transport) { + AzurePipelinesCredentialOptions options; + options.Transport.Transport = transport; + + std::string tenantId = "adfs"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + std::string serviceConnectionId = "a/bc"; + std::string systemAccessToken = "123"; + + return std::make_unique( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + }, + {{{"https://azure.com/.default"}}}, + std::vector{ + "{\"oidcToken\":\"abc/d\"}", "{\"expires_in\":3600, \"access_token\":\"ACCESSTOKEN1\"}"}); + + EXPECT_EQ(actual.Requests.size(), 2U); + EXPECT_EQ(actual.Responses.size(), 1U); + + auto const& request0 = actual.Requests.at(0); + auto const& request1 = actual.Requests.at(1); + + auto const& response0 = actual.Responses.at(0); + + EXPECT_EQ(request0.HttpMethod, HttpMethod::Post); + EXPECT_EQ(request1.HttpMethod, HttpMethod::Post); + + EXPECT_EQ( + request0.AbsoluteUrl, + "https://localhost/instance?api-version=7.1&serviceConnectionId=a%2Fbc"); + + EXPECT_EQ(request1.AbsoluteUrl, "https://login.microsoftonline.com/adfs/oauth2/token"); + + { + 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" + "&scope=https%3A%2F%2Fazure.com" + "&client_assertion=abc%2Fd"; // cspell:enable + + EXPECT_EQ(request0.Body.size(), 0); + EXPECT_EQ(request1.Body.size(), (sizeof(expectedBodyStart1) - 1)); + + EXPECT_EQ(request1.Body, expectedBodyStart1); + + EXPECT_EQ(request0.Headers.find("Content-Length"), request0.Headers.end()); + + 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/json"); + + 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"); + + using namespace std::chrono_literals; + EXPECT_GE(response0.AccessToken.ExpiresOn, response0.EarliestExpiration + 3600s); + EXPECT_LE(response0.AccessToken.ExpiresOn, response0.LatestExpiration + 3600s); +} + +TEST(AzurePipelinesCredential, Authority) +{ + CredentialTestHelper::EnvironmentOverride const env( + {{"SYSTEM_OIDCREQUESTURI", "https://localhost/instance"}, + {"AZURE_AUTHORITY_HOST", "https://microsoft.com/"}}); + + auto const actual = CredentialTestHelper::SimulateTokenRequest( + [](auto transport) { + AzurePipelinesCredentialOptions options; + options.Transport.Transport = transport; + + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + std::string serviceConnectionId = "a/bc"; + std::string systemAccessToken = "123"; + + return std::make_unique( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + }, + {{{"https://azure.com/.default"}}}, + std::vector{ + "{\"oidcToken\":\"abc/d\"}", "{\"expires_in\":3600, \"access_token\":\"ACCESSTOKEN1\"}"}); + + EXPECT_EQ(actual.Requests.size(), 2U); + EXPECT_EQ(actual.Responses.size(), 1U); + + auto const& request0 = actual.Requests.at(0); + auto const& request1 = actual.Requests.at(1); + + auto const& response0 = actual.Responses.at(0); + + EXPECT_EQ(request0.HttpMethod, HttpMethod::Post); + EXPECT_EQ(request1.HttpMethod, HttpMethod::Post); + + EXPECT_EQ( + request0.AbsoluteUrl, + "https://localhost/instance?api-version=7.1&serviceConnectionId=a%2Fbc"); + + EXPECT_EQ( + request1.AbsoluteUrl, + "https://microsoft.com/01234567-89ab-cdef-fedc-ba8976543210/oauth2/v2.0/token"); + + { + 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" + "&scope=https%3A%2F%2Fazure.com%2F.default" + "&client_assertion=abc%2Fd"; // cspell:enable + + EXPECT_EQ(request0.Body.size(), 0); + EXPECT_EQ(request1.Body.size(), (sizeof(expectedBodyStart1) - 1)); + + EXPECT_EQ(request1.Body, expectedBodyStart1); + + EXPECT_EQ(request0.Headers.find("Content-Length"), request0.Headers.end()); + + 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/json"); + + 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"); + + using namespace std::chrono_literals; + EXPECT_GE(response0.AccessToken.ExpiresOn, response0.EarliestExpiration + 3600s); + EXPECT_LE(response0.AccessToken.ExpiresOn, response0.LatestExpiration + 3600s); +} + +TEST(AzurePipelinesCredential, HttpSchemeNotSupported) +{ + CredentialTestHelper::EnvironmentOverride const env( + {{"SYSTEM_OIDCREQUESTURI", "https://localhost/instance"}, + {"AZURE_AUTHORITY_HOST", "http://microsoft.com/"}}); + + try + { + auto const actual = CredentialTestHelper::SimulateTokenRequest( + [](auto transport) { + AzurePipelinesCredentialOptions options; + options.Transport.Transport = transport; + + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + std::string serviceConnectionId = "a/bc"; + std::string systemAccessToken = "123"; + + return std::make_unique( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + }, + {{{"https://azure.com/.default"}}}, + std::vector{ + "{\"oidcToken\":\"abc\"}", "{\"expires_in\":3600, \"access_token\":\"ACCESSTOKEN1\"}"}); + } + catch (AuthenticationException const& e) + { + EXPECT_TRUE(std::string(e.what()).find("https") != std::string::npos) << e.what(); + } +} + +TEST(AzurePipelinesCredential, InvalidOidcResponse) +{ + std::map validEnvVars + = {{"SYSTEM_OIDCREQUESTURI", "https://localhost/instance"}}; + CredentialTestHelper::EnvironmentOverride const env(validEnvVars); + + std::string tenantId = "01234567-89ab-cdef-fedc-ba8976543210"; + std::string clientId = "fedcba98-7654-3210-0123-456789abcdef"; + std::string serviceConnectionId = "a/bc"; + std::string systemAccessToken = "123"; + + // Non-OK response + try + { + using Azure::Core::Http::HttpStatusCode; + std::vector const testScopes; + CredentialTestHelper::TokenRequestSimulationServerResponse testResponse; + testResponse.StatusCode = HttpStatusCode::BadRequest; + testResponse.Body = "Invalid response body"; + + static_cast(CredentialTestHelper::SimulateTokenRequest( + [&](auto transport) { + AzurePipelinesCredentialOptions options; + options.Transport.Transport = transport; + + return std::make_unique( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + }, + {testScopes}, + {testResponse})); + + EXPECT_TRUE(!"AzurePipelinesCredential should throw given the response above."); + } + catch (AuthenticationException const& ex) + { + std::string expectedMessage + = "AzurePipelinesCredential : 400 (Test) response from the OIDC endpoint. Check service " + "connection ID and Pipeline configuration.\n\nInvalid response body"; + EXPECT_EQ(ex.what(), expectedMessage) << ex.what(); + } + + // Invalid JSON + EXPECT_THROW( + CredentialTestHelper::SimulateTokenRequest( + [&](auto transport) { + AzurePipelinesCredentialOptions options; + options.Transport.Transport = transport; + + return std::make_unique( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + }, + {{{"https://azure.com/.default"}}}, + std::vector{"{\"oidc\":\"abc\"]", ""}), + AuthenticationException); + + // Missing token + EXPECT_THROW( + CredentialTestHelper::SimulateTokenRequest( + [&](auto transport) { + AzurePipelinesCredentialOptions options; + options.Transport.Transport = transport; + + return std::make_unique( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + }, + {{{"https://azure.com/.default"}}}, + std::vector{"{\"oidc\":\"abc\"}", ""}), + AuthenticationException); + + // Incorrect token type + EXPECT_THROW( + CredentialTestHelper::SimulateTokenRequest( + [&](auto transport) { + AzurePipelinesCredentialOptions options; + options.Transport.Transport = transport; + + return std::make_unique( + tenantId, clientId, serviceConnectionId, systemAccessToken, options); + }, + {{{"https://azure.com/.default"}}}, + std::vector{"{\"oidcToken\":5}", ""}), + AuthenticationException); +}