Skip to content

Commit 695af07

Browse files
authored
impl(oauth2): add GDCH service account credentials (googleapis#16126)
1 parent 9918d39 commit 695af07

10 files changed

Lines changed: 979 additions & 9 deletions

google/cloud/google_cloud_cpp_rest_internal.bzl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ google_cloud_cpp_rest_internal_hdrs = [
4747
"internal/oauth2_error_credentials.h",
4848
"internal/oauth2_external_account_credentials.h",
4949
"internal/oauth2_external_account_token_source.h",
50+
"internal/oauth2_gdch_service_account_credentials.h",
5051
"internal/oauth2_google_application_default_credentials_file.h",
5152
"internal/oauth2_google_credentials.h",
5253
"internal/oauth2_http_client_factory.h",
@@ -108,6 +109,7 @@ google_cloud_cpp_rest_internal_srcs = [
108109
"internal/oauth2_decorate_credentials.cc",
109110
"internal/oauth2_error_credentials.cc",
110111
"internal/oauth2_external_account_credentials.cc",
112+
"internal/oauth2_gdch_service_account_credentials.cc",
111113
"internal/oauth2_google_application_default_credentials_file.cc",
112114
"internal/oauth2_google_credentials.cc",
113115
"internal/oauth2_impersonate_service_account_credentials.cc",

google/cloud/google_cloud_cpp_rest_internal.cmake

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ add_library(
7979
internal/oauth2_external_account_credentials.cc
8080
internal/oauth2_external_account_credentials.h
8181
internal/oauth2_external_account_token_source.h
82+
internal/oauth2_gdch_service_account_credentials.cc
83+
internal/oauth2_gdch_service_account_credentials.h
8284
internal/oauth2_google_application_default_credentials_file.cc
8385
internal/oauth2_google_application_default_credentials_file.h
8486
internal/oauth2_google_credentials.cc
@@ -280,6 +282,7 @@ if (BUILD_TESTING)
280282
internal/oauth2_compute_engine_credentials_test.cc
281283
internal/oauth2_credentials_test.cc
282284
internal/oauth2_external_account_credentials_test.cc
285+
internal/oauth2_gdch_service_account_credentials_test.cc
283286
internal/oauth2_google_application_default_credentials_file_test.cc
284287
internal/oauth2_google_credentials_test.cc
285288
internal/oauth2_impersonate_service_account_credentials_test.cc

google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ google_cloud_cpp_rest_internal_unit_tests = [
4545
"internal/oauth2_compute_engine_credentials_test.cc",
4646
"internal/oauth2_credentials_test.cc",
4747
"internal/oauth2_external_account_credentials_test.cc",
48+
"internal/oauth2_gdch_service_account_credentials_test.cc",
4849
"internal/oauth2_google_application_default_credentials_file_test.cc",
4950
"internal/oauth2_google_credentials_test.cc",
5051
"internal/oauth2_impersonate_service_account_credentials_test.cc",

google/cloud/internal/make_jwt_assertion.cc

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ namespace internal {
2323

2424
StatusOr<std::string> MakeJWTAssertionNoThrow(std::string const& header,
2525
std::string const& payload,
26-
std::string const& pem_contents) {
26+
std::string const& pem_contents,
27+
SignatureFormat format) {
2728
auto const body =
2829
UrlsafeBase64Encode(header) + '.' + UrlsafeBase64Encode(payload);
29-
auto pem_signature = internal::SignUsingSha256(body, pem_contents);
30+
auto pem_signature = internal::SignUsingSha256(body, pem_contents, format);
3031
if (!pem_signature) return std::move(pem_signature).status();
3132
return body + '.' + UrlsafeBase64Encode(*pem_signature);
3233
}

google/cloud/internal/make_jwt_assertion.h

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#ifndef GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_MAKE_JWT_ASSERTION_H
1616
#define GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_MAKE_JWT_ASSERTION_H
1717

18+
#include "google/cloud/internal/sign_using_sha256.h"
1819
#include "google/cloud/status_or.h"
1920
#include "google/cloud/version.h"
2021
#include <string>
@@ -23,9 +24,17 @@ namespace google {
2324
namespace cloud {
2425
GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN
2526
namespace internal {
26-
StatusOr<std::string> MakeJWTAssertionNoThrow(std::string const& header,
27-
std::string const& payload,
28-
std::string const& pem_contents);
27+
28+
/**
29+
* Creates a JWT.
30+
*
31+
* @note SignatureFormat defaults to SignatureFormat::kDER for backwards
32+
* compatibility.
33+
*/
34+
StatusOr<std::string> MakeJWTAssertionNoThrow(
35+
std::string const& header, std::string const& payload,
36+
std::string const& pem_contents,
37+
SignatureFormat format = SignatureFormat::kDER);
2938

3039
} // namespace internal
3140
GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#include "google/cloud/internal/oauth2_gdch_service_account_credentials.h"
16+
#include "google/cloud/credentials.h"
17+
#include "google/cloud/internal/make_jwt_assertion.h"
18+
#include "google/cloud/internal/make_status.h"
19+
#include "google/cloud/internal/oauth2_google_credentials.h"
20+
#include "google/cloud/internal/parse_service_account_p12_file.h"
21+
#include "google/cloud/internal/rest_response.h"
22+
#include "absl/strings/str_join.h"
23+
#include "absl/strings/str_split.h"
24+
#include <nlohmann/json.hpp>
25+
#include <fstream>
26+
#include <functional>
27+
28+
namespace google {
29+
namespace cloud {
30+
namespace oauth2_internal {
31+
GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN
32+
33+
StatusOr<GDCHServiceAccountCredentials::Info>
34+
GDCHServiceAccountCredentials::Parse(std::string const& content,
35+
std::string const& source) {
36+
auto credentials = nlohmann::json::parse(content, nullptr, false);
37+
if (credentials.is_discarded() || !credentials.is_object()) {
38+
return internal::InvalidArgumentError(absl::StrCat(
39+
"Invalid GDCHServiceAccountCredentials, parsing failed on ",
40+
"data loaded from ", source));
41+
}
42+
43+
using Validator =
44+
std::function<Status(absl::string_view name, nlohmann::json::iterator)>;
45+
using Store = std::function<void(Info&, nlohmann::json::iterator const&)>;
46+
auto optional_field = [](absl::string_view, nlohmann::json::iterator const&) {
47+
return Status{};
48+
};
49+
auto non_empty_field = [&](absl::string_view name,
50+
nlohmann::json::iterator const& l) {
51+
if (l == credentials.end()) return Status{};
52+
if (!l->get<std::string>().empty()) return Status{};
53+
return internal::InvalidArgumentError(
54+
absl::StrCat("Invalid GDCHServiceAccountCredentials, the ", name,
55+
" field is empty on data loaded from ", source));
56+
};
57+
auto required_field = [&](absl::string_view name,
58+
nlohmann::json::iterator const& l) {
59+
if (l == credentials.end()) {
60+
return internal::InvalidArgumentError(
61+
absl::StrCat("Invalid GDCHServiceAccountCredentials, the ", name,
62+
" field is missing on data loaded from ", source));
63+
}
64+
return non_empty_field(name, l);
65+
};
66+
67+
struct Field {
68+
std::string name;
69+
Validator validator;
70+
Store store;
71+
};
72+
std::vector<Field> fields{
73+
{"project", required_field,
74+
[](Info& info, nlohmann::json::iterator const& l) {
75+
info.project_id = l->get<std::string>();
76+
}},
77+
{"private_key_id", required_field,
78+
[&](Info& info, nlohmann::json::iterator const& l) {
79+
if (l == credentials.end()) return;
80+
info.private_key_id = l->get<std::string>();
81+
}},
82+
{"private_key", required_field,
83+
[](Info& info, nlohmann::json::iterator const& l) {
84+
info.private_key = l->get<std::string>();
85+
}},
86+
{"name", required_field,
87+
[&](Info& info, nlohmann::json::iterator const& l) {
88+
info.service_identity_name = l->get<std::string>();
89+
}},
90+
{"ca_cert_path", optional_field,
91+
[&](Info& info, nlohmann::json::iterator const& l) {
92+
if (l == credentials.end()) return;
93+
info.ca_cert_path = l->get<std::string>();
94+
}},
95+
{"token_uri", required_field,
96+
[&](Info& info, nlohmann::json::iterator const& l) {
97+
info.token_uri = l->get<std::string>();
98+
}}};
99+
100+
Info info;
101+
for (auto& f : fields) {
102+
auto l = credentials.find(f.name);
103+
if (l != credentials.end() && !l->is_string()) {
104+
return internal::InvalidArgumentError(absl::StrCat(
105+
"Invalid GDCHServiceAccountCredentials, the ", f.name,
106+
" field is present and is not a string, on data loaded from ",
107+
source));
108+
}
109+
auto status = f.validator(f.name, l);
110+
if (!status.ok()) return status;
111+
f.store(info, l);
112+
}
113+
return info;
114+
}
115+
116+
std::pair<std::string, std::string>
117+
GDCHServiceAccountCredentials::AssertionComponentsFromInfo(
118+
Info const& info, std::chrono::system_clock::time_point now) {
119+
nlohmann::json assertion_header = {{"alg", "ES256"}, {"typ", "JWT"}};
120+
if (!info.private_key_id.empty()) {
121+
assertion_header["kid"] = info.private_key_id;
122+
}
123+
124+
auto expiration = now + GoogleOAuthAccessTokenLifetime();
125+
// As much as possible, do the time arithmetic using the std::chrono types.
126+
// Convert to an integer only when we are dealing with timestamps since the
127+
// epoch. Note that we cannot use `time_t` directly because that might be a
128+
// floating point.
129+
auto const now_from_epoch =
130+
static_cast<std::intmax_t>(std::chrono::system_clock::to_time_t(now));
131+
auto const expiration_from_epoch = static_cast<std::intmax_t>(
132+
std::chrono::system_clock::to_time_t(expiration));
133+
auto iss_sub_value = absl::StrCat("system:serviceaccount:", info.project_id,
134+
":", info.service_identity_name);
135+
nlohmann::json assertion_payload = {
136+
{"iss", iss_sub_value},
137+
{"sub", iss_sub_value},
138+
{"aud", info.token_uri},
139+
{"iat", now_from_epoch},
140+
// Resulting access token should expire after one hour.
141+
{"exp", expiration_from_epoch}};
142+
143+
// Note: we don't move here as it would prevent copy elision.
144+
return std::make_pair(assertion_header.dump(), assertion_payload.dump());
145+
}
146+
147+
StatusOr<std::string> GDCHServiceAccountCredentials::MakeJWTAssertion(
148+
std::string const& header, std::string const& payload,
149+
std::string const& pem_contents) {
150+
return internal::MakeJWTAssertionNoThrow(header, payload, pem_contents,
151+
internal::SignatureFormat::kRaw);
152+
}
153+
154+
StatusOr<nlohmann::json> GDCHServiceAccountCredentials::CreateRefreshPayload(
155+
Info const& info, std::chrono::system_clock::time_point now) {
156+
auto [header, payload] = AssertionComponentsFromInfo(info, now);
157+
auto jwt = MakeJWTAssertion(header, payload, info.private_key);
158+
if (!jwt) return jwt.status();
159+
return nlohmann::json{
160+
{"grant_type", "urn:ietf:params:oauth:token-type:token-exchange"},
161+
{"audience", info.audience},
162+
{"requested_token_type", "urn:ietf:params:oauth:token-type:access_token"},
163+
{"subject_token", std::move(*jwt)},
164+
{"subject_token_type", "urn:k8s:params:oauth:token-type:serviceaccount"}};
165+
}
166+
167+
StatusOr<AccessToken> GDCHServiceAccountCredentials::ParseRefreshResponse(
168+
rest_internal::RestResponse& response,
169+
std::chrono::system_clock::time_point now) {
170+
auto payload = rest_internal::ReadAll(std::move(response).ExtractPayload());
171+
if (!payload.ok()) return std::move(payload).status();
172+
auto payload_copy = *payload;
173+
auto access_token = nlohmann::json::parse(*payload, nullptr, false);
174+
if (access_token.is_discarded() || !access_token.is_object() ||
175+
access_token.count("access_token") == 0 ||
176+
access_token.count("expires_in") == 0 ||
177+
access_token.count("token_type") == 0 ||
178+
access_token.count("issued_token_type") == 0) {
179+
auto error_payload =
180+
payload_copy +
181+
": Could not find all required fields in response (access_token,"
182+
" expires_in, token_type, issued_token_type) while trying to obtain an"
183+
" access token for GDCH service account credentials.";
184+
return internal::InvalidArgumentError(error_payload, GCP_ERROR_INFO());
185+
}
186+
auto expires_in = std::chrono::seconds(access_token.value("expires_in", 0));
187+
return AccessToken{access_token.value("access_token", ""), now + expires_in};
188+
}
189+
190+
StatusOr<std::unique_ptr<Credentials>>
191+
GDCHServiceAccountCredentials::CreateFromInfo(
192+
Info info, Options const& options, HttpClientFactory client_factory) {
193+
// Verify this is usable before returning it.
194+
auto const tp = std::chrono::system_clock::time_point{};
195+
auto const [header, payload] = AssertionComponentsFromInfo(info, tp);
196+
auto jwt = MakeJWTAssertion(header, payload, info.private_key);
197+
if (!jwt) return jwt.status();
198+
return StatusOr<std::unique_ptr<Credentials>>(
199+
std::unique_ptr<GDCHServiceAccountCredentials>(
200+
new GDCHServiceAccountCredentials(std::move(info), options,
201+
std::move(client_factory))));
202+
}
203+
204+
StatusOr<std::unique_ptr<Credentials>>
205+
GDCHServiceAccountCredentials::CreateFromJsonContents(
206+
std::string const& contents, std::string const& audience,
207+
Options const& options, HttpClientFactory client_factory) {
208+
auto info = Parse(contents, "memory");
209+
if (!info) return info.status();
210+
info->audience = audience;
211+
return CreateFromInfo(*std::move(info), options, std::move(client_factory));
212+
}
213+
214+
StatusOr<std::unique_ptr<Credentials>>
215+
GDCHServiceAccountCredentials::CreateFromFilePath(
216+
std::string const& path, std::string const& audience,
217+
Options const& options, HttpClientFactory client_factory) {
218+
if (path.empty()) {
219+
return internal::InvalidArgumentError(
220+
"GOOGLE_APPLICATION_CREDENTIALS env var was empty.", GCP_ERROR_INFO());
221+
}
222+
std::ifstream is(path);
223+
if (!is.is_open()) {
224+
// We use kUnknown here because we don't know if the file does not exist, or
225+
// if we were unable to open it for some other reason.
226+
return internal::UnknownError("Cannot open credentials file " + path,
227+
GCP_ERROR_INFO());
228+
}
229+
std::string contents(std::istreambuf_iterator<char>{is}, {});
230+
return CreateFromJsonContents(std::move(contents), audience, options,
231+
std::move(client_factory));
232+
}
233+
234+
GDCHServiceAccountCredentials::GDCHServiceAccountCredentials(
235+
Info info, Options options, HttpClientFactory client_factory)
236+
: info_(std::move(info)),
237+
options_(std::move(options)),
238+
client_factory_(std::move(client_factory)) {}
239+
240+
StatusOr<AccessToken> GDCHServiceAccountCredentials::GetToken(
241+
std::chrono::system_clock::time_point tp) {
242+
Options options = options_;
243+
if (!info_.ca_cert_path.empty()) {
244+
options.set<CARootsFilePathOption>(info_.ca_cert_path);
245+
}
246+
auto client = client_factory_(std::move(options));
247+
rest_internal::RestRequest request;
248+
request.SetPath(info_.token_uri);
249+
request.AddHeader("Content-Type", "application/json");
250+
auto payload = CreateRefreshPayload(info_, tp);
251+
if (!payload) return std::move(payload).status();
252+
rest_internal::RestContext context;
253+
auto response = client->Post(context, request, {payload->dump()});
254+
if (!response) return std::move(response).status();
255+
if (IsHttpError(**response)) return AsStatus(std::move(**response));
256+
return ParseRefreshResponse(**response, tp);
257+
}
258+
259+
StatusOr<std::string> GDCHServiceAccountCredentials::project_id() const {
260+
return info_.project_id;
261+
}
262+
263+
StatusOr<std::string> GDCHServiceAccountCredentials::project_id(
264+
Options const&) const {
265+
// project_id() is stored locally, so any retry options are unnecessary.
266+
return project_id();
267+
}
268+
269+
GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END
270+
} // namespace oauth2_internal
271+
} // namespace cloud
272+
} // namespace google

0 commit comments

Comments
 (0)