dodopayments 2.35.0 → 2.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 759472ef7ceda29e93921ba3842b3d7f64e23700c06195f12636ac0758c38ea2
4
- data.tar.gz: 966be0a02c69b7f86643da56082d9a478199a658b2865a591b62684dbe294170
3
+ metadata.gz: c880e2fe0384012ad81cbc198006a6ee65af759203c8bbce66e8030039bb18d5
4
+ data.tar.gz: 5ab427e23feeba04ec23161257c215986f89ffea690e95cf45508d9eab3f174b
5
5
  SHA512:
6
- metadata.gz: 9ee34c03c5070b13a2631a9a85d1e21477206be0f943dba2f08b6138949891e6eb68bec0778bb1adc4df471e6c1c89963c158c29606cd166263c66fe7b2830c5
7
- data.tar.gz: b64bd1a280eb380543f5ec863b6034f4960b3fe886e1a684b4dd3324ef313b8ca3daca25a4da406ba654c2b44397539d690da6fcc424fb2f847bdb3a06d483dc
6
+ metadata.gz: 515ea4d5db9220dd321658f0f88c662e26203336402af529e1ea158bbad6ec30cef9e29f6225f7fac3235682e46a9b2aefb175c0de8f8f286f7e9ea310503303
7
+ data.tar.gz: cab907a551a55c24c492e28287b20e8f13e336ddc1fe2333e3357ed1229b0505e69d003c0b4802c1b0a1e58ad046fb54f410dceea6718b06d10c0ed9061e30f2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.37.0](https://github.com/dodopayments/dodopayments-ruby/compare/v2.36.0...v2.37.0) (2026-10-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * **api:** regenerate SDK from latest OpenAPI spec ([085b68e](https://github.com/dodopayments/dodopayments-ruby/commit/085b68ebf669f2e35556f322b309cc985172789c))
9
+ * **api:** regenerate SDK from latest OpenAPI spec ([f9d6fe6](https://github.com/dodopayments/dodopayments-ruby/commit/f9d6fe6886183baafb6256b064e50b70bc294fe0))
10
+
11
+ ## [2.36.0](https://github.com/dodopayments/dodopayments-ruby/compare/v2.35.0...v2.36.0) (2026-10-28)
12
+
13
+
14
+ ### Features
15
+
16
+ * **api:** regenerate SDK from latest OpenAPI spec ([8f4451c](https://github.com/dodopayments/dodopayments-ruby/commit/8f4451cf4bee3e5ed70f7d83b0e59ca1ca822a1e))
17
+ * **api:** regenerate SDK from latest OpenAPI spec ([3089c6d](https://github.com/dodopayments/dodopayments-ruby/commit/3089c6df767756323fc412e46b4c78100727dd0b))
18
+ * **api:** regenerate SDK from latest OpenAPI spec ([075d355](https://github.com/dodopayments/dodopayments-ruby/commit/075d35555b354afd7d6b3a4d16d1924805c28b4a))
19
+
3
20
  ## [2.35.0](https://github.com/dodopayments/dodopayments-ruby/compare/v2.34.0...v2.35.0) (2026-10-24)
4
21
 
5
22
 
data/README.md CHANGED
@@ -23,7 +23,7 @@ To use this gem, install via Bundler by adding the following to your application
23
23
  <!-- x-release-please-start-version -->
24
24
 
25
25
  ```ruby
26
- gem "dodopayments", "~> 2.35.0"
26
+ gem "dodopayments", "~> 2.37.0"
27
27
  ```
28
28
 
29
29
  <!-- x-release-please-end -->
@@ -72,6 +72,26 @@ if page.next_page?
72
72
  end
73
73
  ```
74
74
 
75
+ ### Workload identity
76
+
77
+ Instead of an API key, the client can exchange an OIDC token (JWT) from your platform for a short-lived access token. The client caches the access token and gets a new one when 5 minutes or less remain.
78
+
79
+ ```ruby
80
+ dodo_payments = Dodopayments::Client.new(
81
+ workload_identity: Dodopayments::WorkloadIdentity::Config.new(
82
+ identity_provider_id: "wip_...", # defaults to ENV["DODO_PAYMENTS_IDENTITY_PROVIDER_ID"]
83
+ provider: Dodopayments::WorkloadIdentity.github_actions
84
+ )
85
+ )
86
+
87
+ # Revoke the cached access token, for example at the end of a job.
88
+ dodo_payments.workload_identity.revoke
89
+ ```
90
+
91
+ The `Dodopayments::WorkloadIdentity` module has these token sources: `github_actions`, `gitlab`, `terraform_cloud`, `gcp`, `vercel`, `kubernetes`, `file`, `aws` (wraps your function), `static`, and `auto`. A `provider` can also be any object that responds to `call` and returns the JWT. `vercel` gets a Vercel OIDC token with the Dodo Payments audience from the Vercel token endpoint.
92
+
93
+ Do not set `bearer_token` together with `workload_identity`. If you set neither, and `ENV["DODO_PAYMENTS_API_KEY"]` is not set, the client reads `ENV["DODO_PAYMENTS_IDENTITY_PROVIDER_ID"]` and uses the token source that `auto` finds.
94
+
75
95
  ### Handling errors
76
96
 
77
97
  When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `Dodopayments::Errors::APIError` will be thrown:
@@ -21,12 +21,17 @@ module Dodopayments
21
21
  # rubocop:enable Style/MutableConstant
22
22
 
23
23
  # Bearer Token for API authentication
24
- # @return [String]
24
+ # @return [String, nil]
25
25
  attr_reader :bearer_token
26
26
 
27
27
  # @return [String, nil]
28
28
  attr_reader :webhook_key
29
29
 
30
+ # Workload identity session, or `nil` if the client uses a bearer token.
31
+ #
32
+ # @return [Dodopayments::WorkloadIdentity::Session, nil]
33
+ attr_reader :workload_identity
34
+
30
35
  # @return [Dodopayments::Resources::CheckoutSessions]
31
36
  attr_reader :checkout_sessions
32
37
 
@@ -114,12 +119,114 @@ module Dodopayments
114
119
  {"authorization" => "Bearer #{@bearer_token}"}
115
120
  end
116
121
 
122
+ # @api private
123
+ #
124
+ # With workload identity, a 401 response clears the cached access token. The
125
+ # client then gets a new access token and sends the request one more time.
126
+ #
127
+ # @param req [Hash{Symbol=>Object}]
128
+ #
129
+ # @raise [Dodopayments::Errors::APIError]
130
+ # @return [Object]
131
+ def request(req)
132
+ return super if @workload_identity.nil?
133
+
134
+ token = @workload_identity.access_token
135
+ begin
136
+ super(workload_identity_authorize(req, token))
137
+ rescue Dodopayments::Errors::AuthenticationError
138
+ @workload_identity.invalidate(token)
139
+ super(workload_identity_authorize(req, @workload_identity.access_token))
140
+ end
141
+ end
142
+
143
+ # @api private
144
+ #
145
+ # @param req [Hash{Symbol=>Object}]
146
+ # @param token [String]
147
+ #
148
+ # @return [Hash{Symbol=>Object}]
149
+ private def workload_identity_authorize(req, token)
150
+ {**req, headers: {**req[:headers].to_h, "authorization" => "Bearer #{token}"}}
151
+ end
152
+
153
+ # @api private
154
+ #
155
+ # Sends a workload identity request without the client credentials. It retries
156
+ # connection errors and 5xx responses, but not 4xx responses.
157
+ #
158
+ # @param method [Symbol]
159
+ # @param path [String]
160
+ # @param headers [Hash{String=>String}]
161
+ # @param body [Object, nil]
162
+ #
163
+ # @raise [Dodopayments::Errors::APIError]
164
+ # @return [Object]
165
+ private def workload_identity_request(method:, path:, headers:, body:)
166
+ url = Dodopayments::Internal::Util.join_parsed_uri(@base_url_components, {path: path})
167
+ headers = Dodopayments::Internal::Util.normalized_headers(@headers.except("authorization"), headers)
168
+ encoded = nil
169
+ if body.nil?
170
+ headers.delete("content-type")
171
+ else
172
+ headers, encoded = Dodopayments::Internal::Util.encode_content(headers, body)
173
+ end
174
+ request = {method: method, url: url, headers: headers, body: encoded, max_retries: 0, timeout: @timeout}
175
+
176
+ retry_count = 0
177
+ begin
178
+ _, response, stream = send_request(
179
+ request,
180
+ redirect_count: 0,
181
+ retry_count: retry_count,
182
+ send_retry_header: true
183
+ )
184
+ rescue Dodopayments::Errors::APIConnectionError, Dodopayments::Errors::InternalServerError => e
185
+ if retry_count < @max_retries
186
+ sleep(retry_delay(e.headers.to_h, retry_count: retry_count))
187
+ retry_count += 1
188
+ retry
189
+ end
190
+ raise workload_identity_error(e)
191
+ rescue Dodopayments::Errors::APIStatusError => e
192
+ raise workload_identity_error(e)
193
+ end
194
+
195
+ response_headers = Dodopayments::Internal::Util.normalized_headers(response.each_header.to_h)
196
+ Dodopayments::Internal::Util.decode_content(response_headers, stream: stream, suppress_error: true)
197
+ end
198
+
199
+ # @api private
200
+ #
201
+ # @param error [Dodopayments::Errors::APIError]
202
+ #
203
+ # @return [Dodopayments::Errors::APIError]
204
+ private def workload_identity_error(error)
205
+ return error unless error.is_a?(Dodopayments::Errors::APIStatusError)
206
+
207
+ body = error.body
208
+ details = body.is_a?(Hash) ? [body[:error], body[:error_description]].compact : []
209
+ message = ["Workload identity request failed with status #{error.status}", *details].join(": ")
210
+ Dodopayments::Errors::APIStatusError.for(
211
+ url: error.url,
212
+ status: error.status.to_i,
213
+ headers: error.headers,
214
+ body: body,
215
+ request: nil,
216
+ response: nil,
217
+ message: message
218
+ )
219
+ end
220
+
117
221
  # Creates and returns a new client for interacting with the API.
118
222
  #
119
223
  # @param bearer_token [String, nil] Bearer Token for API authentication Defaults to `ENV["DODO_PAYMENTS_API_KEY"]`
120
224
  #
121
225
  # @param webhook_key [String, nil] Defaults to `ENV["DODO_PAYMENTS_WEBHOOK_KEY"]`
122
226
  #
227
+ # @param workload_identity [Dodopayments::WorkloadIdentity::Config, Hash{Symbol=>Object}, nil] Exchange an OIDC token for an access token, instead of a bearer token. See
228
+ # {Dodopayments::WorkloadIdentity}.
229
+ #
123
230
  # @param environment [:live_mode, :test_mode, nil] Specifies the environment to use for the API.
124
231
  #
125
232
  # Each environment maps to a different base URL:
@@ -138,8 +245,9 @@ module Dodopayments
138
245
  #
139
246
  # @param max_retry_delay [Float]
140
247
  def initialize(
141
- bearer_token: ENV["DODO_PAYMENTS_API_KEY"],
248
+ bearer_token: nil,
142
249
  webhook_key: ENV["DODO_PAYMENTS_WEBHOOK_KEY"],
250
+ workload_identity: nil,
143
251
  environment: nil,
144
252
  base_url: ENV["DODO_PAYMENTS_BASE_URL"],
145
253
  max_retries: self.class::DEFAULT_MAX_RETRIES,
@@ -152,7 +260,17 @@ module Dodopayments
152
260
  raise ArgumentError.new(message)
153
261
  end
154
262
 
155
- if bearer_token.nil?
263
+ unless bearer_token.nil? || workload_identity.nil?
264
+ raise ArgumentError.new("bearer_token and workload_identity cannot be used together")
265
+ end
266
+ bearer_token = ENV["DODO_PAYMENTS_API_KEY"] if bearer_token.nil? && workload_identity.nil?
267
+ if bearer_token.nil? && workload_identity.nil? && !ENV["DODO_PAYMENTS_IDENTITY_PROVIDER_ID"].to_s.empty?
268
+ provider = Dodopayments::WorkloadIdentity.auto
269
+ workload_identity = {provider: provider} unless provider.nil?
270
+ end
271
+ workload_identity = Dodopayments::WorkloadIdentity::Config.coerce(workload_identity) unless workload_identity.nil?
272
+
273
+ if bearer_token.nil? && workload_identity.nil?
156
274
  raise ArgumentError.new("bearer_token is required, and can be set via environ: \"DODO_PAYMENTS_API_KEY\"")
157
275
  end
158
276
 
@@ -169,7 +287,7 @@ module Dodopayments
169
287
  headers = parsed.merge(headers)
170
288
  end
171
289
 
172
- @bearer_token = bearer_token.to_s
290
+ @bearer_token = bearer_token&.to_s
173
291
  @webhook_key = webhook_key&.to_s
174
292
 
175
293
  super(
@@ -207,6 +325,17 @@ module Dodopayments
207
325
  @entitlements = Dodopayments::Resources::Entitlements.new(client: self)
208
326
  @product_collections = Dodopayments::Resources::ProductCollections.new(client: self)
209
327
  @moderation = Dodopayments::Resources::Moderation.new(client: self)
328
+
329
+ @workload_identity = nil
330
+ return if workload_identity.nil?
331
+ @workload_identity = Dodopayments::WorkloadIdentity::Session.new(
332
+ config: workload_identity,
333
+ context: Dodopayments::WorkloadIdentity::Context.new(
334
+ base_url: @base_url,
335
+ audience: Dodopayments::Internal::Util.uri_origin(@base_url)
336
+ ),
337
+ transport: method(:workload_identity_request)
338
+ )
210
339
  end
211
340
  end
212
341
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dodopayments
4
- VERSION = "2.35.0"
4
+ VERSION = "2.37.0"
5
5
  end
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dodopayments
4
+ # Workload identity federation: exchange a short-lived OIDC token (JWT) from
5
+ # your platform for a short-lived API access token.
6
+ #
7
+ # @example
8
+ # client = Dodopayments::Client.new(
9
+ # workload_identity: Dodopayments::WorkloadIdentity::Config.new(
10
+ # identity_provider_id: "wip_...",
11
+ # provider: Dodopayments::WorkloadIdentity.github_actions
12
+ # )
13
+ # )
14
+ module WorkloadIdentity
15
+ # @api private
16
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
17
+
18
+ # @api private
19
+ SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
20
+
21
+ # The client refreshes the access token when this many seconds or less remain.
22
+ REFRESH_WINDOW_IN_SECONDS = 300
23
+
24
+ # @api private
25
+ PROVIDER_TIMEOUT_IN_SECONDS = 10
26
+
27
+ # @api private
28
+ KUBERNETES_TOKEN_PATH = "/var/run/secrets/tokens/dodo-token"
29
+
30
+ # @api private
31
+ GCP_METADATA_URL =
32
+ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity"
33
+
34
+ # @api private
35
+ VERCEL_TOKEN_URL = "https://oidc.vercel.com/~token"
36
+
37
+ # The values that a subject token provider gets on each token exchange.
38
+ class Context
39
+ # The base URL of the client.
40
+ # @return [URI::Generic]
41
+ attr_reader :base_url
42
+
43
+ # The default audience: the origin of the client base URL.
44
+ # @return [String]
45
+ attr_reader :audience
46
+
47
+ # @api private
48
+ #
49
+ # @param base_url [URI::Generic]
50
+ # @param audience [String]
51
+ def initialize(base_url:, audience:)
52
+ @base_url = base_url
53
+ @audience = audience
54
+ end
55
+ end
56
+
57
+ # The `workload_identity` option of {Dodopayments::Client}.
58
+ class Config
59
+ # @return [String]
60
+ attr_reader :identity_provider_id
61
+
62
+ # @return [#call]
63
+ attr_reader :provider
64
+
65
+ # @param provider [#call] Returns the subject token (a JWT string). It can take one
66
+ # argument, a {Dodopayments::WorkloadIdentity::Context}.
67
+ #
68
+ # @param identity_provider_id [String, nil] Defaults to `ENV["DODO_PAYMENTS_IDENTITY_PROVIDER_ID"]`
69
+ def initialize(provider:, identity_provider_id: ENV["DODO_PAYMENTS_IDENTITY_PROVIDER_ID"])
70
+ if identity_provider_id.nil? || identity_provider_id.to_s.empty?
71
+ raise ArgumentError.new(
72
+ "identity_provider_id is required, and can be set via environ: \"DODO_PAYMENTS_IDENTITY_PROVIDER_ID\""
73
+ )
74
+ end
75
+ unless provider.respond_to?(:call)
76
+ raise ArgumentError.new("provider must respond to `call`")
77
+ end
78
+
79
+ @identity_provider_id = identity_provider_id.to_s
80
+ @provider = provider
81
+ end
82
+
83
+ # @api private
84
+ #
85
+ # @param value [Dodopayments::WorkloadIdentity::Config, Hash{Symbol=>Object}]
86
+ #
87
+ # @return [Dodopayments::WorkloadIdentity::Config]
88
+ def self.coerce(value)
89
+ case value
90
+ in Dodopayments::WorkloadIdentity::Config
91
+ value
92
+ in Hash
93
+ new(**value)
94
+ else
95
+ raise ArgumentError.new("workload_identity must be a #{name} or a Hash, got #{value.class}")
96
+ end
97
+ end
98
+
99
+ # @return [String]
100
+ def inspect = "#<#{self.class.name} identity_provider_id=#{@identity_provider_id}>"
101
+ end
102
+
103
+ # Caches the access token of one client. All methods are thread-safe.
104
+ class Session
105
+ # @return [String]
106
+ attr_reader :identity_provider_id
107
+
108
+ # @api private
109
+ #
110
+ # @param config [Dodopayments::WorkloadIdentity::Config]
111
+ # @param context [Dodopayments::WorkloadIdentity::Context]
112
+ # @param transport [Proc] Sends a request without the client credentials.
113
+ def initialize(config:, context:, transport:)
114
+ @identity_provider_id = config.identity_provider_id
115
+ @provider = config.provider
116
+ @context = context
117
+ @transport = transport
118
+ @mutex = Mutex.new
119
+ @access_token = nil
120
+ @expires_at = nil
121
+ end
122
+
123
+ # Returns a valid access token. This method does a token exchange if no token is
124
+ # cached, or if 5 minutes or less remain. Concurrent callers share one exchange.
125
+ #
126
+ # @raise [Dodopayments::Errors::APIError]
127
+ # @return [String]
128
+ def access_token
129
+ @mutex.synchronize do
130
+ exchange! unless fresh?
131
+ @access_token.to_s
132
+ end
133
+ end
134
+
135
+ # @api private
136
+ #
137
+ # Clears the cache, but only if it still holds `token`.
138
+ #
139
+ # @param token [String, nil]
140
+ def invalidate(token = nil)
141
+ @mutex.synchronize do
142
+ clear! if token.nil? || token == @access_token
143
+ end
144
+ nil
145
+ end
146
+
147
+ # Revokes the cached access token and clears the cache. Does nothing if no token
148
+ # is cached.
149
+ #
150
+ # @raise [Dodopayments::Errors::APIError]
151
+ def revoke
152
+ token = @mutex.synchronize { @access_token }
153
+ return if token.nil?
154
+
155
+ begin
156
+ @transport.call(
157
+ method: :post,
158
+ path: "workload-identity/token/revoke",
159
+ headers: {"authorization" => "Bearer #{token}"},
160
+ body: nil
161
+ )
162
+ ensure
163
+ invalidate(token)
164
+ end
165
+ nil
166
+ end
167
+
168
+ # @return [String]
169
+ def inspect = "#<#{self.class.name} identity_provider_id=#{@identity_provider_id}>"
170
+
171
+ private def fresh?
172
+ !@access_token.nil? && !@expires_at.nil? && @expires_at - Time.now > REFRESH_WINDOW_IN_SECONDS
173
+ end
174
+
175
+ private def clear!
176
+ @access_token = nil
177
+ @expires_at = nil
178
+ end
179
+
180
+ private def subject_token
181
+ token = @provider.respond_to?(:arity) && @provider.arity.zero? ? @provider.call : @provider.call(@context)
182
+ if !token.is_a?(String) || token.strip.empty?
183
+ raise Dodopayments::Errors::Error.new("The workload identity provider did not return a token")
184
+ end
185
+ token.strip
186
+ end
187
+
188
+ private def exchange!
189
+ clear!
190
+ body = {
191
+ grant_type: GRANT_TYPE,
192
+ subject_token_type: SUBJECT_TOKEN_TYPE,
193
+ subject_token: subject_token,
194
+ identity_provider_id: @identity_provider_id
195
+ }
196
+ now = Time.now
197
+ response = @transport.call(method: :post, path: "workload-identity/token", headers: {}, body: body)
198
+ token = response.is_a?(Hash) ? response[:access_token] : nil
199
+ if !token.is_a?(String) || token.empty?
200
+ raise Dodopayments::Errors::Error.new("The token exchange response has no access_token")
201
+ end
202
+
203
+ @expires_at = expiry(response, now: now)
204
+ @access_token = token
205
+ end
206
+
207
+ private def expiry(response, now:)
208
+ expires_at = response[:expires_at]
209
+ if expires_at.is_a?(String)
210
+ begin
211
+ return Time.iso8601(expires_at)
212
+ rescue ArgumentError
213
+ nil
214
+ end
215
+ end
216
+ now + Float(response[:expires_in], exception: false).to_f
217
+ end
218
+ end
219
+
220
+ class << self
221
+ # GitHub Actions. The job must have the `id-token: write` permission.
222
+ #
223
+ # @param audience [String, nil] Defaults to the origin of the client base URL.
224
+ #
225
+ # @return [Proc]
226
+ def github_actions(audience: nil)
227
+ lambda do |context|
228
+ url = env!("ACTIONS_ID_TOKEN_REQUEST_URL")
229
+ request_token = env!("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
230
+ separator = url.include?("?") ? "&" : "?"
231
+ uri = URI("#{url}#{separator}audience=#{CGI.escape(audience || context.audience)}")
232
+ body = http_get(uri, {"authorization" => "bearer #{request_token}"})
233
+ JSON.parse(body).fetch("value")
234
+ end
235
+ end
236
+
237
+ # GitLab CI/CD. Declare the token in `id_tokens` of the job.
238
+ #
239
+ # @param env_var [String]
240
+ #
241
+ # @return [Proc]
242
+ def gitlab(env_var: "DODO_PAYMENTS_ID_TOKEN") = ->(_context = nil) { env!(env_var) }
243
+
244
+ # HCP Terraform (Terraform Cloud) dynamic credentials.
245
+ #
246
+ # @return [Proc]
247
+ def terraform_cloud
248
+ lambda do |_context = nil|
249
+ ENV["TFC_WORKLOAD_IDENTITY_TOKEN"] || env!("TFC_WORKLOAD_IDENTITY_TOKEN_DODO")
250
+ end
251
+ end
252
+
253
+ # Google Cloud: the metadata server of the attached service account.
254
+ #
255
+ # @param audience [String, nil] Defaults to the origin of the client base URL.
256
+ #
257
+ # @return [Proc]
258
+ def gcp(audience: nil)
259
+ lambda do |context|
260
+ query = URI.encode_www_form(audience: audience || context.audience, format: "full")
261
+ http_get(URI("#{GCP_METADATA_URL}?#{query}"), {"metadata-flavor" => "Google"})
262
+ end
263
+ end
264
+
265
+ # Vercel: gets a Vercel OIDC token for the Dodo Payments audience on each exchange.
266
+ # The base token comes from `ENV["VERCEL_OIDC_TOKEN_FILE"]` or `ENV["VERCEL_OIDC_TOKEN"]`.
267
+ # Vercel exchanges the base token for a token with the audience.
268
+ #
269
+ # @param audience [String, nil] Defaults to the origin of the client base URL.
270
+ #
271
+ # @return [Proc]
272
+ def vercel(audience: nil)
273
+ lambda do |context|
274
+ body = {token: vercel_base_token, aud: audience || context.audience}
275
+ response = http_post_json(URI(VERCEL_TOKEN_URL), body)
276
+ unless response.is_a?(Net::HTTPSuccess)
277
+ raise Dodopayments::Errors::Error.new(
278
+ "The Vercel OIDC token request failed with status #{response.code}: #{response.body.to_s[0,
279
+ 200]}"
280
+ )
281
+ end
282
+ token = vercel_token(response.body.to_s)
283
+ if token.nil?
284
+ raise Dodopayments::Errors::Error.new(
285
+ "The Vercel OIDC token response has no token (status #{response.code})"
286
+ )
287
+ end
288
+ token
289
+ end
290
+ end
291
+
292
+ # Kubernetes: a projected service account token. The file is read on each exchange.
293
+ #
294
+ # @param path [String]
295
+ #
296
+ # @return [Proc]
297
+ def kubernetes(path: KUBERNETES_TOKEN_PATH) = file(path: path)
298
+
299
+ # A token file. The file is read on each exchange.
300
+ #
301
+ # @param path [String, nil] Defaults to `ENV["DODO_PAYMENTS_IDENTITY_TOKEN_FILE"]`
302
+ #
303
+ # @return [Proc]
304
+ def file(path: ENV["DODO_PAYMENTS_IDENTITY_TOKEN_FILE"])
305
+ if path.nil? || path.empty?
306
+ raise ArgumentError.new(
307
+ "path is required, and can be set via environ: \"DODO_PAYMENTS_IDENTITY_TOKEN_FILE\""
308
+ )
309
+ end
310
+ ->(_context = nil) { File.read(path).strip }
311
+ end
312
+
313
+ # AWS: wraps your function that returns the JWT, for example from STS
314
+ # `GetWebIdentityToken`.
315
+ #
316
+ # @param fn [#call, nil]
317
+ #
318
+ # @return [Proc]
319
+ def aws(fn = nil, &blk)
320
+ callable = fn || blk
321
+ raise ArgumentError.new("aws requires a function or a block") if callable.nil?
322
+ ->(_context = nil) { callable.call }
323
+ end
324
+
325
+ # A fixed token. Use it for tests.
326
+ #
327
+ # @param token [String]
328
+ #
329
+ # @return [Proc]
330
+ def static(token) = ->(_context = nil) { token }
331
+
332
+ # Finds a token source in the environment, in this order: GitHub Actions, HCP
333
+ # Terraform, `ENV["DODO_PAYMENTS_IDENTITY_TOKEN_FILE"]`, and the Kubernetes path.
334
+ #
335
+ # @return [Proc, nil] `nil` if no source is found.
336
+ def auto
337
+ if ENV["ACTIONS_ID_TOKEN_REQUEST_URL"] && ENV["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
338
+ return github_actions
339
+ end
340
+ if ENV["TFC_WORKLOAD_IDENTITY_TOKEN"] || ENV["TFC_WORKLOAD_IDENTITY_TOKEN_DODO"]
341
+ return terraform_cloud
342
+ end
343
+ path = ENV["DODO_PAYMENTS_IDENTITY_TOKEN_FILE"]
344
+ return file(path: path) unless path.nil? || path.empty?
345
+ return kubernetes if File.file?(KUBERNETES_TOKEN_PATH)
346
+ nil
347
+ end
348
+
349
+ private def env!(name)
350
+ value = ENV[name]
351
+ if value.nil? || value.empty?
352
+ raise Dodopayments::Errors::Error.new("The environment variable #{name} is not set")
353
+ end
354
+ value
355
+ end
356
+
357
+ private def vercel_base_token
358
+ path = ENV["VERCEL_OIDC_TOKEN_FILE"]
359
+ token = path.nil? || path.empty? ? ENV["VERCEL_OIDC_TOKEN"].to_s.strip : File.read(path).strip
360
+ if token.empty?
361
+ raise Dodopayments::Errors::Error.new(
362
+ "Cannot find a Vercel OIDC token. Set VERCEL_OIDC_TOKEN_FILE or VERCEL_OIDC_TOKEN."
363
+ )
364
+ end
365
+ token
366
+ end
367
+
368
+ private def vercel_token(body)
369
+ data = JSON.parse(body)
370
+ token = data.is_a?(Hash) ? data["token"] : nil
371
+ token.is_a?(String) && !token.empty? ? token : nil
372
+ rescue JSON::ParserError
373
+ nil
374
+ end
375
+
376
+ private def http_post_json(uri, body)
377
+ Net::HTTP.start(
378
+ uri.host,
379
+ uri.port,
380
+ use_ssl: uri.scheme == "https",
381
+ open_timeout: PROVIDER_TIMEOUT_IN_SECONDS,
382
+ read_timeout: PROVIDER_TIMEOUT_IN_SECONDS
383
+ ) do |http|
384
+ http.post(
385
+ uri.request_uri,
386
+ JSON.generate(body),
387
+ {"content-type" => "application/json", "accept" => "application/json"}
388
+ )
389
+ end
390
+ end
391
+
392
+ private def http_get(uri, headers)
393
+ response = Net::HTTP.start(
394
+ uri.host,
395
+ uri.port,
396
+ use_ssl: uri.scheme == "https",
397
+ open_timeout: PROVIDER_TIMEOUT_IN_SECONDS,
398
+ read_timeout: PROVIDER_TIMEOUT_IN_SECONDS
399
+ ) do |http|
400
+ http.get(uri.request_uri, headers)
401
+ end
402
+ unless response.is_a?(Net::HTTPSuccess)
403
+ raise Dodopayments::Errors::Error.new(
404
+ "The subject token request to #{uri.host} failed with status #{response.code}"
405
+ )
406
+ end
407
+ response.body.to_s
408
+ end
409
+ end
410
+ end
411
+ end
data/lib/dodopayments.rb CHANGED
@@ -55,6 +55,7 @@ require_relative "dodopayments/internal/transport/pooled_net_requester"
55
55
  require_relative "dodopayments/client"
56
56
  require_relative "dodopayments/internal/cursor_page_pagination"
57
57
  require_relative "dodopayments/internal/default_page_number_pagination"
58
+ require_relative "dodopayments/workload_identity"
58
59
  require_relative "dodopayments/models/blocklist/block_by_email"
59
60
  require_relative "dodopayments/models/blocklist/block_by_customer_id"
60
61
  require_relative "dodopayments/models/blocklist/create_blocked_customer_request"
@@ -20,12 +20,15 @@ module Dodopayments
20
20
  )
21
21
 
22
22
  # Bearer Token for API authentication
23
- sig { returns(String) }
23
+ sig { returns(T.nilable(String)) }
24
24
  attr_reader :bearer_token
25
25
 
26
26
  sig { returns(T.nilable(String)) }
27
27
  attr_reader :webhook_key
28
28
 
29
+ sig { returns(T.nilable(Dodopayments::WorkloadIdentity::Session)) }
30
+ attr_reader :workload_identity
31
+
29
32
  sig { returns(Dodopayments::Resources::CheckoutSessions) }
30
33
  attr_reader :checkout_sessions
31
34
 
@@ -114,6 +117,13 @@ module Dodopayments
114
117
  params(
115
118
  bearer_token: T.nilable(String),
116
119
  webhook_key: T.nilable(String),
120
+ workload_identity:
121
+ T.nilable(
122
+ T.any(
123
+ Dodopayments::WorkloadIdentity::Config,
124
+ T::Hash[Symbol, T.anything]
125
+ )
126
+ ),
117
127
  environment: T.nilable(T.any(Symbol, String)),
118
128
  base_url: T.nilable(String),
119
129
  max_retries: Integer,
@@ -124,9 +134,12 @@ module Dodopayments
124
134
  end
125
135
  def self.new(
126
136
  # Bearer Token for API authentication Defaults to `ENV["DODO_PAYMENTS_API_KEY"]`
127
- bearer_token: ENV["DODO_PAYMENTS_API_KEY"],
137
+ bearer_token: nil,
128
138
  # Defaults to `ENV["DODO_PAYMENTS_WEBHOOK_KEY"]`
129
139
  webhook_key: ENV["DODO_PAYMENTS_WEBHOOK_KEY"],
140
+ # Exchange an OIDC token for an access token, instead of a bearer token. See
141
+ # {Dodopayments::WorkloadIdentity}.
142
+ workload_identity: nil,
130
143
  # Specifies the environment to use for the API.
131
144
  #
132
145
  # Each environment maps to a different base URL:
@@ -0,0 +1,229 @@
1
+ # typed: strong
2
+
3
+ # typed: strong
4
+
5
+ module Dodopayments
6
+ # Workload identity federation: exchange a short-lived OIDC token (JWT) from
7
+ # your platform for a short-lived API access token.
8
+ module WorkloadIdentity
9
+ GRANT_TYPE = T.let(T.unsafe(nil), String)
10
+
11
+ SUBJECT_TOKEN_TYPE = T.let(T.unsafe(nil), String)
12
+
13
+ # The client refreshes the access token when this many seconds or less remain.
14
+ REFRESH_WINDOW_IN_SECONDS = T.let(T.unsafe(nil), Integer)
15
+
16
+ PROVIDER_TIMEOUT_IN_SECONDS = T.let(T.unsafe(nil), Integer)
17
+
18
+ KUBERNETES_TOKEN_PATH = T.let(T.unsafe(nil), String)
19
+
20
+ GCP_METADATA_URL = T.let(T.unsafe(nil), String)
21
+
22
+ VERCEL_TOKEN_URL = T.let(T.unsafe(nil), String)
23
+
24
+ # The values that a subject token provider gets on each token exchange.
25
+ class Context
26
+ # The base URL of the client.
27
+ sig { returns(URI::Generic) }
28
+ attr_reader :base_url
29
+
30
+ # The default audience: the origin of the client base URL.
31
+ sig { returns(String) }
32
+ attr_reader :audience
33
+
34
+ # @api private
35
+ sig do
36
+ params(base_url: URI::Generic, audience: String).returns(
37
+ T.attached_class
38
+ )
39
+ end
40
+ def self.new(base_url:, audience:)
41
+ end
42
+ end
43
+
44
+ # The `workload_identity` option of the client.
45
+ class Config
46
+ sig { returns(String) }
47
+ attr_reader :identity_provider_id
48
+
49
+ sig { returns(T.anything) }
50
+ attr_reader :provider
51
+
52
+ sig do
53
+ params(
54
+ provider: T.anything,
55
+ identity_provider_id: T.nilable(String)
56
+ ).returns(T.attached_class)
57
+ end
58
+ def self.new(
59
+ # Returns the subject token (a JWT string). It can take one argument, a
60
+ # `WorkloadIdentity::Context`.
61
+ provider:,
62
+ # Defaults to `ENV["DODO_PAYMENTS_IDENTITY_PROVIDER_ID"]`
63
+ identity_provider_id: ENV["DODO_PAYMENTS_IDENTITY_PROVIDER_ID"]
64
+ )
65
+ end
66
+
67
+ # @api private
68
+ sig do
69
+ params(
70
+ value:
71
+ T.any(
72
+ Dodopayments::WorkloadIdentity::Config,
73
+ T::Hash[Symbol, T.anything]
74
+ )
75
+ ).returns(Dodopayments::WorkloadIdentity::Config)
76
+ end
77
+ def self.coerce(value)
78
+ end
79
+
80
+ sig { returns(String) }
81
+ def inspect
82
+ end
83
+ end
84
+
85
+ # Caches the access token of one client. All methods are thread-safe.
86
+ class Session
87
+ sig { returns(String) }
88
+ attr_reader :identity_provider_id
89
+
90
+ # @api private
91
+ sig do
92
+ params(
93
+ config: Dodopayments::WorkloadIdentity::Config,
94
+ context: Dodopayments::WorkloadIdentity::Context,
95
+ transport: T.anything
96
+ ).returns(T.attached_class)
97
+ end
98
+ def self.new(config:, context:, transport:)
99
+ end
100
+
101
+ # Returns a valid access token. This method does a token exchange if no token is
102
+ # cached, or if 5 minutes or less remain. Concurrent callers share one exchange.
103
+ sig { returns(String) }
104
+ def access_token
105
+ end
106
+
107
+ # @api private
108
+ sig { params(token: T.nilable(String)).returns(NilClass) }
109
+ def invalidate(token = nil)
110
+ end
111
+
112
+ # Revokes the cached access token and clears the cache. Does nothing if no token
113
+ # is cached.
114
+ sig { returns(NilClass) }
115
+ def revoke
116
+ end
117
+
118
+ sig { returns(String) }
119
+ def inspect
120
+ end
121
+ end
122
+
123
+ Provider =
124
+ T.type_alias do
125
+ T
126
+ .proc
127
+ .params(context: Dodopayments::WorkloadIdentity::Context)
128
+ .returns(String)
129
+ end
130
+
131
+ class << self
132
+ # GitHub Actions. The job must have the `id-token: write` permission.
133
+ sig do
134
+ params(audience: T.nilable(String)).returns(
135
+ Dodopayments::WorkloadIdentity::Provider
136
+ )
137
+ end
138
+ def github_actions(
139
+ # Defaults to the origin of the client base URL.
140
+ audience: nil
141
+ )
142
+ end
143
+
144
+ # GitLab CI/CD. Declare the token in `id_tokens` of the job.
145
+ sig do
146
+ params(env_var: String).returns(
147
+ Dodopayments::WorkloadIdentity::Provider
148
+ )
149
+ end
150
+ def gitlab(env_var: "DODO_PAYMENTS_ID_TOKEN")
151
+ end
152
+
153
+ # HCP Terraform (Terraform Cloud) dynamic credentials.
154
+ sig { returns(Dodopayments::WorkloadIdentity::Provider) }
155
+ def terraform_cloud
156
+ end
157
+
158
+ # Google Cloud: the metadata server of the attached service account.
159
+ sig do
160
+ params(audience: T.nilable(String)).returns(
161
+ Dodopayments::WorkloadIdentity::Provider
162
+ )
163
+ end
164
+ def gcp(
165
+ # Defaults to the origin of the client base URL.
166
+ audience: nil
167
+ )
168
+ end
169
+
170
+ # Vercel: gets a Vercel OIDC token for the Dodo Payments audience on each
171
+ # exchange.
172
+ sig do
173
+ params(audience: T.nilable(String)).returns(
174
+ Dodopayments::WorkloadIdentity::Provider
175
+ )
176
+ end
177
+ def vercel(
178
+ # Defaults to the origin of the client base URL.
179
+ audience: nil
180
+ )
181
+ end
182
+
183
+ # Kubernetes: a projected service account token. The file is read on each
184
+ # exchange.
185
+ sig do
186
+ params(path: String).returns(Dodopayments::WorkloadIdentity::Provider)
187
+ end
188
+ def kubernetes(path: KUBERNETES_TOKEN_PATH)
189
+ end
190
+
191
+ # A token file. The file is read on each exchange.
192
+ sig do
193
+ params(path: T.nilable(String)).returns(
194
+ Dodopayments::WorkloadIdentity::Provider
195
+ )
196
+ end
197
+ def file(
198
+ # Defaults to `ENV["DODO_PAYMENTS_IDENTITY_TOKEN_FILE"]`
199
+ path: ENV["DODO_PAYMENTS_IDENTITY_TOKEN_FILE"]
200
+ )
201
+ end
202
+
203
+ # AWS: wraps your function that returns the JWT, for example from STS
204
+ # `GetWebIdentityToken`.
205
+ sig do
206
+ params(
207
+ fn: T.nilable(T.anything),
208
+ blk: T.nilable(T.proc.returns(String))
209
+ ).returns(Dodopayments::WorkloadIdentity::Provider)
210
+ end
211
+ def aws(fn = nil, &blk)
212
+ end
213
+
214
+ # A fixed token. Use it for tests.
215
+ sig do
216
+ params(token: String).returns(Dodopayments::WorkloadIdentity::Provider)
217
+ end
218
+ def static(token)
219
+ end
220
+
221
+ # Finds a token source in the environment, in this order: GitHub Actions, HCP
222
+ # Terraform, `ENV["DODO_PAYMENTS_IDENTITY_TOKEN_FILE"]`, and the Kubernetes path.
223
+ # Returns `nil` if no source is found.
224
+ sig { returns(T.nilable(Dodopayments::WorkloadIdentity::Provider)) }
225
+ def auto
226
+ end
227
+ end
228
+ end
229
+ end
@@ -13,10 +13,12 @@ module Dodopayments
13
13
  test_mode: "https://test.dodopayments.com"
14
14
  }
15
15
 
16
- attr_reader bearer_token: String
16
+ attr_reader bearer_token: String?
17
17
 
18
18
  attr_reader webhook_key: String?
19
19
 
20
+ attr_reader workload_identity: Dodopayments::WorkloadIdentity::Session?
21
+
20
22
  attr_reader checkout_sessions: Dodopayments::Resources::CheckoutSessions
21
23
 
22
24
  attr_reader payments: Dodopayments::Resources::Payments
@@ -74,6 +76,9 @@ module Dodopayments
74
76
  def initialize: (
75
77
  ?bearer_token: String?,
76
78
  ?webhook_key: String?,
79
+ ?workload_identity: Dodopayments::WorkloadIdentity::Config
80
+ | ::Hash[Symbol, top]
81
+ | nil,
77
82
  ?environment: :live_mode | :test_mode | nil,
78
83
  ?base_url: String?,
79
84
  ?max_retries: Integer,
@@ -0,0 +1,93 @@
1
+ module Dodopayments
2
+ module WorkloadIdentity
3
+ GRANT_TYPE: String
4
+
5
+ SUBJECT_TOKEN_TYPE: String
6
+
7
+ REFRESH_WINDOW_IN_SECONDS: Integer
8
+
9
+ PROVIDER_TIMEOUT_IN_SECONDS: Integer
10
+
11
+ KUBERNETES_TOKEN_PATH: String
12
+
13
+ GCP_METADATA_URL: String
14
+
15
+ VERCEL_TOKEN_URL: String
16
+
17
+ type provider = ^(Dodopayments::WorkloadIdentity::Context context) -> String
18
+
19
+ class Context
20
+ attr_reader base_url: URI::Generic
21
+
22
+ attr_reader audience: String
23
+
24
+ def initialize: (base_url: URI::Generic, audience: String) -> void
25
+ end
26
+
27
+ class Config
28
+ attr_reader identity_provider_id: String
29
+
30
+ attr_reader provider: top
31
+
32
+ def initialize: (provider: top, ?identity_provider_id: String?) -> void
33
+
34
+ def self.coerce: (
35
+ Dodopayments::WorkloadIdentity::Config | ::Hash[Symbol, top] value
36
+ ) -> Dodopayments::WorkloadIdentity::Config
37
+
38
+ def inspect: -> String
39
+ end
40
+
41
+ class Session
42
+ attr_reader identity_provider_id: String
43
+
44
+ def initialize: (
45
+ config: Dodopayments::WorkloadIdentity::Config,
46
+ context: Dodopayments::WorkloadIdentity::Context,
47
+ transport: top
48
+ ) -> void
49
+
50
+ def access_token: -> String
51
+
52
+ def invalidate: (?String? token) -> nil
53
+
54
+ def revoke: -> nil
55
+
56
+ def inspect: -> String
57
+ end
58
+
59
+ def self.github_actions: (
60
+ ?audience: String?
61
+ ) -> Dodopayments::WorkloadIdentity::provider
62
+
63
+ def self.gitlab: (
64
+ ?env_var: String
65
+ ) -> Dodopayments::WorkloadIdentity::provider
66
+
67
+ def self.terraform_cloud: -> Dodopayments::WorkloadIdentity::provider
68
+
69
+ def self.gcp: (
70
+ ?audience: String?
71
+ ) -> Dodopayments::WorkloadIdentity::provider
72
+
73
+ def self.vercel: (
74
+ ?audience: String?
75
+ ) -> Dodopayments::WorkloadIdentity::provider
76
+
77
+ def self.kubernetes: (
78
+ ?path: String
79
+ ) -> Dodopayments::WorkloadIdentity::provider
80
+
81
+ def self.file: (?path: String?) -> Dodopayments::WorkloadIdentity::provider
82
+
83
+ def self.aws: (
84
+ ?top fn
85
+ ) ?{
86
+ -> String
87
+ } -> Dodopayments::WorkloadIdentity::provider
88
+
89
+ def self.static: (String token) -> Dodopayments::WorkloadIdentity::provider
90
+
91
+ def self.auto: -> Dodopayments::WorkloadIdentity::provider?
92
+ end
93
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dodopayments
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.35.0
4
+ version: 2.37.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dodo Payments
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-24 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cgi
@@ -542,6 +542,7 @@ files:
542
542
  - lib/dodopayments/resources/webhooks.rb
543
543
  - lib/dodopayments/resources/webhooks/headers.rb
544
544
  - lib/dodopayments/version.rb
545
+ - lib/dodopayments/workload_identity.rb
545
546
  - manifest.yaml
546
547
  - rbi/dodopayments/client.rbi
547
548
  - rbi/dodopayments/errors.rbi
@@ -1021,6 +1022,7 @@ files:
1021
1022
  - rbi/dodopayments/resources/webhooks.rbi
1022
1023
  - rbi/dodopayments/resources/webhooks/headers.rbi
1023
1024
  - rbi/dodopayments/version.rbi
1025
+ - rbi/dodopayments/workload_identity.rbi
1024
1026
  - sig/dodopayments/client.rbs
1025
1027
  - sig/dodopayments/errors.rbs
1026
1028
  - sig/dodopayments/file_part.rbs
@@ -1499,6 +1501,7 @@ files:
1499
1501
  - sig/dodopayments/resources/webhooks.rbs
1500
1502
  - sig/dodopayments/resources/webhooks/headers.rbs
1501
1503
  - sig/dodopayments/version.rbs
1504
+ - sig/dodopayments/workload_identity.rbs
1502
1505
  homepage: https://gemdocs.org/gems/dodopayments
1503
1506
  licenses: []
1504
1507
  metadata: