Skip to content

feat: support external_account_authorized_user credentials - #16581

Merged
scotthart merged 4 commits into
googleapis:mainfrom
sachinpro:feat-external-account-authorized-user
Oct 23, 2026
Merged

scotthart merged 4 commits into
googleapis:mainfrom
sachinpro:feat-external-account-authorized-user

Conversation

@sachinpro

Copy link
Copy Markdown
Contributor

Adds support for "type": "external_account_authorized_user" credentials (OAuth 2.0 refresh token flow for Workforce Identity Federation external accounts) when loading credentials via Application Default Credentials (MakeGoogleDefaultCredentials) or MakeExternalAccountCredentials.

  • Parses "type": "external_account_authorized_user" configurations (client_id, client_secret, refresh_token, token_url, and optional universe_domain).
  • Implements ExternalAccountAuthorizedUserCredentials::GetToken using HTTP Basic Authentication (client_id:client_secret) and grant_type=refresh_token, including optional refresh_token rotation in the token response.
  • Wires external_account_authorized_user into LoadCredsFromString (ADC) and ExternalAccountConfig in unified_rest_credentials.cc.
  • Adds unit tests for configuration parsing, token refresh, refresh token rotation, error handling, ADC loading, and MapCredentials.

@sachinpro
sachinpro requested a review from a team as a code owner October 21, 2026 15:44

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces ExternalAccountAuthorizedUserCredentials and its configuration parser to support external account authorized user configurations, along with comprehensive unit tests. The reviewer feedback highlights a critical thread-safety issue where concurrent calls to GetToken can cause a data race on info_.refresh_token during token rotation, recommending the use of std::mutex to protect this state. Additionally, several style guide violations were identified, including the use of default parameters in an internal namespace constructor and the use of auto where it obscures StatusOr<T> and other return types.

Comment on lines +152 to +156
private:
ExternalAccountAuthorizedUserInfo info_;
HttpClientFactory client_factory_;
Options options_;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since ExternalAccountAuthorizedUserCredentials::GetToken modifies info_.refresh_token during refresh token rotation, this class has mutable state. To prevent data races when multiple threads call GetToken concurrently, we must protect access to info_.refresh_token using a std::mutex.

 private:
  mutable std::mutex mu_;
  ExternalAccountAuthorizedUserInfo info_;
  HttpClientFactory client_factory_;
  Options options_;
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added mutable std::mutex mu_; to protect info_.refresh_token.

Comment on lines +384 to +433
StatusOr<AccessToken> ExternalAccountAuthorizedUserCredentials::GetToken(
std::chrono::system_clock::time_point tp) {
internal::Base64Encoder encoder;
for (unsigned char const c :
absl::StrCat(info_.client_id, ":", info_.client_secret)) {
encoder.PushBack(c);
}
auto request =
rest_internal::RestRequest(info_.token_url)
.AddHeader("content-type", "application/x-www-form-urlencoded")
.AddHeader("Authorization",
absl::StrCat("Basic ", std::move(encoder).FlushAndPad()));

auto form_data = std::vector<std::pair<std::string, std::string>>{
{"grant_type", "refresh_token"},
{"refresh_token", info_.refresh_token},
};

auto client = client_factory_(options_);
rest_internal::RestContext unused;
auto response = client->Post(unused, request, form_data);
if (!response) return std::move(response).status();
if (IsHttpError(**response)) return AsStatus(std::move(**response));
auto payload = rest_internal::ReadAll(std::move(**response).ExtractPayload());
if (!payload) return std::move(payload).status();

auto ec = internal::ErrorContext({
{"token_url", info_.token_url},
});

auto access = nlohmann::json::parse(*payload, nullptr, false);
if (!access.is_object()) {
return InvalidArgumentError(
"token refresh response cannot be parsed as JSON object",
GCP_ERROR_INFO().WithContext(ec));
}
auto token =
ValidateStringField(access, "access_token", "token-refresh-response", ec);
if (!token) return std::move(token).status();
auto expires_in =
ValidateIntField(access, "expires_in", "token-refresh-response", ec);
if (!expires_in) return std::move(expires_in).status();
auto refresh_token = ValidateStringField(access, "refresh_token",
"token-refresh-response", "", ec);
if (!refresh_token) return std::move(refresh_token).status();
if (!refresh_token->empty()) {
info_.refresh_token = *std::move(refresh_token);
}
return AccessToken{*std::move(token), tp + std::chrono::seconds(*expires_in)};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This suggestion addresses two issues:

  1. Thread Safety: Protects info_.refresh_token with a mutex lock to prevent data races during concurrent token refreshes.
  2. Style Guide Adherence: Replaces auto with explicit types for StatusOr<T> and other return types to comply with the repository style guide (lines 104-108).
StatusOr<AccessToken> ExternalAccountAuthorizedUserCredentials::GetToken(
    std::chrono::system_clock::time_point tp) {
  std::string refresh_token;
  {
    std::lock_guard<std::mutex> lk(mu_);
    refresh_token = info_.refresh_token;
  }
  internal::Base64Encoder encoder;
  for (unsigned char const c :
       absl::StrCat(info_.client_id, ":", info_.client_secret)) {
    encoder.PushBack(c);
  }
  rest_internal::RestRequest request =
      rest_internal::RestRequest(info_.token_url)
          .AddHeader("content-type", "application/x-www-form-urlencoded")
          .AddHeader("Authorization",
                     absl::StrCat("Basic ", std::move(encoder).FlushAndPad()));

  std::vector<std::pair<std::string, std::string>> form_data = {
      {"grant_type", "refresh_token"},
      {"refresh_token", std::move(refresh_token)},
  };

  std::unique_ptr<rest_internal::RestClient> client = client_factory_(options_);
  rest_internal::RestContext unused;
  StatusOr<std::unique_ptr<rest_internal::HttpResponse>> response =
      client->Post(unused, request, form_data);
  if (!response) return std::move(response).status();
  if (IsHttpError(**response)) return AsStatus(std::move(**response));
  StatusOr<std::string> payload =
      rest_internal::ReadAll(std::move(**response).ExtractPayload());
  if (!payload) return std::move(payload).status();

  internal::ErrorContext const ec = internal::ErrorContext({
      {"token_url", info_.token_url},
  });

  nlohmann::json access = nlohmann::json::parse(*payload, nullptr, false);
  if (!access.is_object()) {
    return InvalidArgumentError(
        "token refresh response cannot be parsed as JSON object",
        GCP_ERROR_INFO().WithContext(ec));
  }
  StatusOr<std::string> token =
      ValidateStringField(access, "access_token", "token-refresh-response", ec);
  if (!token) return std::move(token).status();
  StatusOr<std::int64_t> expires_in =
      ValidateIntField(access, "expires_in", "token-refresh-response", ec);
  if (!expires_in) return std::move(expires_in).status();
  StatusOr<std::string> new_refresh_token = ValidateStringField(
      access, "refresh_token", "token-refresh-response", "", ec);
  if (!new_refresh_token) return std::move(new_refresh_token).status();
  if (!new_refresh_token->empty()) {
    std::lock_guard<std::mutex> lk(mu_);
    info_.refresh_token = *std::move(new_refresh_token);
  }
  return AccessToken{*std::move(token), tp + std::chrono::seconds(*expires_in)};
}
References
  1. Reject Obscured Domain & Return Types: Flag and reject auto when it hides StatusOr, domain objects, protobuf messages/fields, or function return types. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — protected info_.refresh_token with std::lock_guard<std::mutex> and replaced auto with explicit types.

Comment thread google/cloud/internal/oauth2_external_account_credentials.cc
Comment on lines +140 to +142
ExternalAccountAuthorizedUserCredentials(
ExternalAccountAuthorizedUserInfo info, HttpClientFactory client_factory,
Options options = {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The repository style guide (lines 35-40) disallows default parameters in internal namespaces (such as oauth2_internal). Please split this constructor into two explicit overloads to conform to the style guide.

  ExternalAccountAuthorizedUserCredentials(
      ExternalAccountAuthorizedUserInfo info, HttpClientFactory client_factory);
  ExternalAccountAuthorizedUserCredentials(
      ExternalAccountAuthorizedUserInfo info, HttpClientFactory client_factory,
      Options options);
References
  1. Disallow Default Parameters in internal Namespaces: Reject default argument values in functions, member functions, and constructors declared in internal namespaces. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — split into two explicit constructor overloads without default parameters.

Comment on lines +338 to +374
StatusOr<ExternalAccountAuthorizedUserInfo>
ParseExternalAccountAuthorizedUserConfiguration(
std::string const& configuration, internal::ErrorContext const& ec) {
auto json = nlohmann::json::parse(configuration, nullptr, false);
if (!json.is_object()) {
return InvalidArgumentError(
"external account authorized user configuration was not a JSON object",
GCP_ERROR_INFO().WithContext(ec));
}
auto type = ValidateStringField(json, "type", "credentials-file", ec);
if (!type) return std::move(type).status();
if (*type != "external_account_authorized_user") {
return InvalidArgumentError(
"mismatched type (" + *type +
") in external account authorized user configuration",
GCP_ERROR_INFO().WithContext(ec));
}
auto client_id =
ValidateStringField(json, "client_id", "credentials-file", ec);
if (!client_id) return std::move(client_id).status();
auto client_secret =
ValidateStringField(json, "client_secret", "credentials-file", ec);
if (!client_secret) return std::move(client_secret).status();
auto refresh_token =
ValidateStringField(json, "refresh_token", "credentials-file", ec);
if (!refresh_token) return std::move(refresh_token).status();
auto token_url =
ValidateStringField(json, "token_url", "credentials-file", ec);
if (!token_url) return std::move(token_url).status();
auto universe_domain = GetUniverseDomainFromCredentialsJson(json);
if (!universe_domain) return std::move(universe_domain).status();

return ExternalAccountAuthorizedUserInfo{
*std::move(client_id), *std::move(client_secret),
*std::move(refresh_token), *std::move(token_url),
*std::move(universe_domain)};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The repository style guide (lines 104-108) rejects the use of auto when it hides StatusOr<T> or other domain/return types. Please use explicit types instead of auto for these fields.

StatusOr<ExternalAccountAuthorizedUserInfo>
ParseExternalAccountAuthorizedUserConfiguration(
    std::string const& configuration, internal::ErrorContext const& ec) {
  nlohmann::json json = nlohmann::json::parse(configuration, nullptr, false);
  if (!json.is_object()) {
    return InvalidArgumentError(
        "external account authorized user configuration was not a JSON object",
        GCP_ERROR_INFO().WithContext(ec));
  }
  StatusOr<std::string> type = ValidateStringField(json, "type", "credentials-file", ec);
  if (!type) return std::move(type).status();
  if (*type != "external_account_authorized_user") {
    return InvalidArgumentError(
        "mismatched type (" + *type +
            ") in external account authorized user configuration",
        GCP_ERROR_INFO().WithContext(ec));
  }
  StatusOr<std::string> client_id =
      ValidateStringField(json, "client_id", "credentials-file", ec);
  if (!client_id) return std::move(client_id).status();
  StatusOr<std::string> client_secret =
      ValidateStringField(json, "client_secret", "credentials-file", ec);
  if (!client_secret) return std::move(client_secret).status();
  StatusOr<std::string> refresh_token =
      ValidateStringField(json, "refresh_token", "credentials-file", ec);
  if (!refresh_token) return std::move(refresh_token).status();
  StatusOr<std::string> token_url =
      ValidateStringField(json, "token_url", "credentials-file", ec);
  if (!token_url) return std::move(token_url).status();
  StatusOr<std::string> universe_domain = GetUniverseDomainFromCredentialsJson(json);
  if (!universe_domain) return std::move(universe_domain).status();

  return ExternalAccountAuthorizedUserInfo{
      *std::move(client_id), *std::move(client_secret),
      *std::move(refresh_token), *std::move(token_url),
      *std::move(universe_domain)};
}
References
  1. Reject Obscured Domain & Return Types: Flag and reject auto when it hides StatusOr, domain objects, protobuf messages/fields, or function return types. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — replaced auto with explicit types.

Comment on lines +63 to +70
if (cred_type == "external_account_authorized_user") {
auto info = ParseExternalAccountAuthorizedUserConfiguration(
contents, internal::ErrorContext{});
if (!info) return std::move(info).status();
return std::unique_ptr<Credentials>(
std::make_unique<ExternalAccountAuthorizedUserCredentials>(
*std::move(info), std::move(client_factory), options));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The repository style guide (lines 104-108) rejects the use of auto when it hides StatusOr<T>. Please use explicit types instead of auto for info.

  if (cred_type == "external_account_authorized_user") {
    StatusOr<ExternalAccountAuthorizedUserInfo> info =
        ParseExternalAccountAuthorizedUserConfiguration(
            contents, internal::ErrorContext{});
    if (!info) return std::move(info).status();
    return std::unique_ptr<Credentials>(
        std::make_unique<ExternalAccountAuthorizedUserCredentials>(
            *std::move(info), std::move(client_factory), options));
  }
References
  1. Reject Obscured Domain & Return Types: Flag and reject auto when it hides StatusOr, domain objects, protobuf messages/fields, or function return types. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — replaced auto with StatusOr<ExternalAccountAuthorizedUserInfo>.

Comment on lines +135 to +154
auto const json =
nlohmann::json::parse(cfg.json_object(), nullptr, false);
if (json.is_object()) {
auto const it = json.find("type");
if (it != json.end() && it->is_string() &&
*it == "external_account_authorized_user") {
auto info =
oauth2_internal::ParseExternalAccountAuthorizedUserConfiguration(
cfg.json_object(), ec);
if (!info) {
result = MakeErrorCredentials(std::move(info).status());
return;
}
auto creds = std::make_shared<
oauth2_internal::ExternalAccountAuthorizedUserCredentials>(
*std::move(info), client_factory_, cfg.options());
result = Decorate(std::move(creds), std::move(client_factory_),
cfg.options());
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The repository style guide (lines 104-108) rejects the use of auto when it hides StatusOr<T> or other return types. Please use explicit types instead of auto.

      nlohmann::json const json =
          nlohmann::json::parse(cfg.json_object(), nullptr, false);
      if (json.is_object()) {
        nlohmann::json::const_iterator const it = json.find("type");
        if (it != json.end() && it->is_string() &&
            *it == "external_account_authorized_user") {
          StatusOr<oauth2_internal::ExternalAccountAuthorizedUserInfo> info =
              oauth2_internal::ParseExternalAccountAuthorizedUserConfiguration(
                  cfg.json_object(), ec);
          if (!info) {
            result = MakeErrorCredentials(std::move(info).status());
            return;
          }
          std::shared_ptr<oauth2_internal::Credentials> creds =
              std::make_shared<
                  oauth2_internal::ExternalAccountAuthorizedUserCredentials>(
                  *std::move(info), client_factory_, cfg.options());
          result = Decorate(std::move(creds), std::move(client_factory_),
                            cfg.options());
          return;
        }
      }
References
  1. Reject Obscured Domain & Return Types: Flag and reject auto when it hides StatusOr, domain objects, protobuf messages/fields, or function return types. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — replaced auto with explicit types.

@sachinpro

Copy link
Copy Markdown
Contributor Author

@colinmoy Can we please /gcbrun?

@colinmoy

Copy link
Copy Markdown
Collaborator

/gcbrun

@sachinpro
sachinpro force-pushed the feat-external-account-authorized-user branch from 6cc466a to 48b4a01 Compare October 21, 2026 18:19
@sachinpro

sachinpro commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

@colinmoy I updated the PR because gcc-oldest-pr was failing. Please /gcbrun again.

@colinmoy

Copy link
Copy Markdown
Collaborator

/gcbrun

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.85864% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.41%. Comparing base (2e97e76) to head (febbd44).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...ternal/oauth2_external_account_credentials_test.cc 94.38% 10 Missing ⚠️
google/cloud/internal/unified_grpc_credentials.cc 75.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #16581      +/-   ##
==========================================
+ Coverage   92.38%   92.41%   +0.02%     
==========================================
  Files        2266     2266              
  Lines      219636   220009     +373     
==========================================
+ Hits       202904   203313     +409     
+ Misses      16732    16696      -36     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.


void visit(ExternalAccountConfig const& cfg) override {
auto const ec = internal::ErrorContext();
nlohmann::json const json =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block of code seems to duplicate most of the error checking below. Also, it introduces a new call to json::parse such that in non-external account authorized user instances, we end up parsing the same json twice. Let's look at trying to work this new credential into the existing external account cred flow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed all the changes from unified_rest_credentials.cc since we are now handling it inside ParseExternalAccountConfiguration.

If it looks good, please /gcbrun.

@scotthart

Copy link
Copy Markdown
Member

/gcbrun

When an external account credential configuration cannot be parsed or is
unsupported by gRPC C++'s internal parser (such as
external_account_authorized_user), GrpcExternalAccountCredentials() returns
nullptr. Passing nullptr directly into grpc::CompositeChannelCredentials()
causes a segmentation fault.

Check for null and return a GrpcErrorCredentialsAuthentication with
InvalidArgumentError instead of crashing.
@sachinpro

sachinpro commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

@scotthart @colinmoy I added a minor commit to fix a crash when unsupported config is sent to gRPC. Please /gcbrun again. Thanks!

I have verified this e2e on GCD.

@scotthart

Copy link
Copy Markdown
Member

/gcbrun

@scotthart

Copy link
Copy Markdown
Member

/gharun

@scotthart
scotthart merged commit c5125ad into googleapis:main Oct 23, 2026
67 checks passed

This branch was successfully deployed

1 active deployment
false — febbd44f Deployed Oct 22, 2026 by sachinpro via Save PR ref #12560
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants