boldsign 0.4.0 → 0.5.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: cbe0865fb65380ebdee0b2a2ccca3804a3cac90639d93778cc7350ca202294d4
4
- data.tar.gz: 93dc1c1e373d29ea4bf07af0622da0662fd5d6d0b044e317ce6e69ceb442743b
3
+ metadata.gz: 9fc744443078d2ffab4156204b7921ef617c3788d373253956a0fe9f3455a14a
4
+ data.tar.gz: 133c334403bb3a2655f61ab7c14f8d9d75c9e3adf5212f9bdf7e69811a2cda2a
5
5
  SHA512:
6
- metadata.gz: 27eb15a5d89fe793cbae44f5e67ebb6b1c52eb71313e2ef677e57b004384f40e552dcebd53525c86c7ddb14d30f3fb278fe9e955aeadb39b5a2d573eb309704a
7
- data.tar.gz: 6d11a55a16fb6f553e5136dde756baa972ebed48e0b6a5ebeac50cc3abf27b3d0ca2d2bc47103fe42ea2c3933c3a4a2c9f2d8704ed7ebd546d60c7cee69ca953
6
+ metadata.gz: 96e5622f76bf23b2df6be9663542fb0db5da070f40480e6649960da33d336c14afcafc36c308b5dad867fa4ce90ab3faf574c3c956011c330c7599713cf3afe2
7
+ data.tar.gz: 640539d3b260f4b50de92bddca66ed45a794c0c54c722d32d807134eee505d06f737542d077403ab71f5699f23e106567d1bf19dfb4954420398904e9cd756e2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] — 2026-06-02
11
+
12
+ ### Added
13
+ - OAuth 2.0 authentication via the **client credentials** grant. Configure
14
+ `client_id` / `client_secret` (on `Boldsign.configure` or `Client.new`) and
15
+ the client fetches a bearer token from the region's account host
16
+ (`https://account.boldsign.com/connect/token` for `:us`), caches it until
17
+ shortly before `expires_in` lapses, and sends `Authorization: Bearer …`
18
+ instead of `X-API-KEY`. Token fetches are mutex-guarded for shared clients.
19
+ - `scope:` (defaults to `BoldSign.Documents.All`) and `token_url:` options to
20
+ override the requested scope and token endpoint.
21
+ - `access_token:` option to supply a pre-obtained bearer token that is used
22
+ as-is (not refreshed).
23
+ - `Boldsign::AccessToken` encapsulating the client-credentials token lifecycle.
24
+ - `TOKEN_REGIONS` / `DEFAULT_TOKEN_BASE_URL` constants and `Client#auth_mode`.
25
+
26
+ ### Changed
27
+ - `Client.new` no longer requires an API key. It accepts any one of
28
+ `client_id`/`client_secret`, `access_token`, or `api_key`, and raises
29
+ `ConfigurationError` only when none is provided. The API-key path is
30
+ unchanged, so existing `api_key:` callers keep working.
31
+
10
32
  ## [0.4.0] — 2026-05-22
11
33
 
12
34
  ### Changed
@@ -68,5 +90,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
68
90
  - 100% line and branch test coverage enforced via SimpleCov.
69
91
  - GitHub Actions CI on Ruby 3.4 and 4.0.
70
92
 
71
- [Unreleased]: https://github.com/kleinjm/boldsign-ruby/compare/v0.1.0...HEAD
93
+ [Unreleased]: https://github.com/kleinjm/boldsign-ruby/compare/v0.5.0...HEAD
94
+ [0.5.0]: https://github.com/kleinjm/boldsign-ruby/compare/v0.4.0...v0.5.0
72
95
  [0.1.0]: https://github.com/kleinjm/boldsign-ruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -17,6 +17,32 @@ gem "boldsign"
17
17
 
18
18
  ## Configuration
19
19
 
20
+ The client authenticates with either an API key or OAuth 2.0.
21
+
22
+ ### OAuth 2.0 (client credentials)
23
+
24
+ Register an OAuth app in the BoldSign dashboard (API → OAuth Apps) and configure
25
+ its `client_id` / `client_secret`. The client fetches a bearer token from the
26
+ region's account host, caches it until shortly before it expires, and refreshes
27
+ automatically — no token plumbing on your side.
28
+
29
+ ```ruby
30
+ Boldsign.configure do |c|
31
+ c.client_id = ENV["BOLDSIGN_CLIENT_ID"]
32
+ c.client_secret = ENV["BOLDSIGN_CLIENT_SECRET"]
33
+ c.region = :us # :us, :eu, :ca, :au
34
+ # c.scope = "BoldSign.Documents.All" # optional; empty grants all the app allows
35
+ end
36
+ ```
37
+
38
+ You can also pass a token you obtained yourself (used as-is, not refreshed):
39
+
40
+ ```ruby
41
+ Boldsign::Client.new(access_token: "…", region: :us)
42
+ ```
43
+
44
+ ### API key
45
+
20
46
  ```ruby
21
47
  Boldsign.configure do |c|
22
48
  c.api_key = ENV["BOLDSIGN_API_KEY"]
@@ -30,14 +56,17 @@ Or instantiate a client directly:
30
56
  client = Boldsign::Client.new(api_key: "…", region: :us)
31
57
  ```
32
58
 
33
- Region base URLs:
59
+ Region hosts:
60
+
61
+ | Region | API base URL | OAuth account host |
62
+ | ------ | ------------ | ------------------ |
63
+ | `:us` | `https://api.boldsign.com` | `https://account.boldsign.com` |
64
+ | `:eu` | `https://api-eu.boldsign.com` | `https://account-eu.boldsign.com` |
65
+ | `:ca` | `https://api-ca.boldsign.com` | `https://account-ca.boldsign.com` |
66
+ | `:au` | `https://api-au.boldsign.com` | `https://account-au.boldsign.com` |
34
67
 
35
- | Region | URL |
36
- | ------ | --- |
37
- | `:us` | `https://api.boldsign.com` |
38
- | `:eu` | `https://api-eu.boldsign.com` |
39
- | `:ca` | `https://api-ca.boldsign.com` |
40
- | `:au` | `https://api-au.boldsign.com` |
68
+ The token endpoint is the account host + `/connect/token`; override the full URL
69
+ with `token_url:` if a region differs.
41
70
 
42
71
  ## Usage
43
72
 
@@ -0,0 +1,95 @@
1
+ module Boldsign
2
+ # Fetches and caches an OAuth 2.0 access token using the client credentials
3
+ # grant (server-to-server; no user interaction).
4
+ #
5
+ # A single instance is shared by a {Client} and may be hit from multiple
6
+ # threads, so token reads/refreshes are guarded by a mutex. The token is
7
+ # cached until shortly before it expires ({EXPIRY_LEEWAY_SECONDS}), then a new
8
+ # one is requested on the next call. The client credentials grant does not
9
+ # issue refresh tokens — expiry is handled by re-requesting.
10
+ #
11
+ # @see https://developers.boldsign.com/authentication/oauth-2-0/
12
+ class AccessToken
13
+ # Token endpoint path, appended to the region's account host.
14
+ TOKEN_PATH = "/connect/token".freeze
15
+
16
+ # Default scope requested for the access token. Covers the document /
17
+ # signature endpoints this gem is primarily used for. Pass a different
18
+ # `scope:` to request others (an empty scope grants all the app is allowed).
19
+ DEFAULT_SCOPE = "BoldSign.Documents.All".freeze
20
+
21
+ # Refresh this many seconds before the token actually expires, so an
22
+ # in-flight request never carries a token that lapses server-side mid-call.
23
+ EXPIRY_LEEWAY_SECONDS = 60
24
+
25
+ # Fallback lifetime (seconds) when the token response omits `expires_in`.
26
+ DEFAULT_EXPIRES_IN = 3600
27
+
28
+ # @param client_id [String] OAuth app client ID.
29
+ # @param client_secret [String] OAuth app client secret.
30
+ # @param token_url [String] Full token endpoint URL (host + {TOKEN_PATH}).
31
+ # @param scope [String] OAuth scope to request.
32
+ # @param adapter [Symbol] Faraday adapter.
33
+ def initialize(client_id:, client_secret:, token_url:, scope: DEFAULT_SCOPE,
34
+ adapter: Faraday.default_adapter)
35
+ @client_id = client_id
36
+ @client_secret = client_secret
37
+ @token_url = token_url
38
+ @scope = scope
39
+ @adapter = adapter
40
+ @mutex = Mutex.new
41
+ end
42
+
43
+ # @return [String] a currently-valid bearer token, fetching or refreshing
44
+ # one if the cached token is missing or near expiry.
45
+ def value
46
+ @mutex.synchronize do
47
+ refresh! if expired?
48
+ @token
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def expired?
55
+ @token.nil? || Time.now >= @expires_at
56
+ end
57
+
58
+ def refresh!
59
+ response = connection.post(@token_url) do |req|
60
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
61
+ req.body = URI.encode_www_form(
62
+ grant_type: "client_credentials",
63
+ client_id: @client_id,
64
+ client_secret: @client_secret,
65
+ scope: @scope
66
+ )
67
+ end
68
+ raise_token_error(response) unless response.success?
69
+
70
+ body = JSON.parse(response.body)
71
+ @token = body["access_token"]
72
+ expires_in = (body["expires_in"] || DEFAULT_EXPIRES_IN).to_i
73
+ @expires_at = Time.now + expires_in - EXPIRY_LEEWAY_SECONDS
74
+ end
75
+
76
+ def connection
77
+ Faraday.new do |f|
78
+ f.adapter @adapter
79
+ end
80
+ end
81
+
82
+ def raise_token_error(response)
83
+ body = begin
84
+ JSON.parse(response.body)
85
+ rescue StandardError
86
+ response.body
87
+ end
88
+ message = body.is_a?(Hash) ? (body["error_description"] || body["error"] || body.to_s) : body.to_s
89
+ raise AuthenticationError.new(
90
+ "BoldSign OAuth token request failed (#{response.status}): #{message}",
91
+ status: response.status, body: body, response: response
92
+ )
93
+ end
94
+ end
95
+ end
@@ -8,9 +8,21 @@ module Boldsign
8
8
  # Low-level verb methods ({#get}, {#post}, {#put}, {#patch}, {#delete},
9
9
  # {#download}) are also available for hitting any endpoint directly.
10
10
  #
11
- # @example
11
+ # Authenticates with either an API key (`X-API-KEY`) or OAuth 2.0
12
+ # (`Authorization: Bearer`). For OAuth, pass `client_id`/`client_secret` and
13
+ # the client fetches + caches a token via the client credentials grant; or
14
+ # pass a `access_token` you obtained yourself.
15
+ #
16
+ # @example API key
12
17
  # client = Boldsign::Client.new(api_key: ENV["BOLDSIGN_API_KEY"], region: :us)
13
18
  # client.documents.send_document(title: "NDA", signers: [...])
19
+ #
20
+ # @example OAuth client credentials
21
+ # client = Boldsign::Client.new(
22
+ # client_id: ENV["BOLDSIGN_CLIENT_ID"],
23
+ # client_secret: ENV["BOLDSIGN_CLIENT_SECRET"],
24
+ # region: :us
25
+ # )
14
26
  class Client
15
27
  # Default region (US).
16
28
  DEFAULT_BASE_URL = "https://api.boldsign.com".freeze
@@ -18,25 +30,50 @@ module Boldsign
18
30
  # User-Agent header value sent on every request.
19
31
  USER_AGENT = "boldsign-ruby/#{Boldsign::VERSION}".freeze
20
32
 
21
- # @return [String] the API key in use.
33
+ # @return [String, nil] the API key in use, when authenticating via API key.
22
34
  attr_reader :api_key
23
35
 
36
+ # @return [Symbol] the resolved auth mode (`:api_key`, `:oauth`, or `:bearer`).
37
+ attr_reader :auth_mode
38
+
24
39
  # @return [String] the resolved base URL (region or explicit override).
25
40
  attr_reader :base_url
26
41
 
27
42
  # @param api_key [String, nil] BoldSign API key. Falls back to `ENV["BOLDSIGN_API_KEY"]`.
28
- # @param region [Symbol, nil] One of `:us`, `:eu`, `:ca`, `:au`. Ignored if `base_url` is given.
29
- # @param base_url [String, nil] Explicit base URL override.
43
+ # @param client_id [String, nil] OAuth app client ID (enables the client credentials grant).
44
+ # @param client_secret [String, nil] OAuth app client secret (required with `client_id`).
45
+ # @param access_token [String, nil] A pre-obtained OAuth bearer token (used as-is, not refreshed).
46
+ # @param scope [String, nil] OAuth scope to request (defaults to {AccessToken::DEFAULT_SCOPE}).
47
+ # @param token_url [String, nil] Override for the full OAuth token endpoint URL.
48
+ # @param region [Symbol, nil] One of `:us`, `:eu`, `:ca`, `:au`. Ignored if `base_url`/`token_url` is given.
49
+ # @param base_url [String, nil] Explicit API base URL override.
30
50
  # @param adapter [Symbol] Faraday adapter (defaults to `Faraday.default_adapter`).
31
51
  # @param logger [Logger, nil] Optional logger; when present, Faraday's logger middleware is enabled.
32
- # @raise [ConfigurationError] when no API key is available.
33
- def initialize(api_key: nil, region: nil, base_url: nil, adapter: Faraday.default_adapter, logger: nil)
52
+ # @raise [ConfigurationError] when no usable credentials are provided.
53
+ def initialize(api_key: nil, client_id: nil, client_secret: nil, access_token: nil,
54
+ scope: nil, token_url: nil, region: nil, base_url: nil,
55
+ adapter: Faraday.default_adapter, logger: nil)
34
56
  @api_key = api_key || ENV["BOLDSIGN_API_KEY"]
35
- raise ConfigurationError, "Missing BoldSign API key" if @api_key.nil? || @api_key.empty?
36
-
57
+ @static_access_token = access_token
37
58
  @base_url = base_url || Boldsign::REGIONS[region&.to_sym] || DEFAULT_BASE_URL
38
59
  @adapter = adapter
39
60
  @logger = logger
61
+
62
+ if present?(client_id) && present?(client_secret)
63
+ @auth_mode = :oauth
64
+ @access_token = AccessToken.new(
65
+ client_id: client_id, client_secret: client_secret,
66
+ token_url: token_url || default_token_url(region),
67
+ scope: scope || AccessToken::DEFAULT_SCOPE, adapter: adapter
68
+ )
69
+ elsif present?(@static_access_token)
70
+ @auth_mode = :bearer
71
+ elsif present?(@api_key)
72
+ @auth_mode = :api_key
73
+ else
74
+ raise ConfigurationError,
75
+ "Missing BoldSign credentials: provide client_id/client_secret, access_token, or api_key"
76
+ end
40
77
  end
41
78
 
42
79
  # @return [Resources::Brand]
@@ -137,7 +174,7 @@ module Boldsign
137
174
  Faraday.new(url: @base_url) do |f|
138
175
  f.request :multipart if multipart
139
176
  f.request :url_encoded
140
- f.headers["X-API-KEY"] = @api_key
177
+ apply_auth(f.headers)
141
178
  f.headers["Accept"] = "application/json"
142
179
  f.headers["User-Agent"] = USER_AGENT
143
180
  f.response :logger, @logger if @logger
@@ -145,6 +182,27 @@ module Boldsign
145
182
  end
146
183
  end
147
184
 
185
+ def apply_auth(headers)
186
+ if @auth_mode == :api_key
187
+ headers["X-API-KEY"] = @api_key
188
+ else
189
+ headers["Authorization"] = "Bearer #{bearer_token}"
190
+ end
191
+ end
192
+
193
+ def bearer_token
194
+ @auth_mode == :oauth ? @access_token.value : @static_access_token
195
+ end
196
+
197
+ def present?(value)
198
+ !value.nil? && !value.empty?
199
+ end
200
+
201
+ def default_token_url(region)
202
+ base = Boldsign::TOKEN_REGIONS[region&.to_sym] || Boldsign::DEFAULT_TOKEN_BASE_URL
203
+ "#{base}#{AccessToken::TOKEN_PATH}"
204
+ end
205
+
148
206
  def parse_body(response)
149
207
  return nil if response.body.nil? || response.body.empty?
150
208
  content_type = response.headers["content-type"].to_s
@@ -1,3 +1,3 @@
1
1
  module Boldsign
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
data/lib/boldsign.rb CHANGED
@@ -1,10 +1,12 @@
1
1
  require "faraday"
2
2
  require "faraday/multipart"
3
3
  require "json"
4
+ require "uri"
4
5
 
5
6
  require_relative "boldsign/version"
6
7
  require_relative "boldsign/error"
7
8
  require_relative "boldsign/case_convert"
9
+ require_relative "boldsign/access_token"
8
10
  require_relative "boldsign/client"
9
11
  require_relative "boldsign/resource"
10
12
  require_relative "boldsign/resources/brand"
@@ -24,10 +26,11 @@ require_relative "boldsign/resources/user"
24
26
  # Configure once at boot and access a shared {Client} via {Boldsign.client}, or
25
27
  # instantiate {Client} directly when you need multiple credentials/regions.
26
28
  #
27
- # @example Configure and use the shared client
29
+ # @example Configure with OAuth and use the shared client
28
30
  # Boldsign.configure do |c|
29
- # c.api_key = ENV["BOLDSIGN_API_KEY"]
30
- # c.region = :us
31
+ # c.client_id = ENV["BOLDSIGN_CLIENT_ID"]
32
+ # c.client_secret = ENV["BOLDSIGN_CLIENT_SECRET"]
33
+ # c.region = :us
31
34
  # end
32
35
  # Boldsign.client.documents.list
33
36
  module Boldsign
@@ -39,10 +42,37 @@ module Boldsign
39
42
  au: "https://api-au.boldsign.com"
40
43
  }.freeze
41
44
 
45
+ # Region → account host map for the OAuth token endpoint. Non-US hosts follow
46
+ # BoldSign's regional naming; override with `token_url:` if a region differs.
47
+ TOKEN_REGIONS = {
48
+ us: "https://account.boldsign.com",
49
+ eu: "https://account-eu.boldsign.com",
50
+ ca: "https://account-ca.boldsign.com",
51
+ au: "https://account-au.boldsign.com"
52
+ }.freeze
53
+
54
+ # Default account host (US) for the OAuth token endpoint.
55
+ DEFAULT_TOKEN_BASE_URL = "https://account.boldsign.com".freeze
56
+
42
57
  class << self
43
58
  # @return [String, nil] API key used by the shared {client}.
44
59
  attr_accessor :api_key
45
60
 
61
+ # @return [String, nil] OAuth app client ID used by the shared {client}.
62
+ attr_accessor :client_id
63
+
64
+ # @return [String, nil] OAuth app client secret used by the shared {client}.
65
+ attr_accessor :client_secret
66
+
67
+ # @return [String, nil] Pre-obtained OAuth bearer token (used as-is, not refreshed).
68
+ attr_accessor :access_token
69
+
70
+ # @return [String, nil] OAuth scope to request (defaults to {AccessToken::DEFAULT_SCOPE}).
71
+ attr_accessor :scope
72
+
73
+ # @return [String, nil] Override for the full OAuth token endpoint URL.
74
+ attr_accessor :token_url
75
+
46
76
  # @return [Symbol, nil] Region key (`:us`, `:eu`, `:ca`, `:au`).
47
77
  attr_accessor :region
48
78
 
@@ -58,7 +88,11 @@ module Boldsign
58
88
 
59
89
  # @return [Client] memoized shared client built from module-level config.
60
90
  def client
61
- @client ||= Client.new(api_key: api_key, region: region, base_url: base_url)
91
+ @client ||= Client.new(
92
+ api_key: api_key, client_id: client_id, client_secret: client_secret,
93
+ access_token: access_token, scope: scope, token_url: token_url,
94
+ region: region, base_url: base_url
95
+ )
62
96
  end
63
97
 
64
98
  # Clears the memoized shared client so the next call to {client} rebuilds it.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: boldsign
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Klein
@@ -92,6 +92,7 @@ files:
92
92
  - README.md
93
93
  - boldsign.gemspec
94
94
  - lib/boldsign.rb
95
+ - lib/boldsign/access_token.rb
95
96
  - lib/boldsign/case_convert.rb
96
97
  - lib/boldsign/client.rb
97
98
  - lib/boldsign/error.rb