/* Copyright © 2025 ESO Maintainer Team 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 cloudsmith import ( "bytes" "context" b64 "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/go-logr/logr" apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1" "github.com/external-secrets/external-secrets/pkg/utils" ) type Generator struct { httpClient *http.Client } type OIDCRequest struct { OIDCToken string `json:"oidc_token"` ServiceSlug string `json:"service_slug"` } type OIDCResponse struct { Token string `json:"token"` } const ( defaultCloudsmithAPIURL = "https://api.cloudsmith.io" errNoSpec = "no config spec provided" errParseSpec = "unable to parse spec: %w" errExchangeToken = "unable to exchange OIDC token: %w" errMarshalRequest = "failed to marshal request payload: %w" errCreateRequest = "failed to create HTTP request: %w" errUnexpectedStatus = "request failed due to unexpected status: %s" errReadResponse = "failed to read response body: %w" errUnmarshalResponse = "failed to unmarshal response: %w" errTokenNotFound = "token not found in response" httpClientTimeout = 30 * time.Second ) func (g *Generator) Generate(ctx context.Context, cloudsmithSpec *apiextensions.JSON, kubeClient client.Client, targetNamespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) { return g.generate( ctx, cloudsmithSpec, kubeClient, targetNamespace, ) } func (g *Generator) Cleanup(_ context.Context, cloudsmithSpec *apiextensions.JSON, providerState genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error { return nil } func (g *Generator) generate( ctx context.Context, cloudsmithSpec *apiextensions.JSON, _ client.Client, targetNamespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) { if cloudsmithSpec == nil { return nil, nil, errors.New(errNoSpec) } res, err := parseSpec(cloudsmithSpec.Raw) if err != nil { return nil, nil, fmt.Errorf(errParseSpec, err) } // Fetch the service account token oidcToken, err := utils.FetchServiceAccountToken(ctx, res.Spec.ServiceAccountRef, targetNamespace) if err != nil { return nil, nil, fmt.Errorf("failed to fetch service account token: %w", err) } apiURL := res.Spec.APIURL if apiURL == "" { apiURL = defaultCloudsmithAPIURL } accessToken, err := g.exchangeTokenWithCloudsmith(ctx, oidcToken, res.Spec.OrgSlug, res.Spec.ServiceSlug, apiURL) if err != nil { return nil, nil, fmt.Errorf(errExchangeToken, err) } exp, err := utils.ExtractJWTExpiration(accessToken) if err != nil { return nil, nil, err } return map[string][]byte{ "auth": []byte(b64.StdEncoding.EncodeToString([]byte("token:" + accessToken))), "expiry": []byte(exp), }, nil, nil } func (g *Generator) exchangeTokenWithCloudsmith(ctx context.Context, oidcToken, orgSlug, serviceSlug, apiURL string) (string, error) { log := logr.FromContextOrDiscard(ctx) log.V(4).Info("Starting OIDC token exchange with Cloudsmith") requestPayload := OIDCRequest{ OIDCToken: oidcToken, ServiceSlug: serviceSlug, } jsonPayload, err := json.Marshal(requestPayload) if err != nil { return "", fmt.Errorf(errMarshalRequest, err) } url := fmt.Sprintf("%s/openid/%s/", strings.TrimSuffix(apiURL, "/"), orgSlug) log.Info("Exchanging OIDC token with Cloudsmith", "url", url, "serviceSlug", serviceSlug, "orgSlug", orgSlug) httpClient := g.httpClient if httpClient == nil { httpClient = &http.Client{ Timeout: httpClientTimeout, } } req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonPayload)) if err != nil { return "", fmt.Errorf(errCreateRequest, err) } req.Header.Set("Content-Type", "application/json") resp, err := httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to execute HTTP request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { return "", fmt.Errorf(errUnexpectedStatus, resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf(errReadResponse, err) } var result OIDCResponse err = json.Unmarshal(body, &result) if err != nil { return "", fmt.Errorf(errUnmarshalResponse, err) } if result.Token == "" { return "", errors.New(errTokenNotFound) } log.V(4).Info("Successfully exchanged OIDC token for Cloudsmith access token") return result.Token, nil } func parseSpec(specData []byte) (*genv1alpha1.CloudsmithAccessToken, error) { var spec genv1alpha1.CloudsmithAccessToken err := yaml.Unmarshal(specData, &spec) return &spec, err } func init() { genv1alpha1.Register(genv1alpha1.CloudsmithAccessTokenKind, &Generator{}) }