auth_oidc.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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. https://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 doppler
  14. import (
  15. "bytes"
  16. "context"
  17. "crypto/tls"
  18. "encoding/json"
  19. "fmt"
  20. "io"
  21. "net/http"
  22. "os"
  23. "sync"
  24. "time"
  25. authv1 "k8s.io/api/authentication/v1"
  26. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  27. typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
  28. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  29. )
  30. const (
  31. defaultTokenTTL = 600
  32. minTokenBuffer = 60
  33. dopplerOIDCPath = "/v3/auth/oidc"
  34. )
  35. // OIDCTokenManager manages OIDC token exchange with Doppler.
  36. type OIDCTokenManager struct {
  37. corev1 typedcorev1.CoreV1Interface
  38. store *esv1.DopplerProvider
  39. namespace string
  40. storeKind string
  41. storeName string
  42. baseURL string
  43. verifyTLS bool
  44. mu sync.RWMutex
  45. cachedToken string
  46. tokenExpiry time.Time
  47. }
  48. // NewOIDCTokenManager creates a new OIDCTokenManager for handling Doppler OIDC authentication.
  49. func NewOIDCTokenManager(
  50. corev1 typedcorev1.CoreV1Interface,
  51. store *esv1.DopplerProvider,
  52. namespace string,
  53. storeKind string,
  54. storeName string,
  55. ) *OIDCTokenManager {
  56. baseURL := "https://api.doppler.com"
  57. if customURL := os.Getenv(customBaseURLEnvVar); customURL != "" {
  58. baseURL = customURL
  59. }
  60. verifyTLS := os.Getenv(verifyTLSOverrideEnvVar) != "false"
  61. return &OIDCTokenManager{
  62. corev1: corev1,
  63. store: store,
  64. namespace: namespace,
  65. storeKind: storeKind,
  66. storeName: storeName,
  67. baseURL: baseURL,
  68. verifyTLS: verifyTLS,
  69. }
  70. }
  71. // Token returns a valid Doppler API token, refreshing it if necessary.
  72. func (m *OIDCTokenManager) Token(ctx context.Context) (string, error) {
  73. m.mu.RLock()
  74. if m.isTokenValid() {
  75. token := m.cachedToken
  76. m.mu.RUnlock()
  77. return token, nil
  78. }
  79. m.mu.RUnlock()
  80. return m.refreshToken(ctx)
  81. }
  82. func (m *OIDCTokenManager) isTokenValid() bool {
  83. if m.cachedToken == "" {
  84. return false
  85. }
  86. return time.Until(m.tokenExpiry) > minTokenBuffer*time.Second
  87. }
  88. func (m *OIDCTokenManager) refreshToken(ctx context.Context) (string, error) {
  89. m.mu.Lock()
  90. defer m.mu.Unlock()
  91. if m.isTokenValid() {
  92. return m.cachedToken, nil
  93. }
  94. saToken, err := m.createServiceAccountToken(ctx)
  95. if err != nil {
  96. return "", fmt.Errorf("failed to create service account token: %w", err)
  97. }
  98. dopplerToken, expiry, err := m.exchangeTokenWithDoppler(ctx, saToken)
  99. if err != nil {
  100. return "", fmt.Errorf("failed to exchange token with Doppler: %w", err)
  101. }
  102. m.cachedToken = dopplerToken
  103. m.tokenExpiry = expiry
  104. return dopplerToken, nil
  105. }
  106. func (m *OIDCTokenManager) createServiceAccountToken(ctx context.Context) (string, error) {
  107. oidcAuth := m.store.Auth.OIDCConfig
  108. audiences := []string{m.baseURL}
  109. // Add custom audiences from serviceAccountRef
  110. if len(oidcAuth.ServiceAccountRef.Audiences) > 0 {
  111. audiences = append(audiences, oidcAuth.ServiceAccountRef.Audiences...)
  112. }
  113. // Add resource-specific audience for cryptographic binding
  114. if m.storeKind == esv1.ClusterSecretStoreKind {
  115. audiences = append(audiences, fmt.Sprintf("clusterSecretStore:%s", m.storeName))
  116. } else {
  117. audiences = append(audiences, fmt.Sprintf("secretStore:%s:%s", m.namespace, m.storeName))
  118. }
  119. expirationSeconds := oidcAuth.ExpirationSeconds
  120. if expirationSeconds == nil {
  121. tmp := int64(defaultTokenTTL)
  122. expirationSeconds = &tmp
  123. }
  124. tokenRequest := &authv1.TokenRequest{
  125. ObjectMeta: metav1.ObjectMeta{
  126. Namespace: m.namespace,
  127. },
  128. Spec: authv1.TokenRequestSpec{
  129. Audiences: audiences,
  130. ExpirationSeconds: expirationSeconds,
  131. },
  132. }
  133. // For ClusterSecretStores, we use the ServiceAccountRef.Namespace if specified
  134. if m.storeKind == esv1.ClusterSecretStoreKind && oidcAuth.ServiceAccountRef.Namespace != nil {
  135. tokenRequest.Namespace = *oidcAuth.ServiceAccountRef.Namespace
  136. }
  137. tokenResponse, err := m.corev1.ServiceAccounts(tokenRequest.Namespace).
  138. CreateToken(ctx, oidcAuth.ServiceAccountRef.Name, tokenRequest, metav1.CreateOptions{})
  139. if err != nil {
  140. return "", fmt.Errorf("failed to create token for service account %s: %w",
  141. oidcAuth.ServiceAccountRef.Name, err)
  142. }
  143. return tokenResponse.Status.Token, nil
  144. }
  145. func (m *OIDCTokenManager) exchangeTokenWithDoppler(ctx context.Context, saToken string) (string, time.Time, error) {
  146. oidcAuth := m.store.Auth.OIDCConfig
  147. url := m.baseURL + dopplerOIDCPath
  148. requestBody := map[string]string{
  149. "identity": oidcAuth.Identity,
  150. "token": saToken,
  151. }
  152. jsonBody, err := json.Marshal(requestBody)
  153. if err != nil {
  154. return "", time.Time{}, fmt.Errorf("failed to marshal request body: %w", err)
  155. }
  156. req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
  157. if err != nil {
  158. return "", time.Time{}, fmt.Errorf("failed to create request: %w", err)
  159. }
  160. req.Header.Set("Content-Type", "application/json")
  161. req.Header.Set("Accept", "application/json")
  162. tlsConfig := &tls.Config{
  163. MinVersion: tls.VersionTLS12,
  164. }
  165. if !m.verifyTLS {
  166. tlsConfig.InsecureSkipVerify = true
  167. }
  168. transport := &http.Transport{
  169. TLSClientConfig: tlsConfig,
  170. }
  171. client := &http.Client{
  172. Timeout: 10 * time.Second,
  173. Transport: transport,
  174. }
  175. resp, err := client.Do(req)
  176. if err != nil {
  177. return "", time.Time{}, fmt.Errorf("failed to make request to Doppler: %w", err)
  178. }
  179. defer func() {
  180. _ = resp.Body.Close()
  181. }()
  182. body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
  183. if err != nil {
  184. return "", time.Time{}, fmt.Errorf("failed to read response body: %w", err)
  185. }
  186. if resp.StatusCode != http.StatusOK {
  187. return "", time.Time{}, fmt.Errorf("Doppler OIDC auth failed with status %d: %s",
  188. resp.StatusCode, string(body))
  189. }
  190. var response struct {
  191. Success bool `json:"success"`
  192. Token string `json:"token"`
  193. ExpiresAt string `json:"expires_at"`
  194. }
  195. if err := json.Unmarshal(body, &response); err != nil {
  196. return "", time.Time{}, fmt.Errorf("failed to parse response: %w", err)
  197. }
  198. if !response.Success {
  199. return "", time.Time{}, fmt.Errorf("Doppler OIDC auth failed: %s", string(body))
  200. }
  201. expiresAt, err := time.Parse(time.RFC3339, response.ExpiresAt)
  202. if err != nil {
  203. return "", time.Time{}, fmt.Errorf("failed to parse expiration time: %w", err)
  204. }
  205. return response.Token, expiresAt, nil
  206. }