Просмотр исходного кода

feat(providers): Nebius MysteryBox SA token federation auth option added (#6784)

* feat(providers): Nebius Mysterybox Workload Identity auth option added

Signed-off-by: annakuzmenko <annakuzmenko@nebius.com>

* crds tests fixed

Signed-off-by: annakuzmenko <annakuzmenko@nebius.com>

* coderabbit review

Signed-off-by: annakuzmenko <annakuzmenko@nebius.com>

* crds tests fixed

Signed-off-by: annakuzmenko <annakuzmenko@nebius.com>

* custom audiences not allowed + panic removed

Signed-off-by: annakuzmenko <annakuzmenko@nebius.com>

* linter fixed

Signed-off-by: annakuzmenko <annakuzmenko@nebius.com>

* sonar issues fixed

Signed-off-by: annakuzmenko <annakuzmenko@nebius.com>

---------

Signed-off-by: annakuzmenko <annakuzmenko@nebius.com>
Anna Kuzmenko 6 дней назад
Родитель
Сommit
c727bf697e
26 измененных файлов с 1493 добавлено и 245 удалено
  1. 24 1
      apis/externalsecrets/v1/secretstore_nebius_types.go
  2. 25 0
      apis/externalsecrets/v1/zz_generated.deepcopy.go
  3. 55 3
      config/crds/bases/external-secrets.io_clustersecretstores.yaml
  4. 55 3
      config/crds/bases/external-secrets.io_secretstores.yaml
  5. 96 4
      deploy/crds/bundle.yaml
  6. 60 0
      docs/api/spec.md
  7. 37 3
      docs/provider/nebius-mysterybox.md
  8. 200 0
      providers/v1/nebius/common/auth/cache_key_test.go
  9. 83 0
      providers/v1/nebius/common/auth/credentials.go
  10. 114 0
      providers/v1/nebius/common/auth/service_account_creds.go
  11. 97 0
      providers/v1/nebius/common/auth/workload_identity.go
  12. 41 5
      providers/v1/nebius/common/sdk/iam/fake_token_exchanger.go
  13. 34 17
      providers/v1/nebius/common/sdk/iam/grpc_token_exchanger.go
  14. 4 1
      providers/v1/nebius/common/sdk/iam/token_exchanger.go
  15. 3 0
      providers/v1/nebius/common/sdk/mysterybox/client.go
  16. 15 0
      providers/v1/nebius/common/sdk/mysterybox/fake/fake_client.go
  17. 4 0
      providers/v1/nebius/common/sdk/mysterybox/grpc_client_test.go
  18. 1 1
      providers/v1/nebius/go.mod
  19. 82 20
      providers/v1/nebius/mysterybox/provider.go
  20. 210 50
      providers/v1/nebius/mysterybox/provider_test.go
  21. 19 45
      providers/v1/nebius/mysterybox/token_getter.go
  22. 91 85
      providers/v1/nebius/mysterybox/token_getter_test.go
  23. 55 5
      providers/v1/nebius/mysterybox/validation.go
  24. 76 2
      providers/v1/nebius/mysterybox/validation_test.go
  25. 6 0
      tests/__snapshot__/clustersecretstore-v1.yaml
  26. 6 0
      tests/__snapshot__/secretstore-v1.yaml

+ 24 - 1
apis/externalsecrets/v1/secretstore_nebius_types.go

@@ -19,7 +19,9 @@ package v1
 import esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
 
 // NebiusAuth defines the authentication method for the Nebius provider.
-// +kubebuilder:validation:XValidation:rule="has(self.serviceAccountCredsSecretRef) || has(self.tokenSecretRef)",message="either serviceAccountCredsSecretRef or tokenSecretRef must be set"
+// +kubebuilder:validation:XValidation:rule="(has(self.serviceAccountCredsSecretRef) && has(self.serviceAccountCredsSecretRef.name) && size(self.serviceAccountCredsSecretRef.name) > 0 ? 1 : 0) + (has(self.tokenSecretRef) && has(self.tokenSecretRef.name) && size(self.tokenSecretRef.name) > 0 ? 1 : 0) + (has(self.workloadIdentity) ? 1 : 0) == 1",message="exactly one of serviceAccountCredsSecretRef, tokenSecretRef, or workloadIdentity must be set"
+//
+//nolint:lll // Kubebuilder validation markers cannot be split across multiple lines.
 type NebiusAuth struct {
 	// ServiceAccountCreds references a Kubernetes Secret key that contains a JSON
 	// document with service account credentials used to get an IAM token.
@@ -39,6 +41,10 @@ type NebiusAuth struct {
 	// Token authenticates with Nebius Mysterybox by presenting a token.
 	// +optional
 	Token esmeta.SecretKeySelector `json:"tokenSecretRef,omitempty"`
+
+	// WorkloadIdentity defines configuration for workload identity authentication to Nebius IAM.
+	// +optional
+	WorkloadIdentity *NebiusWorkloadIdentity `json:"workloadIdentity,omitempty"`
 }
 
 // NebiusCAProvider The provider for the CA bundle to use to validate Nebius server certificate.
@@ -47,6 +53,23 @@ type NebiusCAProvider struct {
 	Certificate esmeta.SecretKeySelector `json:"certSecretRef,omitempty"`
 }
 
+// NebiusWorkloadIdentity defines configuration for workload identity authentication to Nebius IAM.
+type NebiusWorkloadIdentity struct {
+	// ServiceAccountRef references a Kubernetes ServiceAccount used to request a
+	// temporary JWT via the TokenRequest API. The JWT is then exchanged for a
+	// Nebius IAM token using workload federation.
+	// +kubebuilder:validation:Required
+	ServiceAccountRef *esmeta.ServiceAccountSelector `json:"serviceAccountRef"`
+
+	// IAMServiceAccountID is the Nebius IAM service account identifier that the
+	// federated Kubernetes service account should impersonate during token exchange.
+	// +kubebuilder:validation:Required
+	// +kubebuilder:validation:MinLength=1
+	// +kubebuilder:validation:Pattern:=`^serviceaccount-[a-z][a-z0-9]{2}`
+	// +kubebuilder:example="serviceaccount-e00example"
+	IAMServiceAccountID string `json:"iamServiceAccountID"`
+}
+
 // NebiusMysteryboxProvider Configures a store to sync secrets using the Nebius Mysterybox provider.
 type NebiusMysteryboxProvider struct {
 

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

@@ -2878,6 +2878,11 @@ func (in *NebiusAuth) DeepCopyInto(out *NebiusAuth) {
 	*out = *in
 	in.ServiceAccountCreds.DeepCopyInto(&out.ServiceAccountCreds)
 	in.Token.DeepCopyInto(&out.Token)
+	if in.WorkloadIdentity != nil {
+		in, out := &in.WorkloadIdentity, &out.WorkloadIdentity
+		*out = new(NebiusWorkloadIdentity)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NebiusAuth.
@@ -2927,6 +2932,26 @@ func (in *NebiusMysteryboxProvider) DeepCopy() *NebiusMysteryboxProvider {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NebiusWorkloadIdentity) DeepCopyInto(out *NebiusWorkloadIdentity) {
+	*out = *in
+	if in.ServiceAccountRef != nil {
+		in, out := &in.ServiceAccountRef, &out.ServiceAccountRef
+		*out = new(apismetav1.ServiceAccountSelector)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NebiusWorkloadIdentity.
+func (in *NebiusWorkloadIdentity) DeepCopy() *NebiusWorkloadIdentity {
+	if in == nil {
+		return nil
+	}
+	out := new(NebiusWorkloadIdentity)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *NgrokAuth) DeepCopyInto(out *NgrokAuth) {
 	*out = *in

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

@@ -4256,11 +4256,63 @@ spec:
                                 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
                                 type: string
                             type: object
+                          workloadIdentity:
+                            description: WorkloadIdentity defines configuration for
+                              workload identity authentication to Nebius IAM.
+                            properties:
+                              iamServiceAccountID:
+                                description: |-
+                                  IAMServiceAccountID is the Nebius IAM service account identifier that the
+                                  federated Kubernetes service account should impersonate during token exchange.
+                                example: serviceaccount-e00example
+                                minLength: 1
+                                pattern: ^serviceaccount-[a-z][a-z0-9]{2}
+                                type: string
+                              serviceAccountRef:
+                                description: |-
+                                  ServiceAccountRef references a Kubernetes ServiceAccount used to request a
+                                  temporary JWT via the TokenRequest API. The JWT is then exchanged for a
+                                  Nebius IAM token using workload federation.
+                                properties:
+                                  audiences:
+                                    description: |-
+                                      Audience specifies the `aud` claim for the service account token
+                                      Some providers automatically extend the audience field based on well-known annotations for workload
+                                      identity (e.g. IRSA or GCP Workload Identity)
+                                    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:
+                            - iamServiceAccountID
+                            - serviceAccountRef
+                            type: object
                         type: object
                         x-kubernetes-validations:
-                        - message: either serviceAccountCredsSecretRef or tokenSecretRef
-                            must be set
-                          rule: has(self.serviceAccountCredsSecretRef) || has(self.tokenSecretRef)
+                        - message: exactly one of serviceAccountCredsSecretRef, tokenSecretRef,
+                            or workloadIdentity must be set
+                          rule: '(has(self.serviceAccountCredsSecretRef) && has(self.serviceAccountCredsSecretRef.name)
+                            && size(self.serviceAccountCredsSecretRef.name) > 0 ?
+                            1 : 0) + (has(self.tokenSecretRef) && has(self.tokenSecretRef.name)
+                            && size(self.tokenSecretRef.name) > 0 ? 1 : 0) + (has(self.workloadIdentity)
+                            ? 1 : 0) == 1'
                       caProvider:
                         description: The provider for the CA bundle to use to validate
                           NebiusMysterybox server certificate.

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

@@ -4256,11 +4256,63 @@ spec:
                                 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
                                 type: string
                             type: object
+                          workloadIdentity:
+                            description: WorkloadIdentity defines configuration for
+                              workload identity authentication to Nebius IAM.
+                            properties:
+                              iamServiceAccountID:
+                                description: |-
+                                  IAMServiceAccountID is the Nebius IAM service account identifier that the
+                                  federated Kubernetes service account should impersonate during token exchange.
+                                example: serviceaccount-e00example
+                                minLength: 1
+                                pattern: ^serviceaccount-[a-z][a-z0-9]{2}
+                                type: string
+                              serviceAccountRef:
+                                description: |-
+                                  ServiceAccountRef references a Kubernetes ServiceAccount used to request a
+                                  temporary JWT via the TokenRequest API. The JWT is then exchanged for a
+                                  Nebius IAM token using workload federation.
+                                properties:
+                                  audiences:
+                                    description: |-
+                                      Audience specifies the `aud` claim for the service account token
+                                      Some providers automatically extend the audience field based on well-known annotations for workload
+                                      identity (e.g. IRSA or GCP Workload Identity)
+                                    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:
+                            - iamServiceAccountID
+                            - serviceAccountRef
+                            type: object
                         type: object
                         x-kubernetes-validations:
-                        - message: either serviceAccountCredsSecretRef or tokenSecretRef
-                            must be set
-                          rule: has(self.serviceAccountCredsSecretRef) || has(self.tokenSecretRef)
+                        - message: exactly one of serviceAccountCredsSecretRef, tokenSecretRef,
+                            or workloadIdentity must be set
+                          rule: '(has(self.serviceAccountCredsSecretRef) && has(self.serviceAccountCredsSecretRef.name)
+                            && size(self.serviceAccountCredsSecretRef.name) > 0 ?
+                            1 : 0) + (has(self.tokenSecretRef) && has(self.tokenSecretRef.name)
+                            && size(self.tokenSecretRef.name) > 0 ? 1 : 0) + (has(self.workloadIdentity)
+                            ? 1 : 0) == 1'
                       caProvider:
                         description: The provider for the CA bundle to use to validate
                           NebiusMysterybox server certificate.

+ 96 - 4
deploy/crds/bundle.yaml

@@ -6297,10 +6297,56 @@ spec:
                                   pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
                                   type: string
                               type: object
+                            workloadIdentity:
+                              description: WorkloadIdentity defines configuration for workload identity authentication to Nebius IAM.
+                              properties:
+                                iamServiceAccountID:
+                                  description: |-
+                                    IAMServiceAccountID is the Nebius IAM service account identifier that the
+                                    federated Kubernetes service account should impersonate during token exchange.
+                                  example: serviceaccount-e00example
+                                  minLength: 1
+                                  pattern: ^serviceaccount-[a-z][a-z0-9]{2}
+                                  type: string
+                                serviceAccountRef:
+                                  description: |-
+                                    ServiceAccountRef references a Kubernetes ServiceAccount used to request a
+                                    temporary JWT via the TokenRequest API. The JWT is then exchanged for a
+                                    Nebius IAM token using workload federation.
+                                  properties:
+                                    audiences:
+                                      description: |-
+                                        Audience specifies the `aud` claim for the service account token
+                                        Some providers automatically extend the audience field based on well-known annotations for workload
+                                        identity (e.g. IRSA or GCP Workload Identity)
+                                      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:
+                                - iamServiceAccountID
+                                - serviceAccountRef
+                              type: object
                           type: object
                           x-kubernetes-validations:
-                            - message: either serviceAccountCredsSecretRef or tokenSecretRef must be set
-                              rule: has(self.serviceAccountCredsSecretRef) || has(self.tokenSecretRef)
+                            - message: exactly one of serviceAccountCredsSecretRef, tokenSecretRef, or workloadIdentity must be set
+                              rule: '(has(self.serviceAccountCredsSecretRef) && has(self.serviceAccountCredsSecretRef.name) && size(self.serviceAccountCredsSecretRef.name) > 0 ? 1 : 0) + (has(self.tokenSecretRef) && has(self.tokenSecretRef.name) && size(self.tokenSecretRef.name) > 0 ? 1 : 0) + (has(self.workloadIdentity) ? 1 : 0) == 1'
                         caProvider:
                           description: The provider for the CA bundle to use to validate NebiusMysterybox server certificate.
                           properties:
@@ -19602,10 +19648,56 @@ spec:
                                   pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
                                   type: string
                               type: object
+                            workloadIdentity:
+                              description: WorkloadIdentity defines configuration for workload identity authentication to Nebius IAM.
+                              properties:
+                                iamServiceAccountID:
+                                  description: |-
+                                    IAMServiceAccountID is the Nebius IAM service account identifier that the
+                                    federated Kubernetes service account should impersonate during token exchange.
+                                  example: serviceaccount-e00example
+                                  minLength: 1
+                                  pattern: ^serviceaccount-[a-z][a-z0-9]{2}
+                                  type: string
+                                serviceAccountRef:
+                                  description: |-
+                                    ServiceAccountRef references a Kubernetes ServiceAccount used to request a
+                                    temporary JWT via the TokenRequest API. The JWT is then exchanged for a
+                                    Nebius IAM token using workload federation.
+                                  properties:
+                                    audiences:
+                                      description: |-
+                                        Audience specifies the `aud` claim for the service account token
+                                        Some providers automatically extend the audience field based on well-known annotations for workload
+                                        identity (e.g. IRSA or GCP Workload Identity)
+                                      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:
+                                - iamServiceAccountID
+                                - serviceAccountRef
+                              type: object
                           type: object
                           x-kubernetes-validations:
-                            - message: either serviceAccountCredsSecretRef or tokenSecretRef must be set
-                              rule: has(self.serviceAccountCredsSecretRef) || has(self.tokenSecretRef)
+                            - message: exactly one of serviceAccountCredsSecretRef, tokenSecretRef, or workloadIdentity must be set
+                              rule: '(has(self.serviceAccountCredsSecretRef) && has(self.serviceAccountCredsSecretRef.name) && size(self.serviceAccountCredsSecretRef.name) > 0 ? 1 : 0) + (has(self.tokenSecretRef) && has(self.tokenSecretRef.name) && size(self.tokenSecretRef.name) > 0 ? 1 : 0) + (has(self.workloadIdentity) ? 1 : 0) == 1'
                         caProvider:
                           description: The provider for the CA bundle to use to validate NebiusMysterybox server certificate.
                           properties:

+ 60 - 0
docs/api/spec.md

@@ -8173,6 +8173,20 @@ External Secrets meta/v1.SecretKeySelector
 <p>Token authenticates with Nebius Mysterybox by presenting a token.</p>
 </td>
 </tr>
+<tr>
+<td>
+<code>workloadIdentity</code></br>
+<em>
+<a href="#external-secrets.io/v1.NebiusWorkloadIdentity">
+NebiusWorkloadIdentity
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>WorkloadIdentity defines configuration for workload identity authentication to Nebius IAM.</p>
+</td>
+</tr>
 </tbody>
 </table>
 <h3 id="external-secrets.io/v1.NebiusCAProvider">NebiusCAProvider
@@ -8264,6 +8278,52 @@ NebiusCAProvider
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1.NebiusWorkloadIdentity">NebiusWorkloadIdentity
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.NebiusAuth">NebiusAuth</a>)
+</p>
+<p>
+<p>NebiusWorkloadIdentity defines configuration for workload identity authentication to Nebius IAM.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<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 references a Kubernetes ServiceAccount used to request a
+temporary JWT via the TokenRequest API. The JWT is then exchanged for a
+Nebius IAM token using workload federation.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>iamServiceAccountID</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<p>IAMServiceAccountID is the Nebius IAM service account identifier that the
+federated Kubernetes service account should impersonate during token exchange.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1.NgrokAuth">NgrokAuth
 </h3>
 <p>

+ 37 - 3
docs/provider/nebius-mysterybox.md

@@ -4,8 +4,15 @@ External Secrets Operator integrates with [Nebius MysteryBox](https://docs.nebiu
 
 ### Authentication
 
-Currently, only [Service Account credentials](https://docs.nebius.com/grpc-api/auth) authorization is supported.
+Nebius Mysterybox supports the following authentication methods:
 
+- `auth.workloadIdentity`: request a Kubernetes service account token via the `TokenRequest` API and exchange it for a Nebius IAM token using workload federation.
+- `auth.serviceAccountCredsSecretRef`: read Nebius service account credentials JSON from a Kubernetes `Secret` and exchange it for a Nebius IAM token.
+- `auth.tokenSecretRef`: read an already issued Nebius IAM token from a Kubernetes `Secret`.
+
+#### Service Account credentials
+
+_Find more about the authorization option following the [official documentation](https://docs.nebius.com/grpc-api/auth)._
 
 Before you start, create a service account and grant it permission to read desired secrets in MysteryBox.
 For details on required roles and permissions, see [MysteryBox get method](https://docs.nebius.com/mysterybox/secrets/get).
@@ -26,6 +33,35 @@ The Kubernetes secret must be in a Subject Credentials format:
 ```
 
 Follow the [instruction](https://docs.nebius.com/iam/service-accounts/authorized-keys#create) to generate the secret.
+The SecretStore example below uses this authentication method.
+
+#### Workload Identity
+
+**ESO assumes that this Nebius-side federation setup already exists and only performs runtime token exchange.**
+
+To use Workload Identity:
+
+1. Create a Kubernetes `ServiceAccount`.
+2. Create a Nebius IAM service account and grant it permission to read the required MysteryBox secrets. See the permissions for the [MysteryBox get method](https://docs.nebius.com/mysterybox/secrets/get).
+3. Configure Nebius federated credentials for the Kubernetes service account:
+    - use the Kubernetes cluster's service account issuer URL as the OIDC issuer;
+    - use `system:serviceaccount:<namespace>:<service-account-name>` as the federated subject;
+    - use the Nebius IAM service account ID as the subject to impersonate.
+4. Make sure Nebius can access the issuer's OIDC discovery and JWKS endpoints.
+5. Make sure the ESO controller can create tokens for the referenced service account (`create` on `serviceaccounts/token`).
+
+Reference both service accounts in the store:
+
+```yaml
+auth:
+  workloadIdentity:
+    serviceAccountRef:
+      name: <kubernetes-service-account-name>
+    iamServiceAccountID: <nebius-iam-service-account-id>
+```
+
+For a `SecretStore`, the Kubernetes service account must be in the same namespace as the store. For a `ClusterSecretStore`, set `serviceAccountRef.namespace` explicitly.
+
 
 ### Examples
 
@@ -82,5 +118,3 @@ There is also a possibility to specify Version variable to get a secret.
 
 !!! tip inline end
     When the `version` field is not specified, a primary version of the secret will be retrieved.
-
-

+ 200 - 0
providers/v1/nebius/common/auth/cache_key_test.go

@@ -0,0 +1,200 @@
+/*
+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 auth
+
+import (
+	"testing"
+
+	tassert "github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+func Test_ServiceAccountCredentials_CacheKeyInvariants(t *testing.T) {
+	t.Parallel()
+
+	baseStore := newCacheKeyTestStore("store-uid", 1)
+	baseKey := getServiceAccountCredsCacheKey(
+		baseStore,
+		"namespace",
+		"key-id",
+		"subject",
+		"secret-key-material",
+	)
+
+	tests := []struct {
+		name        string
+		store       esv1.GenericStore
+		namespace   string
+		keyID       string
+		subject     string
+		privateKey  string
+		wantSameKey bool
+	}{
+		{
+			name:        "same cache inputs",
+			store:       newCacheKeyTestStore("store-uid", 1),
+			namespace:   "namespace",
+			keyID:       "key-id",
+			subject:     "subject",
+			privateKey:  "secret-key-material",
+			wantSameKey: true,
+		},
+		{
+			name:       "different store UID",
+			store:      newCacheKeyTestStore("another-store-uid", 1),
+			namespace:  "namespace",
+			keyID:      "key-id",
+			subject:    "subject",
+			privateKey: "secret-key-material",
+		},
+		{
+			name:       "different store generation",
+			store:      newCacheKeyTestStore("store-uid", 2),
+			namespace:  "namespace",
+			keyID:      "key-id",
+			subject:    "subject",
+			privateKey: "secret-key-material",
+		},
+		{
+			name:       "different namespace",
+			store:      newCacheKeyTestStore("store-uid", 1),
+			namespace:  "another-namespace",
+			keyID:      "key-id",
+			subject:    "subject",
+			privateKey: "secret-key-material",
+		},
+		{
+			name:       "different key ID",
+			store:      newCacheKeyTestStore("store-uid", 1),
+			namespace:  "namespace",
+			keyID:      "another-key-id",
+			subject:    "subject",
+			privateKey: "secret-key-material",
+		},
+		{
+			name:       "different subject",
+			store:      newCacheKeyTestStore("store-uid", 1),
+			namespace:  "namespace",
+			keyID:      "key-id",
+			subject:    "another-subject",
+			privateKey: "secret-key-material",
+		},
+		{
+			name:       "different private key",
+			store:      newCacheKeyTestStore("store-uid", 1),
+			namespace:  "namespace",
+			keyID:      "key-id",
+			subject:    "subject",
+			privateKey: "rotated-key-material",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			key := getServiceAccountCredsCacheKey(
+				tt.store,
+				tt.namespace,
+				tt.keyID,
+				tt.subject,
+				tt.privateKey,
+			)
+
+			if tt.wantSameKey {
+				tassert.Equal(t, baseKey, key)
+			} else {
+				tassert.NotEqual(t, baseKey, key)
+			}
+		})
+	}
+
+	tassert.NotContains(t, baseKey, "secret-key-material")
+	tassert.Contains(t, baseKey, HashBytes([]byte("secret-key-material")))
+}
+
+func Test_WorkloadIdentity_CacheKeyInvariants(t *testing.T) {
+	t.Parallel()
+
+	baseKey := getWICacheKey("namespace", newCacheKeyTestStore("store-uid", 1))
+
+	tests := []struct {
+		name        string
+		store       esv1.GenericStore
+		namespace   string
+		wantSameKey bool
+	}{
+		{
+			name:        "same cache inputs",
+			store:       newCacheKeyTestStore("store-uid", 1),
+			namespace:   "namespace",
+			wantSameKey: true,
+		},
+		{
+			name:      "different store UID",
+			store:     newCacheKeyTestStore("another-store-uid", 1),
+			namespace: "namespace",
+		},
+		{
+			name:      "different store generation",
+			store:     newCacheKeyTestStore("store-uid", 2),
+			namespace: "namespace",
+		},
+		{
+			name:      "different namespace",
+			store:     newCacheKeyTestStore("store-uid", 1),
+			namespace: "another-namespace",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			key := getWICacheKey(tt.namespace, tt.store)
+			if tt.wantSameKey {
+				tassert.Equal(t, baseKey, key)
+			} else {
+				tassert.NotEqual(t, baseKey, key)
+			}
+		})
+	}
+}
+
+func TestCacheKeysAreDistinctAcrossCredentialTypes(t *testing.T) {
+	t.Parallel()
+
+	store := newCacheKeyTestStore("store-uid", 1)
+	serviceAccountKey := getServiceAccountCredsCacheKey(
+		store,
+		"namespace",
+		"key-id",
+		"subject",
+		"secret-key-material",
+	)
+	workloadIdentityKey := getWICacheKey("namespace", store)
+
+	tassert.NotEqual(t, serviceAccountKey, workloadIdentityKey)
+}
+
+func newCacheKeyTestStore(uid string, generation int64) esv1.GenericStore {
+	return &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			UID:        types.UID(uid),
+			Generation: generation,
+		},
+	}
+}

+ 83 - 0
providers/v1/nebius/common/auth/credentials.go

@@ -0,0 +1,83 @@
+// /*
+// 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 auth
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	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/esutils/resolvers"
+)
+
+// CredentialRequest represents a lazily resolved credential request.
+type CredentialRequest struct {
+	cacheKey string
+	resolve  func(context.Context) (TokenExchangeCredentials, error)
+}
+
+// CacheKey returns the key used to cache the credential request.
+func (r CredentialRequest) CacheKey() string {
+	return r.cacheKey
+}
+
+// Resolve resolves the credential request.
+func (r CredentialRequest) Resolve(ctx context.Context) (TokenExchangeCredentials, error) {
+	return r.resolve(ctx)
+}
+
+// TokenExchangeCredentials represents credentials accepted by the IAM token exchanger.
+type TokenExchangeCredentials interface {
+	isTokenExchangeCredentials()
+}
+
+// TokenCredentials contains an IAM token.
+type TokenCredentials struct {
+	Token string
+}
+
+// GetTokenCredentials reads token credentials from a Kubernetes Secret.
+func GetTokenCredentials(ctx context.Context, secret *esmeta.SecretKeySelector, store esv1.GenericStore, kube client.Client, namespace string) (TokenCredentials, error) {
+	iamToken, err := resolvers.SecretKeyRef(
+		ctx,
+		kube,
+		store.GetKind(),
+		namespace,
+		secret,
+	)
+	if err != nil {
+		return TokenCredentials{}, fmt.Errorf("read token secret %s/%s: %w", namespace, secret.Name, err)
+	}
+	return TokenCredentials{
+		Token: strings.TrimSpace(iamToken),
+	}, nil
+}
+
+// NewCredentialRequest creates a credential request with the provided cache key and resolver.
+func NewCredentialRequest(cacheKey string, resolve func(context.Context) (TokenExchangeCredentials, error)) *CredentialRequest {
+	return &CredentialRequest{
+		cacheKey: cacheKey,
+		resolve:  resolve,
+	}
+}
+
+var _ TokenExchangeCredentials = &ResolvedServiceAccountCreds{}
+var _ TokenExchangeCredentials = &ResolvedFederatedCredentials{}

+ 114 - 0
providers/v1/nebius/common/auth/service_account_creds.go

@@ -0,0 +1,114 @@
+// /*
+// 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 auth
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	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/esutils/resolvers"
+)
+
+const (
+	errInvalidSubjectCreds = "invalid subject credentials: malformed JSON"
+	errReadSecret          = "error read service account secret creds %s/%s: %w"
+)
+
+// ResolvedServiceAccountCreds contains resolved service account credentials.
+type ResolvedServiceAccountCreds struct {
+	PrivateKey string
+	KeyID      string
+	Subject    string
+}
+
+func (r ResolvedServiceAccountCreds) isTokenExchangeCredentials() {
+	// Marker method restricts TokenExchangeCredentials implementations to this package.
+}
+
+// NewServiceAccountCredentialsRequest creates a request from service account credentials stored in a Kubernetes Secret.
+func NewServiceAccountCredentialsRequest(ctx context.Context, secret *esmeta.SecretKeySelector, store esv1.GenericStore, kube client.Client, namespace string) (*CredentialRequest, error) {
+	subjectCreds, err := resolvers.SecretKeyRef(
+		ctx,
+		kube,
+		store.GetKind(),
+		namespace,
+		secret,
+	)
+	if err != nil {
+		return nil, fmt.Errorf(errReadSecret, namespace, secret.Name, err)
+	}
+	parsedSubjectCreds := &serviceAccountCredentials{}
+	err = json.Unmarshal([]byte(subjectCreds), parsedSubjectCreds)
+	if err != nil {
+		return nil, errors.New(errInvalidSubjectCreds)
+	}
+	creds := parsedSubjectCreds.SubjectCredentials
+	return &CredentialRequest{
+		cacheKey: getServiceAccountCredsCacheKey(
+			store,
+			namespace,
+			creds.KeyID,
+			creds.Subject,
+			creds.PrivateKey,
+		),
+		resolve: func(_ context.Context) (TokenExchangeCredentials, error) {
+			return &ResolvedServiceAccountCreds{
+				KeyID:      creds.KeyID,
+				Subject:    creds.Subject,
+				PrivateKey: creds.PrivateKey,
+			}, nil
+		},
+	}, nil
+}
+
+func getServiceAccountCredsCacheKey(store esv1.GenericStore, effectiveNamespace, keyID, subject, privateKey string) string {
+	return strings.Join([]string{
+		"service-account-private-key",
+		string(store.GetUID()),
+		strconv.FormatInt(store.GetGeneration(), 10),
+		effectiveNamespace,
+		keyID,
+		subject,
+		HashBytes([]byte(privateKey)),
+	}, "|")
+}
+
+type serviceAccountCredentials struct {
+	SubjectCredentials serviceAccountSubjectCredentials `json:"subject-credentials"`
+}
+
+type serviceAccountSubjectCredentials struct {
+	PrivateKey string `json:"private-key"`
+	KeyID      string `json:"kid"`
+	Subject    string `json:"sub"`
+}
+
+// HashBytes calculate a hash of the bytes by sha256 algorithm.
+func HashBytes(b []byte) string {
+	sum := sha256.Sum256(b)
+	return hex.EncodeToString(sum[:])
+}

+ 97 - 0
providers/v1/nebius/common/auth/workload_identity.go

@@ -0,0 +1,97 @@
+// /*
+// 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 auth
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	authenticationv1 "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"
+)
+
+const (
+	workloadIdentityTokenTTL = 10 * time.Minute
+	// NebiusIamAudience is the audience required for Nebius IAM token exchange.
+	NebiusIamAudience = "sts.nebius.com"
+)
+
+// ResolvedFederatedCredentials contains resolved workload identity credentials.
+type ResolvedFederatedCredentials struct {
+	ServiceAccountID string
+	SubjectToken     string
+}
+
+func (r ResolvedFederatedCredentials) isTokenExchangeCredentials() {
+	// Marker method restricts TokenExchangeCredentials implementations to this package.
+}
+
+// NewFederatedAccountCredentialsRequest creates a request for federated account credentials.
+func NewFederatedAccountCredentialsRequest(
+	serviceAccountRef esmeta.ServiceAccountSelector,
+	serviceAccountID string,
+	store esv1.GenericStore,
+	corev1 typedcorev1.CoreV1Interface,
+	namespace string,
+) (*CredentialRequest, error) {
+	return &CredentialRequest{
+		cacheKey: getWICacheKey(namespace, store),
+		resolve: func(ctx context.Context) (TokenExchangeCredentials, error) {
+			tokenNamespace := namespace
+			if store.GetKind() == esv1.ClusterSecretStoreKind && serviceAccountRef.Namespace != nil {
+				tokenNamespace = *serviceAccountRef.Namespace
+			}
+
+			audiences := []string{NebiusIamAudience}
+
+			tokenRequest := &authenticationv1.TokenRequest{
+				Spec: authenticationv1.TokenRequestSpec{
+					Audiences:         audiences,
+					ExpirationSeconds: new(int64(workloadIdentityTokenTTL.Seconds())),
+				},
+			}
+			tokenResponse, err := corev1.ServiceAccounts(tokenNamespace).CreateToken(
+				ctx,
+				serviceAccountRef.Name,
+				tokenRequest,
+				metav1.CreateOptions{},
+			)
+			if err != nil {
+				return nil, fmt.Errorf("get workload identity token for service account %s/%s: %w", tokenNamespace, serviceAccountRef.Name, err)
+			}
+			return &ResolvedFederatedCredentials{
+				ServiceAccountID: serviceAccountID,
+				SubjectToken:     strings.TrimSpace(tokenResponse.Status.Token),
+			}, nil
+		},
+	}, nil
+}
+
+func getWICacheKey(effectiveNamespace string, store esv1.GenericStore) string {
+	cacheKey := fmt.Sprintf("workload-identity|%s|%d|%s",
+		store.GetUID(),
+		store.GetGeneration(),
+		effectiveNamespace,
+	)
+	return cacheKey
+}

+ 41 - 5
providers/v1/nebius/common/sdk/iam/fake_token_exchanger.go

@@ -19,26 +19,62 @@ package iam
 import (
 	"context"
 	"fmt"
+	"sync"
 	"sync/atomic"
 	"time"
+
+	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/auth"
 )
 
 // FakeTokenExchanger simulates the process of exchanging credentials to obtain IAM tokens.
 // Calls keeps track of how many times the token exchange method has been invoked.
 // ReturnError, when set to true, forces the token exchange method to return an error.
 type FakeTokenExchanger struct {
-	Calls       atomic.Int64
-	ReturnError bool
+	TokenToIssue            string
+	Calls                   atomic.Int64
+	PrivateKeyRequest       string
+	SubjectRequest          string
+	KeyIDRequest            string
+	TokenRequest            string
+	DomainRequest           string
+	ServiceAccountIDRequest string
+	CaRequest               []byte
+	ReturnError             bool
+
+	mu sync.Mutex
 }
 
 // ExchangeIamToken exchanges credentials to generate a new IAM token with a fixed 100-second validity period.
-func (f *FakeTokenExchanger) ExchangeIamToken(_ context.Context, _, _ string, issuedAt time.Time, _ []byte) (*Token, error) {
-	f.Calls.Add(1)
+func (f *FakeTokenExchanger) ExchangeIamToken(_ context.Context, domain string, resolved auth.TokenExchangeCredentials, issuedAt time.Time, ca []byte) (*Token, error) {
+	call := f.Calls.Add(1)
 	if f.ReturnError {
 		return nil, fmt.Errorf("fake error")
 	}
+
+	f.mu.Lock()
+	defer f.mu.Unlock()
+
+	switch creds := resolved.(type) {
+	case *auth.ResolvedServiceAccountCreds:
+		f.PrivateKeyRequest = creds.PrivateKey
+		f.SubjectRequest = creds.Subject
+		f.KeyIDRequest = creds.KeyID
+	case *auth.ResolvedFederatedCredentials:
+		f.TokenRequest = creds.SubjectToken
+		f.ServiceAccountIDRequest = creds.ServiceAccountID
+	default:
+		return nil, fmt.Errorf("unknown auth type %T", creds)
+	}
+	f.DomainRequest = domain
+	f.CaRequest = ca
+
+	resultToken := fmt.Sprintf("token-%d", call)
+	if f.TokenToIssue != "" {
+		resultToken = f.TokenToIssue
+	}
+
 	return &Token{
-		Token:     fmt.Sprintf("token-%d", f.Calls.Load()),
+		Token:     resultToken,
 		ExpiresAt: issuedAt.Add(100 * time.Second), // lifetime is 100 seconds
 		IssuedAt:  issuedAt,
 	}, nil

+ 34 - 17
providers/v1/nebius/common/sdk/iam/grpc_token_exchanger.go

@@ -18,21 +18,19 @@ package iam
 
 import (
 	"context"
-	"encoding/json"
-	"errors"
 	"fmt"
 	"time"
 
 	"github.com/go-logr/logr"
-	"github.com/nebius/gosdk/auth"
+	gosdkauth "github.com/nebius/gosdk/auth"
 	iam "github.com/nebius/gosdk/services/nebius/iam/v1"
 
+	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/auth"
 	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk"
 )
 
 const (
-	errInvalidSubjectCreds        = "invalid subject credentials: malformed JSON"
-	errSubjectCredsCannotBeSigned = "invalid subject credentials: cannot be signed %w"
+	errCreateExchangeTokenRequest = "could not create token exchange request: %w"
 )
 
 // GrpcTokenExchanger is a client for exchanging credentials over gRPC to obtain IAM tokens.
@@ -50,22 +48,26 @@ func NewGrpcTokenExchanger(logger logr.Logger, exchangeTokenObserveCallFunc func
 }
 
 // ExchangeIamToken exchanges subject credentials for a new IAM token using a gRPC-based token exchange service.
-func (t *GrpcTokenExchanger) ExchangeIamToken(ctx context.Context, apiDomain, subjectCreds string, issuedAt time.Time, caCertificate []byte) (*Token, error) {
-	parsedSubjectCreds := &auth.ServiceAccountCredentials{}
-	if err := json.Unmarshal([]byte(subjectCreds), parsedSubjectCreds); err != nil {
+func (t *GrpcTokenExchanger) ExchangeIamToken(ctx context.Context, apiDomain string, resolvedCreds auth.TokenExchangeCredentials, issuedAt time.Time, caCertificate []byte) (*Token, error) {
+	var tokenRequester gosdkauth.ExchangeTokenRequester
+	var err error
+
+	switch creds := resolvedCreds.(type) {
+	case *auth.ResolvedServiceAccountCreds:
+		tokenRequester = t.newServiceAccountTokenRequester(creds)
+	case *auth.ResolvedFederatedCredentials:
+		tokenRequester = t.newFederatedServiceAccountTokenRequester(creds)
+	default:
+		err = fmt.Errorf("unknown auth type %T", creds)
+	}
+
+	if err != nil {
 		if t.exchangeTokenObserveCall != nil {
 			t.exchangeTokenObserveCall(err)
 		}
-		return nil, errors.New(errInvalidSubjectCreds)
+		return nil, err
 	}
 
-	reader := auth.NewPrivateKeyParser(
-		[]byte(parsedSubjectCreds.SubjectCredentials.PrivateKey),
-		parsedSubjectCreds.SubjectCredentials.KeyID,
-		parsedSubjectCreds.SubjectCredentials.Subject,
-	)
-	tokenRequester := auth.NewServiceAccountExchangeTokenRequester(reader)
-
 	iamSdk, err := sdk.NewSDK(ctx, apiDomain, caCertificate)
 	if err != nil {
 		if t.exchangeTokenObserveCall != nil {
@@ -82,7 +84,7 @@ func (t *GrpcTokenExchanger) ExchangeIamToken(ctx context.Context, apiDomain, su
 		if t.exchangeTokenObserveCall != nil {
 			t.exchangeTokenObserveCall(err)
 		}
-		return nil, fmt.Errorf(errSubjectCredsCannotBeSigned, err)
+		return nil, fmt.Errorf(errCreateExchangeTokenRequest, err)
 	}
 
 	tok, err := tokenExchanger.Exchange(ctx, req)
@@ -100,4 +102,19 @@ func (t *GrpcTokenExchanger) ExchangeIamToken(ctx context.Context, apiDomain, su
 	}, nil
 }
 
+func (t *GrpcTokenExchanger) newServiceAccountTokenRequester(credentials *auth.ResolvedServiceAccountCreds) gosdkauth.ServiceAccountExchangeTokenRequester {
+	reader := gosdkauth.NewPrivateKeyParser(
+		[]byte(credentials.PrivateKey),
+		credentials.KeyID,
+		credentials.Subject,
+	)
+	return gosdkauth.NewServiceAccountExchangeTokenRequester(reader)
+}
+func (t *GrpcTokenExchanger) newFederatedServiceAccountTokenRequester(resolvedCreds *auth.ResolvedFederatedCredentials) *gosdkauth.FederatedCredentialsTokenRequester {
+	reader := gosdkauth.NewStaticFederatedCredentialsReader(
+		gosdkauth.FederatedCredentials(resolvedCreds.SubjectToken),
+	)
+	return gosdkauth.NewFederatedCredentialsTokenRequester(resolvedCreds.ServiceAccountID, reader)
+}
+
 var _ TokenExchanger = &GrpcTokenExchanger{}

+ 4 - 1
providers/v1/nebius/common/sdk/iam/token_exchanger.go

@@ -20,6 +20,8 @@ package iam
 import (
 	"context"
 	"time"
+
+	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/auth"
 )
 
 // Token represents an IAM token with its value, expiration time, and issuance time.
@@ -31,5 +33,6 @@ type Token struct {
 
 // TokenExchanger is an interface for exchanging credentials to obtain IAM tokens.
 type TokenExchanger interface {
-	ExchangeIamToken(ctx context.Context, apiDomain, subjectCreds string, issuedAt time.Time, caCertificate []byte) (*Token, error)
+	// ExchangeIamToken exchanges resolved credentials for an IAM token.
+	ExchangeIamToken(ctx context.Context, apiDomain string, creds auth.TokenExchangeCredentials, issuedAt time.Time, caCertificate []byte) (*Token, error)
 }

+ 3 - 0
providers/v1/nebius/common/sdk/mysterybox/client.go

@@ -21,8 +21,11 @@ import "context"
 
 // Client is an interface that contains the main methods to interact with Secret Service.
 type Client interface {
+	// GetSecret returns a secret payload for the requested version.
 	GetSecret(ctx context.Context, token, secretID, versionID string) (*Payload, error)
+	// GetSecretByKey returns a single entry from the requested secret version.
 	GetSecretByKey(ctx context.Context, token, secretID, versionID, key string) (*PayloadEntry, error)
+	// Close releases resources held by the client.
 	Close() error
 }
 

+ 15 - 0
providers/v1/nebius/common/sdk/mysterybox/fake/fake_client.go

@@ -18,6 +18,7 @@ package fake
 
 import (
 	"context"
+	"errors"
 	"sync"
 	"sync/atomic"
 
@@ -28,16 +29,19 @@ import (
 	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk/mysterybox"
 )
 
+// FakeMysteryboxClient is an in-memory Mysterybox client for tests.
 type FakeMysteryboxClient struct {
 	mysteryboxService *MysteryboxService
 	Closed            int32
 }
 
+// Close records that the fake client was closed.
 func (f *FakeMysteryboxClient) Close() error {
 	atomic.AddInt32(&f.Closed, 1)
 	return nil
 }
 
+// GetSecret returns a secret payload from the fake service.
 func (f *FakeMysteryboxClient) GetSecret(_ context.Context, _, secretId, versionId string) (*mysterybox.Payload, error) {
 	secret, err := f.mysteryboxService.GetSecret(secretId, versionId)
 	if err != nil {
@@ -49,6 +53,7 @@ func (f *FakeMysteryboxClient) GetSecret(_ context.Context, _, secretId, version
 	}, nil
 }
 
+// GetSecretByKey returns a single secret entry from the fake service.
 func (f *FakeMysteryboxClient) GetSecretByKey(_ context.Context, _, secretID, versionID, key string) (*mysterybox.PayloadEntry, error) {
 	secret, err := f.mysteryboxService.GetSecret(secretID, versionID)
 	if err != nil {
@@ -66,17 +71,20 @@ func (f *FakeMysteryboxClient) GetSecretByKey(_ context.Context, _, secretID, ve
 	return nil, notFoundError()
 }
 
+// MysteryboxService stores fake Mysterybox secrets in memory.
 type MysteryboxService struct {
 	mu         sync.RWMutex
 	secretData map[string]map[string][]mysterybox.Entry
 }
 
+// InitMysteryboxService creates an empty fake Mysterybox service.
 func InitMysteryboxService() *MysteryboxService {
 	return &MysteryboxService{
 		secretData: make(map[string]map[string][]mysterybox.Entry),
 	}
 }
 
+// GetSecret returns a secret version from the fake service.
 func (s *MysteryboxService) GetSecret(secretId, versionId string) (*Secret, error) {
 	s.mu.RLock()
 	defer s.mu.RUnlock()
@@ -96,6 +104,7 @@ func (s *MysteryboxService) GetSecret(secretId, versionId string) (*Secret, erro
 	}, nil
 }
 
+// CreateSecret creates a secret with an initial version.
 func (s *MysteryboxService) CreateSecret(payloadEntries []mysterybox.Entry) *Secret {
 	if len(payloadEntries) == 0 {
 		return nil
@@ -118,7 +127,11 @@ func (s *MysteryboxService) CreateSecret(payloadEntries []mysterybox.Entry) *Sec
 	}
 }
 
+// CreateNewSecretVersion adds a new version to an existing secret.
 func (s *MysteryboxService) CreateNewSecretVersion(secretId string, payloadEntries []mysterybox.Entry) (string, error) {
+	if len(payloadEntries) == 0 {
+		return "", errors.New("payload entries should not be empty")
+	}
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
@@ -132,12 +145,14 @@ func (s *MysteryboxService) CreateNewSecretVersion(secretId string, payloadEntri
 	return versionId, nil
 }
 
+// NewFakeMysteryboxClient creates a fake client backed by the provided service.
 func NewFakeMysteryboxClient(service *MysteryboxService) *FakeMysteryboxClient {
 	return &FakeMysteryboxClient{
 		mysteryboxService: service,
 	}
 }
 
+// Secret represents a secret stored by the fake Mysterybox service.
 type Secret struct {
 	Id        string
 	VersionId string

+ 4 - 0
providers/v1/nebius/common/sdk/mysterybox/grpc_client_test.go

@@ -35,10 +35,12 @@ const (
 	notFoundError      = "not found"
 )
 
+// FakePayloadService provides in-memory payload data for tests.
 type FakePayloadService struct {
 	data map[string]map[string]*mbox.SecretPayload
 }
 
+// Get returns a payload from the fake service.
 func (f *FakePayloadService) Get(_ context.Context, r *mbox.GetPayloadRequest, _ ...grpc.CallOption) (*mbox.SecretPayload, error) {
 	version := extractVersionFromRequest(r.VersionId)
 	val, ok := f.data[r.GetSecretId()][version]
@@ -48,6 +50,7 @@ func (f *FakePayloadService) Get(_ context.Context, r *mbox.GetPayloadRequest, _
 	return val, nil
 }
 
+// GetByKey returns a payload entry from the fake service.
 func (f *FakePayloadService) GetByKey(_ context.Context, r *mbox.GetPayloadByKeyRequest, _ ...grpc.CallOption) (*mbox.SecretPayloadEntry, error) {
 	version := extractVersionFromRequest(r.VersionId)
 	payload, ok := f.data[r.GetSecretId()][version]
@@ -64,6 +67,7 @@ func (f *FakePayloadService) GetByKey(_ context.Context, r *mbox.GetPayloadByKey
 	return nil, errors.New(notFoundError)
 }
 
+// InitFakePayloadService creates a fake service populated with test data.
 func InitFakePayloadService() *FakePayloadService {
 	mysteryboxData := map[string]map[string]*mbox.SecretPayload{}
 	mysteryboxData["secret1Id"] = make(map[string]*mbox.SecretPayload)

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

@@ -16,6 +16,7 @@ require (
 	google.golang.org/grpc v1.82.1
 	k8s.io/api v0.36.3
 	k8s.io/apimachinery v0.36.3
+	k8s.io/client-go v0.36.3
 	k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2
 	sigs.k8s.io/controller-runtime v0.24.1
 )
@@ -91,7 +92,6 @@ require (
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	k8s.io/apiextensions-apiserver v0.36.3 // indirect
-	k8s.io/client-go v0.36.3 // indirect
 	k8s.io/klog/v2 v2.140.0 // indirect
 	k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
 	sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect

+ 82 - 20
providers/v1/nebius/mysterybox/provider.go

@@ -27,12 +27,16 @@ import (
 	"github.com/go-logr/logr"
 	lru "github.com/hashicorp/golang-lru"
 	"github.com/spf13/pflag"
+	"k8s.io/client-go/kubernetes"
+	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 	"k8s.io/utils/clock"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	ctrlcfg "sigs.k8s.io/controller-runtime/pkg/client/config"
 
 	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/providers/v1/nebius/common/auth"
 	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk/iam"
 	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk/mysterybox"
 	"github.com/external-secrets/external-secrets/runtime/constants"
@@ -52,12 +56,16 @@ var (
 // NewMysteryboxClient is a function that describes how to create a Nebius MysteryBox client to interact within.
 type NewMysteryboxClient func(ctx context.Context, apiDomain string, caCertificate []byte) (mysterybox.Client, error)
 
+// NewCoreV1Client creates a typed Kubernetes CoreV1 client.
+type NewCoreV1Client func() (typedcorev1.CoreV1Interface, error)
+
 // SecretsClientConfig holds configuration for interacting with.
 type SecretsClientConfig struct {
 	APIDomain           string
 	ServiceAccountCreds *esmeta.SecretKeySelector
 	Token               *esmeta.SecretKeySelector
 	CACertificate       *esmeta.SecretKeySelector
+	WorkloadIdentity    *esv1.NebiusWorkloadIdentity
 }
 
 // ClientCacheKey represents a unique key for identifying cached MysteryBox clients.
@@ -71,9 +79,12 @@ type ClientCacheKey struct {
 type Provider struct {
 	Logger                      logr.Logger
 	NewMysteryboxClient         NewMysteryboxClient
+	NewCoreV1Client             NewCoreV1Client
 	TokenGetter                 TokenGetter
+	coreV1Client                typedcorev1.CoreV1Interface
 	mysteryboxClientsCache      *lru.Cache
 	tokenInitMutex              sync.Mutex
+	coreV1InitMutex             sync.Mutex
 	cacheInitMutex              sync.Mutex
 	mysteryboxClientsCacheMutex sync.Mutex
 }
@@ -124,31 +135,40 @@ func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube
 // getIamToken retrieves an IAM token based on the provided SecretsClientConfig and authentication options.
 // It supports token retrieval from a predefined secret or via service account credentials with the TokenGetter.
 func (p *Provider) getIamToken(ctx context.Context, config *SecretsClientConfig, store esv1.GenericStore, kube client.Client, namespace string, caCert []byte) (string, error) {
-	if config.Token.Name != "" {
-		iamToken, err := resolvers.SecretKeyRef(
-			ctx,
-			kube,
-			store.GetKind(),
-			namespace,
-			config.Token,
-		)
+	if config.Token != nil && config.Token.Name != "" {
+		creds, err := auth.GetTokenCredentials(ctx, config.Token, store, kube, namespace)
+		if err != nil {
+			return "", err
+		}
+		return creds.Token, nil
+	}
+	if config.ServiceAccountCreds != nil && config.ServiceAccountCreds.Name != "" {
+		credentialsRequest, err := auth.NewServiceAccountCredentialsRequest(ctx, config.ServiceAccountCreds, store, kube, namespace)
+		if err != nil {
+			return "", err
+		}
+		token, err := p.TokenGetter.GetToken(ctx, config.APIDomain, credentialsRequest, caCert)
 		if err != nil {
-			return "", fmt.Errorf("read token secret %s/%s: %w", namespace, config.Token.Name, err)
+			return "", fmt.Errorf(errFailedToRetrieveToken, err)
 		}
-		return strings.TrimSpace(iamToken), nil
+		return strings.TrimSpace(token), nil
 	}
-	if config.ServiceAccountCreds.Name != "" {
-		subjectCreds, err := resolvers.SecretKeyRef(
-			ctx,
-			kube,
-			store.GetKind(),
+	if config.WorkloadIdentity != nil && config.WorkloadIdentity.ServiceAccountRef != nil && config.WorkloadIdentity.ServiceAccountRef.Name != "" && config.WorkloadIdentity.IAMServiceAccountID != "" {
+		coreV1Client, err := p.getOrCreateCoreV1Client()
+		if err != nil {
+			return "", fmt.Errorf("initialize Kubernetes CoreV1 client: %w", err)
+		}
+		credentialsRequest, err := auth.NewFederatedAccountCredentialsRequest(
+			*config.WorkloadIdentity.ServiceAccountRef,
+			config.WorkloadIdentity.IAMServiceAccountID,
+			store,
+			coreV1Client,
 			namespace,
-			config.ServiceAccountCreds,
 		)
 		if err != nil {
-			return "", fmt.Errorf("read service account creds %s/%s: %w", namespace, config.ServiceAccountCreds.Name, err)
+			return "", err
 		}
-		token, err := p.TokenGetter.GetToken(ctx, config.APIDomain, subjectCreds, caCert)
+		token, err := p.TokenGetter.GetToken(ctx, config.APIDomain, credentialsRequest, caCert)
 		if err != nil {
 			return "", fmt.Errorf(errFailedToRetrieveToken, err)
 		}
@@ -212,15 +232,24 @@ func parseConfig(store esv1.GenericStore) (*SecretsClientConfig, error) {
 		return nil, errors.New(errMissingAPIDomain)
 	}
 
+	var token *esmeta.SecretKeySelector
+	if nebiusMysteryboxProvider.Auth.Token.Name != "" {
+		token = &nebiusMysteryboxProvider.Auth.Token
+	}
 	var caCertificate *esmeta.SecretKeySelector
 	if nebiusMysteryboxProvider.CAProvider != nil {
 		caCertificate = &nebiusMysteryboxProvider.CAProvider.Certificate
 	}
+	var serviceAccountCreds *esmeta.SecretKeySelector
+	if nebiusMysteryboxProvider.Auth.ServiceAccountCreds.Name != "" {
+		serviceAccountCreds = &nebiusMysteryboxProvider.Auth.ServiceAccountCreds
+	}
 	return &SecretsClientConfig{
 		APIDomain:           strings.TrimSpace(nebiusMysteryboxProvider.APIDomain),
-		ServiceAccountCreds: &nebiusMysteryboxProvider.Auth.ServiceAccountCreds,
-		Token:               &nebiusMysteryboxProvider.Auth.Token,
+		ServiceAccountCreds: serviceAccountCreds,
+		Token:               token,
 		CACertificate:       caCertificate,
+		WorkloadIdentity:    nebiusMysteryboxProvider.Auth.WorkloadIdentity,
 	}, nil
 }
 
@@ -288,11 +317,44 @@ func (p *Provider) initTokenGetter() error {
 	return err
 }
 
+func (p *Provider) getOrCreateCoreV1Client() (typedcorev1.CoreV1Interface, error) {
+	p.coreV1InitMutex.Lock()
+	defer p.coreV1InitMutex.Unlock()
+
+	if p.coreV1Client != nil {
+		return p.coreV1Client, nil
+	}
+
+	newCoreV1Client := p.NewCoreV1Client
+	if newCoreV1Client == nil {
+		newCoreV1Client = newCoreV1ClientFromConfig
+	}
+	coreV1Client, err := newCoreV1Client()
+	if err != nil {
+		return nil, err
+	}
+	p.coreV1Client = coreV1Client
+	return p.coreV1Client, nil
+}
+
+func newCoreV1ClientFromConfig() (typedcorev1.CoreV1Interface, error) {
+	restConfig, err := ctrlcfg.GetConfig()
+	if err != nil {
+		return nil, err
+	}
+	clientset, err := kubernetes.NewForConfig(restConfig)
+	if err != nil {
+		return nil, err
+	}
+	return clientset.CoreV1(), nil
+}
+
 // NewProvider creates a new Provider instance.
 func NewProvider() esv1.Provider {
 	return &Provider{
 		Logger:              log,
 		NewMysteryboxClient: newMysteryboxClient,
+		NewCoreV1Client:     newCoreV1ClientFromConfig,
 	}
 }
 

+ 210 - 50
providers/v1/nebius/mysterybox/provider_test.go

@@ -25,36 +25,57 @@ import (
 	"sync"
 	"sync/atomic"
 	"testing"
+	"time"
 
 	"github.com/google/uuid"
 	lru "github.com/hashicorp/golang-lru"
 	"github.com/nebius/gosdk/auth"
 	tassert "github.com/stretchr/testify/assert"
+	trequire "github.com/stretchr/testify/require"
+	authenticationv1 "k8s.io/api/authentication/v1"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
+	clocktesting "k8s.io/utils/clock/testing"
 	ctrl "sigs.k8s.io/controller-runtime"
 	k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
 	clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	nebiusauth "github.com/external-secrets/external-secrets/providers/v1/nebius/common/auth"
+	nebiusiam "github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk/iam"
 	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk/mysterybox"
 	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk/mysterybox/fake"
+	utilfake "github.com/external-secrets/external-secrets/runtime/util/fake"
 )
 
 const (
-	tokenSecretName   = "tokenSecretName"
-	tokenSecretKey    = "tokenSecretKey"
-	saCredsSecretName = "saCredsSecretName"
-	saCredsSecretKey  = "saCredsSecretKey"
-	authRefName       = "authRefSecretName"
-	authRefKey        = "authRefSecretKey"
-	apiDomain         = "api.public"
-	tokenToBeIssued   = "token-to-be-issued"
+	tokenSecretName       = "tokenSecretName"
+	tokenSecretKey        = "tokenSecretKey"
+	saCredsSecretName     = "saCredsSecretName"
+	saCredsSecretKey      = "saCredsSecretKey"
+	authRefName           = "authRefSecretName"
+	authRefKey            = "authRefSecretKey"
+	apiDomain             = "api.public"
+	tokenToBeIssued       = "token-to-be-issued"
+	serviceAccountName    = "testServiceAccountName"
+	iamServiceAccount     = "serviceaccount-e00test"
+	workloadIdentityToken = "test-service-account-jwt"
+	correctPrivateKey     = "-----BEGIN PRIVATE KEY-----\nTEST-KEY\n-----END PRIVATE KEY-----"
 )
 
 var (
-	logger = ctrl.Log.WithName("provider").WithName("nebius").WithName("mysterybox")
+	logger           = ctrl.Log.WithName("provider").WithName("nebius").WithName("mysterybox")
+	testSubjectCreds = &auth.ServiceAccountCredentials{
+		SubjectCredentials: auth.SubjectCredentials{
+			PrivateKey: correctPrivateKey,
+			KeyID:      "keyId",
+			Subject:    "subjectId",
+			Issuer:     "subjectId",
+		},
+	}
 )
 
 func setupClientWithTokenAuth(t *testing.T, entries []mysterybox.Entry, tokenGetter TokenGetter) (context.Context, *SecretsClient, *fake.Secret, k8sclient.Client, *fake.MysteryboxService) {
@@ -65,6 +86,7 @@ func setupClientWithTokenAuth(t *testing.T, entries []mysterybox.Entry, tokenGet
 	k8sClient := clientfake.NewClientBuilder().Build()
 
 	secret := mysteryboxService.CreateSecret(entries)
+	tassert.NotNil(t, secret)
 
 	provider := newProvider(
 		t,
@@ -91,7 +113,11 @@ func TestNewClient_GetTokenError(t *testing.T) {
 	namespace := uuid.NewString()
 	mysteryboxService := fake.InitMysteryboxService()
 	k8sClient := clientfake.NewClientBuilder().Build()
-	createK8sSecret(ctx, t, k8sClient, namespace, saCredsSecretName, saCredsSecretKey, []byte("PRIVATE KEY"))
+	subjectCreds, err := json.Marshal(testSubjectCreds)
+	if err != nil {
+		t.Fatal(err)
+	}
+	createK8sSecret(ctx, t, k8sClient, namespace, saCredsSecretName, saCredsSecretKey, subjectCreds)
 
 	provider := newProvider(
 		t,
@@ -101,7 +127,7 @@ func TestNewClient_GetTokenError(t *testing.T) {
 		&tokenGetter,
 	)
 
-	_, err := provider.NewClient(ctx, newNebiusMysteryboxSecretStoreWithServiceAccountCreds(apiDomain, namespace, saCredsSecretName, saCredsSecretKey), k8sClient, namespace)
+	_, err = provider.NewClient(ctx, newNebiusMysteryboxSecretStoreWithServiceAccountAndPrivateKeyCreds(apiDomain, namespace, saCredsSecretName, saCredsSecretKey), k8sClient, namespace)
 	tassert.Error(t, err)
 	tassert.ErrorContains(t, err, "failed to retrieve iam token by credentials")
 }
@@ -313,8 +339,7 @@ func TestNewClient_ValidationErrors(t *testing.T) {
 			expectErr: fmt.Sprintf("read token secret %s/%s: cannot get Kubernetes secret", namespace, notExistingSecretName),
 		},
 		{
-			name: "specified service account " +
-				"creds secret does not exist in kubernetes secrets",
+			name: "specified service account creds secret does not exist in kubernetes secrets",
 			storeSpec: func() *esv1.SecretStore {
 				return &esv1.SecretStore{
 					ObjectMeta: metav1.ObjectMeta{Namespace: namespace},
@@ -334,7 +359,7 @@ func TestNewClient_ValidationErrors(t *testing.T) {
 					},
 				}
 			},
-			expectErr: fmt.Sprintf("read service account creds %s/%s: cannot get Kubernetes secret", namespace, notExistingSecretName),
+			expectErr: fmt.Sprintf("error read service account secret creds %s/%s", namespace, notExistingSecretName),
 		},
 		{
 			name: "specified token secret's key does not exist in the secret",
@@ -381,21 +406,16 @@ func TestNewClient_AuthWithSecretAccountCreds(t *testing.T) {
 	k8sClient := clientfake.NewClientBuilder().Build()
 
 	secret := mysteryboxService.CreateSecret([]mysterybox.Entry{{Key: "k", StringValue: "v"}})
+	tassert.NotNil(t, secret)
 
-	providedCreds, _ := json.Marshal(&auth.ServiceAccountCredentials{
-		SubjectCredentials: auth.SubjectCredentials{
-			PrivateKey: "-----BEGIN PRIVATE KEY-----\nTEST-KEY\n-----END PRIVATE KEY-----",
-			KeyID:      "keyId",
-			Subject:    "subjectId",
-			Issuer:     "subjectId",
-		},
-	})
+	providedCreds, _ := json.Marshal(testSubjectCreds)
 
 	tokenToIssue := tokenToBeIssued
 
-	tokenGetter := &faketokenGetter{
-		tokenToIssue: tokenToIssue,
+	tokenExchanger := &nebiusiam.FakeTokenExchanger{
+		TokenToIssue: tokenToIssue,
 	}
+	tokenGetter := newTestCachedTokenGetter(t, tokenExchanger)
 
 	cache, err := lru.New(10)
 	tassert.NoError(t, err)
@@ -406,11 +426,11 @@ func TestNewClient_AuthWithSecretAccountCreds(t *testing.T) {
 			return fake.NewFakeMysteryboxClient(mysteryboxService), nil
 		},
 		mysteryboxClientsCache: cache,
+		TokenGetter:            tokenGetter,
 	}
-	settokenGetterWorkaround(tokenGetter, p)
 
 	createK8sSecret(ctx, t, k8sClient, namespace, authRefName, authRefKey, providedCreds)
-	store := newNebiusMysteryboxSecretStoreWithServiceAccountCreds(apiDomain, namespace, authRefName, authRefKey)
+	store := newNebiusMysteryboxSecretStoreWithServiceAccountAndPrivateKeyCreds(apiDomain, namespace, authRefName, authRefKey)
 
 	client, err := p.NewClient(ctx, store, k8sClient, namespace)
 	tassert.NoError(t, err)
@@ -419,17 +439,84 @@ func TestNewClient_AuthWithSecretAccountCreds(t *testing.T) {
 	tassert.True(t, ok, "expected *MysteryboxSecretsClient, got %T", client)
 	tassert.Equal(t, tokenToIssue, msc.token, fmt.Sprintf("token mismatch: got %q want %q (issued by TokenGetter)", msc.token, tokenToIssue))
 
-	// also ensure TokenGetter was exercised with the domain and creds we expect
-	tassert.Equal(t, int32(1), tokenGetter.calls, "expected TokenGetter to be called once")
-	tassert.Equal(t, apiDomain, tokenGetter.gotDomain, "expected TokenGetter to be called with the correct domain")
-	tassert.Equal(t, string(providedCreds), tokenGetter.gotCreds, "expected TokenGetter to be called with the correct creds")
-	tassert.Nil(t, tokenGetter.gotCACert, "expected TokenGetter to be called without CA cert")
+	// also ensure TokenExchanger was exercised with the domain and creds we expect
+	tassert.Equal(t, int64(1), tokenExchanger.Calls.Load(), "expected TokenExchanger to be called once")
+	tassert.Equal(t, apiDomain, tokenExchanger.DomainRequest, "expected TokenExchanger to be called with the correct domain")
+	tassert.Equal(t, testSubjectCreds.SubjectCredentials.KeyID, tokenExchanger.KeyIDRequest, "expected TokenExchanger to be called with the correct creds")
+	tassert.Equal(t, testSubjectCreds.SubjectCredentials.PrivateKey, tokenExchanger.PrivateKeyRequest, "expected TokenExchanger to be called with the correct creds")
+	tassert.Equal(t, testSubjectCreds.SubjectCredentials.Subject, tokenExchanger.SubjectRequest, "expected TokenExchanger to be called with the correct creds")
+	tassert.Nil(t, tokenExchanger.CaRequest, "expected TokenExchanger to be called without CA cert")
 
 	got, err := msc.GetSecret(ctx, esv1.ExternalSecretDataRemoteRef{Key: secret.Id, Property: "k"})
 	tassert.NoError(t, err)
 	tassert.Equal(t, []byte("v"), got)
 }
 
+func TestNewClient_AuthWithWorkloadIdentityCreds(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+	namespace := uuid.NewString()
+	mysteryboxService := fake.InitMysteryboxService()
+	k8sClient := clientfake.NewClientBuilder().Build()
+
+	secret := mysteryboxService.CreateSecret([]mysterybox.Entry{{Key: "k1", StringValue: "v1"}})
+	tassert.NotNil(t, secret)
+
+	tokenToIssue := tokenToBeIssued
+
+	tokenExchanger := &nebiusiam.FakeTokenExchanger{
+		TokenToIssue: tokenToIssue,
+	}
+	tokenGetter := newTestCachedTokenGetter(t, tokenExchanger)
+
+	cache, err := lru.New(10)
+	tassert.NoError(t, err)
+
+	coreV1Client := &recordingCoreV1{
+		CoreV1Interface: utilfake.NewCreateTokenMock().
+			WithToken(workloadIdentityToken),
+		onTokenRequest: func(request *authenticationv1.TokenRequest) {
+			expectedAudiences := []string{nebiusauth.NebiusIamAudience}
+			tassert.Equal(
+				t,
+				expectedAudiences,
+				request.Spec.Audiences,
+			)
+		},
+	}
+
+	p := &Provider{
+		Logger: logger,
+		NewMysteryboxClient: func(ctx context.Context, apiDomain string, caCertificate []byte) (mysterybox.Client, error) {
+			return fake.NewFakeMysteryboxClient(mysteryboxService), nil
+		},
+		coreV1Client:           coreV1Client,
+		mysteryboxClientsCache: cache,
+		TokenGetter:            tokenGetter,
+	}
+
+	createK8sServiceAccount(ctx, t, k8sClient, namespace, serviceAccountName)
+	store := newNebiusMysteryboxSecretStoreWithK8sServiceAccountCreds(apiDomain, namespace, serviceAccountName, iamServiceAccount)
+
+	client, err := p.NewClient(ctx, store, k8sClient, namespace)
+	tassert.NoError(t, err)
+
+	msc, ok := client.(*SecretsClient)
+	tassert.True(t, ok, "expected *MysteryboxSecretsClient, got %T", client)
+	tassert.Equal(t, tokenToIssue, msc.token, fmt.Sprintf("token mismatch: got %q want %q (issued by TokenGetter)", msc.token, tokenToIssue))
+
+	// also ensure TokenExchanger was exercised with creds we expect
+	tassert.Equal(t, int64(1), tokenExchanger.Calls.Load(), "expected TokenExchanger to be called once")
+	tassert.Equal(t, apiDomain, tokenExchanger.DomainRequest, "expected TokenExchanger to be called with the correct domain")
+	tassert.Equal(t, workloadIdentityToken, tokenExchanger.TokenRequest, "expected jwt token to be received")
+	tassert.Equal(t, iamServiceAccount, tokenExchanger.ServiceAccountIDRequest, "expected serviceAccount to be correct ")
+	tassert.Nil(t, tokenExchanger.CaRequest, "expected TokenExchanger to be called without CA cert")
+
+	got, err := msc.GetSecret(ctx, esv1.ExternalSecretDataRemoteRef{Key: secret.Id, Property: "k1"})
+	tassert.NoError(t, err)
+	tassert.Equal(t, []byte("v1"), got)
+}
+
 func TestGetSecret_NotFound(t *testing.T) {
 	t.Parallel()
 	// Use table-driven tests to cover all NotFound scenarios in one place
@@ -788,6 +875,7 @@ func TestNewClient_Concurrent_SameConfig_SingleClient_DifferentTokens(t *testing
 	k8sClient := clientfake.NewClientBuilder().Build()
 
 	secret := mboxSvc.CreateSecret([]mysterybox.Entry{{Key: "k", StringValue: "v"}})
+	tassert.NotNil(t, secret)
 
 	var factoryCalls int32
 	tokenToIssue := tokenToBeIssued
@@ -801,12 +889,13 @@ func TestNewClient_Concurrent_SameConfig_SingleClient_DifferentTokens(t *testing
 			return fake.NewFakeMysteryboxClient(mboxSvc), nil
 		},
 	}
-	settokenGetterWorkaround(tokenGetter, p)
+	setTokenGetterWorkaround(tokenGetter, p)
 
-	creds := []byte(`{"private_key":"KEY","key_id":"id","subject":"sub","issuer":"iss"}`)
+	creds, err := json.Marshal(testSubjectCreds)
+	trequire.NoError(t, err)
 	createK8sSecret(ctx, t, k8sClient, namespace, authRefName, authRefKey, creds)
 
-	store := newNebiusMysteryboxSecretStoreWithServiceAccountCreds(apiDomain, namespace, authRefName, authRefKey)
+	store := newNebiusMysteryboxSecretStoreWithServiceAccountAndPrivateKeyCreds(apiDomain, namespace, authRefName, authRefKey)
 
 	const goroutines = 10
 	var wg sync.WaitGroup
@@ -835,7 +924,7 @@ func TestNewClient_Concurrent_SameConfig_SingleClient_DifferentTokens(t *testing
 		tassert.Equal(t, []byte("v"), got)
 	}
 
-	tassert.Equal(t, int32(goroutines), atomic.LoadInt32(&tokenGetter.calls), fmt.Sprintf("TokenGetter.GetToken called %d times, want %d", factoryCalls, goroutines))
+	tassert.Equal(t, int32(goroutines), tokenGetter.calls.Load(), fmt.Sprintf("TokenGetter.GetToken called %d times, want %d", factoryCalls, goroutines))
 	tassert.Equal(t, int32(1), atomic.LoadInt32(&factoryCalls), fmt.Sprintf("NewMysteryboxClient called %d times, want 1", factoryCalls))
 }
 
@@ -862,7 +951,7 @@ func newNebiusMysteryboxSecretStoreWithAuthTokenKey(apiDomain, namespace, tokenS
 	}
 }
 
-func newNebiusMysteryboxSecretStoreWithServiceAccountCreds(apiDomain, namespace, keySecretName, keySecretKey string) esv1.GenericStore {
+func newNebiusMysteryboxSecretStoreWithServiceAccountAndPrivateKeyCreds(apiDomain, namespace, keySecretName, keySecretKey string) esv1.GenericStore {
 	return &esv1.SecretStore{
 		ObjectMeta: metav1.ObjectMeta{
 			Namespace: namespace,
@@ -882,6 +971,30 @@ func newNebiusMysteryboxSecretStoreWithServiceAccountCreds(apiDomain, namespace,
 		},
 	}
 }
+func newNebiusMysteryboxSecretStoreWithK8sServiceAccountCreds(apiDomain, namespace, serviceAccountName, iamServiceAccount string) esv1.GenericStore {
+	return &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: namespace,
+			UID:       types.UID(uuid.New().String()),
+		},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				NebiusMysterybox: &esv1.NebiusMysteryboxProvider{
+					APIDomain: apiDomain,
+					Auth: esv1.NebiusAuth{
+						WorkloadIdentity: &esv1.NebiusWorkloadIdentity{
+							ServiceAccountRef: &esmeta.ServiceAccountSelector{
+								Name:      serviceAccountName,
+								Namespace: &namespace,
+							},
+							IAMServiceAccountID: iamServiceAccount,
+						},
+					},
+				},
+			},
+		},
+	}
+}
 
 func createK8sSecret(ctx context.Context, t *testing.T, k8sClient k8sclient.Client, namespace, secretName, secretKey string, secretValue []byte) {
 	err := k8sClient.Create(ctx, &corev1.Secret{
@@ -893,6 +1006,15 @@ func createK8sSecret(ctx context.Context, t *testing.T, k8sClient k8sclient.Clie
 	})
 	tassert.NoError(t, err)
 }
+func createK8sServiceAccount(ctx context.Context, t *testing.T, k8sClient k8sclient.Client, namespace, serviceAccountName string) {
+	err := k8sClient.Create(ctx, &corev1.ServiceAccount{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: namespace,
+			Name:      serviceAccountName,
+		},
+	})
+	tassert.NoError(t, err)
+}
 
 func unmarshalStringMap(t *testing.T, data []byte) map[string]string {
 	stringMap := make(map[string]string)
@@ -913,30 +1035,35 @@ func newProvider(t *testing.T, newMysteryboxClientFunc NewMysteryboxClient, toke
 	}
 }
 
+func newTestCachedTokenGetter(t *testing.T, tokenExchanger nebiusiam.TokenExchanger) *CachedTokenGetter {
+	t.Helper()
+	tokenGetter, err := NewCachedTokenGetter(
+		mysteryboxTokensCacheSize,
+		tokenExchanger,
+		clocktesting.NewFakeClock(time.Unix(0, 0)),
+	)
+	trequire.NoError(t, err)
+	return tokenGetter
+}
+
 type faketokenGetter struct {
-	calls        int32
-	returnError  bool
-	gotDomain    string
-	gotCreds     string
-	gotCACert    []byte
+	calls        atomic.Int32
 	tokenToIssue string
+	returnError  bool
 
 	mu sync.Mutex
 }
 
-func (f *faketokenGetter) GetToken(_ context.Context, apiDomain, subjectCreds string, caCert []byte) (string, error) {
-	atomic.AddInt32(&f.calls, 1)
-
-	f.mu.Lock()
-	defer f.mu.Unlock()
+func (f *faketokenGetter) GetToken(_ context.Context, _ string, _ *nebiusauth.CredentialRequest, _ []byte) (string, error) {
+	f.calls.Add(1)
 
-	f.gotDomain = apiDomain
-	f.gotCreds = subjectCreds
-	f.gotCACert = caCert
 	if f.returnError {
 		return "", errors.New("internal error")
 	}
 
+	f.mu.Lock()
+	defer f.mu.Unlock()
+
 	return f.tokenToIssue, nil
 }
 
@@ -947,7 +1074,7 @@ func setCacheSizeWorkaround(t *testing.T, size int, p *Provider) {
 	p.mysteryboxClientsCache.Resize(size)
 }
 
-func settokenGetterWorkaround(tokenGetter TokenGetter, p *Provider) {
+func setTokenGetterWorkaround(tokenGetter TokenGetter, p *Provider) {
 	p.tokenInitMutex.Lock()
 	defer p.tokenInitMutex.Unlock()
 
@@ -958,3 +1085,36 @@ type ClientData struct {
 	domain string
 	ca     []byte
 }
+
+type recordingCoreV1 struct {
+	typedcorev1.CoreV1Interface
+	onTokenRequest func(*authenticationv1.TokenRequest)
+}
+
+func (c *recordingCoreV1) ServiceAccounts(namespace string) typedcorev1.ServiceAccountInterface {
+	return &recordingServiceAccounts{
+		ServiceAccountInterface: c.CoreV1Interface.ServiceAccounts(namespace),
+		onTokenRequest:          c.onTokenRequest,
+	}
+}
+
+type recordingServiceAccounts struct {
+	typedcorev1.ServiceAccountInterface
+	onTokenRequest func(*authenticationv1.TokenRequest)
+}
+
+func (s *recordingServiceAccounts) CreateToken(
+	ctx context.Context,
+	name string,
+	request *authenticationv1.TokenRequest,
+	opts metav1.CreateOptions,
+) (*authenticationv1.TokenRequest, error) {
+	s.onTokenRequest(request.DeepCopy())
+
+	return s.ServiceAccountInterface.CreateToken(
+		ctx,
+		name,
+		request,
+		opts,
+	)
+}

+ 19 - 45
providers/v1/nebius/mysterybox/token_getter.go

@@ -18,36 +18,20 @@ package mysterybox
 
 import (
 	"context"
-	"encoding/json"
-	"errors"
 	"fmt"
 
 	lru "github.com/hashicorp/golang-lru"
-	"github.com/nebius/gosdk/auth"
 	"golang.org/x/sync/singleflight"
 	"k8s.io/utils/clock"
 
+	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/auth"
 	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk/iam"
 )
 
-const (
-	errInvalidSubjectCreds = "invalid subject credentials: malformed JSON"
-)
-
 // TokenGetter is an interface for generating and retrieving authentication tokens.
 type TokenGetter interface {
-	GetToken(ctx context.Context, apiDomain, subjectCreds string, caCert []byte) (string, error)
-}
-
-type tokenCacheKey struct {
-	APIDomain        string
-	PublicKeyID      string
-	ServiceAccountID string
-	PrivateKeyHash   string
-}
-
-func (k *tokenCacheKey) String() string {
-	return k.APIDomain + "|" + k.PublicKeyID + "|" + k.ServiceAccountID + "|" + k.PrivateKeyHash
+	// GetToken returns an IAM token for the provided credentials request.
+	GetToken(ctx context.Context, apiDomain string, credentialsRequest *auth.CredentialRequest, caCert []byte) (string, error)
 }
 
 // CachedTokenGetter is responsible for managing Nebius IAM token caching and token exchange processes.
@@ -86,15 +70,10 @@ func isTokenExpired(token *iam.Token, clk clock.Clock) bool {
 
 // GetToken retrieves an IAM token for the given API domain and subject credentials, using a cache to optimize requests.
 // It exchanges credentials for a new token if no valid cached token exists or the cached token is nearing expiration.
-func (c *CachedTokenGetter) GetToken(ctx context.Context, apiDomain, subjectCreds string, caCert []byte) (string, error) {
-	byteCreds := []byte(subjectCreds)
-	cacheKey, err := buildTokenCacheKey(byteCreds, apiDomain)
-
-	if err != nil {
-		return "", err
-	}
+func (c *CachedTokenGetter) GetToken(ctx context.Context, apiDomain string, credentialsRequest *auth.CredentialRequest, caCert []byte) (string, error) {
+	cacheKey := credentialsRequest.CacheKey()
 
-	value, ok := c.tokenCache.Get(*cacheKey)
+	value, ok := c.tokenCache.Get(cacheKey)
 	if ok {
 		token := value.(*iam.Token)
 		tokenExpired := isTokenExpired(token, c.Clock)
@@ -103,22 +82,19 @@ func (c *CachedTokenGetter) GetToken(ctx context.Context, apiDomain, subjectCred
 		}
 	}
 
-	tokenCacheKeyString := cacheKey.String()
-
-	token, err, _ := c.sf.Do(tokenCacheKeyString, func() (any, error) {
-		if v, ok := c.tokenCache.Get(*cacheKey); ok {
+	token, err, _ := c.sf.Do(cacheKey, func() (any, error) {
+		if v, ok := c.tokenCache.Get(cacheKey); ok {
 			tok := v.(*iam.Token)
 			if !isTokenExpired(tok, c.Clock) {
 				return tok.Token, nil
 			}
 		}
-
-		newToken, err := c.TokenExchanger.ExchangeIamToken(ctx, apiDomain, subjectCreds, c.Clock.Now(), caCert)
+		newToken, err := c.exchangeToken(ctx, apiDomain, credentialsRequest, caCert)
 		if err != nil {
-			return "", fmt.Errorf("could not exchange creds to iam token: %w", MapGrpcErrors("create token", err))
+			return "", err
 		}
 
-		c.tokenCache.Add(*cacheKey, newToken)
+		c.tokenCache.Add(cacheKey, newToken)
 		return newToken.Token, nil
 	})
 
@@ -128,18 +104,16 @@ func (c *CachedTokenGetter) GetToken(ctx context.Context, apiDomain, subjectCred
 	return token.(string), nil
 }
 
-func buildTokenCacheKey(subjectCreds []byte, apiDomain string) (*tokenCacheKey, error) {
-	parsedSubjectCreds := &auth.ServiceAccountCredentials{}
-	err := json.Unmarshal(subjectCreds, parsedSubjectCreds)
+func (c *CachedTokenGetter) exchangeToken(ctx context.Context, apiDomain string, credentialsRequest *auth.CredentialRequest, caCert []byte) (*iam.Token, error) {
+	resolvedCreds, err := credentialsRequest.Resolve(ctx)
 	if err != nil {
-		return nil, errors.New(errInvalidSubjectCreds)
+		return nil, err
 	}
-	return &tokenCacheKey{
-		APIDomain:        apiDomain,
-		PublicKeyID:      parsedSubjectCreds.SubjectCredentials.KeyID,
-		ServiceAccountID: parsedSubjectCreds.SubjectCredentials.Subject,
-		PrivateKeyHash:   HashBytes([]byte(parsedSubjectCreds.SubjectCredentials.PrivateKey)),
-	}, nil
+	token, err := c.TokenExchanger.ExchangeIamToken(ctx, apiDomain, resolvedCreds, c.Clock.Now(), caCert)
+	if err != nil {
+		return nil, fmt.Errorf("could not exchange creds to iam token: %w", MapGrpcErrors("create token", err))
+	}
+	return token, nil
 }
 
 var _ TokenGetter = &CachedTokenGetter{}

+ 91 - 85
providers/v1/nebius/mysterybox/token_getter_test.go

@@ -18,20 +18,25 @@ package mysterybox
 
 import (
 	"context"
-	"encoding/json"
+	"errors"
 	"strconv"
 	"sync"
+	"sync/atomic"
 	"testing"
 	"time"
 
-	"github.com/nebius/gosdk/auth"
 	tassert "github.com/stretchr/testify/assert"
 	trequire "github.com/stretchr/testify/require"
 	clocktesting "k8s.io/utils/clock/testing"
 
+	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/auth"
 	"github.com/external-secrets/external-secrets/providers/v1/nebius/common/sdk/iam"
 )
 
+func dummyResolveFunc(_ context.Context) (auth.TokenExchangeCredentials, error) {
+	return &auth.ResolvedServiceAccountCreds{}, nil
+}
+
 type tokenTestEnv struct {
 	ctx                context.Context
 	clk                *clocktesting.FakeClock
@@ -48,31 +53,24 @@ func newTokenTestEnv(t *testing.T) *tokenTestEnv {
 	return &tokenTestEnv{ctx: context.Background(), clk: clk, fakeTokenExchanger: ex, cachedTokenGetter: svc}
 }
 
-func buildSubjectCredsJSON(t *testing.T, privateKey, keyID, subject string) string {
-	t.Helper()
-	b, err := json.Marshal(&auth.ServiceAccountCredentials{
-		SubjectCredentials: auth.SubjectCredentials{
-			PrivateKey: privateKey,
-			KeyID:      keyID,
-			Subject:    subject,
-			Issuer:     subject,
-		},
-	})
-	trequire.NoError(t, err)
-	return string(b)
-}
-
 func TestGetToken_CachesUntilTenPercentLeft(t *testing.T) {
 	t.Parallel()
 	env := newTokenTestEnv(t)
 
+	var calls atomic.Int64
+	resolveFunc := func(_ context.Context) (auth.TokenExchangeCredentials, error) {
+		calls.Add(1)
+		return &auth.ResolvedServiceAccountCreds{}, nil
+	}
+
 	ctx := env.ctx
-	creds := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-A")
+	creds := auth.NewCredentialRequest("key", resolveFunc)
 
 	token1, err := env.cachedTokenGetter.GetToken(ctx, "api.example", creds, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, "token-1", token1)
 	tassert.Equal(t, int64(1), env.fakeTokenExchanger.Calls.Load())
+	tassert.Equal(t, int64(1), calls.Load())
 
 	// add 5 seconds (remaining > 10%)
 	addSecondsToClock(env.clk, 5)
@@ -80,6 +78,7 @@ func TestGetToken_CachesUntilTenPercentLeft(t *testing.T) {
 	tassert.NoError(t, err)
 	tassert.Equal(t, token1, token2)
 	tassert.Equal(t, int64(1), env.fakeTokenExchanger.Calls.Load())
+	tassert.Equal(t, int64(1), calls.Load())
 
 	// after >90% elapsed -> should refresh
 	addSecondsToClock(env.clk, 91) // total 96s
@@ -87,23 +86,38 @@ func TestGetToken_CachesUntilTenPercentLeft(t *testing.T) {
 	tassert.NoError(t, err)
 	tassert.NotEqual(t, token1, token3)
 	tassert.Equal(t, int64(2), env.fakeTokenExchanger.Calls.Load())
+
+	tassert.Equal(t, int64(2), calls.Load())
 }
 
-func TestGetToken_SeparateCacheEntriesPerSubjectCreds(t *testing.T) {
+func TestGetToken_SeparateCacheEntriesPerKey(t *testing.T) {
 	t.Parallel()
 	env := newTokenTestEnv(t)
 
+	var callsA atomic.Int64
+	var callsB atomic.Int64
+
 	ctx := env.ctx
-	credsA := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-A")
-	credsB := buildSubjectCredsJSON(t, "priv-B", "kid-B", "sa-B")
+	credsA := auth.NewCredentialRequest("key-a", func(ctx context.Context) (auth.TokenExchangeCredentials, error) {
+		callsA.Add(1)
+		return &auth.ResolvedServiceAccountCreds{}, nil
+	})
+	credsB := auth.NewCredentialRequest("key-b", func(ctx context.Context) (auth.TokenExchangeCredentials, error) {
+		callsB.Add(1)
+		return &auth.ResolvedServiceAccountCreds{}, nil
+	})
 
 	tokenA1, err := env.cachedTokenGetter.GetToken(ctx, "api.example", credsA, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, "token-1", tokenA1)
+	tassert.Equal(t, int64(1), callsA.Load())
+	tassert.Equal(t, int64(0), callsB.Load())
 
 	tokenB1, err := env.cachedTokenGetter.GetToken(ctx, "api.example", credsB, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, "token-2", tokenB1)
+	tassert.Equal(t, int64(1), callsA.Load())
+	tassert.Equal(t, int64(1), callsB.Load())
 
 	tassert.Equal(t, int64(2), env.fakeTokenExchanger.Calls.Load())
 
@@ -113,42 +127,14 @@ func TestGetToken_SeparateCacheEntriesPerSubjectCreds(t *testing.T) {
 	tassert.NoError(t, err)
 	tassert.Equal(t, tokenA1, tokA2)
 	tassert.Equal(t, int64(2), env.fakeTokenExchanger.Calls.Load())
-}
-
-func TestGetToken_InvalidSubjectCreds_ReturnsError(t *testing.T) {
-	t.Parallel()
-	env := newTokenTestEnv(t)
-
-	_, err := env.cachedTokenGetter.GetToken(env.ctx, "api.example", "not a json", nil)
-	tassert.Error(t, err)
+	tassert.Equal(t, int64(1), callsA.Load())
+	tassert.Equal(t, int64(1), callsB.Load())
 }
 
 func addSecondsToClock(clk *clocktesting.FakeClock, second time.Duration) {
 	clk.SetTime(clk.Now().Add(second * time.Second))
 }
 
-func TestGetToken_SeparateCacheEntriesPerApiDomain(t *testing.T) {
-	t.Parallel()
-	env := newTokenTestEnv(t)
-	ctx := env.ctx
-	creds := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-A")
-
-	tokA1, err := env.cachedTokenGetter.GetToken(ctx, "api.one", creds, nil)
-	tassert.NoError(t, err)
-	tassert.Equal(t, "token-1", tokA1)
-
-	tokB1, err := env.cachedTokenGetter.GetToken(ctx, "api.two", creds, nil)
-	tassert.NoError(t, err)
-	tassert.Equal(t, "token-2", tokB1)
-	tassert.NotEqual(t, tokA1, tokB1)
-	tassert.Equal(t, int64(2), env.fakeTokenExchanger.Calls.Load())
-
-	tokA2, err := env.cachedTokenGetter.GetToken(ctx, "api.one", creds, nil)
-	tassert.NoError(t, err)
-	tassert.Equal(t, tokA1, tokA2)
-	tassert.Equal(t, int64(2), env.fakeTokenExchanger.Calls.Load())
-}
-
 func TestGetToken_LRUEviction(t *testing.T) {
 	t.Parallel()
 	clk := clocktesting.NewFakeClock(time.Unix(0, 0))
@@ -156,28 +142,30 @@ func TestGetToken_LRUEviction(t *testing.T) {
 	svc, err := NewCachedTokenGetter(2, ex, clk)
 	tassert.NoError(t, err)
 	ctx := context.Background()
-	creds := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-A")
+	creds1 := auth.NewCredentialRequest("key-1", dummyResolveFunc)
+	creds2 := auth.NewCredentialRequest("key-2", dummyResolveFunc)
+	creds3 := auth.NewCredentialRequest("key-3", dummyResolveFunc)
 
-	tok1, err := svc.GetToken(ctx, "api.first", creds, nil)
+	tok1, err := svc.GetToken(ctx, "api.example", creds1, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, "token-1", tok1)
 
-	tok2, err := svc.GetToken(ctx, "api.second", creds, nil)
+	tok2, err := svc.GetToken(ctx, "api.example", creds2, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, "token-2", tok2)
 	tassert.Equal(t, int64(2), ex.Calls.Load())
 
-	tok1again, err := svc.GetToken(ctx, "api.first", creds, nil)
+	tok1again, err := svc.GetToken(ctx, "api.example", creds1, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, tok1, tok1again)
 	tassert.Equal(t, int64(2), ex.Calls.Load())
 
-	tok3, err := svc.GetToken(ctx, "api.third", creds, nil)
+	tok3, err := svc.GetToken(ctx, "api.example", creds3, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, "token-3", tok3)
 	tassert.Equal(t, int64(3), ex.Calls.Load())
 
-	secondAgain, err := svc.GetToken(ctx, "api.second", creds, nil)
+	secondAgain, err := svc.GetToken(ctx, "api.example", creds2, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, "token-4", secondAgain)
 	tassert.Equal(t, int64(4), ex.Calls.Load())
@@ -187,50 +175,65 @@ func TestGetToken_AfterExpiration_Refreshes(t *testing.T) {
 	t.Parallel()
 	env := newTokenTestEnv(t)
 	ctx := env.ctx
-	creds := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-A")
-
+	var resolveCalls atomic.Int64
+	creds := auth.NewCredentialRequest("key", func(ctx context.Context) (auth.TokenExchangeCredentials, error) {
+		resolveCalls.Add(1)
+		return &auth.ResolvedServiceAccountCreds{}, nil
+	})
 	_, err := env.cachedTokenGetter.GetToken(ctx, "api.example", creds, nil)
 	tassert.NoError(t, err)
 	addSecondsToClock(env.clk, 101)
+	tassert.Equal(t, int64(1), resolveCalls.Load())
 
 	tok2, err := env.cachedTokenGetter.GetToken(ctx, "api.example", creds, nil)
 	tassert.NoError(t, err)
 	tassert.Equal(t, int64(2), env.fakeTokenExchanger.Calls.Load())
+	tassert.Equal(t, int64(2), resolveCalls.Load())
 	tassert.Equal(t, "token-2", tok2)
 }
 
-func TestGetToken_CacheKeyChangesOnKeyRotation(t *testing.T) {
-	t.Parallel()
-	env := newTokenTestEnv(t)
-	ctx := env.ctx
-
-	base := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-A")
-	rotatedKeyID := buildSubjectCredsJSON(t, "priv-A", "kid-B", "sa-A")
-	rotatedPriv := buildSubjectCredsJSON(t, "priv-B", "kid-A", "sa-A")
-	rotatedSubject := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-B")
-
-	t1, _ := env.cachedTokenGetter.GetToken(ctx, "api", base, nil)
-	t2, _ := env.cachedTokenGetter.GetToken(ctx, "api", rotatedKeyID, nil)
-	t3, _ := env.cachedTokenGetter.GetToken(ctx, "api", rotatedPriv, nil)
-	t4, _ := env.cachedTokenGetter.GetToken(ctx, "api", rotatedSubject, nil)
-
-	tassert.NotEqual(t, t1, t2)
-	tassert.NotEqual(t, t1, t3)
-	tassert.NotEqual(t, t1, t4)
-	tassert.Equal(t, int64(4), env.fakeTokenExchanger.Calls.Load())
-}
-
 func TestGetToken_ExchangerErrorIsWrapped(t *testing.T) {
 	t.Parallel()
 	clk := clocktesting.NewFakeClock(time.Unix(0, 0))
 	svc, err := NewCachedTokenGetter(10, &iam.FakeTokenExchanger{ReturnError: true}, clk)
 	trequire.NoError(t, err)
 
-	_, err = svc.GetToken(context.Background(), "api", buildSubjectCredsJSON(t, "p", "k", "s"), nil)
+	var resolveCalls atomic.Int64
+	creds := auth.NewCredentialRequest("key", func(ctx context.Context) (auth.TokenExchangeCredentials, error) {
+		resolveCalls.Add(1)
+		return &auth.ResolvedServiceAccountCreds{}, nil
+	})
+	_, err = svc.GetToken(context.Background(), "api", creds, nil)
 	tassert.Error(t, err)
+	tassert.Equal(t, int64(1), resolveCalls.Load())
 	tassert.Contains(t, err.Error(), "could not exchange creds to iam token:")
 }
 
+func TestGetToken_ResolveErrorIsNotCached(t *testing.T) {
+	t.Parallel()
+	env := newTokenTestEnv(t)
+
+	resolveErr := errors.New("resolve credentials")
+	var resolveCalls atomic.Int64
+	credentialsRequest := auth.NewCredentialRequest("key", func(_ context.Context) (auth.TokenExchangeCredentials, error) {
+		if resolveCalls.Add(1) == 1 {
+			return nil, resolveErr
+		}
+		return &auth.ResolvedServiceAccountCreds{}, nil
+	})
+
+	_, err := env.cachedTokenGetter.GetToken(env.ctx, "api.example", credentialsRequest, nil)
+	trequire.ErrorIs(t, err, resolveErr)
+	tassert.Equal(t, int64(1), resolveCalls.Load())
+	tassert.Equal(t, int64(0), env.fakeTokenExchanger.Calls.Load())
+
+	token, err := env.cachedTokenGetter.GetToken(env.ctx, "api.example", credentialsRequest, nil)
+	trequire.NoError(t, err)
+	tassert.Equal(t, "token-1", token)
+	tassert.Equal(t, int64(2), resolveCalls.Load())
+	tassert.Equal(t, int64(1), env.fakeTokenExchanger.Calls.Load())
+}
+
 func TestGetToken_Singleflight_DedupesConcurrentSameKey(t *testing.T) {
 	t.Parallel()
 	clk := clocktesting.NewFakeClock(time.Unix(0, 0))
@@ -238,7 +241,11 @@ func TestGetToken_Singleflight_DedupesConcurrentSameKey(t *testing.T) {
 	svc, err := NewCachedTokenGetter(10, ex, clk)
 	trequire.NoError(t, err)
 
-	creds := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-A")
+	var resolveCalls atomic.Int64
+	creds := auth.NewCredentialRequest("key", func(ctx context.Context) (auth.TokenExchangeCredentials, error) {
+		resolveCalls.Add(1)
+		return &auth.ResolvedServiceAccountCreds{}, nil
+	})
 
 	const n = 50
 	start := make(chan struct{})
@@ -266,6 +273,7 @@ func TestGetToken_Singleflight_DedupesConcurrentSameKey(t *testing.T) {
 		tassert.Equal(t, tokens[0], tokens[i])
 	}
 	tassert.Equal(t, int64(1), ex.Calls.Load())
+	tassert.Equal(t, int64(1), resolveCalls.Load())
 }
 
 func TestGetToken_ConcurrentDifferentKeys_NoRaceAndWorks(t *testing.T) {
@@ -275,8 +283,6 @@ func TestGetToken_ConcurrentDifferentKeys_NoRaceAndWorks(t *testing.T) {
 	svc, err := NewCachedTokenGetter(2, ex, clk)
 	trequire.NoError(t, err)
 
-	creds := buildSubjectCredsJSON(t, "priv-A", "kid-A", "sa-A")
-
 	const n = 50
 	start := make(chan struct{})
 	var wg sync.WaitGroup
@@ -286,8 +292,8 @@ func TestGetToken_ConcurrentDifferentKeys_NoRaceAndWorks(t *testing.T) {
 		go func() {
 			defer wg.Done()
 			<-start
-			domain := "api." + strconv.Itoa(i%5)
-			_, err := svc.GetToken(context.Background(), domain, creds, nil)
+			creds := auth.NewCredentialRequest("key-"+strconv.Itoa(i%5), dummyResolveFunc)
+			_, err := svc.GetToken(context.Background(), "api.example", creds, nil)
 			tassert.NoError(t, err)
 		}()
 	}

+ 55 - 5
providers/v1/nebius/mysterybox/validation.go

@@ -37,15 +37,18 @@ const (
 	errMissingProvider                         = "storeSpec is missing provider"
 	errInvalidProvider                         = "invalid provider spec. Missing nebiusmysterybox field in store %s"
 	errMissingAuthOptions                      = "invalid auth configuration: none provided"
-	errInvalidAuthConfig                       = "invalid auth configuration: exactly one must be specified"
+	errTooManyAuthConfigs                      = "invalid auth configuration: exactly one must be specified"
 	errInvalidTokenAuthConfig                  = "invalid token auth configuration: no secret key specified"
 	errInvalidSACredsAuthConfig                = "invalid ServiceAccount creds auth configuration: no secret key specified"
+	errEmptyWorkloadIdentityIAMAccount         = "empty workload identity configuration: Nebius IAM service account ID must be specified if kubernetes service account is specified"
+	errInvalidWorkloadIdentityServiceAccount   = "invalid workload identity configuration: Kubernetes service account must be specified for workload identity auth option"
 	errFailedToRetrieveToken                   = "failed to retrieve iam token by credentials: %w"
 	errMissingAPIDomain                        = "API domain must be set"
 	errInvalidAPIDomain                        = "API domain is not valid"
 	errInvalidAPIDomainWithError               = errInvalidAPIDomain + ": %w"
 	errInvalidCertificateConfigNoNameSpecified = "invalid certificate configuration: no name specified"
 	errInvalidCertificateConfigNoKeySpecified  = "invalid certificate configuration: no key specified"
+	errCustomAudienceIsNotAllowed              = "custom audiences are not allowed for Nebius provider"
 )
 
 var labelRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
@@ -66,6 +69,11 @@ func (p *Provider) ValidateStore(store esv1.GenericStore) (admission.Warnings, e
 	if err := validateProviderAuth(provider); err != nil {
 		return nil, err
 	}
+	if provider.Auth.WorkloadIdentity != nil {
+		if err := esutils.ValidateServiceAccountSelector(store, *provider.Auth.WorkloadIdentity.ServiceAccountRef); err != nil {
+			return nil, err
+		}
+	}
 
 	var selectors []*esmeta.SecretKeySelector
 
@@ -114,21 +122,56 @@ func getNebiusMysteryboxProvider(store esv1.GenericStore) (*esv1.NebiusMysterybo
 }
 
 func validateProviderAuth(provider *esv1.NebiusMysteryboxProvider) error {
-	if provider.Auth.Token.Name == "" && provider.Auth.ServiceAccountCreds.Name == "" {
+	specifiedAuthMethods := 0
+	if provider.Auth.Token.Name != "" {
+		specifiedAuthMethods++
+	}
+	if provider.Auth.ServiceAccountCreds.Name != "" {
+		specifiedAuthMethods++
+	}
+	if provider.Auth.WorkloadIdentity != nil {
+		specifiedAuthMethods++
+	}
+
+	if specifiedAuthMethods == 0 {
 		return errors.New(errMissingAuthOptions)
 	}
-	if provider.Auth.Token.Name != "" && provider.Auth.ServiceAccountCreds.Name != "" {
-		return errors.New(errInvalidAuthConfig)
+	if specifiedAuthMethods != 1 {
+		return errors.New(errTooManyAuthConfigs)
 	}
+
 	if provider.Auth.Token.Name != "" && provider.Auth.Token.Key == "" {
 		return errors.New(errInvalidTokenAuthConfig)
 	}
-	if provider.Auth.ServiceAccountCreds.Name != "" && provider.Auth.ServiceAccountCreds.Key == "" {
+	if err := validateServiceAccountCredentialsAuth(provider.Auth.ServiceAccountCreds); err != nil {
+		return err
+	}
+
+	return validateWorkloadIdentityAuth(provider.Auth.WorkloadIdentity)
+}
+
+func validateServiceAccountCredentialsAuth(credentials esmeta.SecretKeySelector) error {
+	if credentials.Name != "" && credentials.Key == "" {
 		return errors.New(errInvalidSACredsAuthConfig)
 	}
+
 	return nil
 }
 
+func validateWorkloadIdentityAuth(workloadIdentity *esv1.NebiusWorkloadIdentity) error {
+	if workloadIdentity == nil {
+		return nil
+	}
+	if workloadIdentity.ServiceAccountRef == nil || workloadIdentity.ServiceAccountRef.Name == "" {
+		return errors.New(errInvalidWorkloadIdentityServiceAccount)
+	}
+	if strings.TrimSpace(workloadIdentity.IAMServiceAccountID) == "" {
+		return errors.New(errEmptyWorkloadIdentityIAMAccount)
+	}
+
+	return validateAudiences(workloadIdentity.ServiceAccountRef.Audiences)
+}
+
 func (p *Provider) validateAPIDomain(apiDomain string) error {
 	if strings.TrimSpace(apiDomain) == "" {
 		return errors.New(errMissingAPIDomain)
@@ -196,3 +239,10 @@ func validateCertificate(certificate *esmeta.SecretKeySelector) error {
 	}
 	return nil
 }
+
+func validateAudiences(audiences []string) error {
+	if audiences != nil && len(audiences) != 0 {
+		return errors.New(errCustomAudienceIsNotAllowed)
+	}
+	return nil
+}

+ 76 - 2
providers/v1/nebius/mysterybox/validation_test.go

@@ -72,13 +72,23 @@ func TestValidateStore(t *testing.T) {
 			wantErr: errMissingAuthOptions,
 		},
 		{
-			name: "invalid auth: both provided",
+			name: "invalid auth: 2 provided",
 			store: mkStore(func(s *esv1.SecretStore) {
 				nm := s.Spec.Provider.NebiusMysterybox
 				nm.Auth.Token = esmeta.SecretKeySelector{Name: "a", Key: "k"}
 				nm.Auth.ServiceAccountCreds = esmeta.SecretKeySelector{Name: "b", Key: "k"}
 			}),
-			wantErr: errInvalidAuthConfig,
+			wantErr: errTooManyAuthConfigs,
+		},
+		{
+			name: "invalid auth: 3 provided",
+			store: mkStore(func(s *esv1.SecretStore) {
+				nm := s.Spec.Provider.NebiusMysterybox
+				nm.Auth.Token = esmeta.SecretKeySelector{Name: "a", Key: "k"}
+				nm.Auth.ServiceAccountCreds = esmeta.SecretKeySelector{Name: "b", Key: "k"}
+				nm.Auth.WorkloadIdentity = &esv1.NebiusWorkloadIdentity{}
+			}),
+			wantErr: errTooManyAuthConfigs,
 		},
 		{
 			name: "invalid token auth: missing key",
@@ -191,6 +201,45 @@ func TestValidateStore(t *testing.T) {
 			}),
 			wantErr: "",
 		},
+		{
+			name: "correct workload identity config",
+			store: mkStore(func(s *esv1.SecretStore) {
+				nm := s.Spec.Provider.NebiusMysterybox
+				nm.Auth.WorkloadIdentity = &esv1.NebiusWorkloadIdentity{
+					ServiceAccountRef: &esmeta.ServiceAccountSelector{
+						Name: "serviceaccount",
+					},
+					IAMServiceAccountID: "serviceaccount-e00test",
+				}
+			}),
+			wantErr: "",
+		},
+		{
+			name: "workload identity config missing IAM service account id",
+			store: mkStore(func(s *esv1.SecretStore) {
+				nm := s.Spec.Provider.NebiusMysterybox
+				nm.Auth.WorkloadIdentity = &esv1.NebiusWorkloadIdentity{
+					ServiceAccountRef: &esmeta.ServiceAccountSelector{
+						Name: "serviceaccount",
+					},
+				}
+			}),
+			wantErr: errEmptyWorkloadIdentityIAMAccount,
+		},
+		{
+			name: "workload identity config with custom audiences not allowed",
+			store: mkStore(func(s *esv1.SecretStore) {
+				nm := s.Spec.Provider.NebiusMysterybox
+				nm.Auth.WorkloadIdentity = &esv1.NebiusWorkloadIdentity{
+					ServiceAccountRef: &esmeta.ServiceAccountSelector{
+						Name:      "serviceaccount",
+						Audiences: []string{"custom.audience"},
+					},
+					IAMServiceAccountID: "serviceaccount-e00test",
+				}
+			}),
+			wantErr: errCustomAudienceIsNotAllowed,
+		},
 	}
 
 	for _, tt := range tests {
@@ -257,6 +306,31 @@ func TestValidateStoreClusterScope(t *testing.T) {
 			}),
 			wantErr: "",
 		},
+		{
+			name: "cluster: sa ref selector requires namespace",
+			store: makeStore(func(nm *esv1.NebiusMysteryboxProvider) {
+				nm.Auth.WorkloadIdentity = &esv1.NebiusWorkloadIdentity{
+					ServiceAccountRef: &esmeta.ServiceAccountSelector{
+						Name: "serviceaccount",
+					},
+					IAMServiceAccountID: "serviceaccount-e00test",
+				}
+			}),
+			wantErr: utilsErrRequireNamespace,
+		},
+		{
+			name: "cluster: namespaced sa ref passes",
+			store: makeStore(func(nm *esv1.NebiusMysteryboxProvider) {
+				nm.Auth.WorkloadIdentity = &esv1.NebiusWorkloadIdentity{
+					ServiceAccountRef: &esmeta.ServiceAccountSelector{
+						Name:      "serviceaccount",
+						Namespace: new("ns1"),
+					},
+					IAMServiceAccountID: "serviceaccount-e00test",
+				}
+			}),
+			wantErr: "",
+		},
 		{
 			name: "cluster: ca cert requires namespace",
 			store: makeStore(func(nm *esv1.NebiusMysteryboxProvider) {

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

@@ -626,6 +626,12 @@ spec:
           key: string
           name: string
           namespace: string
+        workloadIdentity:
+          iamServiceAccountID: "serviceaccount-e00example"
+          serviceAccountRef:
+            audiences: [] # minItems 0 of type string
+            name: string
+            namespace: string
       caProvider:
         certSecretRef:
           key: string

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

@@ -626,6 +626,12 @@ spec:
           key: string
           name: string
           namespace: string
+        workloadIdentity:
+          iamServiceAccountID: "serviceaccount-e00example"
+          serviceAccountRef:
+            audiences: [] # minItems 0 of type string
+            name: string
+            namespace: string
       caProvider:
         certSecretRef:
           key: string