123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- // 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.googleapis.com/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 {
- return fmt.Errorf("oauth2/google: %v missing `%q` field", source, field)
- }
- func jsonParsingError(source, data string) error {
- return fmt.Errorf("oauth2/google: unable to parse %v\nResponse: %v", source, data)
- }
- func malformedFailureError() error {
- return nonCacheableError{"oauth2/google: response must include `error` and `message` fields when unsuccessful"}
- }
- func userDefinedError(code, message string) error {
- return nonCacheableError{fmt.Sprintf("oauth2/google: response contains unsuccessful response: (%v) %v", code, message)}
- }
- func unsupportedVersionError(source string, version int) error {
- return fmt.Errorf("oauth2/google: %v contains unsupported version: %v", source, version)
- }
- func tokenExpiredError() error {
- return nonCacheableError{"oauth2/google: the token returned by the executable is expired"}
- }
- func tokenTypeError(source string) error {
- return fmt.Errorf("oauth2/google: %v contains unsupported token type", source)
- }
- func exitCodeError(exitCode int) error {
- return fmt.Errorf("oauth2/google: executable command failed with exit code %v", exitCode)
- }
- func executableError(err error) error {
- return fmt.Errorf("oauth2/google: executable command failed: %v", err)
- }
- func executablesDisallowedError() error {
- return errors.New("oauth2/google: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run")
- }
- func timeoutRangeError() error {
- return errors.New("oauth2/google: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds")
- }
- func commandMissingError() error {
- return errors.New("oauth2/google: missing `command` field — executable command must be provided")
- }
- 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.
- func CreateExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) {
- 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())
- }
|