chore: migrate to gitea
Some checks failed
golangci-lint / lint (push) Successful in 1m33s
Test / test (push) Failing after 2m16s

This commit is contained in:
2026-01-27 00:12:32 +01:00
parent 79d9f55fdc
commit f81c902ca6
3170 changed files with 1216494 additions and 1586 deletions

View File

@@ -0,0 +1,531 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccount
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"sort"
"strings"
"time"
"cloud.google.com/go/auth/internal"
"github.com/googleapis/gax-go/v2/internallog"
)
var (
// getenv aliases os.Getenv for testing
getenv = os.Getenv
)
const (
// AWS Signature Version 4 signing algorithm identifier.
awsAlgorithm = "AWS4-HMAC-SHA256"
// The termination string for the AWS credential scope value as defined in
// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
awsRequestType = "aws4_request"
// The AWS authorization header name for the security session token if available.
awsSecurityTokenHeader = "x-amz-security-token"
// The name of the header containing the session token for metadata endpoint calls
awsIMDSv2SessionTokenHeader = "X-aws-ec2-metadata-token"
awsIMDSv2SessionTTLHeader = "X-aws-ec2-metadata-token-ttl-seconds"
awsIMDSv2SessionTTL = "300"
// The AWS authorization header name for the auto-generated date.
awsDateHeader = "x-amz-date"
defaultRegionalCredentialVerificationURL = "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
// Supported AWS configuration environment variables.
awsAccessKeyIDEnvVar = "AWS_ACCESS_KEY_ID"
awsDefaultRegionEnvVar = "AWS_DEFAULT_REGION"
awsRegionEnvVar = "AWS_REGION"
awsSecretAccessKeyEnvVar = "AWS_SECRET_ACCESS_KEY"
awsSessionTokenEnvVar = "AWS_SESSION_TOKEN"
awsTimeFormatLong = "20060102T150405Z"
awsTimeFormatShort = "20060102"
awsProviderType = "aws"
)
type awsSubjectProvider struct {
EnvironmentID string
RegionURL string
RegionalCredVerificationURL string
CredVerificationURL string
IMDSv2SessionTokenURL string
TargetResource string
requestSigner *awsRequestSigner
region string
securityCredentialsProvider AwsSecurityCredentialsProvider
reqOpts *RequestOptions
Client *http.Client
logger *slog.Logger
}
func (sp *awsSubjectProvider) subjectToken(ctx context.Context) (string, error) {
// Set Defaults
if sp.RegionalCredVerificationURL == "" {
sp.RegionalCredVerificationURL = defaultRegionalCredentialVerificationURL
}
headers := make(map[string]string)
if sp.shouldUseMetadataServer() {
awsSessionToken, err := sp.getAWSSessionToken(ctx)
if err != nil {
return "", err
}
if awsSessionToken != "" {
headers[awsIMDSv2SessionTokenHeader] = awsSessionToken
}
}
awsSecurityCredentials, err := sp.getSecurityCredentials(ctx, headers)
if err != nil {
return "", err
}
if sp.region, err = sp.getRegion(ctx, headers); err != nil {
return "", err
}
sp.requestSigner = &awsRequestSigner{
RegionName: sp.region,
AwsSecurityCredentials: awsSecurityCredentials,
}
// Generate the signed request to AWS STS GetCallerIdentity API.
// Use the required regional endpoint. Otherwise, the request will fail.
req, err := http.NewRequestWithContext(ctx, "POST", strings.Replace(sp.RegionalCredVerificationURL, "{region}", sp.region, 1), nil)
if err != nil {
return "", err
}
// The full, canonical resource name of the workload identity pool
// provider, with or without the HTTPS prefix.
// Including this header as part of the signature is recommended to
// ensure data integrity.
if sp.TargetResource != "" {
req.Header.Set("x-goog-cloud-target-resource", sp.TargetResource)
}
sp.requestSigner.signRequest(req)
/*
The GCP STS endpoint expects the headers to be formatted as:
# [
# {key: 'x-amz-date', value: '...'},
# {key: 'Authorization', value: '...'},
# ...
# ]
# And then serialized as:
# quote(json.dumps({
# url: '...',
# method: 'POST',
# headers: [{key: 'x-amz-date', value: '...'}, ...]
# }))
*/
awsSignedReq := awsRequest{
URL: req.URL.String(),
Method: "POST",
}
for headerKey, headerList := range req.Header {
for _, headerValue := range headerList {
awsSignedReq.Headers = append(awsSignedReq.Headers, awsRequestHeader{
Key: headerKey,
Value: headerValue,
})
}
}
sort.Slice(awsSignedReq.Headers, func(i, j int) bool {
headerCompare := strings.Compare(awsSignedReq.Headers[i].Key, awsSignedReq.Headers[j].Key)
if headerCompare == 0 {
return strings.Compare(awsSignedReq.Headers[i].Value, awsSignedReq.Headers[j].Value) < 0
}
return headerCompare < 0
})
result, err := json.Marshal(awsSignedReq)
if err != nil {
return "", err
}
return url.QueryEscape(string(result)), nil
}
func (sp *awsSubjectProvider) providerType() string {
if sp.securityCredentialsProvider != nil {
return programmaticProviderType
}
return awsProviderType
}
func (sp *awsSubjectProvider) getAWSSessionToken(ctx context.Context) (string, error) {
if sp.IMDSv2SessionTokenURL == "" {
return "", nil
}
req, err := http.NewRequestWithContext(ctx, "PUT", sp.IMDSv2SessionTokenURL, nil)
if err != nil {
return "", err
}
req.Header.Set(awsIMDSv2SessionTTLHeader, awsIMDSv2SessionTTL)
sp.logger.DebugContext(ctx, "aws session token request", "request", internallog.HTTPRequest(req, nil))
resp, body, err := internal.DoRequest(sp.Client, req)
if err != nil {
return "", err
}
sp.logger.DebugContext(ctx, "aws session token response", "response", internallog.HTTPResponse(resp, body))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("credentials: unable to retrieve AWS session token: %s", body)
}
return string(body), nil
}
func (sp *awsSubjectProvider) getRegion(ctx context.Context, headers map[string]string) (string, error) {
if sp.securityCredentialsProvider != nil {
return sp.securityCredentialsProvider.AwsRegion(ctx, sp.reqOpts)
}
if canRetrieveRegionFromEnvironment() {
if envAwsRegion := getenv(awsRegionEnvVar); envAwsRegion != "" {
return envAwsRegion, nil
}
return getenv(awsDefaultRegionEnvVar), nil
}
if sp.RegionURL == "" {
return "", errors.New("credentials: unable to determine AWS region")
}
req, err := http.NewRequestWithContext(ctx, "GET", sp.RegionURL, nil)
if err != nil {
return "", err
}
for name, value := range headers {
req.Header.Add(name, value)
}
sp.logger.DebugContext(ctx, "aws region request", "request", internallog.HTTPRequest(req, nil))
resp, body, err := internal.DoRequest(sp.Client, req)
if err != nil {
return "", err
}
sp.logger.DebugContext(ctx, "aws region response", "response", internallog.HTTPResponse(resp, body))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("credentials: unable to retrieve AWS region - %s", body)
}
// This endpoint will return the region in format: us-east-2b.
// Only the us-east-2 part should be used.
bodyLen := len(body)
if bodyLen == 0 {
return "", nil
}
return string(body[:bodyLen-1]), nil
}
func (sp *awsSubjectProvider) getSecurityCredentials(ctx context.Context, headers map[string]string) (result *AwsSecurityCredentials, err error) {
if sp.securityCredentialsProvider != nil {
return sp.securityCredentialsProvider.AwsSecurityCredentials(ctx, sp.reqOpts)
}
if canRetrieveSecurityCredentialFromEnvironment() {
return &AwsSecurityCredentials{
AccessKeyID: getenv(awsAccessKeyIDEnvVar),
SecretAccessKey: getenv(awsSecretAccessKeyEnvVar),
SessionToken: getenv(awsSessionTokenEnvVar),
}, nil
}
roleName, err := sp.getMetadataRoleName(ctx, headers)
if err != nil {
return
}
credentials, err := sp.getMetadataSecurityCredentials(ctx, roleName, headers)
if err != nil {
return
}
if credentials.AccessKeyID == "" {
return result, errors.New("credentials: missing AccessKeyId credential")
}
if credentials.SecretAccessKey == "" {
return result, errors.New("credentials: missing SecretAccessKey credential")
}
return credentials, nil
}
func (sp *awsSubjectProvider) getMetadataSecurityCredentials(ctx context.Context, roleName string, headers map[string]string) (*AwsSecurityCredentials, error) {
var result *AwsSecurityCredentials
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s", sp.CredVerificationURL, roleName), nil)
if err != nil {
return result, err
}
for name, value := range headers {
req.Header.Add(name, value)
}
sp.logger.DebugContext(ctx, "aws security credential request", "request", internallog.HTTPRequest(req, nil))
resp, body, err := internal.DoRequest(sp.Client, req)
if err != nil {
return result, err
}
sp.logger.DebugContext(ctx, "aws security credential response", "response", internallog.HTTPResponse(resp, body))
if resp.StatusCode != http.StatusOK {
return result, fmt.Errorf("credentials: unable to retrieve AWS security credentials - %s", body)
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
func (sp *awsSubjectProvider) getMetadataRoleName(ctx context.Context, headers map[string]string) (string, error) {
if sp.CredVerificationURL == "" {
return "", errors.New("credentials: unable to determine the AWS metadata server security credentials endpoint")
}
req, err := http.NewRequestWithContext(ctx, "GET", sp.CredVerificationURL, nil)
if err != nil {
return "", err
}
for name, value := range headers {
req.Header.Add(name, value)
}
sp.logger.DebugContext(ctx, "aws metadata role request", "request", internallog.HTTPRequest(req, nil))
resp, body, err := internal.DoRequest(sp.Client, req)
if err != nil {
return "", err
}
sp.logger.DebugContext(ctx, "aws metadata role response", "response", internallog.HTTPResponse(resp, body))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("credentials: unable to retrieve AWS role name - %s", body)
}
return string(body), nil
}
// awsRequestSigner is a utility class to sign http requests using a AWS V4 signature.
type awsRequestSigner struct {
RegionName string
AwsSecurityCredentials *AwsSecurityCredentials
}
// signRequest adds the appropriate headers to an http.Request
// or returns an error if something prevented this.
func (rs *awsRequestSigner) signRequest(req *http.Request) error {
// req is assumed non-nil
signedRequest := cloneRequest(req)
timestamp := Now()
signedRequest.Header.Set("host", requestHost(req))
if rs.AwsSecurityCredentials.SessionToken != "" {
signedRequest.Header.Set(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SessionToken)
}
if signedRequest.Header.Get("date") == "" {
signedRequest.Header.Set(awsDateHeader, timestamp.Format(awsTimeFormatLong))
}
authorizationCode, err := rs.generateAuthentication(signedRequest, timestamp)
if err != nil {
return err
}
signedRequest.Header.Set("Authorization", authorizationCode)
req.Header = signedRequest.Header
return nil
}
func (rs *awsRequestSigner) generateAuthentication(req *http.Request, timestamp time.Time) (string, error) {
canonicalHeaderColumns, canonicalHeaderData := canonicalHeaders(req)
dateStamp := timestamp.Format(awsTimeFormatShort)
serviceName := ""
if splitHost := strings.Split(requestHost(req), "."); len(splitHost) > 0 {
serviceName = splitHost[0]
}
credentialScope := strings.Join([]string{dateStamp, rs.RegionName, serviceName, awsRequestType}, "/")
requestString, err := canonicalRequest(req, canonicalHeaderColumns, canonicalHeaderData)
if err != nil {
return "", err
}
requestHash, err := getSha256([]byte(requestString))
if err != nil {
return "", err
}
stringToSign := strings.Join([]string{awsAlgorithm, timestamp.Format(awsTimeFormatLong), credentialScope, requestHash}, "\n")
signingKey := []byte("AWS4" + rs.AwsSecurityCredentials.SecretAccessKey)
for _, signingInput := range []string{
dateStamp, rs.RegionName, serviceName, awsRequestType, stringToSign,
} {
signingKey, err = getHmacSha256(signingKey, []byte(signingInput))
if err != nil {
return "", err
}
}
return fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", awsAlgorithm, rs.AwsSecurityCredentials.AccessKeyID, credentialScope, canonicalHeaderColumns, hex.EncodeToString(signingKey)), nil
}
func getSha256(input []byte) (string, error) {
hash := sha256.New()
if _, err := hash.Write(input); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func getHmacSha256(key, input []byte) ([]byte, error) {
hash := hmac.New(sha256.New, key)
if _, err := hash.Write(input); err != nil {
return nil, err
}
return hash.Sum(nil), nil
}
func cloneRequest(r *http.Request) *http.Request {
r2 := new(http.Request)
*r2 = *r
if r.Header != nil {
r2.Header = make(http.Header, len(r.Header))
// Find total number of values.
headerCount := 0
for _, headerValues := range r.Header {
headerCount += len(headerValues)
}
copiedHeaders := make([]string, headerCount) // shared backing array for headers' values
for headerKey, headerValues := range r.Header {
headerCount = copy(copiedHeaders, headerValues)
r2.Header[headerKey] = copiedHeaders[:headerCount:headerCount]
copiedHeaders = copiedHeaders[headerCount:]
}
}
return r2
}
func canonicalPath(req *http.Request) string {
result := req.URL.EscapedPath()
if result == "" {
return "/"
}
return path.Clean(result)
}
func canonicalQuery(req *http.Request) string {
queryValues := req.URL.Query()
for queryKey := range queryValues {
sort.Strings(queryValues[queryKey])
}
return queryValues.Encode()
}
func canonicalHeaders(req *http.Request) (string, string) {
// Header keys need to be sorted alphabetically.
var headers []string
lowerCaseHeaders := make(http.Header)
for k, v := range req.Header {
k := strings.ToLower(k)
if _, ok := lowerCaseHeaders[k]; ok {
// include additional values
lowerCaseHeaders[k] = append(lowerCaseHeaders[k], v...)
} else {
headers = append(headers, k)
lowerCaseHeaders[k] = v
}
}
sort.Strings(headers)
var fullHeaders bytes.Buffer
for _, header := range headers {
headerValue := strings.Join(lowerCaseHeaders[header], ",")
fullHeaders.WriteString(header)
fullHeaders.WriteRune(':')
fullHeaders.WriteString(headerValue)
fullHeaders.WriteRune('\n')
}
return strings.Join(headers, ";"), fullHeaders.String()
}
func requestDataHash(req *http.Request) (string, error) {
var requestData []byte
if req.Body != nil {
requestBody, err := req.GetBody()
if err != nil {
return "", err
}
defer requestBody.Close()
requestData, err = internal.ReadAll(requestBody)
if err != nil {
return "", err
}
}
return getSha256(requestData)
}
func requestHost(req *http.Request) string {
if req.Host != "" {
return req.Host
}
return req.URL.Host
}
func canonicalRequest(req *http.Request, canonicalHeaderColumns, canonicalHeaderData string) (string, error) {
dataHash, err := requestDataHash(req)
if err != nil {
return "", err
}
return fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", req.Method, canonicalPath(req), canonicalQuery(req), canonicalHeaderData, canonicalHeaderColumns, dataHash), nil
}
type awsRequestHeader struct {
Key string `json:"key"`
Value string `json:"value"`
}
type awsRequest struct {
URL string `json:"url"`
Method string `json:"method"`
Headers []awsRequestHeader `json:"headers"`
}
// The AWS region can be provided through AWS_REGION or AWS_DEFAULT_REGION. Only one is
// required.
func canRetrieveRegionFromEnvironment() bool {
return getenv(awsRegionEnvVar) != "" || getenv(awsDefaultRegionEnvVar) != ""
}
// Check if both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are available.
func canRetrieveSecurityCredentialFromEnvironment() bool {
return getenv(awsAccessKeyIDEnvVar) != "" && getenv(awsSecretAccessKeyEnvVar) != ""
}
func (sp *awsSubjectProvider) shouldUseMetadataServer() bool {
return sp.securityCredentialsProvider == nil && (!canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment())
}

View File

@@ -0,0 +1,284 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccount
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"time"
"cloud.google.com/go/auth/internal"
)
const (
executableSupportedMaxVersion = 1
executableDefaultTimeout = 30 * time.Second
executableSource = "response"
executableProviderType = "executable"
outputFileSource = "output file"
allowExecutablesEnvVar = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"
jwtTokenType = "urn:ietf:params:oauth:token-type:jwt"
idTokenType = "urn:ietf:params:oauth:token-type:id_token"
saml2TokenType = "urn:ietf:params:oauth:token-type:saml2"
)
var (
serviceAccountImpersonationRE = regexp.MustCompile(`https://iamcredentials..+/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken`)
)
type nonCacheableError struct {
message string
}
func (nce nonCacheableError) Error() string {
return nce.message
}
// environment is a contract for testing
type environment interface {
existingEnv() []string
getenv(string) string
run(ctx context.Context, command string, env []string) ([]byte, error)
now() time.Time
}
type runtimeEnvironment struct{}
func (r runtimeEnvironment) existingEnv() []string {
return os.Environ()
}
func (r runtimeEnvironment) getenv(key string) string {
return os.Getenv(key)
}
func (r runtimeEnvironment) now() time.Time {
return time.Now().UTC()
}
func (r runtimeEnvironment) run(ctx context.Context, command string, env []string) ([]byte, error) {
splitCommand := strings.Fields(command)
cmd := exec.CommandContext(ctx, splitCommand[0], splitCommand[1:]...)
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, context.DeadlineExceeded
}
if exitError, ok := err.(*exec.ExitError); ok {
return nil, exitCodeError(exitError)
}
return nil, executableError(err)
}
bytesStdout := bytes.TrimSpace(stdout.Bytes())
if len(bytesStdout) > 0 {
return bytesStdout, nil
}
return bytes.TrimSpace(stderr.Bytes()), nil
}
type executableSubjectProvider struct {
Command string
Timeout time.Duration
OutputFile string
client *http.Client
opts *Options
env environment
}
type executableResponse struct {
Version int `json:"version,omitempty"`
Success *bool `json:"success,omitempty"`
TokenType string `json:"token_type,omitempty"`
ExpirationTime int64 `json:"expiration_time,omitempty"`
IDToken string `json:"id_token,omitempty"`
SamlResponse string `json:"saml_response,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
func (sp *executableSubjectProvider) parseSubjectTokenFromSource(response []byte, source string, now int64) (string, error) {
var result executableResponse
if err := json.Unmarshal(response, &result); err != nil {
return "", jsonParsingError(source, string(response))
}
// Validate
if result.Version == 0 {
return "", missingFieldError(source, "version")
}
if result.Success == nil {
return "", missingFieldError(source, "success")
}
if !*result.Success {
if result.Code == "" || result.Message == "" {
return "", malformedFailureError()
}
return "", userDefinedError(result.Code, result.Message)
}
if result.Version > executableSupportedMaxVersion || result.Version < 0 {
return "", unsupportedVersionError(source, result.Version)
}
if result.ExpirationTime == 0 && sp.OutputFile != "" {
return "", missingFieldError(source, "expiration_time")
}
if result.TokenType == "" {
return "", missingFieldError(source, "token_type")
}
if result.ExpirationTime != 0 && result.ExpirationTime < now {
return "", tokenExpiredError()
}
switch result.TokenType {
case jwtTokenType, idTokenType:
if result.IDToken == "" {
return "", missingFieldError(source, "id_token")
}
return result.IDToken, nil
case saml2TokenType:
if result.SamlResponse == "" {
return "", missingFieldError(source, "saml_response")
}
return result.SamlResponse, nil
default:
return "", tokenTypeError(source)
}
}
func (sp *executableSubjectProvider) subjectToken(ctx context.Context) (string, error) {
if token, err := sp.getTokenFromOutputFile(); token != "" || err != nil {
return token, err
}
return sp.getTokenFromExecutableCommand(ctx)
}
func (sp *executableSubjectProvider) providerType() string {
return executableProviderType
}
func (sp *executableSubjectProvider) getTokenFromOutputFile() (token string, err error) {
if sp.OutputFile == "" {
// This ExecutableCredentialSource doesn't use an OutputFile.
return "", nil
}
file, err := os.Open(sp.OutputFile)
if err != nil {
// No OutputFile found. Hasn't been created yet, so skip it.
return "", nil
}
defer file.Close()
data, err := internal.ReadAll(file)
if err != nil || len(data) == 0 {
// Cachefile exists, but no data found. Get new credential.
return "", nil
}
token, err = sp.parseSubjectTokenFromSource(data, outputFileSource, sp.env.now().Unix())
if err != nil {
if _, ok := err.(nonCacheableError); ok {
// If the cached token is expired we need a new token,
// and if the cache contains a failure, we need to try again.
return "", nil
}
// There was an error in the cached token, and the developer should be aware of it.
return "", err
}
// Token parsing succeeded. Use found token.
return token, nil
}
func (sp *executableSubjectProvider) executableEnvironment() []string {
result := sp.env.existingEnv()
result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", sp.opts.Audience))
result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", sp.opts.SubjectTokenType))
result = append(result, "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0")
if sp.opts.ServiceAccountImpersonationURL != "" {
matches := serviceAccountImpersonationRE.FindStringSubmatch(sp.opts.ServiceAccountImpersonationURL)
if matches != nil {
result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=%v", matches[1]))
}
}
if sp.OutputFile != "" {
result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v", sp.OutputFile))
}
return result
}
func (sp *executableSubjectProvider) getTokenFromExecutableCommand(ctx context.Context) (string, error) {
// For security reasons, we need our consumers to set this environment variable to allow executables to be run.
if sp.env.getenv(allowExecutablesEnvVar) != "1" {
return "", errors.New("credentials: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run")
}
ctx, cancel := context.WithDeadline(ctx, sp.env.now().Add(sp.Timeout))
defer cancel()
output, err := sp.env.run(ctx, sp.Command, sp.executableEnvironment())
if err != nil {
return "", err
}
return sp.parseSubjectTokenFromSource(output, executableSource, sp.env.now().Unix())
}
func missingFieldError(source, field string) error {
return fmt.Errorf("credentials: %q missing %q field", source, field)
}
func jsonParsingError(source, data string) error {
return fmt.Errorf("credentials: unable to parse %q: %v", source, data)
}
func malformedFailureError() error {
return nonCacheableError{"credentials: response must include `error` and `message` fields when unsuccessful"}
}
func userDefinedError(code, message string) error {
return nonCacheableError{fmt.Sprintf("credentials: response contains unsuccessful response: (%v) %v", code, message)}
}
func unsupportedVersionError(source string, version int) error {
return fmt.Errorf("credentials: %v contains unsupported version: %v", source, version)
}
func tokenExpiredError() error {
return nonCacheableError{"credentials: the token returned by the executable is expired"}
}
func tokenTypeError(source string) error {
return fmt.Errorf("credentials: %v contains unsupported token type", source)
}
func exitCodeError(err *exec.ExitError) error {
return fmt.Errorf("credentials: executable command failed with exit code %v: %w", err.ExitCode(), err)
}
func executableError(err error) error {
return fmt.Errorf("credentials: executable command failed: %w", err)
}

View File

@@ -0,0 +1,431 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccount
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"cloud.google.com/go/auth"
"cloud.google.com/go/auth/credentials/internal/impersonate"
"cloud.google.com/go/auth/credentials/internal/stsexchange"
"cloud.google.com/go/auth/internal/credsfile"
"github.com/googleapis/gax-go/v2/internallog"
)
const (
timeoutMinimum = 5 * time.Second
timeoutMaximum = 120 * time.Second
universeDomainPlaceholder = "UNIVERSE_DOMAIN"
defaultTokenURL = "https://sts.UNIVERSE_DOMAIN/v1/token"
defaultUniverseDomain = "googleapis.com"
)
var (
// Now aliases time.Now for testing
Now = func() time.Time {
return time.Now().UTC()
}
validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`)
)
// Options stores the configuration for fetching tokens with external credentials.
type Options struct {
// Audience is the Secure Token Service (STS) audience which contains the resource name for the workload
// identity pool or the workforce pool and the provider identifier in that pool.
Audience string
// SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec
// e.g. `urn:ietf:params:oauth:token-type:jwt`.
SubjectTokenType string
// TokenURL is the STS token exchange endpoint.
TokenURL string
// TokenInfoURL is the token_info endpoint used to retrieve the account related information (
// user attributes like account identifier, eg. email, username, uid, etc). This is
// needed for gCloud session account identification.
TokenInfoURL string
// ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only
// required for workload identity pools when APIs to be accessed have not integrated with UberMint.
ServiceAccountImpersonationURL string
// ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation
// token will be valid for.
ServiceAccountImpersonationLifetimeSeconds int
// ClientSecret is currently only required if token_info endpoint also
// needs to be called with the generated GCP access token. When provided, STS will be
// called with additional basic authentication using client_id as username and client_secret as password.
ClientSecret string
// ClientID is only required in conjunction with ClientSecret, as described above.
ClientID string
// CredentialSource contains the necessary information to retrieve the token itself, as well
// as some environmental information.
CredentialSource *credsfile.CredentialSource
// QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries
// will set the x-goog-user-project which overrides the project associated with the credentials.
QuotaProjectID string
// Scopes contains the desired scopes for the returned access token.
Scopes []string
// WorkforcePoolUserProject should be set when it is a workforce pool and
// not a workload identity pool. The underlying principal must still have
// serviceusage.services.use IAM permission to use the project for
// billing/quota. Optional.
WorkforcePoolUserProject string
// UniverseDomain is the default service domain for a given Cloud universe.
// This value will be used in the default STS token URL. The default value
// is "googleapis.com". It will not be used if TokenURL is set. Optional.
UniverseDomain string
// SubjectTokenProvider is an optional token provider for OIDC/SAML
// credentials. One of SubjectTokenProvider, AWSSecurityCredentialProvider
// or CredentialSource must be provided. Optional.
SubjectTokenProvider SubjectTokenProvider
// AwsSecurityCredentialsProvider is an AWS Security Credential provider
// for AWS credentials. One of SubjectTokenProvider,
// AWSSecurityCredentialProvider or CredentialSource must be provided. Optional.
AwsSecurityCredentialsProvider AwsSecurityCredentialsProvider
// Client for token request.
Client *http.Client
// IsDefaultClient marks whether the client passed in is a default client that can be overriden.
// This is important for X509 credentials which should create a new client if the default was used
// but should respect a client explicitly passed in by the user.
IsDefaultClient bool
// Logger is used for debug logging. If provided, logging will be enabled
// at the loggers configured level. By default logging is disabled unless
// enabled by setting GOOGLE_SDK_GO_LOGGING_LEVEL in which case a default
// logger will be used. Optional.
Logger *slog.Logger
}
// SubjectTokenProvider can be used to supply a subject token to exchange for a
// GCP access token.
type SubjectTokenProvider interface {
// SubjectToken should return a valid subject token or an error.
// The external account token provider does not cache the returned subject
// token, so caching logic should be implemented in the provider to prevent
// multiple requests for the same subject token.
SubjectToken(ctx context.Context, opts *RequestOptions) (string, error)
}
// RequestOptions contains information about the requested subject token or AWS
// security credentials from the Google external account credential.
type RequestOptions struct {
// Audience is the requested audience for the external account credential.
Audience string
// Subject token type is the requested subject token type for the external
// account credential. Expected values include:
// “urn:ietf:params:oauth:token-type:jwt”
// “urn:ietf:params:oauth:token-type:id-token”
// “urn:ietf:params:oauth:token-type:saml2”
// “urn:ietf:params:aws:token-type:aws4_request”
SubjectTokenType string
}
// AwsSecurityCredentialsProvider can be used to supply AwsSecurityCredentials
// and an AWS Region to exchange for a GCP access token.
type AwsSecurityCredentialsProvider interface {
// AwsRegion should return the AWS region or an error.
AwsRegion(ctx context.Context, opts *RequestOptions) (string, error)
// GetAwsSecurityCredentials should return a valid set of
// AwsSecurityCredentials or an error. The external account token provider
// does not cache the returned security credentials, so caching logic should
// be implemented in the provider to prevent multiple requests for the
// same security credentials.
AwsSecurityCredentials(ctx context.Context, opts *RequestOptions) (*AwsSecurityCredentials, error)
}
// AwsSecurityCredentials models AWS security credentials.
type AwsSecurityCredentials struct {
// AccessKeyId is the AWS Access Key ID - Required.
AccessKeyID string `json:"AccessKeyID"`
// SecretAccessKey is the AWS Secret Access Key - Required.
SecretAccessKey string `json:"SecretAccessKey"`
// SessionToken is the AWS Session token. This should be provided for
// temporary AWS security credentials - Optional.
SessionToken string `json:"Token"`
}
func (o *Options) validate() error {
if o.Audience == "" {
return fmt.Errorf("externalaccount: Audience must be set")
}
if o.SubjectTokenType == "" {
return fmt.Errorf("externalaccount: Subject token type must be set")
}
if o.WorkforcePoolUserProject != "" {
if valid := validWorkforceAudiencePattern.MatchString(o.Audience); !valid {
return fmt.Errorf("externalaccount: workforce_pool_user_project should not be set for non-workforce pool credentials")
}
}
count := 0
if o.CredentialSource != nil {
count++
}
if o.SubjectTokenProvider != nil {
count++
}
if o.AwsSecurityCredentialsProvider != nil {
count++
}
if count == 0 {
return fmt.Errorf("externalaccount: one of CredentialSource, SubjectTokenProvider, or AwsSecurityCredentialsProvider must be set")
}
if count > 1 {
return fmt.Errorf("externalaccount: only one of CredentialSource, SubjectTokenProvider, or AwsSecurityCredentialsProvider must be set")
}
return nil
}
// client returns the http client that should be used for the token exchange. If a non-default client
// is provided, then the client configured in the options will always be returned. If a default client
// is provided and the options are configured for X509 credentials, a new client will be created.
func (o *Options) client() (*http.Client, error) {
// If a client was provided and no override certificate config location was provided, use the provided client.
if o.CredentialSource == nil || o.CredentialSource.Certificate == nil || (!o.IsDefaultClient && o.CredentialSource.Certificate.CertificateConfigLocation == "") {
return o.Client, nil
}
// If a new client should be created, validate and use the certificate source to create a new mTLS client.
cert := o.CredentialSource.Certificate
if !cert.UseDefaultCertificateConfig && cert.CertificateConfigLocation == "" {
return nil, errors.New("credentials: \"certificate\" object must either specify a certificate_config_location or use_default_certificate_config should be true")
}
if cert.UseDefaultCertificateConfig && cert.CertificateConfigLocation != "" {
return nil, errors.New("credentials: \"certificate\" object cannot specify both a certificate_config_location and use_default_certificate_config=true")
}
return createX509Client(cert.CertificateConfigLocation)
}
// resolveTokenURL sets the default STS token endpoint with the configured
// universe domain.
func (o *Options) resolveTokenURL() {
if o.TokenURL != "" {
return
} else if o.UniverseDomain != "" {
o.TokenURL = strings.Replace(defaultTokenURL, universeDomainPlaceholder, o.UniverseDomain, 1)
} else {
o.TokenURL = strings.Replace(defaultTokenURL, universeDomainPlaceholder, defaultUniverseDomain, 1)
}
}
// NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider]
// configured with the provided options.
func NewTokenProvider(opts *Options) (auth.TokenProvider, error) {
if err := opts.validate(); err != nil {
return nil, err
}
opts.resolveTokenURL()
logger := internallog.New(opts.Logger)
stp, err := newSubjectTokenProvider(opts)
if err != nil {
return nil, err
}
client, err := opts.client()
if err != nil {
return nil, err
}
tp := &tokenProvider{
client: client,
opts: opts,
stp: stp,
logger: logger,
}
if opts.ServiceAccountImpersonationURL == "" {
return auth.NewCachedTokenProvider(tp, nil), nil
}
scopes := make([]string, len(opts.Scopes))
copy(scopes, opts.Scopes)
// needed for impersonation
tp.opts.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
imp, err := impersonate.NewTokenProvider(&impersonate.Options{
Client: client,
URL: opts.ServiceAccountImpersonationURL,
Scopes: scopes,
Tp: auth.NewCachedTokenProvider(tp, nil),
TokenLifetimeSeconds: opts.ServiceAccountImpersonationLifetimeSeconds,
Logger: logger,
})
if err != nil {
return nil, err
}
return auth.NewCachedTokenProvider(imp, nil), nil
}
type subjectTokenProvider interface {
subjectToken(ctx context.Context) (string, error)
providerType() string
}
// tokenProvider is the provider that handles external credentials. It is used to retrieve Tokens.
type tokenProvider struct {
client *http.Client
logger *slog.Logger
opts *Options
stp subjectTokenProvider
}
func (tp *tokenProvider) Token(ctx context.Context) (*auth.Token, error) {
subjectToken, err := tp.stp.subjectToken(ctx)
if err != nil {
return nil, err
}
stsRequest := &stsexchange.TokenRequest{
GrantType: stsexchange.GrantType,
Audience: tp.opts.Audience,
Scope: tp.opts.Scopes,
RequestedTokenType: stsexchange.TokenType,
SubjectToken: subjectToken,
SubjectTokenType: tp.opts.SubjectTokenType,
}
header := make(http.Header)
header.Set("Content-Type", "application/x-www-form-urlencoded")
header.Add("x-goog-api-client", getGoogHeaderValue(tp.opts, tp.stp))
clientAuth := stsexchange.ClientAuthentication{
AuthStyle: auth.StyleInHeader,
ClientID: tp.opts.ClientID,
ClientSecret: tp.opts.ClientSecret,
}
var options map[string]interface{}
// Do not pass workforce_pool_user_project when client authentication is used.
// The client ID is sufficient for determining the user project.
if tp.opts.WorkforcePoolUserProject != "" && tp.opts.ClientID == "" {
options = map[string]interface{}{
"userProject": tp.opts.WorkforcePoolUserProject,
}
}
stsResp, err := stsexchange.ExchangeToken(ctx, &stsexchange.Options{
Client: tp.client,
Endpoint: tp.opts.TokenURL,
Request: stsRequest,
Authentication: clientAuth,
Headers: header,
ExtraOpts: options,
Logger: tp.logger,
})
if err != nil {
return nil, err
}
tok := &auth.Token{
Value: stsResp.AccessToken,
Type: stsResp.TokenType,
}
// The RFC8693 doesn't define the explicit 0 of "expires_in" field behavior.
if stsResp.ExpiresIn <= 0 {
return nil, fmt.Errorf("credentials: got invalid expiry from security token service")
}
tok.Expiry = Now().Add(time.Duration(stsResp.ExpiresIn) * time.Second)
return tok, nil
}
// newSubjectTokenProvider determines the type of credsfile.CredentialSource needed to create a
// subjectTokenProvider
func newSubjectTokenProvider(o *Options) (subjectTokenProvider, error) {
logger := internallog.New(o.Logger)
reqOpts := &RequestOptions{Audience: o.Audience, SubjectTokenType: o.SubjectTokenType}
if o.AwsSecurityCredentialsProvider != nil {
return &awsSubjectProvider{
securityCredentialsProvider: o.AwsSecurityCredentialsProvider,
TargetResource: o.Audience,
reqOpts: reqOpts,
logger: logger,
}, nil
} else if o.SubjectTokenProvider != nil {
return &programmaticProvider{stp: o.SubjectTokenProvider, opts: reqOpts}, nil
} else if len(o.CredentialSource.EnvironmentID) > 3 && o.CredentialSource.EnvironmentID[:3] == "aws" {
if awsVersion, err := strconv.Atoi(o.CredentialSource.EnvironmentID[3:]); err == nil {
if awsVersion != 1 {
return nil, fmt.Errorf("credentials: aws version '%d' is not supported in the current build", awsVersion)
}
awsProvider := &awsSubjectProvider{
EnvironmentID: o.CredentialSource.EnvironmentID,
RegionURL: o.CredentialSource.RegionURL,
RegionalCredVerificationURL: o.CredentialSource.RegionalCredVerificationURL,
CredVerificationURL: o.CredentialSource.URL,
TargetResource: o.Audience,
Client: o.Client,
logger: logger,
}
if o.CredentialSource.IMDSv2SessionTokenURL != "" {
awsProvider.IMDSv2SessionTokenURL = o.CredentialSource.IMDSv2SessionTokenURL
}
return awsProvider, nil
}
} else if o.CredentialSource.File != "" {
return &fileSubjectProvider{File: o.CredentialSource.File, Format: o.CredentialSource.Format}, nil
} else if o.CredentialSource.URL != "" {
return &urlSubjectProvider{
URL: o.CredentialSource.URL,
Headers: o.CredentialSource.Headers,
Format: o.CredentialSource.Format,
Client: o.Client,
Logger: logger,
}, nil
} else if o.CredentialSource.Executable != nil {
ec := o.CredentialSource.Executable
if ec.Command == "" {
return nil, errors.New("credentials: missing `command` field — executable command must be provided")
}
execProvider := &executableSubjectProvider{}
execProvider.Command = ec.Command
if ec.TimeoutMillis == 0 {
execProvider.Timeout = executableDefaultTimeout
} else {
execProvider.Timeout = time.Duration(ec.TimeoutMillis) * time.Millisecond
if execProvider.Timeout < timeoutMinimum || execProvider.Timeout > timeoutMaximum {
return nil, fmt.Errorf("credentials: invalid `timeout_millis` field — executable timeout must be between %v and %v seconds", timeoutMinimum.Seconds(), timeoutMaximum.Seconds())
}
}
execProvider.OutputFile = ec.OutputFile
execProvider.client = o.Client
execProvider.opts = o
execProvider.env = runtimeEnvironment{}
return execProvider, nil
} else if o.CredentialSource.Certificate != nil {
cert := o.CredentialSource.Certificate
if !cert.UseDefaultCertificateConfig && cert.CertificateConfigLocation == "" {
return nil, errors.New("credentials: \"certificate\" object must either specify a certificate_config_location or use_default_certificate_config should be true")
}
if cert.UseDefaultCertificateConfig && cert.CertificateConfigLocation != "" {
return nil, errors.New("credentials: \"certificate\" object cannot specify both a certificate_config_location and use_default_certificate_config=true")
}
return &x509Provider{
TrustChainPath: o.CredentialSource.Certificate.TrustChainPath,
ConfigFilePath: o.CredentialSource.Certificate.CertificateConfigLocation,
}, nil
}
return nil, errors.New("credentials: unable to parse credential source")
}
func getGoogHeaderValue(conf *Options, p subjectTokenProvider) string {
return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t",
goVersion(),
"unknown",
p.providerType(),
conf.ServiceAccountImpersonationURL != "",
conf.ServiceAccountImpersonationLifetimeSeconds != 0)
}

View File

@@ -0,0 +1,78 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccount
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"cloud.google.com/go/auth/internal"
"cloud.google.com/go/auth/internal/credsfile"
)
const (
fileProviderType = "file"
)
type fileSubjectProvider struct {
File string
Format *credsfile.Format
}
func (sp *fileSubjectProvider) subjectToken(context.Context) (string, error) {
tokenFile, err := os.Open(sp.File)
if err != nil {
return "", fmt.Errorf("credentials: failed to open credential file %q: %w", sp.File, err)
}
defer tokenFile.Close()
tokenBytes, err := internal.ReadAll(tokenFile)
if err != nil {
return "", fmt.Errorf("credentials: failed to read credential file: %w", err)
}
tokenBytes = bytes.TrimSpace(tokenBytes)
if sp.Format == nil {
return string(tokenBytes), nil
}
switch sp.Format.Type {
case fileTypeJSON:
jsonData := make(map[string]interface{})
err = json.Unmarshal(tokenBytes, &jsonData)
if err != nil {
return "", fmt.Errorf("credentials: failed to unmarshal subject token file: %w", err)
}
val, ok := jsonData[sp.Format.SubjectTokenFieldName]
if !ok {
return "", errors.New("credentials: provided subject_token_field_name not found in credentials")
}
token, ok := val.(string)
if !ok {
return "", errors.New("credentials: improperly formatted subject token")
}
return token, nil
case fileTypeText:
return string(tokenBytes), nil
default:
return "", errors.New("credentials: invalid credential_source file format type: " + sp.Format.Type)
}
}
func (sp *fileSubjectProvider) providerType() string {
return fileProviderType
}

View File

@@ -0,0 +1,74 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccount
import (
"runtime"
"strings"
"unicode"
)
var (
// version is a package internal global variable for testing purposes.
version = runtime.Version
)
// versionUnknown is only used when the runtime version cannot be determined.
const versionUnknown = "UNKNOWN"
// goVersion returns a Go runtime version derived from the runtime environment
// that is modified to be suitable for reporting in a header, meaning it has no
// whitespace. If it is unable to determine the Go runtime version, it returns
// versionUnknown.
func goVersion() string {
const develPrefix = "devel +"
s := version()
if strings.HasPrefix(s, develPrefix) {
s = s[len(develPrefix):]
if p := strings.IndexFunc(s, unicode.IsSpace); p >= 0 {
s = s[:p]
}
return s
} else if p := strings.IndexFunc(s, unicode.IsSpace); p >= 0 {
s = s[:p]
}
notSemverRune := func(r rune) bool {
return !strings.ContainsRune("0123456789.", r)
}
if strings.HasPrefix(s, "go1") {
s = s[2:]
var prerelease string
if p := strings.IndexFunc(s, notSemverRune); p >= 0 {
s, prerelease = s[:p], s[p:]
}
if strings.HasSuffix(s, ".") {
s += "0"
} else if strings.Count(s, ".") < 2 {
s += ".0"
}
if prerelease != "" {
// Some release candidates already have a dash in them.
if !strings.HasPrefix(prerelease, "-") {
prerelease = "-" + prerelease
}
s += prerelease
}
return s
}
return versionUnknown
}

View File

@@ -0,0 +1,30 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccount
import "context"
type programmaticProvider struct {
opts *RequestOptions
stp SubjectTokenProvider
}
func (pp *programmaticProvider) providerType() string {
return programmaticProviderType
}
func (pp *programmaticProvider) subjectToken(ctx context.Context) (string, error) {
return pp.stp.SubjectToken(ctx, pp.opts)
}

View File

@@ -0,0 +1,93 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccount
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"cloud.google.com/go/auth/internal"
"cloud.google.com/go/auth/internal/credsfile"
"github.com/googleapis/gax-go/v2/internallog"
)
const (
fileTypeText = "text"
fileTypeJSON = "json"
urlProviderType = "url"
programmaticProviderType = "programmatic"
x509ProviderType = "x509"
)
type urlSubjectProvider struct {
URL string
Headers map[string]string
Format *credsfile.Format
Client *http.Client
Logger *slog.Logger
}
func (sp *urlSubjectProvider) subjectToken(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", sp.URL, nil)
if err != nil {
return "", fmt.Errorf("credentials: HTTP request for URL-sourced credential failed: %w", err)
}
for key, val := range sp.Headers {
req.Header.Add(key, val)
}
sp.Logger.DebugContext(ctx, "url subject token request", "request", internallog.HTTPRequest(req, nil))
resp, body, err := internal.DoRequest(sp.Client, req)
if err != nil {
return "", fmt.Errorf("credentials: invalid response when retrieving subject token: %w", err)
}
sp.Logger.DebugContext(ctx, "url subject token response", "response", internallog.HTTPResponse(resp, body))
if c := resp.StatusCode; c < http.StatusOK || c >= http.StatusMultipleChoices {
return "", fmt.Errorf("credentials: status code %d: %s", c, body)
}
if sp.Format == nil {
return string(body), nil
}
switch sp.Format.Type {
case "json":
jsonData := make(map[string]interface{})
err = json.Unmarshal(body, &jsonData)
if err != nil {
return "", fmt.Errorf("credentials: failed to unmarshal subject token file: %w", err)
}
val, ok := jsonData[sp.Format.SubjectTokenFieldName]
if !ok {
return "", errors.New("credentials: provided subject_token_field_name not found in credentials")
}
token, ok := val.(string)
if !ok {
return "", errors.New("credentials: improperly formatted subject token")
}
return token, nil
case fileTypeText:
return string(body), nil
default:
return "", errors.New("credentials: invalid credential_source file format type: " + sp.Format.Type)
}
}
func (sp *urlSubjectProvider) providerType() string {
return urlProviderType
}

View File

@@ -0,0 +1,220 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccount
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"strings"
"time"
"cloud.google.com/go/auth/internal/transport/cert"
)
// x509Provider implements the subjectTokenProvider type for x509 workload
// identity credentials. This provider retrieves and formats a JSON array
// containing the leaf certificate and trust chain (if provided) as
// base64-encoded strings. This JSON array serves as the subject token for
// mTLS authentication.
type x509Provider struct {
// TrustChainPath is the path to the file containing the trust chain certificates.
// The file should contain one or more PEM-encoded certificates.
TrustChainPath string
// ConfigFilePath is the path to the configuration file containing the path
// to the leaf certificate file.
ConfigFilePath string
}
const pemCertificateHeader = "-----BEGIN CERTIFICATE-----"
func (xp *x509Provider) providerType() string {
return x509ProviderType
}
// loadLeafCertificate loads and parses the leaf certificate from the specified
// configuration file. It retrieves the certificate path from the config file,
// reads the certificate file, and parses the certificate data.
func loadLeafCertificate(configFilePath string) (*x509.Certificate, error) {
// Get the path to the certificate file from the configuration file.
path, err := cert.GetCertificatePath(configFilePath)
if err != nil {
return nil, fmt.Errorf("failed to get certificate path from config file: %w", err)
}
leafCertBytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read leaf certificate file: %w", err)
}
// Parse the certificate bytes.
return parseCertificate(leafCertBytes)
}
// encodeCert encodes a x509.Certificate to a base64 string.
func encodeCert(cert *x509.Certificate) string {
// cert.Raw contains the raw DER-encoded certificate. Encode the raw certificate bytes to base64.
return base64.StdEncoding.EncodeToString(cert.Raw)
}
// parseCertificate parses a PEM-encoded certificate from the given byte slice.
func parseCertificate(certData []byte) (*x509.Certificate, error) {
if len(certData) == 0 {
return nil, errors.New("invalid certificate data: empty input")
}
// Decode the PEM-encoded data.
block, _ := pem.Decode(certData)
if block == nil {
return nil, errors.New("invalid PEM-encoded certificate data: no PEM block found")
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("invalid PEM-encoded certificate data: expected CERTIFICATE block type, got %s", block.Type)
}
// Parse the DER-encoded certificate.
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return certificate, nil
}
// readTrustChain reads a file of PEM-encoded X.509 certificates and returns a slice of parsed certificates.
// It splits the file content into PEM certificate blocks and parses each one.
func readTrustChain(trustChainPath string) ([]*x509.Certificate, error) {
certificateTrustChain := []*x509.Certificate{}
// If no trust chain path is provided, return an empty slice.
if trustChainPath == "" {
return certificateTrustChain, nil
}
// Read the trust chain file.
trustChainData, err := os.ReadFile(trustChainPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("trust chain file not found: %w", err)
}
return nil, fmt.Errorf("failed to read trust chain file: %w", err)
}
// Split the file content into PEM certificate blocks.
certBlocks := strings.Split(string(trustChainData), pemCertificateHeader)
// Iterate over each certificate block.
for _, certBlock := range certBlocks {
// Trim whitespace from the block.
certBlock = strings.TrimSpace(certBlock)
if certBlock != "" {
// Add the PEM header to the block.
certData := pemCertificateHeader + "\n" + certBlock
// Parse the certificate data.
cert, err := parseCertificate([]byte(certData))
if err != nil {
return nil, fmt.Errorf("error parsing certificate from trust chain file: %w", err)
}
// Append the certificate to the trust chain.
certificateTrustChain = append(certificateTrustChain, cert)
}
}
return certificateTrustChain, nil
}
// subjectToken retrieves the X.509 subject token. It loads the leaf
// certificate and, if a trust chain path is configured, the trust chain
// certificates. It then constructs a JSON array containing the base64-encoded
// leaf certificate and each base64-encoded certificate in the trust chain.
// The leaf certificate must be at the top of the trust chain file. This JSON
// array is used as the subject token for mTLS authentication.
func (xp *x509Provider) subjectToken(context.Context) (string, error) {
// Load the leaf certificate.
leafCert, err := loadLeafCertificate(xp.ConfigFilePath)
if err != nil {
return "", fmt.Errorf("failed to load leaf certificate: %w", err)
}
// Read the trust chain.
trustChain, err := readTrustChain(xp.TrustChainPath)
if err != nil {
return "", fmt.Errorf("failed to read trust chain: %w", err)
}
// Initialize the certificate chain with the leaf certificate.
certChain := []string{encodeCert(leafCert)}
// If there is a trust chain, add certificates to the certificate chain.
if len(trustChain) > 0 {
firstCert := encodeCert(trustChain[0])
// If the first certificate in the trust chain is not the same as the leaf certificate, add it to the chain.
if firstCert != certChain[0] {
certChain = append(certChain, firstCert)
}
// Iterate over the remaining certificates in the trust chain.
for i := 1; i < len(trustChain); i++ {
encoded := encodeCert(trustChain[i])
// Return an error if the current certificate is the same as the leaf certificate.
if encoded == certChain[0] {
return "", errors.New("the leaf certificate must be at the top of the trust chain file")
}
// Add the current certificate to the chain.
certChain = append(certChain, encoded)
}
}
// Convert the certificate chain to a JSON array of base64-encoded strings.
jsonChain, err := json.Marshal(certChain)
if err != nil {
return "", fmt.Errorf("failed to format certificate data: %w", err)
}
// Return the JSON-formatted certificate chain.
return string(jsonChain), nil
}
// createX509Client creates a new client that is configured with mTLS, using the
// certificate configuration specified in the credential source.
func createX509Client(certificateConfigLocation string) (*http.Client, error) {
certProvider, err := cert.NewWorkloadX509CertProvider(certificateConfigLocation)
if err != nil {
return nil, err
}
trans := http.DefaultTransport.(*http.Transport).Clone()
trans.TLSClientConfig = &tls.Config{
GetClientCertificate: certProvider,
}
// Create a client with default settings plus the X509 workload cert and key.
client := &http.Client{
Transport: trans,
Timeout: 30 * time.Second,
}
return client, nil
}

View File

@@ -0,0 +1,115 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package externalaccountuser
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"cloud.google.com/go/auth"
"cloud.google.com/go/auth/credentials/internal/stsexchange"
"cloud.google.com/go/auth/internal"
"github.com/googleapis/gax-go/v2/internallog"
)
// Options stores the configuration for fetching tokens with external authorized
// user credentials.
type Options struct {
// Audience is the Secure Token Service (STS) audience which contains the
// resource name for the workforce pool and the provider identifier in that
// pool.
Audience string
// RefreshToken is the OAuth 2.0 refresh token.
RefreshToken string
// TokenURL is the STS token exchange endpoint for refresh.
TokenURL string
// TokenInfoURL is the STS endpoint URL for token introspection. Optional.
TokenInfoURL string
// ClientID is only required in conjunction with ClientSecret, as described
// below.
ClientID string
// ClientSecret is currently only required if token_info endpoint also needs
// to be called with the generated a cloud access token. When provided, STS
// will be called with additional basic authentication using client_id as
// username and client_secret as password.
ClientSecret string
// Scopes contains the desired scopes for the returned access token.
Scopes []string
// Client for token request.
Client *http.Client
// Logger for logging.
Logger *slog.Logger
}
func (c *Options) validate() bool {
return c.ClientID != "" && c.ClientSecret != "" && c.RefreshToken != "" && c.TokenURL != ""
}
// NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider]
// configured with the provided options.
func NewTokenProvider(opts *Options) (auth.TokenProvider, error) {
if !opts.validate() {
return nil, errors.New("credentials: invalid external_account_authorized_user configuration")
}
tp := &tokenProvider{
o: opts,
}
return auth.NewCachedTokenProvider(tp, nil), nil
}
type tokenProvider struct {
o *Options
}
func (tp *tokenProvider) Token(ctx context.Context) (*auth.Token, error) {
opts := tp.o
clientAuth := stsexchange.ClientAuthentication{
AuthStyle: auth.StyleInHeader,
ClientID: opts.ClientID,
ClientSecret: opts.ClientSecret,
}
headers := make(http.Header)
headers.Set("Content-Type", "application/x-www-form-urlencoded")
stsResponse, err := stsexchange.RefreshAccessToken(ctx, &stsexchange.Options{
Client: opts.Client,
Endpoint: opts.TokenURL,
RefreshToken: opts.RefreshToken,
Authentication: clientAuth,
Headers: headers,
Logger: internallog.New(tp.o.Logger),
})
if err != nil {
return nil, err
}
if stsResponse.ExpiresIn < 0 {
return nil, errors.New("credentials: invalid expiry from security token service")
}
// guarded by the wrapping with CachedTokenProvider
if stsResponse.RefreshToken != "" {
opts.RefreshToken = stsResponse.RefreshToken
}
return &auth.Token{
Value: stsResponse.AccessToken,
Expiry: time.Now().UTC().Add(time.Duration(stsResponse.ExpiresIn) * time.Second),
Type: internal.TokenTypeBearer,
}, nil
}

View File

@@ -0,0 +1,191 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gdch
import (
"context"
"crypto"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"cloud.google.com/go/auth"
"cloud.google.com/go/auth/internal"
"cloud.google.com/go/auth/internal/credsfile"
"cloud.google.com/go/auth/internal/jwt"
"github.com/googleapis/gax-go/v2/internallog"
)
const (
// GrantType is the grant type for the token request.
GrantType = "urn:ietf:params:oauth:token-type:token-exchange"
requestTokenType = "urn:ietf:params:oauth:token-type:access_token"
subjectTokenType = "urn:k8s:params:oauth:token-type:serviceaccount"
)
var (
gdchSupportFormatVersions map[string]bool = map[string]bool{
"1": true,
}
)
// Options for [NewTokenProvider].
type Options struct {
STSAudience string
Client *http.Client
Logger *slog.Logger
}
// NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider] from a
// GDCH cred file.
func NewTokenProvider(f *credsfile.GDCHServiceAccountFile, o *Options) (auth.TokenProvider, error) {
if !gdchSupportFormatVersions[f.FormatVersion] {
return nil, fmt.Errorf("credentials: unsupported gdch_service_account format %q", f.FormatVersion)
}
if o.STSAudience == "" {
return nil, errors.New("credentials: STSAudience must be set for the GDCH auth flows")
}
signer, err := internal.ParseKey([]byte(f.PrivateKey))
if err != nil {
return nil, err
}
certPool, err := loadCertPool(f.CertPath)
if err != nil {
return nil, err
}
tp := gdchProvider{
serviceIdentity: fmt.Sprintf("system:serviceaccount:%s:%s", f.Project, f.Name),
tokenURL: f.TokenURL,
aud: o.STSAudience,
signer: signer,
pkID: f.PrivateKeyID,
certPool: certPool,
client: o.Client,
logger: internallog.New(o.Logger),
}
return tp, nil
}
func loadCertPool(path string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
pem, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("credentials: failed to read certificate: %w", err)
}
pool.AppendCertsFromPEM(pem)
return pool, nil
}
type gdchProvider struct {
serviceIdentity string
tokenURL string
aud string
signer crypto.Signer
pkID string
certPool *x509.CertPool
client *http.Client
logger *slog.Logger
}
func (g gdchProvider) Token(ctx context.Context) (*auth.Token, error) {
addCertToTransport(g.client, g.certPool)
iat := time.Now()
exp := iat.Add(time.Hour)
claims := jwt.Claims{
Iss: g.serviceIdentity,
Sub: g.serviceIdentity,
Aud: g.tokenURL,
Iat: iat.Unix(),
Exp: exp.Unix(),
}
h := jwt.Header{
Algorithm: jwt.HeaderAlgRSA256,
Type: jwt.HeaderType,
KeyID: string(g.pkID),
}
payload, err := jwt.EncodeJWS(&h, &claims, g.signer)
if err != nil {
return nil, err
}
v := url.Values{}
v.Set("grant_type", GrantType)
v.Set("audience", g.aud)
v.Set("requested_token_type", requestTokenType)
v.Set("subject_token", payload)
v.Set("subject_token_type", subjectTokenType)
req, err := http.NewRequestWithContext(ctx, "POST", g.tokenURL, strings.NewReader(v.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
g.logger.DebugContext(ctx, "gdch token request", "request", internallog.HTTPRequest(req, []byte(v.Encode())))
resp, body, err := internal.DoRequest(g.client, req)
if err != nil {
return nil, fmt.Errorf("credentials: cannot fetch token: %w", err)
}
g.logger.DebugContext(ctx, "gdch token response", "response", internallog.HTTPResponse(resp, body))
if c := resp.StatusCode; c < http.StatusOK || c > http.StatusMultipleChoices {
return nil, &auth.Error{
Response: resp,
Body: body,
}
}
var tokenRes struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"` // relative seconds from now
}
if err := json.Unmarshal(body, &tokenRes); err != nil {
return nil, fmt.Errorf("credentials: cannot fetch token: %w", err)
}
token := &auth.Token{
Value: tokenRes.AccessToken,
Type: tokenRes.TokenType,
}
raw := make(map[string]interface{})
json.Unmarshal(body, &raw) // no error checks for optional fields
token.Metadata = raw
if secs := tokenRes.ExpiresIn; secs > 0 {
token.Expiry = time.Now().Add(time.Duration(secs) * time.Second)
}
return token, nil
}
// addCertToTransport makes a best effort attempt at adding in the cert info to
// the client. It tries to keep all configured transport settings if the
// underlying transport is an http.Transport. Or else it overwrites the
// transport with defaults adding in the certs.
func addCertToTransport(hc *http.Client, certPool *x509.CertPool) {
trans, ok := hc.Transport.(*http.Transport)
if !ok {
trans = http.DefaultTransport.(*http.Transport).Clone()
}
trans.TLSClientConfig = &tls.Config{
RootCAs: certPool,
}
}

View File

@@ -0,0 +1,105 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package impersonate
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"cloud.google.com/go/auth"
"cloud.google.com/go/auth/internal"
"github.com/googleapis/gax-go/v2/internallog"
)
var (
universeDomainPlaceholder = "UNIVERSE_DOMAIN"
iamCredentialsUniverseDomainEndpoint = "https://iamcredentials.UNIVERSE_DOMAIN"
)
// IDTokenIAMOptions provides configuration for [IDTokenIAMOptions.Token].
type IDTokenIAMOptions struct {
// Client is required.
Client *http.Client
// Logger is required.
Logger *slog.Logger
UniverseDomain auth.CredentialsPropertyProvider
ServiceAccountEmail string
GenerateIDTokenRequest
}
// GenerateIDTokenRequest holds the request to the IAM generateIdToken RPC.
type GenerateIDTokenRequest struct {
Audience string `json:"audience"`
IncludeEmail bool `json:"includeEmail"`
// Delegates are the ordered, fully-qualified resource name for service
// accounts in a delegation chain. Each service account must be granted
// roles/iam.serviceAccountTokenCreator on the next service account in the
// chain. The delegates must have the following format:
// projects/-/serviceAccounts/{ACCOUNT_EMAIL_OR_UNIQUEID}. The - wildcard
// character is required; replacing it with a project ID is invalid.
// Optional.
Delegates []string `json:"delegates,omitempty"`
}
// GenerateIDTokenResponse holds the response from the IAM generateIdToken RPC.
type GenerateIDTokenResponse struct {
Token string `json:"token"`
}
// Token call IAM generateIdToken with the configuration provided in [IDTokenIAMOptions].
func (o IDTokenIAMOptions) Token(ctx context.Context) (*auth.Token, error) {
universeDomain, err := o.UniverseDomain.GetProperty(ctx)
if err != nil {
return nil, err
}
endpoint := strings.Replace(iamCredentialsUniverseDomainEndpoint, universeDomainPlaceholder, universeDomain, 1)
url := fmt.Sprintf("%s/v1/%s:generateIdToken", endpoint, internal.FormatIAMServiceAccountResource(o.ServiceAccountEmail))
bodyBytes, err := json.Marshal(o.GenerateIDTokenRequest)
if err != nil {
return nil, fmt.Errorf("impersonate: unable to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("impersonate: unable to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
o.Logger.DebugContext(ctx, "impersonated idtoken request", "request", internallog.HTTPRequest(req, bodyBytes))
resp, body, err := internal.DoRequest(o.Client, req)
if err != nil {
return nil, fmt.Errorf("impersonate: unable to generate ID token: %w", err)
}
o.Logger.DebugContext(ctx, "impersonated idtoken response", "response", internallog.HTTPResponse(resp, body))
if c := resp.StatusCode; c < 200 || c > 299 {
return nil, fmt.Errorf("impersonate: status code %d: %s", c, body)
}
var tokenResp GenerateIDTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("impersonate: unable to parse response: %w", err)
}
return &auth.Token{
Value: tokenResp.Token,
// Generated ID tokens are good for one hour.
Expiry: time.Now().Add(1 * time.Hour),
}, nil
}

View File

@@ -0,0 +1,168 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package impersonate
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"time"
"cloud.google.com/go/auth"
"cloud.google.com/go/auth/internal"
"cloud.google.com/go/auth/internal/transport/headers"
"github.com/googleapis/gax-go/v2/internallog"
)
const (
defaultTokenLifetime = "3600s"
authHeaderKey = "Authorization"
)
var serviceAccountEmailRegex = regexp.MustCompile(`serviceAccounts/(.+?):generateAccessToken`)
// generateAccesstokenReq is used for service account impersonation
type generateAccessTokenReq struct {
Delegates []string `json:"delegates,omitempty"`
Lifetime string `json:"lifetime,omitempty"`
Scope []string `json:"scope,omitempty"`
}
type impersonateTokenResponse struct {
AccessToken string `json:"accessToken"`
ExpireTime string `json:"expireTime"`
}
// NewTokenProvider uses a source credential, stored in Ts, to request an access token to the provided URL.
// Scopes can be defined when the access token is requested.
func NewTokenProvider(opts *Options) (auth.TokenProvider, error) {
if err := opts.validate(); err != nil {
return nil, err
}
return opts, nil
}
// Options for [NewTokenProvider].
type Options struct {
// Tp is the source credential used to generate a token on the
// impersonated service account. Required.
Tp auth.TokenProvider
// URL is the endpoint to call to generate a token
// on behalf of the service account. Required.
URL string
// Scopes that the impersonated credential should have. Required.
Scopes []string
// Delegates are the service account email addresses in a delegation chain.
// Each service account must be granted roles/iam.serviceAccountTokenCreator
// on the next service account in the chain. Optional.
Delegates []string
// TokenLifetimeSeconds is the number of seconds the impersonation token will
// be valid for. Defaults to 1 hour if unset. Optional.
TokenLifetimeSeconds int
// Client configures the underlying client used to make network requests
// when fetching tokens. Required.
Client *http.Client
// Logger is used for debug logging. If provided, logging will be enabled
// at the loggers configured level. By default logging is disabled unless
// enabled by setting GOOGLE_SDK_GO_LOGGING_LEVEL in which case a default
// logger will be used. Optional.
Logger *slog.Logger
// UniverseDomain is the default service domain for a given Cloud universe.
UniverseDomain string
}
func (o *Options) validate() error {
if o.Tp == nil {
return errors.New("credentials: missing required 'source_credentials' field in impersonated credentials")
}
if o.URL == "" {
return errors.New("credentials: missing required 'service_account_impersonation_url' field in impersonated credentials")
}
return nil
}
// Token performs the exchange to get a temporary service account token to allow access to GCP.
func (o *Options) Token(ctx context.Context) (*auth.Token, error) {
logger := internallog.New(o.Logger)
lifetime := defaultTokenLifetime
if o.TokenLifetimeSeconds != 0 {
lifetime = fmt.Sprintf("%ds", o.TokenLifetimeSeconds)
}
reqBody := generateAccessTokenReq{
Lifetime: lifetime,
Scope: o.Scopes,
Delegates: o.Delegates,
}
b, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("credentials: unable to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", o.URL, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("credentials: unable to create impersonation request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
sourceToken, err := o.Tp.Token(ctx)
if err != nil {
return nil, err
}
headers.SetAuthHeader(sourceToken, req)
logger.DebugContext(ctx, "impersonated token request", "request", internallog.HTTPRequest(req, b))
resp, body, err := internal.DoRequest(o.Client, req)
if err != nil {
return nil, fmt.Errorf("credentials: unable to generate access token: %w", err)
}
logger.DebugContext(ctx, "impersonated token response", "response", internallog.HTTPResponse(resp, body))
if c := resp.StatusCode; c < http.StatusOK || c >= http.StatusMultipleChoices {
return nil, fmt.Errorf("credentials: status code %d: %s", c, body)
}
var accessTokenResp impersonateTokenResponse
if err := json.Unmarshal(body, &accessTokenResp); err != nil {
return nil, fmt.Errorf("credentials: unable to parse response: %w", err)
}
expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime)
if err != nil {
return nil, fmt.Errorf("credentials: unable to parse expiry: %w", err)
}
token := &auth.Token{
Value: accessTokenResp.AccessToken,
Expiry: expiry,
Type: internal.TokenTypeBearer,
}
return token, nil
}
// ExtractServiceAccountEmail extracts the service account email from the impersonation URL.
// The impersonation URL is expected to be in the format:
// https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{SERVICE_ACCOUNT_EMAIL}:generateAccessToken
// or
// https://iamcredentials.googleapis.com/v1/projects/{PROJECT_ID}/serviceAccounts/{SERVICE_ACCOUNT_EMAIL}:generateAccessToken
// Returns an error if the email cannot be extracted.
func ExtractServiceAccountEmail(impersonationURL string) (string, error) {
matches := serviceAccountEmailRegex.FindStringSubmatch(impersonationURL)
if len(matches) < 2 {
return "", fmt.Errorf("credentials: invalid impersonation URL format: %s", impersonationURL)
}
return matches[1], nil
}

View File

@@ -0,0 +1,167 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package stsexchange
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"cloud.google.com/go/auth"
"cloud.google.com/go/auth/internal"
"github.com/googleapis/gax-go/v2/internallog"
)
const (
// GrantType for a sts exchange.
GrantType = "urn:ietf:params:oauth:grant-type:token-exchange"
// TokenType for a sts exchange.
TokenType = "urn:ietf:params:oauth:token-type:access_token"
jwtTokenType = "urn:ietf:params:oauth:token-type:jwt"
)
// Options stores the configuration for making an sts exchange request.
type Options struct {
Client *http.Client
Logger *slog.Logger
Endpoint string
Request *TokenRequest
Authentication ClientAuthentication
Headers http.Header
// ExtraOpts are optional fields marshalled into the `options` field of the
// request body.
ExtraOpts map[string]interface{}
RefreshToken string
}
// RefreshAccessToken performs the token exchange using a refresh token flow.
func RefreshAccessToken(ctx context.Context, opts *Options) (*TokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", opts.RefreshToken)
return doRequest(ctx, opts, data)
}
// ExchangeToken performs an oauth2 token exchange with the provided endpoint.
func ExchangeToken(ctx context.Context, opts *Options) (*TokenResponse, error) {
data := url.Values{}
data.Set("audience", opts.Request.Audience)
data.Set("grant_type", GrantType)
data.Set("requested_token_type", TokenType)
data.Set("subject_token_type", opts.Request.SubjectTokenType)
data.Set("subject_token", opts.Request.SubjectToken)
data.Set("scope", strings.Join(opts.Request.Scope, " "))
if opts.ExtraOpts != nil {
opts, err := json.Marshal(opts.ExtraOpts)
if err != nil {
return nil, fmt.Errorf("credentials: failed to marshal additional options: %w", err)
}
data.Set("options", string(opts))
}
return doRequest(ctx, opts, data)
}
func doRequest(ctx context.Context, opts *Options, data url.Values) (*TokenResponse, error) {
opts.Authentication.InjectAuthentication(data, opts.Headers)
encodedData := data.Encode()
logger := internallog.New(opts.Logger)
req, err := http.NewRequestWithContext(ctx, "POST", opts.Endpoint, strings.NewReader(encodedData))
if err != nil {
return nil, fmt.Errorf("credentials: failed to properly build http request: %w", err)
}
for key, list := range opts.Headers {
for _, val := range list {
req.Header.Add(key, val)
}
}
req.Header.Set("Content-Length", strconv.Itoa(len(encodedData)))
logger.DebugContext(ctx, "sts token request", "request", internallog.HTTPRequest(req, []byte(encodedData)))
resp, body, err := internal.DoRequest(opts.Client, req)
if err != nil {
return nil, fmt.Errorf("credentials: invalid response from Secure Token Server: %w", err)
}
logger.DebugContext(ctx, "sts token response", "response", internallog.HTTPResponse(resp, body))
if c := resp.StatusCode; c < http.StatusOK || c > http.StatusMultipleChoices {
return nil, fmt.Errorf("credentials: status code %d: %s", c, body)
}
var stsResp TokenResponse
if err := json.Unmarshal(body, &stsResp); err != nil {
return nil, fmt.Errorf("credentials: failed to unmarshal response body from Secure Token Server: %w", err)
}
return &stsResp, nil
}
// TokenRequest contains fields necessary to make an oauth2 token
// exchange.
type TokenRequest struct {
ActingParty struct {
ActorToken string
ActorTokenType string
}
GrantType string
Resource string
Audience string
Scope []string
RequestedTokenType string
SubjectToken string
SubjectTokenType string
}
// TokenResponse is used to decode the remote server response during
// an oauth2 token exchange.
type TokenResponse struct {
AccessToken string `json:"access_token"`
IssuedTokenType string `json:"issued_token_type"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
RefreshToken string `json:"refresh_token"`
}
// ClientAuthentication represents an OAuth client ID and secret and the
// mechanism for passing these credentials as stated in rfc6749#2.3.1.
type ClientAuthentication struct {
AuthStyle auth.Style
ClientID string
ClientSecret string
}
// InjectAuthentication is used to add authentication to a Secure Token Service
// exchange request. It modifies either the passed url.Values or http.Header
// depending on the desired authentication format.
func (c *ClientAuthentication) InjectAuthentication(values url.Values, headers http.Header) {
if c.ClientID == "" || c.ClientSecret == "" || values == nil || headers == nil {
return
}
switch c.AuthStyle {
case auth.StyleInHeader:
plainHeader := c.ClientID + ":" + c.ClientSecret
headers.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(plainHeader)))
default:
values.Set("client_id", c.ClientID)
values.Set("client_secret", c.ClientSecret)
}
}