executablecredsource.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. // Copyright 2022 The Go Authors. All rights reserved.
  2. // Use of this source code is governed by a BSD-style
  3. // license that can be found in the LICENSE file.
  4. package externalaccount
  5. import (
  6. "bytes"
  7. "context"
  8. "encoding/json"
  9. "errors"
  10. "fmt"
  11. "io"
  12. "io/ioutil"
  13. "os"
  14. "os/exec"
  15. "regexp"
  16. "strings"
  17. "time"
  18. )
  19. var serviceAccountImpersonationRE = regexp.MustCompile("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken")
  20. const (
  21. executableSupportedMaxVersion = 1
  22. defaultTimeout = 30 * time.Second
  23. timeoutMinimum = 5 * time.Second
  24. timeoutMaximum = 120 * time.Second
  25. executableSource = "response"
  26. outputFileSource = "output file"
  27. )
  28. type nonCacheableError struct {
  29. message string
  30. }
  31. func (nce nonCacheableError) Error() string {
  32. return nce.message
  33. }
  34. func missingFieldError(source, field string) error {
  35. return fmt.Errorf("oauth2/google: %v missing `%q` field", source, field)
  36. }
  37. func jsonParsingError(source, data string) error {
  38. return fmt.Errorf("oauth2/google: unable to parse %v\nResponse: %v", source, data)
  39. }
  40. func malformedFailureError() error {
  41. return nonCacheableError{"oauth2/google: response must include `error` and `message` fields when unsuccessful"}
  42. }
  43. func userDefinedError(code, message string) error {
  44. return nonCacheableError{fmt.Sprintf("oauth2/google: response contains unsuccessful response: (%v) %v", code, message)}
  45. }
  46. func unsupportedVersionError(source string, version int) error {
  47. return fmt.Errorf("oauth2/google: %v contains unsupported version: %v", source, version)
  48. }
  49. func tokenExpiredError() error {
  50. return nonCacheableError{"oauth2/google: the token returned by the executable is expired"}
  51. }
  52. func tokenTypeError(source string) error {
  53. return fmt.Errorf("oauth2/google: %v contains unsupported token type", source)
  54. }
  55. func exitCodeError(exitCode int) error {
  56. return fmt.Errorf("oauth2/google: executable command failed with exit code %v", exitCode)
  57. }
  58. func executableError(err error) error {
  59. return fmt.Errorf("oauth2/google: executable command failed: %v", err)
  60. }
  61. func executablesDisallowedError() error {
  62. return errors.New("oauth2/google: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run")
  63. }
  64. func timeoutRangeError() error {
  65. return errors.New("oauth2/google: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds")
  66. }
  67. func commandMissingError() error {
  68. return errors.New("oauth2/google: missing `command` field — executable command must be provided")
  69. }
  70. type environment interface {
  71. existingEnv() []string
  72. getenv(string) string
  73. run(ctx context.Context, command string, env []string) ([]byte, error)
  74. now() time.Time
  75. }
  76. type runtimeEnvironment struct{}
  77. func (r runtimeEnvironment) existingEnv() []string {
  78. return os.Environ()
  79. }
  80. func (r runtimeEnvironment) getenv(key string) string {
  81. return os.Getenv(key)
  82. }
  83. func (r runtimeEnvironment) now() time.Time {
  84. return time.Now().UTC()
  85. }
  86. func (r runtimeEnvironment) run(ctx context.Context, command string, env []string) ([]byte, error) {
  87. splitCommand := strings.Fields(command)
  88. cmd := exec.CommandContext(ctx, splitCommand[0], splitCommand[1:]...)
  89. cmd.Env = env
  90. var stdout, stderr bytes.Buffer
  91. cmd.Stdout = &stdout
  92. cmd.Stderr = &stderr
  93. if err := cmd.Run(); err != nil {
  94. if ctx.Err() == context.DeadlineExceeded {
  95. return nil, context.DeadlineExceeded
  96. }
  97. if exitError, ok := err.(*exec.ExitError); ok {
  98. return nil, exitCodeError(exitError.ExitCode())
  99. }
  100. return nil, executableError(err)
  101. }
  102. bytesStdout := bytes.TrimSpace(stdout.Bytes())
  103. if len(bytesStdout) > 0 {
  104. return bytesStdout, nil
  105. }
  106. return bytes.TrimSpace(stderr.Bytes()), nil
  107. }
  108. type executableCredentialSource struct {
  109. Command string
  110. Timeout time.Duration
  111. OutputFile string
  112. ctx context.Context
  113. config *Config
  114. env environment
  115. }
  116. // CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig.
  117. // It also performs defaulting and type conversions.
  118. func CreateExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) {
  119. if ec.Command == "" {
  120. return executableCredentialSource{}, commandMissingError()
  121. }
  122. result := executableCredentialSource{}
  123. result.Command = ec.Command
  124. if ec.TimeoutMillis == nil {
  125. result.Timeout = defaultTimeout
  126. } else {
  127. result.Timeout = time.Duration(*ec.TimeoutMillis) * time.Millisecond
  128. if result.Timeout < timeoutMinimum || result.Timeout > timeoutMaximum {
  129. return executableCredentialSource{}, timeoutRangeError()
  130. }
  131. }
  132. result.OutputFile = ec.OutputFile
  133. result.ctx = ctx
  134. result.config = config
  135. result.env = runtimeEnvironment{}
  136. return result, nil
  137. }
  138. type executableResponse struct {
  139. Version int `json:"version,omitempty"`
  140. Success *bool `json:"success,omitempty"`
  141. TokenType string `json:"token_type,omitempty"`
  142. ExpirationTime int64 `json:"expiration_time,omitempty"`
  143. IdToken string `json:"id_token,omitempty"`
  144. SamlResponse string `json:"saml_response,omitempty"`
  145. Code string `json:"code,omitempty"`
  146. Message string `json:"message,omitempty"`
  147. }
  148. func (cs executableCredentialSource) parseSubjectTokenFromSource(response []byte, source string, now int64) (string, error) {
  149. var result executableResponse
  150. if err := json.Unmarshal(response, &result); err != nil {
  151. return "", jsonParsingError(source, string(response))
  152. }
  153. if result.Version == 0 {
  154. return "", missingFieldError(source, "version")
  155. }
  156. if result.Success == nil {
  157. return "", missingFieldError(source, "success")
  158. }
  159. if !*result.Success {
  160. if result.Code == "" || result.Message == "" {
  161. return "", malformedFailureError()
  162. }
  163. return "", userDefinedError(result.Code, result.Message)
  164. }
  165. if result.Version > executableSupportedMaxVersion || result.Version < 0 {
  166. return "", unsupportedVersionError(source, result.Version)
  167. }
  168. if result.ExpirationTime == 0 && cs.OutputFile != "" {
  169. return "", missingFieldError(source, "expiration_time")
  170. }
  171. if result.TokenType == "" {
  172. return "", missingFieldError(source, "token_type")
  173. }
  174. if result.ExpirationTime != 0 && result.ExpirationTime < now {
  175. return "", tokenExpiredError()
  176. }
  177. if result.TokenType == "urn:ietf:params:oauth:token-type:jwt" || result.TokenType == "urn:ietf:params:oauth:token-type:id_token" {
  178. if result.IdToken == "" {
  179. return "", missingFieldError(source, "id_token")
  180. }
  181. return result.IdToken, nil
  182. }
  183. if result.TokenType == "urn:ietf:params:oauth:token-type:saml2" {
  184. if result.SamlResponse == "" {
  185. return "", missingFieldError(source, "saml_response")
  186. }
  187. return result.SamlResponse, nil
  188. }
  189. return "", tokenTypeError(source)
  190. }
  191. func (cs executableCredentialSource) credentialSourceType() string {
  192. return "executable"
  193. }
  194. func (cs executableCredentialSource) subjectToken() (string, error) {
  195. if token, err := cs.getTokenFromOutputFile(); token != "" || err != nil {
  196. return token, err
  197. }
  198. return cs.getTokenFromExecutableCommand()
  199. }
  200. func (cs executableCredentialSource) getTokenFromOutputFile() (token string, err error) {
  201. if cs.OutputFile == "" {
  202. // This ExecutableCredentialSource doesn't use an OutputFile.
  203. return "", nil
  204. }
  205. file, err := os.Open(cs.OutputFile)
  206. if err != nil {
  207. // No OutputFile found. Hasn't been created yet, so skip it.
  208. return "", nil
  209. }
  210. defer file.Close()
  211. data, err := ioutil.ReadAll(io.LimitReader(file, 1<<20))
  212. if err != nil || len(data) == 0 {
  213. // Cachefile exists, but no data found. Get new credential.
  214. return "", nil
  215. }
  216. token, err = cs.parseSubjectTokenFromSource(data, outputFileSource, cs.env.now().Unix())
  217. if err != nil {
  218. if _, ok := err.(nonCacheableError); ok {
  219. // If the cached token is expired we need a new token,
  220. // and if the cache contains a failure, we need to try again.
  221. return "", nil
  222. }
  223. // There was an error in the cached token, and the developer should be aware of it.
  224. return "", err
  225. }
  226. // Token parsing succeeded. Use found token.
  227. return token, nil
  228. }
  229. func (cs executableCredentialSource) executableEnvironment() []string {
  230. result := cs.env.existingEnv()
  231. result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", cs.config.Audience))
  232. result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", cs.config.SubjectTokenType))
  233. result = append(result, "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0")
  234. if cs.config.ServiceAccountImpersonationURL != "" {
  235. matches := serviceAccountImpersonationRE.FindStringSubmatch(cs.config.ServiceAccountImpersonationURL)
  236. if matches != nil {
  237. result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=%v", matches[1]))
  238. }
  239. }
  240. if cs.OutputFile != "" {
  241. result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v", cs.OutputFile))
  242. }
  243. return result
  244. }
  245. func (cs executableCredentialSource) getTokenFromExecutableCommand() (string, error) {
  246. // For security reasons, we need our consumers to set this environment variable to allow executables to be run.
  247. if cs.env.getenv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES") != "1" {
  248. return "", executablesDisallowedError()
  249. }
  250. ctx, cancel := context.WithDeadline(cs.ctx, cs.env.now().Add(cs.Timeout))
  251. defer cancel()
  252. output, err := cs.env.run(ctx, cs.Command, cs.executableEnvironment())
  253. if err != nil {
  254. return "", err
  255. }
  256. return cs.parseSubjectTokenFromSource(output, executableSource, cs.env.now().Unix())
  257. }