Sfoglia il codice sorgente

feat: add Pulumi OIDC-based authentication (#5893)

Co-authored-by: Matt Johnston <mattjo@supabase.io>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Matt Johnston 3 settimane fa
parent
commit
b1f01b753d

+ 40 - 2
apis/externalsecrets/v1/secretstore_pulumi_types.go

@@ -21,13 +21,16 @@ import (
 )
 
 // PulumiProvider defines configuration for accessing secrets from Pulumi ESC.
+// +kubebuilder:validation:XValidation:rule="(has(self.auth) && !has(self.accessToken)) || (!has(self.auth) && has(self.accessToken))",message="Exactly one of 'auth' or deprecated 'accessToken' must be specified"
 type PulumiProvider struct {
 	// APIURL is the URL of the Pulumi API.
 	// +kubebuilder:default="https://api.pulumi.com/api/esc"
 	APIURL string `json:"apiUrl,omitempty"`
 
-	// AccessToken is the access tokens to sign in to the Pulumi Cloud Console.
-	AccessToken *PulumiProviderSecretRef `json:"accessToken"`
+	// Auth configures how the Operator authenticates with the Pulumi API.
+	// Either auth or the deprecated accessToken field must be specified.
+	// +optional
+	Auth *PulumiAuth `json:"auth,omitempty"`
 
 	// Organization are a space to collaborate on shared projects and stacks.
 	// To create a new organization, visit https://app.pulumi.com/ and click "New Organization".
@@ -40,6 +43,25 @@ type PulumiProvider struct {
 	// and other Pulumi ESC environments.
 	// To create a new environment, visit https://www.pulumi.com/docs/esc/environments/ for more information.
 	Environment string `json:"environment"`
+
+	// AccessToken is the access tokens to sign in to the Pulumi Cloud Console.
+	//
+	// Deprecated: Use auth.accessToken instead.
+	// +optional
+	AccessToken *PulumiProviderSecretRef `json:"accessToken,omitempty"`
+}
+
+// PulumiAuth configures authentication with the Pulumi API.
+// Exactly one of accessToken or oidcConfig must be specified.
+// +kubebuilder:validation:XValidation:rule="(has(self.accessToken) && !has(self.oidcConfig)) || (!has(self.accessToken) && has(self.oidcConfig))",message="Exactly one of 'accessToken' or 'oidcConfig' must be specified"
+type PulumiAuth struct {
+	// AccessToken authenticates using a Pulumi access token stored in a Kubernetes Secret.
+	// +optional
+	AccessToken *PulumiProviderSecretRef `json:"accessToken,omitempty"`
+
+	// OIDCConfig authenticates using Kubernetes ServiceAccount tokens via OIDC.
+	// +optional
+	OIDCConfig *PulumiOIDCAuth `json:"oidcConfig,omitempty"`
 }
 
 // PulumiProviderSecretRef contains the secret reference for Pulumi authentication.
@@ -47,3 +69,19 @@ type PulumiProviderSecretRef struct {
 	// SecretRef is a reference to a secret containing the Pulumi API token.
 	SecretRef *esmeta.SecretKeySelector `json:"secretRef,omitempty"`
 }
+
+// PulumiOIDCAuth configures OIDC authentication with Pulumi using Kubernetes ServiceAccount tokens.
+type PulumiOIDCAuth struct {
+	// Organization is the name of the Pulumi organization configured for OIDC authentication.
+	Organization string `json:"organization"`
+
+	// ServiceAccountRef specifies the Kubernetes ServiceAccount to use for authentication.
+	ServiceAccountRef esmeta.ServiceAccountSelector `json:"serviceAccountRef"`
+
+	// ExpirationSeconds sets the token validity duration for service account and OIDC token.
+	// Defaults to 10 minutes.
+	// +kubebuilder:default=600
+	// +kubebuilder:validation:Minimum=600
+	// +optional
+	ExpirationSeconds *int64 `json:"expirationSeconds,omitempty"`
+}

+ 51 - 0
apis/externalsecrets/v1/zz_generated.deepcopy.go

@@ -3245,8 +3245,59 @@ func (in *PreviderProvider) DeepCopy() *PreviderProvider {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PulumiAuth) DeepCopyInto(out *PulumiAuth) {
+	*out = *in
+	if in.AccessToken != nil {
+		in, out := &in.AccessToken, &out.AccessToken
+		*out = new(PulumiProviderSecretRef)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.OIDCConfig != nil {
+		in, out := &in.OIDCConfig, &out.OIDCConfig
+		*out = new(PulumiOIDCAuth)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PulumiAuth.
+func (in *PulumiAuth) DeepCopy() *PulumiAuth {
+	if in == nil {
+		return nil
+	}
+	out := new(PulumiAuth)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PulumiOIDCAuth) DeepCopyInto(out *PulumiOIDCAuth) {
+	*out = *in
+	in.ServiceAccountRef.DeepCopyInto(&out.ServiceAccountRef)
+	if in.ExpirationSeconds != nil {
+		in, out := &in.ExpirationSeconds, &out.ExpirationSeconds
+		*out = new(int64)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PulumiOIDCAuth.
+func (in *PulumiOIDCAuth) DeepCopy() *PulumiOIDCAuth {
+	if in == nil {
+		return nil
+	}
+	out := new(PulumiOIDCAuth)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PulumiProvider) DeepCopyInto(out *PulumiProvider) {
 	*out = *in
+	if in.Auth != nil {
+		in, out := &in.Auth, &out.Auth
+		*out = new(PulumiAuth)
+		(*in).DeepCopyInto(*out)
+	}
 	if in.AccessToken != nil {
 		in, out := &in.AccessToken, &out.AccessToken
 		*out = new(PulumiProviderSecretRef)

+ 103 - 3
config/crds/bases/external-secrets.io_clustersecretstores.yaml

@@ -4545,8 +4545,10 @@ spec:
                       the Pulumi provider
                     properties:
                       accessToken:
-                        description: AccessToken is the access tokens to sign in to
-                          the Pulumi Cloud Console.
+                        description: |-
+                          AccessToken is the access tokens to sign in to the Pulumi Cloud Console.
+
+                          Deprecated: Use auth.accessToken instead.
                         properties:
                           secretRef:
                             description: SecretRef is a reference to a secret containing
@@ -4581,6 +4583,100 @@ spec:
                         default: https://api.pulumi.com/api/esc
                         description: APIURL is the URL of the Pulumi API.
                         type: string
+                      auth:
+                        description: |-
+                          Auth configures how the Operator authenticates with the Pulumi API.
+                          Either auth or the deprecated accessToken field must be specified.
+                        properties:
+                          accessToken:
+                            description: AccessToken authenticates using a Pulumi
+                              access token stored in a Kubernetes Secret.
+                            properties:
+                              secretRef:
+                                description: SecretRef is a reference to a secret
+                                  containing the Pulumi API token.
+                                properties:
+                                  key:
+                                    description: |-
+                                      A key in the referenced Secret.
+                                      Some instances of this field may be defaulted, in others it may be required.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[-._a-zA-Z0-9]+$
+                                    type: string
+                                  name:
+                                    description: The name of the Secret resource being
+                                      referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      The namespace of the Secret resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                type: object
+                            type: object
+                          oidcConfig:
+                            description: OIDCConfig authenticates using Kubernetes
+                              ServiceAccount tokens via OIDC.
+                            properties:
+                              expirationSeconds:
+                                default: 600
+                                description: |-
+                                  ExpirationSeconds sets the token validity duration for service account and OIDC token.
+                                  Defaults to 10 minutes.
+                                format: int64
+                                minimum: 600
+                                type: integer
+                              organization:
+                                description: Organization is the name of the Pulumi
+                                  organization configured for OIDC authentication.
+                                type: string
+                              serviceAccountRef:
+                                description: ServiceAccountRef specifies the Kubernetes
+                                  ServiceAccount to use for authentication.
+                                properties:
+                                  audiences:
+                                    description: |-
+                                      Audience specifies the `aud` claim for the service account token
+                                      If the service account uses a well-known annotation for e.g. IRSA or GCP Workload Identity
+                                      then this audiences will be appended to the list
+                                    items:
+                                      type: string
+                                    type: array
+                                  name:
+                                    description: The name of the ServiceAccount resource
+                                      being referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      Namespace of the resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                required:
+                                - name
+                                type: object
+                            required:
+                            - organization
+                            - serviceAccountRef
+                            type: object
+                        type: object
+                        x-kubernetes-validations:
+                        - message: Exactly one of 'accessToken' or 'oidcConfig' must
+                            be specified
+                          rule: (has(self.accessToken) && !has(self.oidcConfig)) ||
+                            (!has(self.accessToken) && has(self.oidcConfig))
                       environment:
                         description: |-
                           Environment are YAML documents composed of static key-value pairs, programmatic expressions,
@@ -4598,11 +4694,15 @@ spec:
                           the environment belongs to.
                         type: string
                     required:
-                    - accessToken
                     - environment
                     - organization
                     - project
                     type: object
+                    x-kubernetes-validations:
+                    - message: Exactly one of 'auth' or deprecated 'accessToken' must
+                        be specified
+                      rule: (has(self.auth) && !has(self.accessToken)) || (!has(self.auth)
+                        && has(self.accessToken))
                   scaleway:
                     description: Scaleway configures this store to sync secrets using
                       the Scaleway provider.

+ 103 - 3
config/crds/bases/external-secrets.io_secretstores.yaml

@@ -4545,8 +4545,10 @@ spec:
                       the Pulumi provider
                     properties:
                       accessToken:
-                        description: AccessToken is the access tokens to sign in to
-                          the Pulumi Cloud Console.
+                        description: |-
+                          AccessToken is the access tokens to sign in to the Pulumi Cloud Console.
+
+                          Deprecated: Use auth.accessToken instead.
                         properties:
                           secretRef:
                             description: SecretRef is a reference to a secret containing
@@ -4581,6 +4583,100 @@ spec:
                         default: https://api.pulumi.com/api/esc
                         description: APIURL is the URL of the Pulumi API.
                         type: string
+                      auth:
+                        description: |-
+                          Auth configures how the Operator authenticates with the Pulumi API.
+                          Either auth or the deprecated accessToken field must be specified.
+                        properties:
+                          accessToken:
+                            description: AccessToken authenticates using a Pulumi
+                              access token stored in a Kubernetes Secret.
+                            properties:
+                              secretRef:
+                                description: SecretRef is a reference to a secret
+                                  containing the Pulumi API token.
+                                properties:
+                                  key:
+                                    description: |-
+                                      A key in the referenced Secret.
+                                      Some instances of this field may be defaulted, in others it may be required.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[-._a-zA-Z0-9]+$
+                                    type: string
+                                  name:
+                                    description: The name of the Secret resource being
+                                      referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      The namespace of the Secret resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                type: object
+                            type: object
+                          oidcConfig:
+                            description: OIDCConfig authenticates using Kubernetes
+                              ServiceAccount tokens via OIDC.
+                            properties:
+                              expirationSeconds:
+                                default: 600
+                                description: |-
+                                  ExpirationSeconds sets the token validity duration for service account and OIDC token.
+                                  Defaults to 10 minutes.
+                                format: int64
+                                minimum: 600
+                                type: integer
+                              organization:
+                                description: Organization is the name of the Pulumi
+                                  organization configured for OIDC authentication.
+                                type: string
+                              serviceAccountRef:
+                                description: ServiceAccountRef specifies the Kubernetes
+                                  ServiceAccount to use for authentication.
+                                properties:
+                                  audiences:
+                                    description: |-
+                                      Audience specifies the `aud` claim for the service account token
+                                      If the service account uses a well-known annotation for e.g. IRSA or GCP Workload Identity
+                                      then this audiences will be appended to the list
+                                    items:
+                                      type: string
+                                    type: array
+                                  name:
+                                    description: The name of the ServiceAccount resource
+                                      being referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      Namespace of the resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                required:
+                                - name
+                                type: object
+                            required:
+                            - organization
+                            - serviceAccountRef
+                            type: object
+                        type: object
+                        x-kubernetes-validations:
+                        - message: Exactly one of 'accessToken' or 'oidcConfig' must
+                            be specified
+                          rule: (has(self.accessToken) && !has(self.oidcConfig)) ||
+                            (!has(self.accessToken) && has(self.oidcConfig))
                       environment:
                         description: |-
                           Environment are YAML documents composed of static key-value pairs, programmatic expressions,
@@ -4598,11 +4694,15 @@ spec:
                           the environment belongs to.
                         type: string
                     required:
-                    - accessToken
                     - environment
                     - organization
                     - project
                     type: object
+                    x-kubernetes-validations:
+                    - message: Exactly one of 'auth' or deprecated 'accessToken' must
+                        be specified
+                      rule: (has(self.auth) && !has(self.accessToken)) || (!has(self.auth)
+                        && has(self.accessToken))
                   scaleway:
                     description: Scaleway configures this store to sync secrets using
                       the Scaleway provider.

+ 184 - 4
deploy/crds/bundle.yaml

@@ -6484,7 +6484,10 @@ spec:
                       description: Pulumi configures this store to sync secrets using the Pulumi provider
                       properties:
                         accessToken:
-                          description: AccessToken is the access tokens to sign in to the Pulumi Cloud Console.
+                          description: |-
+                            AccessToken is the access tokens to sign in to the Pulumi Cloud Console.
+
+                            Deprecated: Use auth.accessToken instead.
                           properties:
                             secretRef:
                               description: SecretRef is a reference to a secret containing the Pulumi API token.
@@ -6517,6 +6520,91 @@ spec:
                           default: https://api.pulumi.com/api/esc
                           description: APIURL is the URL of the Pulumi API.
                           type: string
+                        auth:
+                          description: |-
+                            Auth configures how the Operator authenticates with the Pulumi API.
+                            Either auth or the deprecated accessToken field must be specified.
+                          properties:
+                            accessToken:
+                              description: AccessToken authenticates using a Pulumi access token stored in a Kubernetes Secret.
+                              properties:
+                                secretRef:
+                                  description: SecretRef is a reference to a secret containing the Pulumi API token.
+                                  properties:
+                                    key:
+                                      description: |-
+                                        A key in the referenced Secret.
+                                        Some instances of this field may be defaulted, in others it may be required.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[-._a-zA-Z0-9]+$
+                                      type: string
+                                    name:
+                                      description: The name of the Secret resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        The namespace of the Secret resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  type: object
+                              type: object
+                            oidcConfig:
+                              description: OIDCConfig authenticates using Kubernetes ServiceAccount tokens via OIDC.
+                              properties:
+                                expirationSeconds:
+                                  default: 600
+                                  description: |-
+                                    ExpirationSeconds sets the token validity duration for service account and OIDC token.
+                                    Defaults to 10 minutes.
+                                  format: int64
+                                  minimum: 600
+                                  type: integer
+                                organization:
+                                  description: Organization is the name of the Pulumi organization configured for OIDC authentication.
+                                  type: string
+                                serviceAccountRef:
+                                  description: ServiceAccountRef specifies the Kubernetes ServiceAccount to use for authentication.
+                                  properties:
+                                    audiences:
+                                      description: |-
+                                        Audience specifies the `aud` claim for the service account token
+                                        If the service account uses a well-known annotation for e.g. IRSA or GCP Workload Identity
+                                        then this audiences will be appended to the list
+                                      items:
+                                        type: string
+                                      type: array
+                                    name:
+                                      description: The name of the ServiceAccount resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        Namespace of the resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  required:
+                                    - name
+                                  type: object
+                              required:
+                                - organization
+                                - serviceAccountRef
+                              type: object
+                          type: object
+                          x-kubernetes-validations:
+                            - message: Exactly one of 'accessToken' or 'oidcConfig' must be specified
+                              rule: (has(self.accessToken) && !has(self.oidcConfig)) || (!has(self.accessToken) && has(self.oidcConfig))
                         environment:
                           description: |-
                             Environment are YAML documents composed of static key-value pairs, programmatic expressions,
@@ -6533,11 +6621,13 @@ spec:
                           description: Project is the name of the Pulumi ESC project the environment belongs to.
                           type: string
                       required:
-                        - accessToken
                         - environment
                         - organization
                         - project
                       type: object
+                      x-kubernetes-validations:
+                        - message: Exactly one of 'auth' or deprecated 'accessToken' must be specified
+                          rule: (has(self.auth) && !has(self.accessToken)) || (!has(self.auth) && has(self.accessToken))
                     scaleway:
                       description: Scaleway configures this store to sync secrets using the Scaleway provider.
                       properties:
@@ -18635,7 +18725,10 @@ spec:
                       description: Pulumi configures this store to sync secrets using the Pulumi provider
                       properties:
                         accessToken:
-                          description: AccessToken is the access tokens to sign in to the Pulumi Cloud Console.
+                          description: |-
+                            AccessToken is the access tokens to sign in to the Pulumi Cloud Console.
+
+                            Deprecated: Use auth.accessToken instead.
                           properties:
                             secretRef:
                               description: SecretRef is a reference to a secret containing the Pulumi API token.
@@ -18668,6 +18761,91 @@ spec:
                           default: https://api.pulumi.com/api/esc
                           description: APIURL is the URL of the Pulumi API.
                           type: string
+                        auth:
+                          description: |-
+                            Auth configures how the Operator authenticates with the Pulumi API.
+                            Either auth or the deprecated accessToken field must be specified.
+                          properties:
+                            accessToken:
+                              description: AccessToken authenticates using a Pulumi access token stored in a Kubernetes Secret.
+                              properties:
+                                secretRef:
+                                  description: SecretRef is a reference to a secret containing the Pulumi API token.
+                                  properties:
+                                    key:
+                                      description: |-
+                                        A key in the referenced Secret.
+                                        Some instances of this field may be defaulted, in others it may be required.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[-._a-zA-Z0-9]+$
+                                      type: string
+                                    name:
+                                      description: The name of the Secret resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        The namespace of the Secret resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  type: object
+                              type: object
+                            oidcConfig:
+                              description: OIDCConfig authenticates using Kubernetes ServiceAccount tokens via OIDC.
+                              properties:
+                                expirationSeconds:
+                                  default: 600
+                                  description: |-
+                                    ExpirationSeconds sets the token validity duration for service account and OIDC token.
+                                    Defaults to 10 minutes.
+                                  format: int64
+                                  minimum: 600
+                                  type: integer
+                                organization:
+                                  description: Organization is the name of the Pulumi organization configured for OIDC authentication.
+                                  type: string
+                                serviceAccountRef:
+                                  description: ServiceAccountRef specifies the Kubernetes ServiceAccount to use for authentication.
+                                  properties:
+                                    audiences:
+                                      description: |-
+                                        Audience specifies the `aud` claim for the service account token
+                                        If the service account uses a well-known annotation for e.g. IRSA or GCP Workload Identity
+                                        then this audiences will be appended to the list
+                                      items:
+                                        type: string
+                                      type: array
+                                    name:
+                                      description: The name of the ServiceAccount resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        Namespace of the resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  required:
+                                    - name
+                                  type: object
+                              required:
+                                - organization
+                                - serviceAccountRef
+                              type: object
+                          type: object
+                          x-kubernetes-validations:
+                            - message: Exactly one of 'accessToken' or 'oidcConfig' must be specified
+                              rule: (has(self.accessToken) && !has(self.oidcConfig)) || (!has(self.accessToken) && has(self.oidcConfig))
                         environment:
                           description: |-
                             Environment are YAML documents composed of static key-value pairs, programmatic expressions,
@@ -18684,11 +18862,13 @@ spec:
                           description: Project is the name of the Pulumi ESC project the environment belongs to.
                           type: string
                       required:
-                        - accessToken
                         - environment
                         - organization
                         - project
                       type: object
+                      x-kubernetes-validations:
+                        - message: Exactly one of 'auth' or deprecated 'accessToken' must be specified
+                          rule: (has(self.auth) && !has(self.accessToken)) || (!has(self.auth) && has(self.accessToken))
                     scaleway:
                       description: Scaleway configures this store to sync secrets using the Scaleway provider.
                       properties:

+ 126 - 4
docs/api/spec.md

@@ -8835,6 +8835,110 @@ string
 <p>
 <p>Provider is a common interface for interacting with secret backends.</p>
 </p>
+<h3 id="external-secrets.io/v1.PulumiAuth">PulumiAuth
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.PulumiProvider">PulumiProvider</a>)
+</p>
+<p>
+<p>PulumiAuth configures authentication with the Pulumi API.
+Exactly one of accessToken or oidcConfig must be specified.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>accessToken</code></br>
+<em>
+<a href="#external-secrets.io/v1.PulumiProviderSecretRef">
+PulumiProviderSecretRef
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>AccessToken authenticates using a Pulumi access token stored in a Kubernetes Secret.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>oidcConfig</code></br>
+<em>
+<a href="#external-secrets.io/v1.PulumiOIDCAuth">
+PulumiOIDCAuth
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>OIDCConfig authenticates using Kubernetes ServiceAccount tokens via OIDC.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1.PulumiOIDCAuth">PulumiOIDCAuth
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.PulumiAuth">PulumiAuth</a>)
+</p>
+<p>
+<p>PulumiOIDCAuth configures OIDC authentication with Pulumi using Kubernetes ServiceAccount tokens.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>organization</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<p>Organization is the name of the Pulumi organization configured for OIDC authentication.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>serviceAccountRef</code></br>
+<em>
+<a href="https://pkg.go.dev/github.com/external-secrets/external-secrets/apis/meta/v1#ServiceAccountSelector">
+External Secrets meta/v1.ServiceAccountSelector
+</a>
+</em>
+</td>
+<td>
+<p>ServiceAccountRef specifies the Kubernetes ServiceAccount to use for authentication.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>expirationSeconds</code></br>
+<em>
+int64
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>ExpirationSeconds sets the token validity duration for service account and OIDC token.
+Defaults to 10 minutes.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1.PulumiProvider">PulumiProvider
 </h3>
 <p>
@@ -8865,15 +8969,17 @@ string
 </tr>
 <tr>
 <td>
-<code>accessToken</code></br>
+<code>auth</code></br>
 <em>
-<a href="#external-secrets.io/v1.PulumiProviderSecretRef">
-PulumiProviderSecretRef
+<a href="#external-secrets.io/v1.PulumiAuth">
+PulumiAuth
 </a>
 </em>
 </td>
 <td>
-<p>AccessToken is the access tokens to sign in to the Pulumi Cloud Console.</p>
+<em>(Optional)</em>
+<p>Auth configures how the Operator authenticates with the Pulumi API.
+Either auth or the deprecated accessToken field must be specified.</p>
 </td>
 </tr>
 <tr>
@@ -8913,12 +9019,28 @@ and other Pulumi ESC environments.
 To create a new environment, visit <a href="https://www.pulumi.com/docs/esc/environments/">https://www.pulumi.com/docs/esc/environments/</a> for more information.</p>
 </td>
 </tr>
+<tr>
+<td>
+<code>accessToken</code></br>
+<em>
+<a href="#external-secrets.io/v1.PulumiProviderSecretRef">
+PulumiProviderSecretRef
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>AccessToken is the access tokens to sign in to the Pulumi Cloud Console.</p>
+<p>Deprecated: Use auth.accessToken instead.</p>
+</td>
+</tr>
 </tbody>
 </table>
 <h3 id="external-secrets.io/v1.PulumiProviderSecretRef">PulumiProviderSecretRef
 </h3>
 <p>
 (<em>Appears on:</em>
+<a href="#external-secrets.io/v1.PulumiAuth">PulumiAuth</a>, 
 <a href="#external-secrets.io/v1.PulumiProvider">PulumiProvider</a>)
 </p>
 <p>

+ 65 - 10
docs/provider/pulumi.md

@@ -8,10 +8,15 @@ More information about setting up [Pulumi](https://www.pulumi.com/) ESC can be f
 
 ### Authentication
 
-Pulumi [Access Tokens](https://www.pulumi.com/docs/pulumi-cloud/access-management/access-tokens/) are recommended to access Pulumi ESC.
+The Pulumi provider supports two authentication methods:
+
+1. **Access Token** (recommended for most use cases): Use Pulumi [Access Tokens](https://www.pulumi.com/docs/pulumi-cloud/access-management/access-tokens/) stored in Kubernetes secrets.
+2. **OIDC** (recommended for workload identity): Use Kubernetes ServiceAccount tokens to authenticate via OIDC, eliminating the need to store static credentials.
 
 ### Creating a SecretStore
 
+#### Using Access Token
+
 A Pulumi `SecretStore` can be created by specifying the `organization`, `project` and `environment` and referencing a Kubernetes secret containing the `accessToken`.
 
 ```yaml
@@ -25,16 +30,35 @@ spec:
       organization: <NAME_OF_THE_ORGANIZATION>
       project: <NAME_OF_THE_PROJECT>
       environment: <NAME_OF_THE_ENVIRONMENT>
-      accessToken:
-        secretRef:
-          name: <NAME_OF_KUBE_SECRET>
-          key: <KEY_IN_KUBE_SECRET>
+      auth:
+        accessToken:
+          secretRef:
+            name: <NAME_OF_KUBE_SECRET>
+            key: <KEY_IN_KUBE_SECRET>
+```
+
+**Note:** The deprecated `accessToken` field at the root level is still supported for backward compatibility, but using `auth.accessToken` is recommended.
+
+#### Using OIDC
+
+Alternatively, you can use OIDC authentication with Kubernetes ServiceAccount tokens. This method eliminates the need to store static credentials.
+
+First, configure OIDC in your Pulumi organization by following the [Pulumi OIDC documentation](https://www.pulumi.com/docs/pulumi-cloud/access-management/oidc/).
+
+Then create a ServiceAccount and SecretStore:
+
+```yaml
+{% include 'pulumi-oidc-secret-store.yaml' %}
 ```
 
+The `expirationSeconds` field is optional and defaults to 600 seconds (10 minutes).
+
 If required, the API URL (`apiUrl`) can be customized as well. If not specified, the default value is `https://api.pulumi.com/api/esc`.
 
 ### Creating a ClusterSecretStore
 
+#### Using Access Token
+
 Similarly, a `ClusterSecretStore` can be created by specifying the `namespace` and referencing a Kubernetes secret containing the `accessToken`.
 
 ```yaml
@@ -48,11 +72,42 @@ spec:
       organization: <NAME_OF_THE_ORGANIZATION>
       project: <NAME_OF_THE_PROJECT>
       environment: <NAME_OF_THE_ENVIRONMENT>
-      accessToken:
-        secretRef:
-          name: <NAME_OF_KUBE_SECRET>
-          key: <KEY_IN_KUBE_SECRET>
-          namespace: <NAMESPACE>
+      auth:
+        accessToken:
+          secretRef:
+            name: <NAME_OF_KUBE_SECRET>
+            key: <KEY_IN_KUBE_SECRET>
+            namespace: <NAMESPACE>
+```
+
+#### Using OIDC
+
+For ClusterSecretStore with OIDC, you need to specify the ServiceAccount namespace:
+
+```yaml
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: pulumi-oidc-sa
+  namespace: external-secrets
+---
+apiVersion: external-secrets.io/v1
+kind: ClusterSecretStore
+metadata:
+  name: pulumi-oidc-cluster-store
+spec:
+  provider:
+    pulumi:
+      organization: my-org
+      project: my-project
+      environment: production
+      auth:
+        oidcConfig:
+          organization: my-org
+          serviceAccountRef:
+            name: pulumi-oidc-sa
+            namespace: external-secrets
+          expirationSeconds: 600
 ```
 
 ### Referencing Secrets

+ 23 - 0
docs/snippets/pulumi-oidc-secret-store.yaml

@@ -0,0 +1,23 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: pulumi-oidc-sa
+  namespace: default
+---
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: pulumi-oidc-store
+  namespace: default
+spec:
+  provider:
+    pulumi:
+      organization: my-org
+      project: my-project
+      environment: production
+      auth:
+        oidcConfig:
+          organization: my-org
+          serviceAccountRef:
+            name: pulumi-oidc-sa
+          expirationSeconds: 600  # Optional: defaults to 600 (10 minutes)

+ 3 - 3
go.mod

@@ -357,9 +357,9 @@ require (
 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
-	go.opentelemetry.io/otel v1.39.0 // indirect
-	go.opentelemetry.io/otel/metric v1.39.0 // indirect
-	go.opentelemetry.io/otel/trace v1.39.0 // indirect
+	go.opentelemetry.io/otel v1.43.0 // indirect
+	go.opentelemetry.io/otel/metric v1.43.0 // indirect
+	go.opentelemetry.io/otel/trace v1.43.0 // indirect
 	go.opentelemetry.io/proto/otlp v1.8.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.4 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect

+ 6 - 6
go.sum

@@ -1134,20 +1134,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
-go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
-go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE=
-go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
-go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
 go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
 go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
 go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
 go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
-go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
-go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
 go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=

+ 31 - 168
providers/v1/doppler/auth_oidc.go

@@ -17,43 +17,25 @@ 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"
+	"github.com/external-secrets/external-secrets/runtime/oidc"
 )
 
-const (
-	defaultTokenTTL = 600
-	minTokenBuffer  = 60
-	dopplerOIDCPath = "/v3/auth/oidc"
-)
+const dopplerOIDCPath = "/v3/auth/oidc"
 
 // OIDCTokenManager manages OIDC token exchange with Doppler.
+// It embeds the shared BaseTokenManager and implements the TokenExchanger interface.
 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
+	*oidc.BaseTokenManager
+	identity string
 }
 
 // NewOIDCTokenManager creates a new OIDCTokenManager for handling Doppler OIDC authentication.
@@ -64,170 +46,51 @@ func NewOIDCTokenManager(
 	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
+	if store == nil || store.Auth == nil || store.Auth.OIDCConfig == nil {
+		return nil
 	}
-	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}
+	oidcAuth := store.Auth.OIDCConfig
 
-	// Add custom audiences from serviceAccountRef
-	if len(oidcAuth.ServiceAccountRef.Audiences) > 0 {
-		audiences = append(audiences, oidcAuth.ServiceAccountRef.Audiences...)
+	baseURL := "https://api.doppler.com"
+	if customURL := os.Getenv(customBaseURLEnvVar); customURL != "" {
+		baseURL = customURL
 	}
 
-	// Add resource-specific audience for cryptographic binding
-	if m.storeKind == esv1.ClusterSecretStoreKind {
-		audiences = append(audiences, fmt.Sprintf("clusterSecretStore:%s", m.storeName))
+	// Resource-specific audience binds the SA token to a specific
+	// SecretStore/ClusterSecretStore, preventing token reuse across stores.
+	var resourceAudience string
+	if storeKind == esv1.ClusterSecretStoreKind {
+		resourceAudience = fmt.Sprintf("clusterSecretStore:%s", 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,
-		},
+		resourceAudience = fmt.Sprintf("secretStore:%s:%s", namespace, storeName)
 	}
 
-	// For ClusterSecretStores, we use the ServiceAccountRef.Namespace if specified
-	if m.storeKind == esv1.ClusterSecretStoreKind && oidcAuth.ServiceAccountRef.Namespace != nil {
-		tokenRequest.Namespace = *oidcAuth.ServiceAccountRef.Namespace
-	}
+	btm := oidc.NewBaseTokenManager(corev1, namespace, storeKind, baseURL, oidcAuth.ServiceAccountRef)
+	btm.ExtraAudiences = []string{resourceAudience}
+	btm.ExpirationSeconds = oidcAuth.ExpirationSeconds
 
-	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)
+	manager := &OIDCTokenManager{
+		identity:         oidcAuth.Identity,
+		BaseTokenManager: btm,
 	}
+	manager.Exchanger = manager
 
-	return tokenResponse.Status.Token, nil
+	return manager
 }
 
-func (m *OIDCTokenManager) exchangeTokenWithDoppler(ctx context.Context, saToken string) (string, time.Time, error) {
-	oidcAuth := m.store.Auth.OIDCConfig
-	url := m.baseURL + dopplerOIDCPath
+// ExchangeToken exchanges a ServiceAccount token for a Doppler API token.
+func (m *OIDCTokenManager) ExchangeToken(ctx context.Context, saToken string) (string, time.Time, error) {
+	url := m.BaseURL + dopplerOIDCPath
 
 	requestBody := map[string]string{
-		"identity": oidcAuth.Identity,
+		"identity": m.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)
+	body, err := oidc.PostJSONRequest(ctx, url, requestBody, "Doppler")
 	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))
+		return "", time.Time{}, err
 	}
 
 	var response struct {

+ 115 - 75
providers/v1/doppler/auth_oidc_test.go

@@ -18,6 +18,7 @@ package doppler
 
 import (
 	"context"
+	"encoding/json"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -25,102 +26,141 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"k8s.io/client-go/kubernetes/fake"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
-	"github.com/external-secrets/external-secrets/runtime/util/fake"
 )
 
-func TestOIDCTokenManager_Token(t *testing.T) {
-	// Mock Doppler OIDC endpoint
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.Path == "/v3/auth/oidc" {
-			w.Header().Set("Content-Type", "application/json")
-			w.WriteHeader(http.StatusOK)
-			// Return a token that expires in 1 hour
-			expiresAt := time.Now().Add(time.Hour).Format(time.RFC3339)
-			if _, err := w.Write([]byte(`{"success": true, "token": "doppler_token_123", "expires_at": "` + expiresAt + `"}`)); err != nil {
-				t.Errorf("failed to write response: %v", err)
-			}
-			return
-		}
-		w.WriteHeader(http.StatusNotFound)
-	}))
-	defer server.Close()
+func TestNewOIDCTokenManager_NilConfig(t *testing.T) {
+	fakeClient := fake.NewSimpleClientset()
 
-	store := &esv1.DopplerProvider{
-		Auth: &esv1.DopplerAuth{
-			OIDCConfig: &esv1.DopplerOIDCAuth{
-				Identity:          "test-identity",
-				ServiceAccountRef: esmeta.ServiceAccountSelector{Name: "test-sa"},
-				ExpirationSeconds: func() *int64 { v := int64(600); return &v }(),
+	// Test with nil store
+	manager := NewOIDCTokenManager(fakeClient.CoreV1(), nil, "default", esv1.SecretStoreKind, "test-store")
+	assert.Nil(t, manager)
+
+	// Test with nil Auth
+	store := &esv1.DopplerProvider{}
+	manager = NewOIDCTokenManager(fakeClient.CoreV1(), store, "default", esv1.SecretStoreKind, "test-store")
+	assert.Nil(t, manager)
+
+	// Test with nil OIDCConfig
+	store.Auth = &esv1.DopplerAuth{}
+	manager = NewOIDCTokenManager(fakeClient.CoreV1(), store, "default", esv1.SecretStoreKind, "test-store")
+	assert.Nil(t, manager)
+}
+
+func TestOIDCTokenManager_ExchangeToken(t *testing.T) {
+	tests := []struct {
+		name           string
+		responseBody   map[string]any
+		responseStatus int
+		wantError      bool
+		errorContains  string
+	}{
+		{
+			name: "successful exchange",
+			responseBody: map[string]any{
+				"success":    true,
+				"token":      "doppler_token_123",
+				"expires_at": time.Now().Add(time.Hour).Format(time.RFC3339),
+			},
+			responseStatus: http.StatusOK,
+			wantError:      false,
+		},
+		{
+			name: "failed exchange - success false",
+			responseBody: map[string]any{
+				"success": false,
+				"error":   "invalid identity",
+			},
+			responseStatus: http.StatusOK,
+			wantError:      true,
+			errorContains:  "Doppler OIDC auth failed",
+		},
+		{
+			name: "unauthorized",
+			responseBody: map[string]any{
+				"error": "invalid_token",
 			},
+			responseStatus: http.StatusUnauthorized,
+			wantError:      true,
+			errorContains:  "Doppler OIDC auth failed",
+		},
+		{
+			name:           "server error",
+			responseBody:   nil,
+			responseStatus: http.StatusInternalServerError,
+			wantError:      true,
+			errorContains:  "Doppler OIDC auth failed",
 		},
 	}
 
-	manager := &OIDCTokenManager{
-		corev1:    fake.NewCreateTokenMock().WithToken("k8s_jwt_token"),
-		store:     store,
-		namespace: "test-namespace",
-		storeKind: "SecretStore",
-		storeName: "test-store",
-		baseURL:   server.URL,
-		verifyTLS: false,
-	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(tt.responseStatus)
+				if tt.responseBody != nil {
+					_ = json.NewEncoder(w).Encode(tt.responseBody)
+				}
+			}))
+			defer server.Close()
+
+			// Set the custom base URL env var to use the test server
+			t.Setenv(customBaseURLEnvVar, server.URL)
+
+			fakeClient := fake.NewSimpleClientset()
+			store := &esv1.DopplerProvider{
+				Auth: &esv1.DopplerAuth{
+					OIDCConfig: &esv1.DopplerOIDCAuth{
+						Identity: "test-identity",
+						ServiceAccountRef: esmeta.ServiceAccountSelector{
+							Name: "test-sa",
+						},
+					},
+				},
+			}
 
-	ctx := context.Background()
+			manager := NewOIDCTokenManager(fakeClient.CoreV1(), store, "default", esv1.SecretStoreKind, "test-store")
+			require.NotNil(t, manager)
 
-	// First call should fetch a new token
-	token1, err := manager.Token(ctx)
-	require.NoError(t, err)
-	assert.Equal(t, "doppler_token_123", token1)
+			token, _, err := manager.ExchangeToken(context.Background(), "k8s-token")
 
-	// Second call should return cached token
-	token2, err := manager.Token(ctx)
-	require.NoError(t, err)
-	assert.Equal(t, token1, token2)
+			if tt.wantError {
+				require.Error(t, err)
+				if tt.errorContains != "" {
+					assert.Contains(t, err.Error(), tt.errorContains)
+				}
+			} else {
+				require.NoError(t, err)
+				assert.NotEmpty(t, token)
+			}
+		})
+	}
 }
 
-func TestOIDCTokenManager_CreateServiceAccountToken(t *testing.T) {
+func TestNewOIDCTokenManager_ValidConfig(t *testing.T) {
+	fakeClient := fake.NewSimpleClientset()
+	expSec := int64(600)
 	store := &esv1.DopplerProvider{
 		Auth: &esv1.DopplerAuth{
 			OIDCConfig: &esv1.DopplerOIDCAuth{
-				Identity:          "test-identity",
-				ServiceAccountRef: esmeta.ServiceAccountSelector{Name: "test-sa", Namespace: func() *string { s := "custom-ns"; return &s }()},
-				ExpirationSeconds: func() *int64 { v := int64(600); return &v }(),
+				Identity: "test-identity",
+				ServiceAccountRef: esmeta.ServiceAccountSelector{
+					Name: "test-sa",
+				},
+				ExpirationSeconds: &expSec,
 			},
 		},
 	}
 
-	manager := &OIDCTokenManager{
-		corev1:    fake.NewCreateTokenMock().WithToken("k8s_jwt_token"),
-		store:     store,
-		namespace: "default-namespace",
-		storeKind: "SecretStore",
-		storeName: "test-store",
-		baseURL:   "https://api.doppler.com",
-		verifyTLS: true,
-	}
-
-	token, err := manager.createServiceAccountToken(context.Background())
-	require.NoError(t, err)
-	assert.Equal(t, "k8s_jwt_token", token)
-}
-
-func TestOIDCTokenManager_TokenExpiry(t *testing.T) {
-	manager := &OIDCTokenManager{
-		cachedToken: "test_token",
-		tokenExpiry: time.Now().Add(30 * time.Second), // Token expires in 30 seconds
-	}
-
-	// Token should be considered invalid (less than 60 second buffer)
-	assert.False(t, manager.isTokenValid())
-
-	// Token with more time should be valid
-	manager.tokenExpiry = time.Now().Add(2 * time.Minute)
-	assert.True(t, manager.isTokenValid())
+	manager := NewOIDCTokenManager(
+		fakeClient.CoreV1(),
+		store,
+		"default",
+		esv1.SecretStoreKind,
+		"test-store",
+	)
 
-	// Empty token should be invalid
-	manager.cachedToken = ""
-	assert.False(t, manager.isTokenValid())
+	assert.NotNil(t, manager)
 }

+ 2 - 2
providers/v1/doppler/client.go

@@ -90,7 +90,7 @@ func (c *Client) setAuth(ctx context.Context) error {
 		}
 		c.dopplerToken = token
 	} else if c.store.Auth.OIDCConfig != nil {
-		token, err := c.oidcManager.Token(ctx)
+		token, err := c.oidcManager.GetToken(ctx)
 		if err != nil {
 			return fmt.Errorf("failed to get OIDC token: %w", err)
 		}
@@ -103,7 +103,7 @@ func (c *Client) setAuth(ctx context.Context) error {
 
 func (c *Client) refreshAuthIfNeeded(ctx context.Context) error {
 	if c.store != nil && c.store.Auth != nil && c.store.Auth.OIDCConfig != nil && c.oidcManager != nil {
-		token, err := c.oidcManager.Token(ctx)
+		token, err := c.oidcManager.GetToken(ctx)
 		if err != nil {
 			return fmt.Errorf("failed to refresh OIDC token: %w", err)
 		}

+ 1 - 1
providers/v1/doppler/go.mod

@@ -9,7 +9,6 @@ require (
 	github.com/spf13/pflag v1.0.10
 	github.com/stretchr/testify v1.11.1
 	k8s.io/api v0.35.0
-	k8s.io/apimachinery v0.35.0
 	k8s.io/client-go v0.35.0
 	sigs.k8s.io/controller-runtime v0.23.1
 )
@@ -88,6 +87,7 @@ require (
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	k8s.io/apiextensions-apiserver v0.35.0 // indirect
+	k8s.io/apimachinery v0.35.0 // indirect
 	k8s.io/klog/v2 v2.130.1 // indirect
 	k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect
 	k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect

+ 8 - 4
providers/v1/doppler/provider.go

@@ -22,6 +22,7 @@ import (
 	"fmt"
 	"os"
 	"strconv"
+	"sync"
 
 	"github.com/spf13/pflag"
 	"k8s.io/client-go/kubernetes"
@@ -54,6 +55,7 @@ var (
 	oidcClientCache      *cache.Cache[esv1.SecretsClient]
 	defaultOIDCCacheSize = 2 << 17
 	defaultETagCacheSize = 1 << 14
+	oidcCacheOnce        sync.Once
 )
 
 func init() {
@@ -88,9 +90,11 @@ func init() {
 
 // Gating on enableCache to not enable cache out of the blue for new releases.
 func initOIDCCache(cacheSize int) {
-	if oidcClientCache == nil && cacheSize > 0 && enableCache {
-		oidcClientCache = cache.Must(cacheSize, func(_ esv1.SecretsClient) {
-			// No cleanup is needed when evicting OIDC clients from cache
+	if cacheSize > 0 && enableCache {
+		oidcCacheOnce.Do(func() {
+			oidcClientCache = cache.Must(cacheSize, func(_ esv1.SecretsClient) {
+				// No cleanup is needed when evicting OIDC clients from cache
+			})
 		})
 	}
 }
@@ -184,7 +188,7 @@ func (p *Provider) setupOIDCAuth(client *Client, dopplerStoreSpec *esv1.DopplerP
 		dopplerStoreSpec,
 		namespace,
 		store.GetObjectKind().GroupVersionKind().Kind,
-		store.GetObjectMeta().Name,
+		store.GetName(),
 	)
 
 	return nil

+ 131 - 0
providers/v1/pulumi/auth_oidc.go

@@ -0,0 +1,131 @@
+/*
+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 pulumi
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/runtime/oidc"
+)
+
+// Pulumi OAuth token exchange endpoint and constants per:
+// https://www.pulumi.com/docs/reference/cloud-rest-api/oauth-token-exchange/
+const (
+	pulumiOAuthPath             = "/api/oauth/token"
+	pulumiGrantType             = "urn:ietf:params:oauth:grant-type:token-exchange"
+	pulumiSubjectTokenType      = "urn:ietf:params:oauth:token-type:id_token"
+	pulumiRequestedTokenTypeOrg = "urn:pulumi:token-type:access_token:organization"
+)
+
+// OIDCTokenManager manages OIDC token exchange with Pulumi.
+// It embeds the shared BaseTokenManager and implements the TokenExchanger interface.
+type OIDCTokenManager struct {
+	*oidc.BaseTokenManager
+	organization string
+	expiration   int64
+}
+
+// NewOIDCTokenManager creates a new OIDCTokenManager for handling Pulumi OIDC authentication.
+func NewOIDCTokenManager(
+	corev1 typedcorev1.CoreV1Interface,
+	store *esv1.PulumiProvider,
+	namespace string,
+	storeKind string,
+) *OIDCTokenManager {
+	if store == nil || store.Auth == nil || store.Auth.OIDCConfig == nil {
+		return nil
+	}
+
+	oidcAuth := store.Auth.OIDCConfig
+
+	// Normalize the URL first by trimming trailing slash, then remove /api/esc suffix
+	apiURL := strings.TrimSuffix(store.APIURL, "/")
+	baseURL := strings.TrimSuffix(apiURL, "/api/esc")
+	if baseURL == "" {
+		baseURL = "https://api.pulumi.com"
+	}
+
+	// Get expiration from config, default to 600 seconds (10 minutes) if not set
+	// This matches the CRD default for expirationSeconds
+	expiration := int64(600)
+	if oidcAuth.ExpirationSeconds != nil && *oidcAuth.ExpirationSeconds > 0 {
+		expiration = *oidcAuth.ExpirationSeconds
+	}
+
+	btm := oidc.NewBaseTokenManager(corev1, namespace, storeKind, baseURL, oidcAuth.ServiceAccountRef)
+	btm.ExpirationSeconds = oidcAuth.ExpirationSeconds
+
+	manager := &OIDCTokenManager{
+		organization:     oidcAuth.Organization,
+		expiration:       expiration,
+		BaseTokenManager: btm,
+	}
+	manager.Exchanger = manager
+
+	return manager
+}
+
+// ExchangeToken exchanges a ServiceAccount token for a Pulumi access token using the
+// OAuth 2.0 Token Exchange flow per RFC 8693.
+// See: https://www.pulumi.com/docs/reference/cloud-rest-api/oauth-token-exchange/
+func (m *OIDCTokenManager) ExchangeToken(ctx context.Context, saToken string) (string, time.Time, error) {
+	url := m.BaseURL + pulumiOAuthPath
+
+	// Build the OAuth 2.0 Token Exchange request per Pulumi's API specification
+	requestBody := map[string]any{
+		"audience":             fmt.Sprintf("urn:pulumi:org:%s", m.organization),
+		"grant_type":           pulumiGrantType,
+		"subject_token_type":   pulumiSubjectTokenType,
+		"requested_token_type": pulumiRequestedTokenTypeOrg,
+		"subject_token":        saToken,
+		"expiration":           m.expiration,
+		"scope":                "",
+	}
+
+	body, err := oidc.PostJSONRequestInterface(ctx, url, requestBody, "Pulumi")
+	if err != nil {
+		return "", time.Time{}, err
+	}
+
+	var response struct {
+		AccessToken string `json:"access_token"`
+		ExpiresIn   int    `json:"expires_in"`
+	}
+
+	if err := json.Unmarshal(body, &response); err != nil {
+		return "", time.Time{}, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	if response.AccessToken == "" {
+		return "", time.Time{}, fmt.Errorf("Pulumi OIDC auth failed: no access_token in response")
+	}
+
+	if response.ExpiresIn <= 0 {
+		return "", time.Time{}, fmt.Errorf("Pulumi OIDC auth failed: invalid expires_in value %d", response.ExpiresIn)
+	}
+
+	expiresAt := time.Now().Add(time.Duration(response.ExpiresIn) * time.Second)
+
+	return response.AccessToken, expiresAt, nil
+}

+ 233 - 0
providers/v1/pulumi/auth_oidc_test.go

@@ -0,0 +1,233 @@
+/*
+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 pulumi
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"k8s.io/client-go/kubernetes/fake"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+func TestNewOIDCTokenManager_NilConfig(t *testing.T) {
+	fakeClient := fake.NewSimpleClientset()
+
+	// Test with nil store
+	manager := NewOIDCTokenManager(fakeClient.CoreV1(), nil, "default", esv1.SecretStoreKind)
+	assert.Nil(t, manager)
+
+	// Test with nil Auth
+	store := &esv1.PulumiProvider{
+		APIURL:       "https://api.pulumi.com/api/esc",
+		Organization: "test-org",
+	}
+	manager = NewOIDCTokenManager(fakeClient.CoreV1(), store, "default", esv1.SecretStoreKind)
+	assert.Nil(t, manager)
+
+	// Test with nil OIDCConfig
+	store.Auth = &esv1.PulumiAuth{}
+	manager = NewOIDCTokenManager(fakeClient.CoreV1(), store, "default", esv1.SecretStoreKind)
+	assert.Nil(t, manager)
+}
+
+func TestOIDCTokenManager_ExchangeToken(t *testing.T) {
+	tests := []struct {
+		name           string
+		responseBody   map[string]any
+		responseStatus int
+		wantError      bool
+		errorContains  string
+	}{
+		{
+			name: "successful exchange",
+			responseBody: map[string]any{
+				"access_token": "pul-test-token",
+				"expires_in":   3600,
+			},
+			responseStatus: http.StatusOK,
+			wantError:      false,
+		},
+		{
+			name: "missing access_token",
+			responseBody: map[string]any{
+				"expires_in": 3600,
+			},
+			responseStatus: http.StatusOK,
+			wantError:      true,
+			errorContains:  "no access_token",
+		},
+		{
+			name: "unauthorized",
+			responseBody: map[string]any{
+				"error": "invalid_token",
+			},
+			responseStatus: http.StatusUnauthorized,
+			wantError:      true,
+			errorContains:  "Pulumi OIDC auth failed",
+		},
+		{
+			name:           "server error",
+			responseBody:   nil,
+			responseStatus: http.StatusInternalServerError,
+			wantError:      true,
+			errorContains:  "Pulumi OIDC auth failed",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(tt.responseStatus)
+				if tt.responseBody != nil {
+					_ = json.NewEncoder(w).Encode(tt.responseBody)
+				}
+			}))
+			defer server.Close()
+
+			fakeClient := fake.NewSimpleClientset()
+			expSec := int64(3600)
+			store := &esv1.PulumiProvider{
+				APIURL:       server.URL,
+				Organization: "test-org",
+				Auth: &esv1.PulumiAuth{
+					OIDCConfig: &esv1.PulumiOIDCAuth{
+						Organization: "test-org",
+						ServiceAccountRef: esmeta.ServiceAccountSelector{
+							Name: "test-sa",
+						},
+						ExpirationSeconds: &expSec,
+					},
+				},
+			}
+
+			manager := NewOIDCTokenManager(fakeClient.CoreV1(), store, "default", esv1.SecretStoreKind)
+			require.NotNil(t, manager)
+
+			token, _, err := manager.ExchangeToken(context.Background(), "k8s-token")
+
+			if tt.wantError {
+				require.Error(t, err)
+				if tt.errorContains != "" {
+					assert.Contains(t, err.Error(), tt.errorContains)
+				}
+			} else {
+				require.NoError(t, err)
+				assert.NotEmpty(t, token)
+			}
+		})
+	}
+}
+
+func TestNewOIDCTokenManager_BaseURLParsing(t *testing.T) {
+	tests := []struct {
+		name            string
+		apiURL          string
+		expectedBaseURL string
+	}{
+		{
+			name:            "standard API URL",
+			apiURL:          "https://api.pulumi.com/api/esc",
+			expectedBaseURL: "https://api.pulumi.com",
+		},
+		{
+			name:            "custom API URL",
+			apiURL:          "https://custom.pulumi.io/api/esc",
+			expectedBaseURL: "https://custom.pulumi.io",
+		},
+		{
+			name:            "base URL without /api/esc",
+			apiURL:          "https://api.pulumi.com",
+			expectedBaseURL: "https://api.pulumi.com",
+		},
+		{
+			name:            "empty URL",
+			apiURL:          "",
+			expectedBaseURL: "https://api.pulumi.com",
+		},
+		{
+			name:            "URL with trailing slash",
+			apiURL:          "https://api.pulumi.com/api/esc/",
+			expectedBaseURL: "https://api.pulumi.com",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Track what URL the exchanger actually calls to verify baseURL parsing
+			var calledURL string
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				calledURL = r.URL.Path
+				w.WriteHeader(http.StatusOK)
+				_ = json.NewEncoder(w).Encode(map[string]any{
+					"access_token": "test-token",
+					"expires_in":   3600,
+				})
+			}))
+			defer server.Close()
+
+			// Build the test URL by replacing the expected base URL with the test server URL
+			// This allows us to verify the URL parsing logic works correctly
+			var testAPIURL string
+			if tt.apiURL == "" {
+				testAPIURL = ""
+			} else {
+				// Replace the expected base URL with the test server URL to verify parsing
+				testAPIURL = server.URL + tt.apiURL[len(tt.expectedBaseURL):]
+			}
+
+			fakeClient := fake.NewSimpleClientset()
+			expSec := int64(600)
+			store := &esv1.PulumiProvider{
+				APIURL:       testAPIURL,
+				Organization: "test-org",
+				Auth: &esv1.PulumiAuth{
+					OIDCConfig: &esv1.PulumiOIDCAuth{
+						Organization: "test-org",
+						ServiceAccountRef: esmeta.ServiceAccountSelector{
+							Name: "test-sa",
+						},
+						ExpirationSeconds: &expSec,
+					},
+				},
+			}
+
+			manager := NewOIDCTokenManager(
+				fakeClient.CoreV1(),
+				store,
+				"default",
+				esv1.SecretStoreKind,
+			)
+
+			require.NotNil(t, manager)
+
+			// For non-empty URLs, verify the manager would call the correct OAuth endpoint
+			// by checking that the URL parsing extracted the base URL correctly
+			if tt.apiURL != "" {
+				_, _, _ = manager.ExchangeToken(context.Background(), "test-token")
+				assert.Equal(t, "/api/oauth/token", calledURL, "OAuth endpoint should be called at /api/oauth/token")
+			}
+		})
+	}
+}

+ 3 - 2
providers/v1/pulumi/go.mod

@@ -7,8 +7,10 @@ require (
 	github.com/external-secrets/external-secrets/apis v0.0.0
 	github.com/external-secrets/external-secrets/runtime v0.0.0
 	github.com/pulumi/esc-sdk/sdk v0.12.3
+	github.com/spf13/pflag v1.0.10
 	github.com/stretchr/testify v1.11.1
 	k8s.io/api v0.35.0
+	k8s.io/client-go v0.35.0
 	sigs.k8s.io/controller-runtime v0.23.1
 )
 
@@ -74,6 +76,7 @@ require (
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/go-multierror v1.1.1 // indirect
+	github.com/hashicorp/golang-lru v1.0.2 // indirect
 	github.com/hashicorp/hcl/v2 v2.24.0 // indirect
 	github.com/huandu/xstrings v1.5.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -124,7 +127,6 @@ require (
 	github.com/skeema/knownhosts v1.3.2 // indirect
 	github.com/spf13/cast v1.10.0 // indirect
 	github.com/spf13/cobra v1.10.1 // indirect
-	github.com/spf13/pflag v1.0.10 // indirect
 	github.com/texttheater/golang-levenshtein v1.0.1 // indirect
 	github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
 	github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
@@ -156,7 +158,6 @@ require (
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	k8s.io/apiextensions-apiserver v0.35.0 // indirect
 	k8s.io/apimachinery v0.35.0 // indirect
-	k8s.io/client-go v0.35.0 // indirect
 	k8s.io/klog/v2 v2.130.1 // indirect
 	k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect
 	k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect

+ 2 - 0
providers/v1/pulumi/go.sum

@@ -168,6 +168,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
+github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
 github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
 github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=

+ 198 - 5
providers/v1/pulumi/provider.go

@@ -20,14 +20,20 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"sync"
 
 	esc "github.com/pulumi/esc-sdk/sdk/go"
+	"github.com/spf13/pflag"
+	"k8s.io/client-go/kubernetes"
 	kclient "sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/config"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/runtime/cache"
 	"github.com/external-secrets/external-secrets/runtime/esutils"
 	"github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
+	"github.com/external-secrets/external-secrets/runtime/feature"
 )
 
 // Provider implements the esv1.Provider interface for Pulumi ESC.
@@ -35,6 +41,37 @@ type Provider struct{}
 
 var _ esv1.Provider = &Provider{}
 
+var (
+	oidcClientCache  *cache.Cache[esv1.SecretsClient]
+	defaultCacheSize = 2 << 17
+	cacheOnce        sync.Once
+)
+
+func initCache(cacheSize int) {
+	if cacheSize > 0 {
+		cacheOnce.Do(func() {
+			oidcClientCache = cache.Must(cacheSize, func(_ esv1.SecretsClient) {
+				// No cleanup is needed when evicting OIDC clients from cache
+			})
+		})
+	}
+}
+
+// InitializeFlags registers Pulumi-specific flags with the feature system.
+func InitializeFlags() *feature.Feature {
+	var pulumiOIDCCacheSize int
+	fs := pflag.NewFlagSet("pulumi", pflag.ExitOnError)
+	fs.IntVar(&pulumiOIDCCacheSize, "pulumi-oidc-cache-size", defaultCacheSize,
+		"Maximum size of Pulumi OIDC provider cache. Set to 0 to disable caching.")
+
+	return &feature.Feature{
+		Flags: fs,
+		Initialize: func() {
+			initCache(pulumiOIDCCacheSize)
+		},
+	}
+}
+
 const (
 	errClusterStoreRequiresNamespace = "cluster store requires namespace"
 	errCannotResolveSecretKeyRef     = "cannot resolve secret key ref: %w"
@@ -57,10 +94,73 @@ func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube
 	if storeKind == esv1.ClusterSecretStoreKind && doesConfigDependOnNamespace(cfg) {
 		return nil, errors.New(errClusterStoreRequiresNamespace)
 	}
-	accessToken, err := loadAccessTokenSecret(ctx, cfg.AccessToken, kube, storeKind, namespace)
+
+	// Check if we should use cache
+	useCache := cfg.Auth != nil && cfg.Auth.OIDCConfig != nil && oidcClientCache != nil
+
+	key := cache.Key{
+		Name:      store.GetObjectMeta().Name,
+		Namespace: namespace,
+		Kind:      storeKind,
+	}
+
+	if useCache {
+		if cachedClient, ok := oidcClientCache.Get(store.GetObjectMeta().ResourceVersion, key); ok {
+			return cachedClient, nil
+		}
+	}
+
+	accessToken, oidcManager, err := p.resolveAuthentication(ctx, cfg, store, kube, storeKind, namespace)
 	if err != nil {
 		return nil, err
 	}
+
+	client := p.createClient(cfg, accessToken, oidcManager)
+
+	if useCache {
+		oidcClientCache.Add(store.GetObjectMeta().ResourceVersion, key, client)
+	}
+
+	return client, nil
+}
+
+// resolveAuthentication determines the authentication method and returns the access token and optional OIDC manager.
+func (p *Provider) resolveAuthentication(ctx context.Context, cfg *esv1.PulumiProvider, store esv1.GenericStore, kube kclient.Client, storeKind, namespace string) (string, *OIDCTokenManager, error) {
+	// New auth structure with access token
+	if cfg.Auth != nil && cfg.Auth.AccessToken != nil {
+		token, err := loadAccessTokenSecret(ctx, cfg.Auth.AccessToken, kube, storeKind, namespace)
+		return token, nil, err
+	}
+
+	// New auth structure with OIDC
+	if cfg.Auth != nil && cfg.Auth.OIDCConfig != nil {
+		return p.resolveOIDCAuthentication(ctx, cfg, store, namespace)
+	}
+
+	// Deprecated AccessToken field
+	if cfg.AccessToken != nil {
+		token, err := loadAccessTokenSecret(ctx, cfg.AccessToken, kube, storeKind, namespace)
+		return token, nil, err
+	}
+
+	return "", nil, errors.New("no authentication method configured: either auth.accessToken, auth.oidcConfig, or accessToken must be specified")
+}
+
+// resolveOIDCAuthentication sets up OIDC authentication and returns the token and manager.
+func (p *Provider) resolveOIDCAuthentication(ctx context.Context, cfg *esv1.PulumiProvider, store esv1.GenericStore, namespace string) (string, *OIDCTokenManager, error) {
+	oidcManager, err := p.setupOIDCAuth(cfg, store, namespace)
+	if err != nil {
+		return "", nil, err
+	}
+	token, err := oidcManager.GetToken(ctx)
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to get OIDC token: %w", err)
+	}
+	return token, oidcManager, nil
+}
+
+// createClient creates a new Pulumi ESC client with the given configuration.
+func (p *Provider) createClient(cfg *esv1.PulumiProvider, accessToken string, oidcManager *OIDCTokenManager) *client {
 	configuration := esc.NewConfiguration()
 	configuration.UserAgent = "external-secrets-operator"
 	configuration.Servers = esc.ServerConfigurations{
@@ -70,13 +170,40 @@ func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube
 	}
 	authCtx := esc.NewAuthContext(accessToken)
 	escClient := esc.NewClient(configuration)
+
 	return &client{
 		escClient:    *escClient,
 		authCtx:      authCtx,
 		project:      cfg.Project,
 		environment:  cfg.Environment,
 		organization: cfg.Organization,
-	}, nil
+		oidcManager:  oidcManager,
+		store:        cfg,
+	}
+}
+
+func (p *Provider) setupOIDCAuth(cfg *esv1.PulumiProvider, store esv1.GenericStore, namespace string) (*OIDCTokenManager, error) {
+	k8sConfig, err := config.GetConfig()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get kubernetes config: %w", err)
+	}
+
+	clientset, err := kubernetes.NewForConfig(k8sConfig)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create kubernetes clientset: %w", err)
+	}
+
+	manager := NewOIDCTokenManager(
+		clientset.CoreV1(),
+		cfg,
+		namespace,
+		store.GetKind(),
+	)
+	if manager == nil {
+		return nil, errors.New("failed to create OIDC token manager: invalid OIDC configuration")
+	}
+
+	return manager, nil
 }
 
 func loadAccessTokenSecret(ctx context.Context, ref *esv1.PulumiProviderSecretRef, kube kclient.Client, storeKind, namespace string) (string, error) {
@@ -88,7 +215,15 @@ func loadAccessTokenSecret(ctx context.Context, ref *esv1.PulumiProviderSecretRe
 }
 
 func doesConfigDependOnNamespace(cfg *esv1.PulumiProvider) bool {
-	if cfg.AccessToken.SecretRef != nil && cfg.AccessToken.SecretRef.Namespace == nil {
+	// OIDC ServiceAccountRef.Namespace is enforced for ClusterSecretStore by
+	// validateOIDCConfig with a more specific error message, so it is not
+	// re-checked here.
+	if cfg.Auth != nil {
+		if cfg.Auth.AccessToken != nil && cfg.Auth.AccessToken.SecretRef != nil && cfg.Auth.AccessToken.SecretRef.Namespace == nil {
+			return true
+		}
+	}
+	if cfg.AccessToken != nil && cfg.AccessToken.SecretRef != nil && cfg.AccessToken.SecretRef.Namespace == nil {
 		return true
 	}
 	return false
@@ -117,13 +252,71 @@ func getConfig(store esv1.GenericStore) (*esv1.PulumiProvider, error) {
 	if cfg.Project == "" {
 		return nil, errors.New(errProjectIsRequired)
 	}
-	err := validateStoreSecretRef(store, cfg.AccessToken)
-	if err != nil {
+
+	// Validate authentication configuration
+	if err := validateAuth(store, cfg); err != nil {
 		return nil, err
 	}
+
 	return cfg, nil
 }
 
+func validateAuth(store esv1.GenericStore, cfg *esv1.PulumiProvider) error {
+	hasNewAccessToken := cfg.Auth != nil && cfg.Auth.AccessToken != nil
+	hasOIDCConfig := cfg.Auth != nil && cfg.Auth.OIDCConfig != nil
+	hasDeprecatedAuth := cfg.AccessToken != nil
+
+	// Count how many auth methods are configured
+	authMethodCount := 0
+	if hasNewAccessToken {
+		authMethodCount++
+	}
+	if hasOIDCConfig {
+		authMethodCount++
+	}
+	if hasDeprecatedAuth {
+		authMethodCount++
+	}
+
+	// Enforce mutual exclusivity
+	if authMethodCount > 1 {
+		return errors.New("only one authentication method may be configured: use either auth.accessToken, auth.oidcConfig, or the deprecated accessToken field")
+	}
+
+	if authMethodCount == 0 {
+		return errors.New("no authentication method configured: either auth.accessToken, auth.oidcConfig, or accessToken must be specified")
+	}
+
+	// Validate the configured auth method
+	if hasNewAccessToken {
+		return validateStoreSecretRef(store, cfg.Auth.AccessToken)
+	}
+	if hasOIDCConfig {
+		return validateOIDCConfig(store, cfg.Auth.OIDCConfig)
+	}
+	if hasDeprecatedAuth {
+		return validateStoreSecretRef(store, cfg.AccessToken)
+	}
+
+	return nil
+}
+
+func validateOIDCConfig(store esv1.GenericStore, oidcConfig *esv1.PulumiOIDCAuth) error {
+	if oidcConfig.Organization == "" {
+		return errors.New("oidcConfig.organization is required")
+	}
+	if oidcConfig.ServiceAccountRef.Name == "" {
+		return errors.New("oidcConfig.serviceAccountRef.name is required")
+	}
+	// ClusterSecretStore requires namespace to be specified for OIDC ServiceAccountRef
+	if store.GetKind() == esv1.ClusterSecretStoreKind {
+		if oidcConfig.ServiceAccountRef.Namespace == nil {
+			return errors.New("oidcConfig.serviceAccountRef.namespace is required for ClusterSecretStore")
+		}
+	}
+	return nil
+}
+
 func validateStoreSecretRef(store esv1.GenericStore, ref *esv1.PulumiProviderSecretRef) error {
 	if ref != nil {
 		if err := esutils.ValidateReferentSecretSelector(store, *ref.SecretRef); err != nil {

+ 38 - 9
providers/v1/pulumi/pulumi.go

@@ -36,6 +36,8 @@ type client struct {
 	project      string
 	environment  string
 	organization string
+	oidcManager  *OIDCTokenManager
+	store        *esv1.PulumiProvider
 }
 
 const (
@@ -51,12 +53,31 @@ const (
 
 var _ esv1.SecretsClient = &client{}
 
-func (c *client) GetSecret(_ context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
-	env, err := c.escClient.OpenEnvironment(c.authCtx, c.organization, c.project, c.environment)
+// getAuthContext returns the auth context for API calls.
+// For OIDC auth, it fetches a fresh token if needed (the OIDCTokenManager handles caching internally).
+// For static token auth, it returns the pre-configured auth context.
+// This method is safe for concurrent use as it doesn't mutate shared state.
+func (c *client) getAuthContext(ctx context.Context) (context.Context, error) {
+	if c.store != nil && c.store.Auth != nil && c.store.Auth.OIDCConfig != nil && c.oidcManager != nil {
+		token, err := c.oidcManager.GetToken(ctx)
+		if err != nil {
+			return nil, fmt.Errorf("failed to get OIDC token: %w", err)
+		}
+		return esc.NewAuthContext(token), nil
+	}
+	return c.authCtx, nil
+}
+
+func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
+	authCtx, err := c.getAuthContext(ctx)
 	if err != nil {
 		return nil, err
 	}
-	value, _, err := c.escClient.ReadEnvironmentProperty(c.authCtx, c.organization, c.project, c.environment, env.GetId(), ref.Key)
+	env, err := c.escClient.OpenEnvironment(authCtx, c.organization, c.project, c.environment)
+	if err != nil {
+		return nil, err
+	}
+	value, _, err := c.escClient.ReadEnvironmentProperty(authCtx, c.organization, c.project, c.environment, env.GetId(), ref.Key)
 	if err != nil {
 		return nil, err
 	}
@@ -85,7 +106,11 @@ func createSubmaps(input map[string]any) map[string]any {
 	return result
 }
 
-func (c *client) PushSecret(_ context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
+func (c *client) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
+	authCtx, err := c.getAuthContext(ctx)
+	if err != nil {
+		return err
+	}
 	secretKey := data.GetSecretKey()
 	if secretKey == "" {
 		return errors.New(errPushWholeSecret)
@@ -99,7 +124,7 @@ func (c *client) PushSecret(_ context.Context, secret *corev1.Secret, data esv1.
 			},
 		},
 	}
-	_, oldValues, err := c.escClient.OpenAndReadEnvironment(c.authCtx, c.organization, c.project, c.environment)
+	_, oldValues, err := c.escClient.OpenAndReadEnvironment(authCtx, c.organization, c.project, c.environment)
 	if err != nil {
 		return fmt.Errorf(errReadEnvironment, err)
 	}
@@ -107,7 +132,7 @@ func (c *client) PushSecret(_ context.Context, secret *corev1.Secret, data esv1.
 	if err := mergo.Merge(&updatePayload.Values.AdditionalProperties, oldValues); err != nil {
 		return fmt.Errorf(errPushSecrets, err)
 	}
-	_, err = c.escClient.UpdateEnvironment(c.authCtx, c.organization, c.project, c.environment, updatePayload)
+	_, err = c.escClient.UpdateEnvironment(authCtx, c.organization, c.project, c.environment, updatePayload)
 	if err != nil {
 		return fmt.Errorf(errPushSecrets, err)
 	}
@@ -147,12 +172,16 @@ func GetMapFromInterface(i any) (map[string][]byte, error) {
 	return result, nil
 }
 
-func (c *client) GetSecretMap(_ context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
-	env, err := c.escClient.OpenEnvironment(c.authCtx, c.organization, c.project, c.environment)
+func (c *client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
+	authCtx, err := c.getAuthContext(ctx)
+	if err != nil {
+		return nil, err
+	}
+	env, err := c.escClient.OpenEnvironment(authCtx, c.organization, c.project, c.environment)
 	if err != nil {
 		return nil, err
 	}
-	value, _, err := c.escClient.ReadEnvironmentProperty(c.authCtx, c.organization, c.project, c.environment, env.GetId(), ref.Key)
+	value, _, err := c.escClient.ReadEnvironmentProperty(authCtx, c.organization, c.project, c.environment, env.GetId(), ref.Key)
 	if err != nil {
 		return nil, err
 	}

+ 288 - 0
runtime/oidc/token_manager.go

@@ -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
+}

+ 13 - 0
tests/__snapshot__/clustersecretstore-v1.yaml

@@ -686,6 +686,19 @@ spec:
           name: string
           namespace: string
       apiUrl: "https://api.pulumi.com/api/esc"
+      auth:
+        accessToken:
+          secretRef:
+            key: string
+            name: string
+            namespace: string
+        oidcConfig:
+          expirationSeconds: 600
+          organization: string
+          serviceAccountRef:
+            audiences: [] # minItems 0 of type string
+            name: string
+            namespace: string
       environment: string
       organization: string
       project: string

+ 13 - 0
tests/__snapshot__/secretstore-v1.yaml

@@ -686,6 +686,19 @@ spec:
           name: string
           namespace: string
       apiUrl: "https://api.pulumi.com/api/esc"
+      auth:
+        accessToken:
+          secretRef:
+            key: string
+            name: string
+            namespace: string
+        oidcConfig:
+          expirationSeconds: 600
+          organization: string
+          serviceAccountRef:
+            audiences: [] # minItems 0 of type string
+            name: string
+            namespace: string
       environment: string
       organization: string
       project: string