Add AzurePipelinesCredential for authenticating an Azure Pipelines service connection with workload identity federation. (#5733)

* Add AzurePipelinesCredential for authenticating an Azure Pipelines service connection with workload identity federation.

* Add unit tests.

* Add comment about not throwing in the ctor, but rather deferring it.

* Order field in order of initialization and fix cspell.

* Fix ambiguous call to EnvironmentOverride in tests.

* Address PR feedback, suppress warning, move oidc fetch in token cache,
and update exception message.

* Address PR feedback, use ID and capitalize Azure Pipelines.

* Revert back to the workaround for the warning, rather than suppressing
it.

* Address PR feedback, move getting an assertion to a helper, and add
const.
This commit is contained in:
Ahson Khan 2024-06-20 21:09:31 -07:00 committed by GitHub
parent ed933f5dfd
commit 9b2c3b337e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 898 additions and 1 deletions

3
.vscode/cspell.json vendored
View File

@ -188,7 +188,10 @@
"Oaep",
"odata",
"ofstad",
"oidc",
"Oidc",
"OIDC",
"OIDCREQUESTURI",
"okhttp",
"opentelemetry",
"Osterman",

View File

@ -4,6 +4,8 @@
### Features Added
- Added `AzurePipelinesCredential` for authenticating an Azure Pipelines service connection with workload identity federation.
### Breaking Changes
### Bugs Fixed

View File

@ -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

View File

@ -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).

View File

@ -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"

View File

@ -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 <azure/core/credentials/token_credential_options.hpp>
#include <azure/core/http/http.hpp>
#include <azure/core/internal/http/pipeline.hpp>
#include <string>
#include <vector>
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<std::string> 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<Azure::Core::Http::RawResponse> 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

View File

@ -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 {

View File

@ -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 <azure/core/internal/json/json.hpp>
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<TokenCredentialImpl>(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<RawResponse> 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<std::underlying_type<decltype(statusCode)>::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<std::string>();
}
AzurePipelinesCredential::~AzurePipelinesCredential() = default;
std::string AzurePipelinesCredential::GetAssertion(Context const& context) const
{
Azure::Core::Http::Request oidcRequest = CreateOidcRequestMessage();
std::unique_ptr<RawResponse> 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<char const*>(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<TokenCredentialImpl::TokenRequest>(HttpMethod::Post, requestUrl, body);
request->HttpRequest.SetHeader("Host", requestUrl.GetHost());
return request;
});
});
}

View File

@ -421,6 +421,7 @@ AccessToken TokenCredentialImpl::ParseToken(
auto const tzOffsetStr = TimeZoneOffsetAsString(utcDiffSeconds);
if (expiresOn.is_string())
{
bool successfulParse = false;
auto const expiresOnAsString = expiresOn.get<std::string>();
for (auto const& parse : {
std::function<DateTime(std::string const&)>([&](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;
}
}
}
}

View File

@ -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

View File

@ -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 <cstdio>
#include <fstream>
#include <gtest/gtest.h>
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<std::string, std::string> 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<std::string, std::string> envVars = {{"AZURE_AUTHORITY_HOST", "foo"}};
CredentialTestHelper::EnvironmentOverride const env(envVars);
AzurePipelinesCredentialOptions options;
options.AuthorityHost = "bar";
EXPECT_EQ(options.AuthorityHost, "bar");
}
{
std::map<std::string, std::string> 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<std::string, std::string> validEnvVars
= {{"SYSTEM_OIDCREQUESTURI", "https://localhost/instance"}};
// Empty Oidc Request Uri
{
std::map<std::string, std::string> 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<std::string, std::string> 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<AzurePipelinesCredential>(
tenantId, clientId, serviceConnectionId, systemAccessToken, options);
},
{{{"https://azure.com/.default"}}},
std::vector<std::string>{
"{\"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<int>(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<std::string, std::string> 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<AzurePipelinesCredential>(
tenantId, clientId, serviceConnectionId, systemAccessToken, options);
},
{{{"https://azure.com/.default"}}},
std::vector<std::string>{
"{\"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<int>(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<AzurePipelinesCredential>(
tenantId, clientId, serviceConnectionId, systemAccessToken, options);
},
{{{"https://azure.com/.default"}}},
std::vector<std::string>{
"{\"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<int>(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<AzurePipelinesCredential>(
tenantId, clientId, serviceConnectionId, systemAccessToken, options);
},
{{{"https://azure.com/.default"}}},
std::vector<std::string>{
"{\"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<std::string, std::string> 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<std::string> const testScopes;
CredentialTestHelper::TokenRequestSimulationServerResponse testResponse;
testResponse.StatusCode = HttpStatusCode::BadRequest;
testResponse.Body = "Invalid response body";
static_cast<void>(CredentialTestHelper::SimulateTokenRequest(
[&](auto transport) {
AzurePipelinesCredentialOptions options;
options.Transport.Transport = transport;
return std::make_unique<AzurePipelinesCredential>(
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<AzurePipelinesCredential>(
tenantId, clientId, serviceConnectionId, systemAccessToken, options);
},
{{{"https://azure.com/.default"}}},
std::vector<std::string>{"{\"oidc\":\"abc\"]", ""}),
AuthenticationException);
// Missing token
EXPECT_THROW(
CredentialTestHelper::SimulateTokenRequest(
[&](auto transport) {
AzurePipelinesCredentialOptions options;
options.Transport.Transport = transport;
return std::make_unique<AzurePipelinesCredential>(
tenantId, clientId, serviceConnectionId, systemAccessToken, options);
},
{{{"https://azure.com/.default"}}},
std::vector<std::string>{"{\"oidc\":\"abc\"}", ""}),
AuthenticationException);
// Incorrect token type
EXPECT_THROW(
CredentialTestHelper::SimulateTokenRequest(
[&](auto transport) {
AzurePipelinesCredentialOptions options;
options.Transport.Transport = transport;
return std::make_unique<AzurePipelinesCredential>(
tenantId, clientId, serviceConnectionId, systemAccessToken, options);
},
{{{"https://azure.com/.default"}}},
std::vector<std::string>{"{\"oidcToken\":5}", ""}),
AuthenticationException);
}