Skip to content

Commit 9b92734

Browse files
authored
Add SSO credentials tracking and tests (#3536)
- Add UserAgent tracking for SSO credentials - Add comprehensive SSO credential tracking tests - Update UserAgent implementation for SSO support
1 parent 770cea2 commit 9b92734

File tree

4 files changed

+292
-0
lines changed

4 files changed

+292
-0
lines changed

src/aws-cpp-sdk-core/include/aws/core/client/UserAgent.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ enum class UserAgentFeature {
3939
CREDENTIALS_STS_ASSUME_ROLE,
4040
CREDENTIALS_STS_WEB_IDENTITY_TOKEN,
4141
CREDENTIALS_HTTP,
42+
CREDENTIALS_SSO,
43+
CREDENTIALS_SSO_LEGACY,
4244
CREDENTIALS_PROFILE_SOURCE_PROFILE,
4345
};
4446

src/aws-cpp-sdk-core/source/auth/SSOCredentialsProvider.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ void SSOCredentialsProvider::Reload()
101101
AWS_LOGSTREAM_TRACE(SSO_CREDENTIALS_PROVIDER_LOG_TAG, "Successfully retrieved credentials with AWS_ACCESS_KEY: " << result.creds.GetAWSAccessKeyId());
102102

103103
m_credentials = result.creds;
104+
if (!m_credentials.IsEmpty()) {
105+
if (!profile.IsSsoSessionSet()) {
106+
m_credentials.AddUserAgentFeature(Aws::Client::UserAgentFeature::CREDENTIALS_SSO_LEGACY);
107+
} else {
108+
m_credentials.AddUserAgentFeature(Aws::Client::UserAgentFeature::CREDENTIALS_SSO);
109+
}
110+
}
104111
}
105112

106113
void SSOCredentialsProvider::RefreshIfExpired()

src/aws-cpp-sdk-core/source/client/UserAgent.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const std::pair<UserAgentFeature, const char*> BUSINESS_METRIC_MAPPING[] = {
4949
{UserAgentFeature::CREDENTIALS_STS_ASSUME_ROLE, "i"},
5050
{UserAgentFeature::CREDENTIALS_STS_WEB_IDENTITY_TOKEN, "q"},
5151
{UserAgentFeature::CREDENTIALS_HTTP, "z"},
52+
{UserAgentFeature::CREDENTIALS_SSO, "s"},
53+
{UserAgentFeature::CREDENTIALS_SSO_LEGACY, "u"},
5254
{UserAgentFeature::CREDENTIALS_PROFILE_SOURCE_PROFILE, "p"},
5355
};
5456

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// SSOCredentialTrackingTest.cpp
2+
3+
#include <aws/testing/AwsCppSdkGTestSuite.h>
4+
#include <aws/testing/mocks/aws/client/MockAWSClient.h>
5+
#include <aws/testing/mocks/http/MockHttpClient.h>
6+
#include <aws/testing/platform/PlatformTesting.h>
7+
8+
#include <aws/core/auth/AWSCredentialsProvider.h>
9+
#include <aws/core/auth/AWSCredentialsProviderChain.h>
10+
#include <aws/core/auth/SSOCredentialsProvider.h>
11+
#include <aws/core/client/AWSClient.h>
12+
#include <aws/core/http/standard/StandardHttpResponse.h>
13+
#include <aws/core/platform/Environment.h>
14+
#include <aws/core/platform/FileSystem.h>
15+
#include <aws/core/utils/DateTime.h>
16+
#include <aws/core/utils/HashingUtils.h>
17+
#include <aws/core/utils/StringUtils.h>
18+
#include <aws/core/config/AWSProfileConfigLoader.h>
19+
20+
#include <fstream>
21+
#include <thread>
22+
23+
using namespace Aws;
24+
using namespace Aws::Auth;
25+
using namespace Aws::Client;
26+
using namespace Aws::Http;
27+
using namespace Aws::Http::Standard;
28+
using namespace Aws::Utils;
29+
using namespace Aws::FileSystem;
30+
31+
namespace {
32+
const char* TEST_LOG_TAG = "CredentialTrackingTest";
33+
}
34+
35+
Aws::String computeHashedStartUrl(const Aws::String& startUrl) {
36+
auto sha1 = HashingUtils::CalculateSHA1(startUrl);
37+
return HashingUtils::HexEncode(sha1); // lower-case hex the same as provider
38+
}
39+
40+
class CredentialTestingClient : public AWSClient
41+
{
42+
public:
43+
CredentialTestingClient() : AWSClient(ClientConfiguration(), Aws::MakeShared<AWSAuthV4Signer>(TEST_LOG_TAG, Aws::MakeShared<DefaultAWSCredentialsProviderChain>(TEST_LOG_TAG),
44+
"service", Aws::Region::US_EAST_1), Aws::MakeShared<MockAWSErrorMarshaller>(TEST_LOG_TAG)) {}
45+
CredentialTestingClient(const Aws::Client::ClientConfiguration& configuration, const std::shared_ptr<Aws::Client::AWSAuthSigner>& signer) :
46+
AWSClient(configuration, signer, Aws::MakeShared<MockAWSErrorMarshaller>(TEST_LOG_TAG)) {}
47+
48+
Aws::Client::HttpResponseOutcome PublicAttemptExhaustively(
49+
const Aws::Http::URI& uri,
50+
const Aws::AmazonWebServiceRequest& request,
51+
Http::HttpMethod method,
52+
const char* signerName) {
53+
return AttemptExhaustively(uri.GetURIString(), request, method, signerName);
54+
}
55+
56+
Aws::Client::AWSError<Aws::Client::CoreErrors> BuildAWSError(const std::shared_ptr<Aws::Http::HttpResponse>&) const override
57+
{
58+
Aws::Client::AWSError<Aws::Client::CoreErrors> error;
59+
return error;
60+
}
61+
};
62+
63+
class SSOCredentialsProviderTrackingTest : public Aws::Testing::AwsCppSdkGTestSuite {
64+
protected:
65+
void SetUp() override {
66+
AwsCppSdkGTestSuite::SetUp();
67+
68+
// Build paths the same way the SDK does
69+
const Aws::String profileDir = ProfileConfigFileAWSCredentialsProvider::GetProfileDirectory();
70+
const Aws::String ssoDir = profileDir + PATH_DELIM + "sso";
71+
const Aws::String cacheDir = ssoDir + PATH_DELIM + "cache";
72+
73+
CreateDirectoryIfNotExists(profileDir.c_str());
74+
CreateDirectoryIfNotExists(ssoDir.c_str());
75+
CreateDirectoryIfNotExists(cacheDir.c_str());
76+
77+
// Point AWS_CONFIG_FILE at a unique temp path the provider will read
78+
StringStream ss;
79+
ss << Aws::Auth::GetConfigProfileFilename() << "_blah" << std::this_thread::get_id();
80+
m_configPath = ss.str();
81+
Aws::Environment::SetEnv("AWS_CONFIG_FILE", m_configPath.c_str(), 1);
82+
83+
m_profileDir = profileDir;
84+
m_ssoCacheDir = cacheDir;
85+
86+
// Mock HTTP client
87+
mockHttpClient = Aws::MakeShared<MockHttpClient>(TEST_LOG_TAG);
88+
mockHttpClientFactory = Aws::MakeShared<MockHttpClientFactory>(TEST_LOG_TAG);
89+
mockHttpClientFactory->SetClient(mockHttpClient);
90+
SetHttpClientFactory(mockHttpClientFactory);
91+
}
92+
93+
void TearDown() override {
94+
if (mockHttpClient) { mockHttpClient->Reset(); mockHttpClient = nullptr; }
95+
mockHttpClientFactory = nullptr;
96+
Aws::FileSystem::RemoveFileIfExists(m_configPath.c_str());
97+
AwsCppSdkGTestSuite::TearDown();
98+
}
99+
100+
void CreateTestConfig(const Aws::String& startUrl) {
101+
Aws::OFStream cfg(m_configPath.c_str());
102+
cfg << "[default]\n"
103+
"sso_session = my-sso\n"
104+
"sso_account_id = 123456789012\n"
105+
"sso_role_name = TestRole\n"
106+
"\n"
107+
"[sso-session my-sso]\n"
108+
"sso_region = us-east-1\n"
109+
"sso_start_url = " << startUrl << "\n";
110+
cfg.close();
111+
112+
Aws::IFStream check(m_configPath.c_str());
113+
ASSERT_TRUE(check.good()) << "Config not created at: " << m_configPath;
114+
check.close();
115+
116+
Aws::Config::ReloadCachedConfigFile();
117+
}
118+
119+
void CreateSSOTokenFile(const Aws::String& startUrl) {
120+
const Aws::String hash = computeHashedStartUrl(startUrl);
121+
const Aws::String tokenPath = m_ssoCacheDir + PATH_DELIM + hash + ".json";
122+
123+
Aws::OFStream tokenFile(tokenPath.c_str());
124+
ASSERT_TRUE(tokenFile.good()) << "Failed to open " << tokenPath;
125+
126+
const auto futureTime = DateTime::Now() + std::chrono::hours(1);
127+
tokenFile << "{\n"
128+
" \"accessToken\": \"test-token\",\n"
129+
" \"expiresAt\": \"" << futureTime.ToGmtString(DateFormat::ISO_8601) << "\",\n"
130+
" \"region\": \"us-east-1\",\n"
131+
" \"startUrl\": \"" << startUrl << "\"\n"
132+
"}\n";
133+
tokenFile.close();
134+
135+
Aws::IFStream check(tokenPath.c_str());
136+
ASSERT_TRUE(check.good()) << "Token not created at: " << tokenPath;
137+
check.close();
138+
}
139+
140+
void CreateSSOSessionTokenFile(const Aws::String& sessionName) {
141+
const Aws::String hash = Aws::Utils::HashingUtils::HexEncode(
142+
Aws::Utils::HashingUtils::CalculateSHA1(sessionName));
143+
const Aws::String tokenPath = m_ssoCacheDir + PATH_DELIM + hash + ".json";
144+
145+
Aws::OFStream tokenFile(tokenPath.c_str());
146+
ASSERT_TRUE(tokenFile.good()) << "Failed to open " << tokenPath;
147+
148+
const auto futureTime = Aws::Utils::DateTime::Now().Millis() + 3600000;
149+
const auto futureDateTime = Aws::Utils::DateTime(futureTime);
150+
tokenFile << "{\n"
151+
" \"accessToken\": \"test-token\",\n"
152+
" \"expiresAt\": \"" << futureDateTime.ToGmtString(Aws::Utils::DateFormat::ISO_8601) << "\"\n"
153+
// (region/startUrl fields are optional on this path)
154+
"}\n";
155+
tokenFile.close();
156+
157+
Aws::IFStream check(tokenPath.c_str());
158+
ASSERT_TRUE(check.good()) << "Token not created at: " << tokenPath;
159+
check.close();
160+
}
161+
162+
void RunTestWithCredentialsProvider(const std::shared_ptr<AWSCredentialsProvider>& provider, const Aws::String& marker) {
163+
// 200 OK dummy response for the signed call
164+
auto req = CreateHttpRequest(URI("https://test-service.us-east-1.amazonaws.com/"), HttpMethod::HTTP_POST, Aws::Utils::Stream::DefaultResponseStreamFactoryMethod);
165+
auto ok = Aws::MakeShared<StandardHttpResponse>(TEST_LOG_TAG, req);
166+
ok->SetResponseCode(HttpResponseCode::OK);
167+
ok->GetResponseBody() << "{}";
168+
mockHttpClient->AddResponseToReturn(ok);
169+
170+
ClientConfigurationInitValues initVals; initVals.shouldDisableIMDS = true;
171+
ClientConfiguration cfg(initVals);
172+
cfg.region = Aws::Region::US_EAST_1;
173+
174+
auto signer = Aws::MakeShared<Aws::Client::AWSAuthV4Signer>(TEST_LOG_TAG, provider, "test-service", cfg.region);
175+
CredentialTestingClient client(cfg, signer);
176+
AmazonWebServiceRequestMock mockReq;
177+
178+
// Use public AWS client method to make a request
179+
URI uri("https://test-service.us-east-1.amazonaws.com/");
180+
auto outcome = client.PublicAttemptExhaustively(uri, mockReq, HttpMethod::HTTP_POST, Aws::Auth::SIGV4_SIGNER);
181+
ASSERT_TRUE(outcome.IsSuccess());
182+
183+
auto last = mockHttpClient->GetMostRecentHttpRequest();
184+
ASSERT_TRUE(last.HasHeader(USER_AGENT_HEADER));
185+
const auto userAgent = last.GetHeaderValue(USER_AGENT_HEADER);
186+
ASSERT_FALSE(userAgent.empty());
187+
188+
const auto userAgentParsed = StringUtils::Split(userAgent, ' ');
189+
int mCount = 0;
190+
for (const auto& p : userAgentParsed) if (p.find("m/") != Aws::String::npos) ++mCount;
191+
EXPECT_EQ(1, mCount); // only one m/ section
192+
193+
auto businessMetrics = std::find_if(userAgentParsed.begin(), userAgentParsed.end(),
194+
[&marker](const Aws::String& v){ return v.find("m/") != Aws::String::npos && v.find(marker) != Aws::String::npos; });
195+
EXPECT_TRUE(businessMetrics != userAgentParsed.end());
196+
}
197+
198+
Aws::String m_profileDir;
199+
Aws::String m_ssoCacheDir;
200+
Aws::String m_configPath;
201+
202+
std::shared_ptr<MockHttpClient> mockHttpClient;
203+
std::shared_ptr<MockHttpClientFactory> mockHttpClientFactory;
204+
};
205+
206+
TEST_F(SSOCredentialsProviderTrackingTest, TestSSOCredentialsTracking){
207+
const Aws::String startUrl = "https://test.awsapps.com/start";
208+
209+
CreateTestConfig(startUrl);
210+
CreateSSOSessionTokenFile("my-sso");
211+
212+
// Prepare mock SSO GetRoleCredentials response
213+
auto ssoReq = CreateHttpRequest(
214+
URI("https://portal.sso.us-east-1.amazonaws.com/federation/credentials"),
215+
HttpMethod::HTTP_GET,
216+
Aws::Utils::Stream::DefaultResponseStreamFactoryMethod);
217+
218+
auto ssoResp = Aws::MakeShared<StandardHttpResponse>(TEST_LOG_TAG, ssoReq);
219+
ssoResp->SetResponseCode(HttpResponseCode::OK);
220+
ssoResp->GetResponseBody()
221+
<< R"({"roleCredentials":{
222+
"accessKeyId":"AKIAIOSFODNN7EXAMPLE",
223+
"secretAccessKey":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
224+
"sessionToken":"AQoDYXdzEJr...",
225+
"expiration":)"
226+
<< (DateTime::Now().Millis() + 3600000) << "}}";
227+
mockHttpClient->AddResponseToReturn(ssoResp);
228+
229+
// Provider should read config + token from the real cache dir and call mock
230+
auto provider = Aws::MakeShared<SSOCredentialsProvider>(TEST_LOG_TAG);
231+
auto creds = provider->GetAWSCredentials();
232+
233+
ASSERT_FALSE(creds.IsEmpty());
234+
EXPECT_EQ("AKIAIOSFODNN7EXAMPLE", creds.GetAWSAccessKeyId());
235+
EXPECT_EQ("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", creds.GetAWSSecretKey());
236+
237+
// Fire a signed request and assert the business metric appears once
238+
RunTestWithCredentialsProvider(provider, "s");
239+
}
240+
241+
TEST_F(SSOCredentialsProviderTrackingTest, TestSSOLegacyCredentialsTracking){
242+
const Aws::String startUrl = "https://test.awsapps.com/start";
243+
244+
// Create legacy SSO config (without sso_session)
245+
Aws::OFStream cfg(m_configPath.c_str());
246+
cfg << "[default]\n"
247+
"sso_account_id = 123456789012\n"
248+
"sso_region = us-east-1\n"
249+
"sso_role_name = TestRole\n"
250+
"sso_start_url = " << startUrl << "\n";
251+
cfg.close();
252+
Aws::Config::ReloadCachedConfigFile();
253+
254+
CreateSSOTokenFile(startUrl);
255+
256+
// Prepare mock SSO GetRoleCredentials response
257+
auto ssoReq = CreateHttpRequest(
258+
URI("https://portal.sso.us-east-1.amazonaws.com/federation/credentials"),
259+
HttpMethod::HTTP_GET,
260+
Aws::Utils::Stream::DefaultResponseStreamFactoryMethod);
261+
262+
auto ssoResp = Aws::MakeShared<StandardHttpResponse>(TEST_LOG_TAG, ssoReq);
263+
ssoResp->SetResponseCode(HttpResponseCode::OK);
264+
ssoResp->GetResponseBody()
265+
<< R"({"roleCredentials":{
266+
"accessKeyId":"AKIAIOSFODNN7EXAMPLE",
267+
"secretAccessKey":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
268+
"sessionToken":"AQoDYXdzEJr...",
269+
"expiration":)" << (DateTime::Now().Millis() + 3600000) << "}}";
270+
mockHttpClient->AddResponseToReturn(ssoResp);
271+
272+
auto provider = Aws::MakeShared<SSOCredentialsProvider>(TEST_LOG_TAG);
273+
auto creds = provider->GetAWSCredentials();
274+
275+
ASSERT_FALSE(creds.IsEmpty());
276+
EXPECT_EQ("AKIAIOSFODNN7EXAMPLE", creds.GetAWSAccessKeyId());
277+
EXPECT_EQ("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", creds.GetAWSSecretKey());
278+
279+
// Fire a signed request and assert the legacy SSO business metric appears
280+
RunTestWithCredentialsProvider(provider, "u");
281+
}

0 commit comments

Comments
 (0)