|
|
@@ -0,0 +1,288 @@
|
|
|
+/*
|
|
|
+Copyright © The ESO Authors
|
|
|
+
|
|
|
+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 oidc provides shared OIDC token management utilities for External Secrets providers.
|
|
|
+// It includes token caching, ServiceAccount token creation, and HTTP utilities for token exchange.
|
|
|
+package oidc
|
|
|
+
|
|
|
+import (
|
|
|
+ "bytes"
|
|
|
+ "context"
|
|
|
+ "crypto/tls"
|
|
|
+ "encoding/json"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "net/http"
|
|
|
+ "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"
|
|
|
+ esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
|
|
|
+)
|
|
|
+
|
|
|
+// Token TTL and buffer constants for OIDC token management.
|
|
|
+const (
|
|
|
+ // DefaultTokenTTL is the default time-to-live in seconds for ServiceAccount tokens.
|
|
|
+ DefaultTokenTTL = 600
|
|
|
+ // MinTokenBuffer is the minimum buffer time in seconds before token expiry to trigger refresh.
|
|
|
+ MinTokenBuffer = 60
|
|
|
+)
|
|
|
+
|
|
|
+// TokenProvider is the interface that provider-specific OIDC implementations must satisfy.
|
|
|
+// Providers implement this interface to handle their own ServiceAccount token creation
|
|
|
+// and token exchange logic.
|
|
|
+type TokenProvider interface {
|
|
|
+ // GetToken returns a valid access token, refreshing it if necessary.
|
|
|
+ GetToken(ctx context.Context) (string, error)
|
|
|
+}
|
|
|
+
|
|
|
+// TokenExchanger is the interface that provider-specific token exchange implementations must satisfy.
|
|
|
+type TokenExchanger interface {
|
|
|
+ ExchangeToken(ctx context.Context, saToken string) (token string, expiry time.Time, err error)
|
|
|
+}
|
|
|
+
|
|
|
+// BaseTokenManager provides common OIDC token management functionality.
|
|
|
+// Provider-specific implementations embed this struct and provide their own TokenExchanger.
|
|
|
+type BaseTokenManager struct {
|
|
|
+ Corev1 typedcorev1.CoreV1Interface
|
|
|
+ Namespace string
|
|
|
+ StoreKind string
|
|
|
+ BaseURL string
|
|
|
+ SaRef esmeta.ServiceAccountSelector
|
|
|
+ Cache *TokenCache
|
|
|
+ Exchanger TokenExchanger
|
|
|
+ // ExpirationSeconds is the requested ServiceAccount token TTL in seconds.
|
|
|
+ // When nil or non-positive, DefaultTokenTTL is used.
|
|
|
+ ExpirationSeconds *int64
|
|
|
+ // ExtraAudiences are appended to the audience list after the user-provided or
|
|
|
+ // default audience. Providers populate this for resource-specific bindings.
|
|
|
+ ExtraAudiences []string
|
|
|
+
|
|
|
+ // refreshMu serializes the slow path so concurrent callers do not all
|
|
|
+ // trigger a token exchange when the cache is cold.
|
|
|
+ refreshMu sync.Mutex
|
|
|
+}
|
|
|
+
|
|
|
+// NewBaseTokenManager creates a new BaseTokenManager with the given parameters.
|
|
|
+// The exchanger parameter should be set after creation to point to the embedding struct.
|
|
|
+func NewBaseTokenManager(
|
|
|
+ corev1 typedcorev1.CoreV1Interface,
|
|
|
+ namespace, storeKind, baseURL string,
|
|
|
+ saRef esmeta.ServiceAccountSelector,
|
|
|
+) *BaseTokenManager {
|
|
|
+ return &BaseTokenManager{
|
|
|
+ Corev1: corev1,
|
|
|
+ Namespace: namespace,
|
|
|
+ StoreKind: storeKind,
|
|
|
+ BaseURL: baseURL,
|
|
|
+ SaRef: saRef,
|
|
|
+ Cache: NewTokenCache(),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// GetToken returns a valid access token, refreshing it if necessary.
|
|
|
+// This is the common implementation used by all OIDC providers.
|
|
|
+//
|
|
|
+// Uses double-checked locking: a fast read-locked cache check, then if the
|
|
|
+// cache is cold a full lock with a re-check so concurrent callers wait on a
|
|
|
+// single token exchange instead of each performing their own.
|
|
|
+func (m *BaseTokenManager) GetToken(ctx context.Context) (string, error) {
|
|
|
+ if m == nil {
|
|
|
+ return "", fmt.Errorf("OIDC token manager is not initialized")
|
|
|
+ }
|
|
|
+ if m.Exchanger == nil {
|
|
|
+ return "", fmt.Errorf("OIDC token exchanger is not configured")
|
|
|
+ }
|
|
|
+
|
|
|
+ if token, ok := m.Cache.Get(); ok {
|
|
|
+ return token, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ m.refreshMu.Lock()
|
|
|
+ defer m.refreshMu.Unlock()
|
|
|
+
|
|
|
+ // Re-check after acquiring the refresh lock — another goroutine may have
|
|
|
+ // populated the cache while we were waiting.
|
|
|
+ if token, ok := m.Cache.Get(); ok {
|
|
|
+ return token, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ saToken, err := m.CreateServiceAccountToken(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return "", fmt.Errorf("failed to create service account token: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ token, expiry, err := m.Exchanger.ExchangeToken(ctx, saToken)
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+
|
|
|
+ m.Cache.Set(token, expiry)
|
|
|
+
|
|
|
+ return token, nil
|
|
|
+}
|
|
|
+
|
|
|
+// CreateServiceAccountToken creates a Kubernetes ServiceAccount token for OIDC authentication.
|
|
|
+// This is the common implementation used by all OIDC providers.
|
|
|
+func (m *BaseTokenManager) CreateServiceAccountToken(ctx context.Context) (string, error) {
|
|
|
+ audiences := m.BuildAudiences()
|
|
|
+
|
|
|
+ expirationSeconds := int64(DefaultTokenTTL)
|
|
|
+ if m.ExpirationSeconds != nil && *m.ExpirationSeconds > 0 {
|
|
|
+ expirationSeconds = *m.ExpirationSeconds
|
|
|
+ }
|
|
|
+
|
|
|
+ tokenRequest := &authv1.TokenRequest{
|
|
|
+ ObjectMeta: metav1.ObjectMeta{
|
|
|
+ Namespace: m.Namespace,
|
|
|
+ },
|
|
|
+ Spec: authv1.TokenRequestSpec{
|
|
|
+ Audiences: audiences,
|
|
|
+ ExpirationSeconds: &expirationSeconds,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ // For ClusterSecretStore, use the namespace from the ServiceAccountRef if specified
|
|
|
+ tokenNamespace := m.Namespace
|
|
|
+ if m.StoreKind == esv1.ClusterSecretStoreKind && m.SaRef.Namespace != nil {
|
|
|
+ tokenNamespace = *m.SaRef.Namespace
|
|
|
+ }
|
|
|
+
|
|
|
+ tokenResponse, err := m.Corev1.ServiceAccounts(tokenNamespace).
|
|
|
+ CreateToken(ctx, m.SaRef.Name, tokenRequest, metav1.CreateOptions{})
|
|
|
+ if err != nil {
|
|
|
+ return "", fmt.Errorf("failed to create token for service account %s: %w",
|
|
|
+ m.SaRef.Name, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ return tokenResponse.Status.Token, nil
|
|
|
+}
|
|
|
+
|
|
|
+// BuildAudiences builds the audiences list for the ServiceAccount token.
|
|
|
+// If the user has explicitly configured audiences on the ServiceAccountRef,
|
|
|
+// those are used as-is. Otherwise it falls back to BaseURL so OIDC providers
|
|
|
+// that validate the audience continue to work without explicit user config.
|
|
|
+// Provider-specific resource bindings (set via ExtraAudiences) are appended.
|
|
|
+func (m *BaseTokenManager) BuildAudiences() []string {
|
|
|
+ var audiences []string
|
|
|
+ if len(m.SaRef.Audiences) > 0 {
|
|
|
+ audiences = append(audiences, m.SaRef.Audiences...)
|
|
|
+ } else {
|
|
|
+ audiences = append(audiences, m.BaseURL)
|
|
|
+ }
|
|
|
+ audiences = append(audiences, m.ExtraAudiences...)
|
|
|
+ return audiences
|
|
|
+}
|
|
|
+
|
|
|
+// TokenCache provides thread-safe caching for OIDC tokens.
|
|
|
+type TokenCache struct {
|
|
|
+ mu sync.RWMutex
|
|
|
+ cachedToken string
|
|
|
+ tokenExpiry time.Time
|
|
|
+}
|
|
|
+
|
|
|
+// NewTokenCache creates a new TokenCache.
|
|
|
+func NewTokenCache() *TokenCache {
|
|
|
+ return &TokenCache{}
|
|
|
+}
|
|
|
+
|
|
|
+// Get returns the cached token if it's still valid, otherwise returns empty string.
|
|
|
+func (c *TokenCache) Get() (string, bool) {
|
|
|
+ c.mu.RLock()
|
|
|
+ defer c.mu.RUnlock()
|
|
|
+
|
|
|
+ if c.cachedToken == "" {
|
|
|
+ return "", false
|
|
|
+ }
|
|
|
+ if time.Until(c.tokenExpiry) <= MinTokenBuffer*time.Second {
|
|
|
+ return "", false
|
|
|
+ }
|
|
|
+ return c.cachedToken, true
|
|
|
+}
|
|
|
+
|
|
|
+// Set stores a token with its expiry time.
|
|
|
+func (c *TokenCache) Set(token string, expiry time.Time) {
|
|
|
+ c.mu.Lock()
|
|
|
+ defer c.mu.Unlock()
|
|
|
+ c.cachedToken = token
|
|
|
+ c.tokenExpiry = expiry
|
|
|
+}
|
|
|
+
|
|
|
+// PostJSONRequest sends a POST request with JSON body and returns the response body.
|
|
|
+// This is a shared utility for OIDC token exchange implementations.
|
|
|
+func PostJSONRequest(ctx context.Context, url string, requestBody map[string]string, providerName string) ([]byte, error) {
|
|
|
+ return postJSONRequestInternal(ctx, url, requestBody, providerName)
|
|
|
+}
|
|
|
+
|
|
|
+// PostJSONRequestInterface sends a POST request with JSON body (supporting any values) and returns the response body.
|
|
|
+// This is a shared utility for OIDC token exchange implementations that need non-string values in the request body.
|
|
|
+func PostJSONRequestInterface(ctx context.Context, url string, requestBody map[string]any, providerName string) ([]byte, error) {
|
|
|
+ return postJSONRequestInternal(ctx, url, requestBody, providerName)
|
|
|
+}
|
|
|
+
|
|
|
+func postJSONRequestInternal(ctx context.Context, url string, requestBody any, providerName string) ([]byte, error) {
|
|
|
+ jsonBody, err := json.Marshal(requestBody)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to create request: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ req.Header.Set("Content-Type", "application/json")
|
|
|
+ req.Header.Set("Accept", "application/json")
|
|
|
+
|
|
|
+ // Clone the default transport if possible, otherwise create a new one
|
|
|
+ var transport *http.Transport
|
|
|
+ if t, ok := http.DefaultTransport.(*http.Transport); ok {
|
|
|
+ transport = t.Clone()
|
|
|
+ } else {
|
|
|
+ transport = &http.Transport{}
|
|
|
+ }
|
|
|
+ transport.TLSClientConfig = &tls.Config{
|
|
|
+ MinVersion: tls.VersionTLS12,
|
|
|
+ }
|
|
|
+
|
|
|
+ client := &http.Client{
|
|
|
+ Timeout: 10 * time.Second,
|
|
|
+ Transport: transport,
|
|
|
+ }
|
|
|
+
|
|
|
+ resp, err := client.Do(req)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to make request to %s: %w", providerName, err)
|
|
|
+ }
|
|
|
+ defer func() {
|
|
|
+ _ = resp.Body.Close()
|
|
|
+ }()
|
|
|
+
|
|
|
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if resp.StatusCode != http.StatusOK {
|
|
|
+ return nil, fmt.Errorf("%s OIDC auth failed with status %d", providerName, resp.StatusCode)
|
|
|
+ }
|
|
|
+
|
|
|
+ return body, nil
|
|
|
+}
|