Add support for Workload Identity Credential for Kubernetes. (#4872)

* Add support for Workload Identity Credential for Kubernetes.

* Fix order of initialization for member fields.
This commit is contained in:
Ahson Khan 2023-08-11 17:43:02 -07:00 committed by GitHub
parent 69e5f1a627
commit e43c34c90a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 555 additions and 0 deletions

View File

@ -4,6 +4,8 @@
### Features Added
- Added support for `WorkloadIdentityCredential`.
### Breaking Changes
### Bugs Fixed

View File

@ -57,6 +57,7 @@ set(
inc/azure/identity/environment_credential.hpp
inc/azure/identity/managed_identity_credential.hpp
inc/azure/identity/rtti.hpp
inc/azure/identity/workload_identity_credential.hpp
inc/azure/identity.hpp
)
@ -80,6 +81,7 @@ set(
src/tenant_id_resolver.cpp
src/token_cache.cpp
src/token_credential_impl.cpp
src/workload_identity_credential.cpp
)
add_library(azure-identity ${AZURE_IDENTITY_HEADER} ${AZURE_IDENTITY_SOURCE})

View File

@ -127,6 +127,7 @@ Configuration is attempted in the above order. For example, if values for a clie
|`ChainedTokenCredential`|Allows users to define custom authentication flows composing multiple credentials.
|`ManagedIdentityCredential`|Authenticates the managed identity of an Azure resource.
|`EnvironmentCredential`|Authenticates a service principal or user via credential information specified in environment variables.
|`WorkloadIdentityCredential`|Authenticate a workload identity on Kubernetes.
### Authenticate service principals
|Credential | Usage

View File

@ -17,3 +17,4 @@
#include "azure/identity/environment_credential.hpp"
#include "azure/identity/managed_identity_credential.hpp"
#include "azure/identity/rtti.hpp"
#include "azure/identity/workload_identity_credential.hpp"

View File

@ -0,0 +1,120 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
/**
* @file
* @brief Workload Identity 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 <string>
#include <vector>
namespace Azure { namespace Identity {
namespace _detail {
class TokenCredentialImpl;
} // namespace _detail
/**
* @brief Options for workload identity credential.
*
*/
struct WorkloadIdentityCredentialOptions final : public Core::Credentials::TokenCredentialOptions
{
/**
* @brief Authentication authority URL.
* @note Default value is Azure AD global authority (https://login.microsoftonline.com/).
*
* @note Example of an authority host string: "https://login.microsoftonline.us/". See national
* clouds' Azure AD authentication endpoints:
* https://docs.microsoft.com/azure/active-directory/develop/authentication-national-cloud.
*/
std::string AuthorityHost = _detail::ClientCredentialCore::AadGlobalAuthority;
/**
* @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 Workload Identity Credential supports Azure workload identity authentication on
* Kubernetes and other hosts supporting workload identity. See the Azure Kubernetes Service
* documentation at https://learn.microsoft.com/azure/aks/workload-identity-overview for more
* information.
*
*/
class WorkloadIdentityCredential final : public Core::Credentials::TokenCredential {
private:
_detail::TokenCache m_tokenCache;
_detail::ClientCredentialCore m_clientCredentialCore;
std::unique_ptr<_detail::TokenCredentialImpl> m_tokenCredentialImpl;
std::string m_requestBody;
std::string m_tokenFilePath;
explicit WorkloadIdentityCredential(
std::string tenantId,
std::string const& clientId,
std::string const& tokenFilePath,
std::string const& authorityHost,
std::vector<std::string> additionallyAllowedTenants,
Core::Credentials::TokenCredentialOptions const& options);
public:
/**
* @brief Constructs a Workload Identity Credential.
*
* @param tenantId Tenant ID.
* @param clientId Client ID.
* @param tokenFilePath Path of a file containing a Kubernetes service account token.
* @param options Options for token retrieval.
*/
explicit WorkloadIdentityCredential(
std::string tenantId,
std::string const& clientId,
std::string const& tokenFilePath,
Core::Credentials::TokenCredentialOptions const& options
= Core::Credentials::TokenCredentialOptions());
/**
* @brief Constructs a Workload Identity Credential.
*
* @param tenantId Tenant ID.
* @param clientId Client ID.
* @param tokenFilePath Path of a file containing a Kubernetes service account token.
* @param options Options for token retrieval.
*/
explicit WorkloadIdentityCredential(
std::string tenantId,
std::string const& clientId,
std::string const& tokenFilePath,
WorkloadIdentityCredentialOptions const& options);
/**
* @brief Destructs `%WorkloadIdentityCredential`.
*
*/
~WorkloadIdentityCredential() 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

@ -0,0 +1,118 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#include "azure/identity/workload_identity_credential.hpp"
#include "private/tenant_id_resolver.hpp"
#include "private/token_credential_impl.hpp"
#include <fstream>
#include <streambuf>
using Azure::Identity::WorkloadIdentityCredential;
using Azure::Core::Context;
using Azure::Core::Url;
using Azure::Core::Credentials::AccessToken;
using Azure::Core::Credentials::TokenRequestContext;
using Azure::Core::Http::HttpMethod;
using Azure::Identity::_detail::TenantIdResolver;
using Azure::Identity::_detail::TokenCredentialImpl;
WorkloadIdentityCredential::WorkloadIdentityCredential(
std::string tenantId,
std::string const& clientId,
std::string const& tokenFilePath,
std::string const& authorityHost,
std::vector<std::string> additionallyAllowedTenants,
Core::Credentials::TokenCredentialOptions const& options)
: TokenCredential("WorkloadIdentityCredential"),
m_clientCredentialCore(tenantId, authorityHost, additionallyAllowedTenants),
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)),
m_tokenFilePath(tokenFilePath)
{
}
WorkloadIdentityCredential::WorkloadIdentityCredential(
std::string tenantId,
std::string const& clientId,
std::string const& tokenFilePath,
WorkloadIdentityCredentialOptions const& options)
: WorkloadIdentityCredential(
tenantId,
clientId,
tokenFilePath,
options.AuthorityHost,
options.AdditionallyAllowedTenants,
options)
{
}
WorkloadIdentityCredential::WorkloadIdentityCredential(
std::string tenantId,
std::string const& clientId,
std::string const& tokenFilePath,
Core::Credentials::TokenCredentialOptions const& options)
: WorkloadIdentityCredential(
tenantId,
clientId,
tokenFilePath,
WorkloadIdentityCredentialOptions{}.AuthorityHost,
WorkloadIdentityCredentialOptions{}.AdditionallyAllowedTenants,
options)
{
}
WorkloadIdentityCredential::~WorkloadIdentityCredential() = default;
AccessToken WorkloadIdentityCredential::GetToken(
TokenRequestContext const& tokenRequestContext,
Context const& context) const
{
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, [&]() {
auto body = m_requestBody;
if (!scopesStr.empty())
{
body += "&scope=" + scopesStr;
}
auto const requestUrl = m_clientCredentialCore.GetRequestUrl(tenantId);
// Read the specified file's content, which is expected to be a Kubernetes service account
// token. Kubernetes is responsible for updating the file as service account tokens expire.
std::ifstream azureFederatedTokenFile(m_tokenFilePath);
std::string assertion(
(std::istreambuf_iterator<char>(azureFederatedTokenFile)),
std::istreambuf_iterator<char>());
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

@ -31,6 +31,7 @@ add_executable (
token_cache_test.cpp
token_credential_impl_test.cpp
token_credential_test.cpp
workload_identity_credential_test.cpp
)
create_per_service_target_build(identity azure-identity-test)

View File

@ -0,0 +1,310 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#include "azure/identity/workload_identity_credential.hpp"
#include "credential_test_helper.hpp"
#include <cstdio>
#include <fstream>
#include <gtest/gtest.h>
using Azure::Core::Http::HttpMethod;
using Azure::Identity::WorkloadIdentityCredential;
using Azure::Identity::WorkloadIdentityCredentialOptions;
using Azure::Identity::Test::_detail::CredentialTestHelper;
namespace {
struct TempCertFile final
{
static const char* const Path;
~TempCertFile();
TempCertFile();
};
} // namespace
TEST(WorkloadIdentityCredential, GetCredentialName)
{
TempCertFile const tempCertFile;
WorkloadIdentityCredential const cred(
"01234567-89ab-cdef-fedc-ba8976543210",
"fedcba98-7654-3210-0123-456789abcdef",
TempCertFile::Path);
EXPECT_EQ(cred.GetCredentialName(), "WorkloadIdentityCredential");
}
TEST(WorkloadIdentityCredential, Regular)
{
TempCertFile const tempCertFile;
auto const actual = CredentialTestHelper::SimulateTokenRequest(
[](auto transport) {
WorkloadIdentityCredentialOptions options;
options.Transport.Transport = transport;
return std::make_unique<WorkloadIdentityCredential>(
"01234567-89ab-cdef-fedc-ba8976543210",
"fedcba98-7654-3210-0123-456789abcdef",
TempCertFile::Path,
options);
},
{{{"https://azure.com/.default"}}, {{}}},
std::vector<std::string>{
"{\"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="; // 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="; // cspell:enable
EXPECT_GT(request0.Body.size(), (sizeof(expectedBodyStart0) - 1));
EXPECT_GT(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_GT(
std::stoi(request0.Headers.at("Content-Length")),
static_cast<int>(sizeof(expectedBodyStart0) - 1));
EXPECT_NE(request1.Headers.find("Content-Length"), request1.Headers.end());
EXPECT_GT(
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/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(WorkloadIdentityCredential, AzureStack)
{
TempCertFile const tempCertFile;
auto const actual = CredentialTestHelper::SimulateTokenRequest(
[](auto transport) {
WorkloadIdentityCredentialOptions options;
options.Transport.Transport = transport;
return std::make_unique<WorkloadIdentityCredential>(
"adfs", "fedcba98-7654-3210-0123-456789abcdef", TempCertFile::Path, options);
},
{{{"https://azure.com/.default"}}, {{}}},
std::vector<std::string>{
"{\"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="; // 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="; // cspell:enable
EXPECT_GT(request0.Body.size(), (sizeof(expectedBodyStart0) - 1));
EXPECT_GT(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_GT(
std::stoi(request0.Headers.at("Content-Length")),
static_cast<int>(sizeof(expectedBodyStart0) - 1));
EXPECT_NE(request1.Headers.find("Content-Length"), request1.Headers.end());
EXPECT_GT(
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/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(WorkloadIdentityCredential, Authority)
{
TempCertFile const tempCertFile;
auto const actual = CredentialTestHelper::SimulateTokenRequest(
[](auto transport) {
WorkloadIdentityCredentialOptions options;
options.AuthorityHost = "https://microsoft.com/";
options.Transport.Transport = transport;
return std::make_unique<WorkloadIdentityCredential>(
"01234567-89ab-cdef-fedc-ba8976543210",
"fedcba98-7654-3210-0123-456789abcdef",
TempCertFile::Path,
options);
},
{{{"https://azure.com/.default"}}, {{}}},
std::vector<std::string>{
"{\"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="; // 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="; // cspell:enable
EXPECT_GT(request0.Body.size(), (sizeof(expectedBodyStart0) - 1));
EXPECT_GT(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_GT(
std::stoi(request0.Headers.at("Content-Length")),
static_cast<int>(sizeof(expectedBodyStart0) - 1));
EXPECT_NE(request1.Headers.find("Content-Length"), request1.Headers.end());
EXPECT_GT(
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/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);
}
namespace {
const char* const TempCertFile::Path = "azure-identity-test.pem";
TempCertFile::~TempCertFile() { std::remove(Path); }
TempCertFile::TempCertFile()
{
std::ofstream cert(Path, std::ios_base::out | std::ios_base::trunc);
// The file contents is the following text, encoded as base64:
// "Base64 encoded JSON text to simulate a client assertion"
cert << // cspell:disable
"QmFzZTY0IGVuY29kZWQgSlNPTiB0ZXh0IHRvIHNpbXVsYXRlIGEgY2xpZW50IGFzc2VydGlvbg==\n";
// cspell:enable
}
} // namespace