token_manager.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. /*
  2. Copyright © The ESO Authors
  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 oidc provides shared OIDC token management utilities for External Secrets providers.
  14. // It includes token caching, ServiceAccount token creation, and HTTP utilities for token exchange.
  15. package oidc
  16. import (
  17. "bytes"
  18. "context"
  19. "crypto/tls"
  20. "encoding/json"
  21. "fmt"
  22. "io"
  23. "net/http"
  24. "sync"
  25. "time"
  26. authv1 "k8s.io/api/authentication/v1"
  27. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  28. typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
  29. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  30. esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
  31. )
  32. // Token TTL and buffer constants for OIDC token management.
  33. const (
  34. // DefaultTokenTTL is the default time-to-live in seconds for ServiceAccount tokens.
  35. DefaultTokenTTL = 600
  36. // MinTokenBuffer is the minimum buffer time in seconds before token expiry to trigger refresh.
  37. MinTokenBuffer = 60
  38. )
  39. // TokenProvider is the interface that provider-specific OIDC implementations must satisfy.
  40. // Providers implement this interface to handle their own ServiceAccount token creation
  41. // and token exchange logic.
  42. type TokenProvider interface {
  43. // GetToken returns a valid access token, refreshing it if necessary.
  44. GetToken(ctx context.Context) (string, error)
  45. }
  46. // TokenExchanger is the interface that provider-specific token exchange implementations must satisfy.
  47. type TokenExchanger interface {
  48. ExchangeToken(ctx context.Context, saToken string) (token string, expiry time.Time, err error)
  49. }
  50. // BaseTokenManager provides common OIDC token management functionality.
  51. // Provider-specific implementations embed this struct and provide their own TokenExchanger.
  52. type BaseTokenManager struct {
  53. Corev1 typedcorev1.CoreV1Interface
  54. Namespace string
  55. StoreKind string
  56. BaseURL string
  57. SaRef esmeta.ServiceAccountSelector
  58. Cache *TokenCache
  59. Exchanger TokenExchanger
  60. // ExpirationSeconds is the requested ServiceAccount token TTL in seconds.
  61. // When nil or non-positive, DefaultTokenTTL is used.
  62. ExpirationSeconds *int64
  63. // ExtraAudiences are appended to the audience list after the user-provided or
  64. // default audience. Providers populate this for resource-specific bindings.
  65. ExtraAudiences []string
  66. // refreshMu serializes the slow path so concurrent callers do not all
  67. // trigger a token exchange when the cache is cold.
  68. refreshMu sync.Mutex
  69. }
  70. // NewBaseTokenManager creates a new BaseTokenManager with the given parameters.
  71. // The exchanger parameter should be set after creation to point to the embedding struct.
  72. func NewBaseTokenManager(
  73. corev1 typedcorev1.CoreV1Interface,
  74. namespace, storeKind, baseURL string,
  75. saRef esmeta.ServiceAccountSelector,
  76. ) *BaseTokenManager {
  77. return &BaseTokenManager{
  78. Corev1: corev1,
  79. Namespace: namespace,
  80. StoreKind: storeKind,
  81. BaseURL: baseURL,
  82. SaRef: saRef,
  83. Cache: NewTokenCache(),
  84. }
  85. }
  86. // GetToken returns a valid access token, refreshing it if necessary.
  87. // This is the common implementation used by all OIDC providers.
  88. //
  89. // Uses double-checked locking: a fast read-locked cache check, then if the
  90. // cache is cold a full lock with a re-check so concurrent callers wait on a
  91. // single token exchange instead of each performing their own.
  92. func (m *BaseTokenManager) GetToken(ctx context.Context) (string, error) {
  93. if m == nil {
  94. return "", fmt.Errorf("OIDC token manager is not initialized")
  95. }
  96. if m.Exchanger == nil {
  97. return "", fmt.Errorf("OIDC token exchanger is not configured")
  98. }
  99. if token, ok := m.Cache.Get(); ok {
  100. return token, nil
  101. }
  102. m.refreshMu.Lock()
  103. defer m.refreshMu.Unlock()
  104. // Re-check after acquiring the refresh lock — another goroutine may have
  105. // populated the cache while we were waiting.
  106. if token, ok := m.Cache.Get(); ok {
  107. return token, nil
  108. }
  109. saToken, err := m.CreateServiceAccountToken(ctx)
  110. if err != nil {
  111. return "", fmt.Errorf("failed to create service account token: %w", err)
  112. }
  113. token, expiry, err := m.Exchanger.ExchangeToken(ctx, saToken)
  114. if err != nil {
  115. return "", err
  116. }
  117. m.Cache.Set(token, expiry)
  118. return token, nil
  119. }
  120. // CreateServiceAccountToken creates a Kubernetes ServiceAccount token for OIDC authentication.
  121. // This is the common implementation used by all OIDC providers.
  122. func (m *BaseTokenManager) CreateServiceAccountToken(ctx context.Context) (string, error) {
  123. audiences := m.BuildAudiences()
  124. expirationSeconds := int64(DefaultTokenTTL)
  125. if m.ExpirationSeconds != nil && *m.ExpirationSeconds > 0 {
  126. expirationSeconds = *m.ExpirationSeconds
  127. }
  128. tokenRequest := &authv1.TokenRequest{
  129. ObjectMeta: metav1.ObjectMeta{
  130. Namespace: m.Namespace,
  131. },
  132. Spec: authv1.TokenRequestSpec{
  133. Audiences: audiences,
  134. ExpirationSeconds: &expirationSeconds,
  135. },
  136. }
  137. // For ClusterSecretStore, use the namespace from the ServiceAccountRef if specified
  138. tokenNamespace := m.Namespace
  139. if m.StoreKind == esv1.ClusterSecretStoreKind && m.SaRef.Namespace != nil {
  140. tokenNamespace = *m.SaRef.Namespace
  141. }
  142. tokenResponse, err := m.Corev1.ServiceAccounts(tokenNamespace).
  143. CreateToken(ctx, m.SaRef.Name, tokenRequest, metav1.CreateOptions{})
  144. if err != nil {
  145. return "", fmt.Errorf("failed to create token for service account %s: %w",
  146. m.SaRef.Name, err)
  147. }
  148. return tokenResponse.Status.Token, nil
  149. }
  150. // BuildAudiences builds the audiences list for the ServiceAccount token.
  151. // If the user has explicitly configured audiences on the ServiceAccountRef,
  152. // those are used as-is. Otherwise it falls back to BaseURL so OIDC providers
  153. // that validate the audience continue to work without explicit user config.
  154. // Provider-specific resource bindings (set via ExtraAudiences) are appended.
  155. func (m *BaseTokenManager) BuildAudiences() []string {
  156. var audiences []string
  157. if len(m.SaRef.Audiences) > 0 {
  158. audiences = append(audiences, m.SaRef.Audiences...)
  159. } else {
  160. audiences = append(audiences, m.BaseURL)
  161. }
  162. audiences = append(audiences, m.ExtraAudiences...)
  163. return audiences
  164. }
  165. // TokenCache provides thread-safe caching for OIDC tokens.
  166. type TokenCache struct {
  167. mu sync.RWMutex
  168. cachedToken string
  169. tokenExpiry time.Time
  170. }
  171. // NewTokenCache creates a new TokenCache.
  172. func NewTokenCache() *TokenCache {
  173. return &TokenCache{}
  174. }
  175. // Get returns the cached token if it's still valid, otherwise returns empty string.
  176. func (c *TokenCache) Get() (string, bool) {
  177. c.mu.RLock()
  178. defer c.mu.RUnlock()
  179. if c.cachedToken == "" {
  180. return "", false
  181. }
  182. if time.Until(c.tokenExpiry) <= MinTokenBuffer*time.Second {
  183. return "", false
  184. }
  185. return c.cachedToken, true
  186. }
  187. // Set stores a token with its expiry time.
  188. func (c *TokenCache) Set(token string, expiry time.Time) {
  189. c.mu.Lock()
  190. defer c.mu.Unlock()
  191. c.cachedToken = token
  192. c.tokenExpiry = expiry
  193. }
  194. // PostJSONRequest sends a POST request with JSON body and returns the response body.
  195. // This is a shared utility for OIDC token exchange implementations.
  196. func PostJSONRequest(ctx context.Context, url string, requestBody map[string]string, providerName string) ([]byte, error) {
  197. return postJSONRequestInternal(ctx, url, requestBody, providerName)
  198. }
  199. // PostJSONRequestInterface sends a POST request with JSON body (supporting any values) and returns the response body.
  200. // This is a shared utility for OIDC token exchange implementations that need non-string values in the request body.
  201. func PostJSONRequestInterface(ctx context.Context, url string, requestBody map[string]any, providerName string) ([]byte, error) {
  202. return postJSONRequestInternal(ctx, url, requestBody, providerName)
  203. }
  204. func postJSONRequestInternal(ctx context.Context, url string, requestBody any, providerName string) ([]byte, error) {
  205. jsonBody, err := json.Marshal(requestBody)
  206. if err != nil {
  207. return nil, fmt.Errorf("failed to marshal request body: %w", err)
  208. }
  209. req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
  210. if err != nil {
  211. return nil, fmt.Errorf("failed to create request: %w", err)
  212. }
  213. req.Header.Set("Content-Type", "application/json")
  214. req.Header.Set("Accept", "application/json")
  215. // Clone the default transport if possible, otherwise create a new one
  216. var transport *http.Transport
  217. if t, ok := http.DefaultTransport.(*http.Transport); ok {
  218. transport = t.Clone()
  219. } else {
  220. transport = &http.Transport{}
  221. }
  222. transport.TLSClientConfig = &tls.Config{
  223. MinVersion: tls.VersionTLS12,
  224. }
  225. client := &http.Client{
  226. Timeout: 10 * time.Second,
  227. Transport: transport,
  228. }
  229. resp, err := client.Do(req)
  230. if err != nil {
  231. return nil, fmt.Errorf("failed to make request to %s: %w", providerName, err)
  232. }
  233. defer func() {
  234. _ = resp.Body.Close()
  235. }()
  236. body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
  237. if err != nil {
  238. return nil, fmt.Errorf("failed to read response body: %w", err)
  239. }
  240. if resp.StatusCode != http.StatusOK {
  241. return nil, fmt.Errorf("%s OIDC auth failed with status %d", providerName, resp.StatusCode)
  242. }
  243. return body, nil
  244. }