| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- /*
- 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
- }
|