cloudsmith.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /*
  2. Copyright © 2025 ESO Maintainer Team
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. // Package cloudsmith implements a generator for Cloudsmith access tokens using OIDC.
  14. package cloudsmith
  15. import (
  16. "bytes"
  17. "context"
  18. b64 "encoding/base64"
  19. "encoding/json"
  20. "errors"
  21. "fmt"
  22. "io"
  23. "net/http"
  24. "strings"
  25. "time"
  26. "github.com/go-logr/logr"
  27. apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
  28. "sigs.k8s.io/controller-runtime/pkg/client"
  29. "sigs.k8s.io/yaml"
  30. genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
  31. "github.com/external-secrets/external-secrets/pkg/esutils"
  32. )
  33. // Generator implements the Cloudsmith access token generator.
  34. type Generator struct {
  35. httpClient *http.Client
  36. }
  37. // OIDCRequest represents the payload sent to Cloudsmith for OIDC token exchange.
  38. type OIDCRequest struct {
  39. OIDCToken string `json:"oidc_token"`
  40. ServiceSlug string `json:"service_slug"`
  41. }
  42. // OIDCResponse represents the response from Cloudsmith containing the access token.
  43. type OIDCResponse struct {
  44. Token string `json:"token"`
  45. }
  46. const (
  47. defaultCloudsmithAPIURL = "https://api.cloudsmith.io"
  48. errNoSpec = "no config spec provided"
  49. errParseSpec = "unable to parse spec: %w"
  50. errExchangeToken = "unable to exchange OIDC token: %w"
  51. errMarshalRequest = "failed to marshal request payload: %w"
  52. errCreateRequest = "failed to create HTTP request: %w"
  53. errUnexpectedStatus = "request failed due to unexpected status: %s"
  54. errReadResponse = "failed to read response body: %w"
  55. errUnmarshalResponse = "failed to unmarshal response: %w"
  56. errTokenNotFound = "token not found in response"
  57. httpClientTimeout = 30 * time.Second
  58. )
  59. // Generate generates a Cloudsmith access token using the provided cloudsmith JSON spec.
  60. func (g *Generator) Generate(ctx context.Context, cloudsmithSpec *apiextensions.JSON, kubeClient client.Client, targetNamespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
  61. return g.generate(
  62. ctx,
  63. cloudsmithSpec,
  64. kubeClient,
  65. targetNamespace,
  66. )
  67. }
  68. // Cleanup is a no-op for the Cloudsmith generator.
  69. func (g *Generator) Cleanup(_ context.Context, _ *apiextensions.JSON, _ genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
  70. return nil
  71. }
  72. // generate performs the main logic of the Cloudsmith generator.
  73. func (g *Generator) generate(ctx context.Context, cloudsmithSpec *apiextensions.JSON, _ client.Client, targetNamespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
  74. if cloudsmithSpec == nil {
  75. return nil, nil, errors.New(errNoSpec)
  76. }
  77. res, err := parseSpec(cloudsmithSpec.Raw)
  78. if err != nil {
  79. return nil, nil, fmt.Errorf(errParseSpec, err)
  80. }
  81. // Fetch the service account token
  82. oidcToken, err := esutils.FetchServiceAccountToken(ctx, res.Spec.ServiceAccountRef, targetNamespace)
  83. if err != nil {
  84. return nil, nil, fmt.Errorf("failed to fetch service account token: %w", err)
  85. }
  86. apiURL := res.Spec.APIURL
  87. if apiURL == "" {
  88. apiURL = defaultCloudsmithAPIURL
  89. }
  90. accessToken, err := g.exchangeTokenWithCloudsmith(ctx, oidcToken, res.Spec.OrgSlug, res.Spec.ServiceSlug, apiURL)
  91. if err != nil {
  92. return nil, nil, fmt.Errorf(errExchangeToken, err)
  93. }
  94. exp, err := esutils.ExtractJWTExpiration(accessToken)
  95. if err != nil {
  96. return nil, nil, err
  97. }
  98. return map[string][]byte{
  99. "auth": []byte(b64.StdEncoding.EncodeToString([]byte("token:" + accessToken))),
  100. "expiry": []byte(exp),
  101. }, nil, nil
  102. }
  103. func (g *Generator) exchangeTokenWithCloudsmith(ctx context.Context, oidcToken, orgSlug, serviceSlug, apiURL string) (string, error) {
  104. log := logr.FromContextOrDiscard(ctx)
  105. log.V(4).Info("Starting OIDC token exchange with Cloudsmith")
  106. requestPayload := OIDCRequest{
  107. OIDCToken: oidcToken,
  108. ServiceSlug: serviceSlug,
  109. }
  110. jsonPayload, err := json.Marshal(requestPayload)
  111. if err != nil {
  112. return "", fmt.Errorf(errMarshalRequest, err)
  113. }
  114. url := fmt.Sprintf("%s/openid/%s/", strings.TrimSuffix(apiURL, "/"), orgSlug)
  115. log.Info("Exchanging OIDC token with Cloudsmith",
  116. "url", url,
  117. "serviceSlug", serviceSlug,
  118. "orgSlug", orgSlug)
  119. httpClient := g.httpClient
  120. if httpClient == nil {
  121. httpClient = &http.Client{
  122. Timeout: httpClientTimeout,
  123. }
  124. }
  125. req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonPayload))
  126. if err != nil {
  127. return "", fmt.Errorf(errCreateRequest, err)
  128. }
  129. req.Header.Set("Content-Type", "application/json")
  130. resp, err := httpClient.Do(req)
  131. if err != nil {
  132. return "", fmt.Errorf("failed to execute HTTP request: %w", err)
  133. }
  134. defer func() {
  135. _ = resp.Body.Close()
  136. }()
  137. if resp.StatusCode != http.StatusCreated {
  138. return "", fmt.Errorf(errUnexpectedStatus, resp.Status)
  139. }
  140. body, err := io.ReadAll(resp.Body)
  141. if err != nil {
  142. return "", fmt.Errorf(errReadResponse, err)
  143. }
  144. var result OIDCResponse
  145. err = json.Unmarshal(body, &result)
  146. if err != nil {
  147. return "", fmt.Errorf(errUnmarshalResponse, err)
  148. }
  149. if result.Token == "" {
  150. return "", errors.New(errTokenNotFound)
  151. }
  152. log.V(4).Info("Successfully exchanged OIDC token for Cloudsmith access token")
  153. return result.Token, nil
  154. }
  155. func parseSpec(specData []byte) (*genv1alpha1.CloudsmithAccessToken, error) {
  156. var spec genv1alpha1.CloudsmithAccessToken
  157. err := yaml.Unmarshal(specData, &spec)
  158. return &spec, err
  159. }
  160. func init() {
  161. genv1alpha1.Register(genv1alpha1.CloudsmithAccessTokenKind, &Generator{})
  162. }