2024-02-24 00:34:55 +00:00
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
var serviceAccountImpersonationRE = regexp . MustCompile ( "https://iamcredentials\\..+/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken" )
const (
executableSupportedMaxVersion = 1
defaultTimeout = 30 * time . Second
timeoutMinimum = 5 * time . Second
timeoutMaximum = 120 * time . Second
executableSource = "response"
outputFileSource = "output file"
)
type nonCacheableError struct {
message string
}
func ( nce nonCacheableError ) Error ( ) string {
return nce . message
}
func missingFieldError ( source , field string ) error {
2024-10-16 10:54:40 +00:00
return fmt . Errorf ( "oauth2/google/externalaccount: %v missing `%q` field" , source , field )
2024-02-24 00:34:55 +00:00
}
func jsonParsingError ( source , data string ) error {
2024-10-16 10:54:40 +00:00
return fmt . Errorf ( "oauth2/google/externalaccount: unable to parse %v\nResponse: %v" , source , data )
2024-02-24 00:34:55 +00:00
}
func malformedFailureError ( ) error {
2024-10-16 10:54:40 +00:00
return nonCacheableError { "oauth2/google/externalaccount: response must include `error` and `message` fields when unsuccessful" }
2024-02-24 00:34:55 +00:00
}
func userDefinedError ( code , message string ) error {
2024-10-16 10:54:40 +00:00
return nonCacheableError { fmt . Sprintf ( "oauth2/google/externalaccount: response contains unsuccessful response: (%v) %v" , code , message ) }
2024-02-24 00:34:55 +00:00
}
func unsupportedVersionError ( source string , version int ) error {
2024-10-16 10:54:40 +00:00
return fmt . Errorf ( "oauth2/google/externalaccount: %v contains unsupported version: %v" , source , version )
2024-02-24 00:34:55 +00:00
}
func tokenExpiredError ( ) error {
2024-10-16 10:54:40 +00:00
return nonCacheableError { "oauth2/google/externalaccount: the token returned by the executable is expired" }
2024-02-24 00:34:55 +00:00
}
func tokenTypeError ( source string ) error {
2024-10-16 10:54:40 +00:00
return fmt . Errorf ( "oauth2/google/externalaccount: %v contains unsupported token type" , source )
2024-02-24 00:34:55 +00:00
}
func exitCodeError ( exitCode int ) error {
2024-10-16 10:54:40 +00:00
return fmt . Errorf ( "oauth2/google/externalaccount: executable command failed with exit code %v" , exitCode )
2024-02-24 00:34:55 +00:00
}
func executableError ( err error ) error {
2024-10-16 10:54:40 +00:00
return fmt . Errorf ( "oauth2/google/externalaccount: executable command failed: %v" , err )
2024-02-24 00:34:55 +00:00
}
func executablesDisallowedError ( ) error {
2024-10-16 10:54:40 +00:00
return errors . New ( "oauth2/google/externalaccount: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run" )
2024-02-24 00:34:55 +00:00
}
func timeoutRangeError ( ) error {
2024-10-16 10:54:40 +00:00
return errors . New ( "oauth2/google/externalaccount: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds" )
2024-02-24 00:34:55 +00:00
}
func commandMissingError ( ) error {
2024-10-16 10:54:40 +00:00
return errors . New ( "oauth2/google/externalaccount: missing `command` field — executable command must be provided" )
2024-02-24 00:34:55 +00:00
}
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 . ExitCode ( ) )
}
return nil , executableError ( err )
}
bytesStdout := bytes . TrimSpace ( stdout . Bytes ( ) )
if len ( bytesStdout ) > 0 {
return bytesStdout , nil
}
return bytes . TrimSpace ( stderr . Bytes ( ) ) , nil
}
type executableCredentialSource struct {
Command string
Timeout time . Duration
OutputFile string
ctx context . Context
config * Config
env environment
}
// CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig.
// It also performs defaulting and type conversions.
2024-10-16 10:54:40 +00:00
func createExecutableCredential ( ctx context . Context , ec * ExecutableConfig , config * Config ) ( executableCredentialSource , error ) {
2024-02-24 00:34:55 +00:00
if ec . Command == "" {
return executableCredentialSource { } , commandMissingError ( )
}
result := executableCredentialSource { }
result . Command = ec . Command
if ec . TimeoutMillis == nil {
result . Timeout = defaultTimeout
} else {
result . Timeout = time . Duration ( * ec . TimeoutMillis ) * time . Millisecond
if result . Timeout < timeoutMinimum || result . Timeout > timeoutMaximum {
return executableCredentialSource { } , timeoutRangeError ( )
}
}
result . OutputFile = ec . OutputFile
result . ctx = ctx
result . config = config
result . env = runtimeEnvironment { }
return result , nil
}
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 ( cs executableCredentialSource ) 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 ) )
}
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 && cs . OutputFile != "" {
return "" , missingFieldError ( source , "expiration_time" )
}
if result . TokenType == "" {
return "" , missingFieldError ( source , "token_type" )
}
if result . ExpirationTime != 0 && result . ExpirationTime < now {
return "" , tokenExpiredError ( )
}
if result . TokenType == "urn:ietf:params:oauth:token-type:jwt" || result . TokenType == "urn:ietf:params:oauth:token-type:id_token" {
if result . IdToken == "" {
return "" , missingFieldError ( source , "id_token" )
}
return result . IdToken , nil
}
if result . TokenType == "urn:ietf:params:oauth:token-type:saml2" {
if result . SamlResponse == "" {
return "" , missingFieldError ( source , "saml_response" )
}
return result . SamlResponse , nil
}
return "" , tokenTypeError ( source )
}
func ( cs executableCredentialSource ) credentialSourceType ( ) string {
return "executable"
}
func ( cs executableCredentialSource ) subjectToken ( ) ( string , error ) {
if token , err := cs . getTokenFromOutputFile ( ) ; token != "" || err != nil {
return token , err
}
return cs . getTokenFromExecutableCommand ( )
}
func ( cs executableCredentialSource ) getTokenFromOutputFile ( ) ( token string , err error ) {
if cs . OutputFile == "" {
// This ExecutableCredentialSource doesn't use an OutputFile.
return "" , nil
}
file , err := os . Open ( cs . OutputFile )
if err != nil {
// No OutputFile found. Hasn't been created yet, so skip it.
return "" , nil
}
defer file . Close ( )
data , err := ioutil . ReadAll ( io . LimitReader ( file , 1 << 20 ) )
if err != nil || len ( data ) == 0 {
// Cachefile exists, but no data found. Get new credential.
return "" , nil
}
token , err = cs . parseSubjectTokenFromSource ( data , outputFileSource , cs . 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 ( cs executableCredentialSource ) executableEnvironment ( ) [ ] string {
result := cs . env . existingEnv ( )
result = append ( result , fmt . Sprintf ( "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v" , cs . config . Audience ) )
result = append ( result , fmt . Sprintf ( "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v" , cs . config . SubjectTokenType ) )
result = append ( result , "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0" )
if cs . config . ServiceAccountImpersonationURL != "" {
matches := serviceAccountImpersonationRE . FindStringSubmatch ( cs . config . ServiceAccountImpersonationURL )
if matches != nil {
result = append ( result , fmt . Sprintf ( "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=%v" , matches [ 1 ] ) )
}
}
if cs . OutputFile != "" {
result = append ( result , fmt . Sprintf ( "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v" , cs . OutputFile ) )
}
return result
}
func ( cs executableCredentialSource ) getTokenFromExecutableCommand ( ) ( string , error ) {
// For security reasons, we need our consumers to set this environment variable to allow executables to be run.
if cs . env . getenv ( "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES" ) != "1" {
return "" , executablesDisallowedError ( )
}
ctx , cancel := context . WithDeadline ( cs . ctx , cs . env . now ( ) . Add ( cs . Timeout ) )
defer cancel ( )
output , err := cs . env . run ( ctx , cs . Command , cs . executableEnvironment ( ) )
if err != nil {
return "" , err
}
return cs . parseSubjectTokenFromSource ( output , executableSource , cs . env . now ( ) . Unix ( ) )
}