| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- /*
- 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
- https://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 doppler
- import (
- "bytes"
- "context"
- "crypto/tls"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "sync"
- "time"
- authv1 "k8s.io/api/authentication/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
- esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
- )
- const (
- defaultTokenTTL = 600
- minTokenBuffer = 60
- dopplerOIDCPath = "/v3/auth/oidc"
- )
- // OIDCTokenManager manages OIDC token exchange with Doppler.
- type OIDCTokenManager struct {
- corev1 typedcorev1.CoreV1Interface
- store *esv1.DopplerProvider
- namespace string
- storeKind string
- storeName string
- baseURL string
- verifyTLS bool
- mu sync.RWMutex
- cachedToken string
- tokenExpiry time.Time
- }
- // NewOIDCTokenManager creates a new OIDCTokenManager for handling Doppler OIDC authentication.
- func NewOIDCTokenManager(
- corev1 typedcorev1.CoreV1Interface,
- store *esv1.DopplerProvider,
- namespace string,
- storeKind string,
- storeName string,
- ) *OIDCTokenManager {
- baseURL := "https://api.doppler.com"
- if customURL := os.Getenv(customBaseURLEnvVar); customURL != "" {
- baseURL = customURL
- }
- verifyTLS := os.Getenv(verifyTLSOverrideEnvVar) != "false"
- return &OIDCTokenManager{
- corev1: corev1,
- store: store,
- namespace: namespace,
- storeKind: storeKind,
- storeName: storeName,
- baseURL: baseURL,
- verifyTLS: verifyTLS,
- }
- }
- // Token returns a valid Doppler API token, refreshing it if necessary.
- func (m *OIDCTokenManager) Token(ctx context.Context) (string, error) {
- m.mu.RLock()
- if m.isTokenValid() {
- token := m.cachedToken
- m.mu.RUnlock()
- return token, nil
- }
- m.mu.RUnlock()
- return m.refreshToken(ctx)
- }
- func (m *OIDCTokenManager) isTokenValid() bool {
- if m.cachedToken == "" {
- return false
- }
- return time.Until(m.tokenExpiry) > minTokenBuffer*time.Second
- }
- func (m *OIDCTokenManager) refreshToken(ctx context.Context) (string, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.isTokenValid() {
- return m.cachedToken, nil
- }
- saToken, err := m.createServiceAccountToken(ctx)
- if err != nil {
- return "", fmt.Errorf("failed to create service account token: %w", err)
- }
- dopplerToken, expiry, err := m.exchangeTokenWithDoppler(ctx, saToken)
- if err != nil {
- return "", fmt.Errorf("failed to exchange token with Doppler: %w", err)
- }
- m.cachedToken = dopplerToken
- m.tokenExpiry = expiry
- return dopplerToken, nil
- }
- func (m *OIDCTokenManager) createServiceAccountToken(ctx context.Context) (string, error) {
- oidcAuth := m.store.Auth.OIDCConfig
- audiences := []string{m.baseURL}
- // Add custom audiences from serviceAccountRef
- if len(oidcAuth.ServiceAccountRef.Audiences) > 0 {
- audiences = append(audiences, oidcAuth.ServiceAccountRef.Audiences...)
- }
- // Add resource-specific audience for cryptographic binding
- if m.storeKind == esv1.ClusterSecretStoreKind {
- audiences = append(audiences, fmt.Sprintf("clusterSecretStore:%s", m.storeName))
- } else {
- audiences = append(audiences, fmt.Sprintf("secretStore:%s:%s", m.namespace, m.storeName))
- }
- expirationSeconds := oidcAuth.ExpirationSeconds
- if expirationSeconds == nil {
- tmp := int64(defaultTokenTTL)
- expirationSeconds = &tmp
- }
- tokenRequest := &authv1.TokenRequest{
- ObjectMeta: metav1.ObjectMeta{
- Namespace: m.namespace,
- },
- Spec: authv1.TokenRequestSpec{
- Audiences: audiences,
- ExpirationSeconds: expirationSeconds,
- },
- }
- // For ClusterSecretStores, we use the ServiceAccountRef.Namespace if specified
- if m.storeKind == esv1.ClusterSecretStoreKind && oidcAuth.ServiceAccountRef.Namespace != nil {
- tokenRequest.Namespace = *oidcAuth.ServiceAccountRef.Namespace
- }
- tokenResponse, err := m.corev1.ServiceAccounts(tokenRequest.Namespace).
- CreateToken(ctx, oidcAuth.ServiceAccountRef.Name, tokenRequest, metav1.CreateOptions{})
- if err != nil {
- return "", fmt.Errorf("failed to create token for service account %s: %w",
- oidcAuth.ServiceAccountRef.Name, err)
- }
- return tokenResponse.Status.Token, nil
- }
- func (m *OIDCTokenManager) exchangeTokenWithDoppler(ctx context.Context, saToken string) (string, time.Time, error) {
- oidcAuth := m.store.Auth.OIDCConfig
- url := m.baseURL + dopplerOIDCPath
- requestBody := map[string]string{
- "identity": oidcAuth.Identity,
- "token": saToken,
- }
- jsonBody, err := json.Marshal(requestBody)
- if err != nil {
- return "", time.Time{}, fmt.Errorf("failed to marshal request body: %w", err)
- }
- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
- if err != nil {
- return "", time.Time{}, fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- tlsConfig := &tls.Config{
- MinVersion: tls.VersionTLS12,
- }
- if !m.verifyTLS {
- tlsConfig.InsecureSkipVerify = true
- }
- transport := &http.Transport{
- TLSClientConfig: tlsConfig,
- }
- client := &http.Client{
- Timeout: 10 * time.Second,
- Transport: transport,
- }
- resp, err := client.Do(req)
- if err != nil {
- return "", time.Time{}, fmt.Errorf("failed to make request to Doppler: %w", err)
- }
- defer func() {
- _ = resp.Body.Close()
- }()
- body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
- if err != nil {
- return "", time.Time{}, fmt.Errorf("failed to read response body: %w", err)
- }
- if resp.StatusCode != http.StatusOK {
- return "", time.Time{}, fmt.Errorf("Doppler OIDC auth failed with status %d: %s",
- resp.StatusCode, string(body))
- }
- var response struct {
- Success bool `json:"success"`
- Token string `json:"token"`
- ExpiresAt string `json:"expires_at"`
- }
- if err := json.Unmarshal(body, &response); err != nil {
- return "", time.Time{}, fmt.Errorf("failed to parse response: %w", err)
- }
- if !response.Success {
- return "", time.Time{}, fmt.Errorf("Doppler OIDC auth failed: %s", string(body))
- }
- expiresAt, err := time.Parse(time.RFC3339, response.ExpiresAt)
- if err != nil {
- return "", time.Time{}, fmt.Errorf("failed to parse expiration time: %w", err)
- }
- return response.Token, expiresAt, nil
- }
|