booqable 1.1.0 → 1.2.1

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: 13ee5f72ca221d2613d8691dd8c04fa189a38fbed8276b60878d2cce4c7f4f23
4
- data.tar.gz: 54028d30a657d3651ad0d49fab923efba00e09fa9f0d2e36906e8cf068975abd
3
+ metadata.gz: eb7d9650b7c441ded7b819566722c9dd860d83c28338407897c3e359367954a3
4
+ data.tar.gz: 6fd5b29991f14b4503b244fcb05db3613c7d87baf215904274066081a2d71107
5
5
  SHA512:
6
- metadata.gz: 51cb9ed5b66956cee3e429c51057e12884574fe3c8b3b907d6998caa1b037b5ca861c929ea7b26e8364369bf424b3686feff274149fa6a169da09d8048118390
7
- data.tar.gz: d6389d7f934088b0447dba290054456f10380578f5b38622275d6e846c99872a5060c21b192b8524aa7d867fe1b58917c9ad03eebfacef716b83eca28dab8549
6
+ metadata.gz: 38b96b39b3d88bfe1209a102f15117f2f7292d38f0ffebb5d2d0e80cbf93d8cb5622462b9bfef7a3cc784a52f25c4bdcad97333de5401a7d8cc2ca0abcbaae15
7
+ data.tar.gz: f53f2f5c9347c8f3ea266abfc8882b42e8e1239c9df104cb46277a2467ad22710fc68a7417808eb32ac37d1ee0a3891a9e704e558101dbb30962b54da2d1b917
data/CHANGELOG.md CHANGED
@@ -1,4 +1,17 @@
1
- ## [Unreleased]
1
+ ## [1.2.1] - 2026-06-10
2
+
3
+ - Require `oauth2 >= 2.0.22` to address GHSA-pp92-crg2-gfv9, where a
4
+ protocol-relative redirect `Location` could override the request authority and
5
+ leak the bearer `Authorization` header to an attacker-controlled host.
6
+
7
+
8
+ ## [1.2.0] - 2026-05-27
9
+
10
+ - Add optional `around_refresh_token` configuration. When provided, the OAuth
11
+ middleware yields the read + expiry-check + refresh sequence to the callable
12
+ so host applications can serialize concurrent token refreshes (e.g. with a
13
+ database transaction and advisory lock). The gem keeps no lock dependency.
14
+
2
15
 
3
16
  ## [1.1.0] - 2026-03-11
4
17
 
data/README.md CHANGED
@@ -105,6 +105,30 @@ client = Booqable::Client.new(
105
105
  client.authenticate_with_code(params[:code])
106
106
  ```
107
107
 
108
+ #### Serializing concurrent token refreshes
109
+
110
+ When multiple processes share the same OAuth token (e.g. the same installation
111
+ serving concurrent requests), pass an `around_refresh_token` callable to
112
+ serialize the read + expiry-check + refresh sequence. The middleware yields
113
+ to the callable once per request; the host application decides how to lock.
114
+
115
+ ```ruby
116
+ Booqable::Client.new(
117
+ # ...other oauth options...
118
+ around_refresh_token: ->(&block) {
119
+ AppInstallation.transaction do
120
+ installation.with_advisory_lock!("app_installation:#{installation.id}", transaction: true) do
121
+ installation.reload
122
+ block.call
123
+ end
124
+ end
125
+ }
126
+ )
127
+ ```
128
+
129
+ The gem itself has no advisory-lock dependency — `around_refresh_token` is
130
+ just a callable that takes a block.
131
+
108
132
  ### Single-Use Token Authentication
109
133
 
110
134
  For server-to-server communication requiring enhanced security:
data/lib/booqable/auth.rb CHANGED
@@ -57,7 +57,8 @@ module Booqable
57
57
  api_endpoint: api_endpoint,
58
58
  redirect_uri: redirect_uri,
59
59
  read_token: read_token,
60
- write_token: write_token
60
+ write_token: write_token,
61
+ around_refresh_token: around_refresh_token
61
62
  } if oauth_authenticated?
62
63
 
63
64
  builder.use Booqable::Middleware::Auth::ApiKey, {
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
4
+
3
5
  module Booqable
4
6
  # Client for the Booqable API
5
7
  #
@@ -40,11 +42,22 @@ module Booqable
40
42
  access_token
41
43
  ]
42
44
 
45
+ # Accepted aliases for configuration options, mapping the alias to its
46
+ # canonical {Booqable::Configurable} key.
47
+ OPTION_ALIASES = {
48
+ skip_retries: :no_retries
49
+ }.freeze
50
+
43
51
  # Initialize a new Client
44
52
  #
45
53
  # @param options [Hash] Configuration options for the client
46
54
  # @see Booqable::Configurable For a complete list of supported configuration options
47
55
  def initialize(options = {})
56
+ options = normalize_aliases(options)
57
+
58
+ unknown_keys = options.keys.map(&:to_sym) - Booqable::Configurable.keys
59
+ raise ArgumentError, unknown_options_message(unknown_keys) unless unknown_keys.empty?
60
+
48
61
  # Use options passed in, but fall back to module defaults
49
62
  #
50
63
  # This may look like a `.keys.each` which should be replaced with `#each_key`, but
@@ -94,5 +107,34 @@ module Booqable
94
107
 
95
108
  inspected
96
109
  end
110
+
111
+ private
112
+
113
+ # Rewrite any aliased option keys to their canonical configuration key.
114
+ #
115
+ # @param options [Hash] options as passed to {#initialize}
116
+ # @return [Hash] options with aliases replaced by their canonical keys
117
+ def normalize_aliases(options)
118
+ options.to_h do |key, value|
119
+ [ OPTION_ALIASES.fetch(key.to_sym, key), value ]
120
+ end
121
+ end
122
+
123
+ # Build an error message for unknown configuration options, suggesting
124
+ # similarly-named valid options via did_you_mean when one is close enough.
125
+ #
126
+ # @param unknown_keys [Array<Symbol>] options that aren't valid config keys
127
+ # @return [String]
128
+ def unknown_options_message(unknown_keys)
129
+ dictionary = Booqable::Configurable.keys + OPTION_ALIASES.keys
130
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
131
+
132
+ message = "unknown configuration option(s): #{unknown_keys.join(", ")}"
133
+
134
+ suggestions = unknown_keys.flat_map { |key| spell_checker.correct(key) }.uniq
135
+ message += ". Did you mean: #{suggestions.join(", ")}?" unless suggestions.empty?
136
+
137
+ message
138
+ end
97
139
  end
98
140
  end
@@ -48,6 +48,7 @@ module Booqable
48
48
  :proxy,
49
49
  :read_token,
50
50
  :redirect_uri,
51
+ :around_refresh_token,
51
52
  :single_use_token,
52
53
  :single_use_token_algorithm,
53
54
  :single_use_token_company_id,
@@ -81,6 +82,7 @@ module Booqable
81
82
  proxy
82
83
  read_token
83
84
  redirect_uri
85
+ around_refresh_token
84
86
  single_use_token
85
87
  single_use_token_algorithm
86
88
  single_use_token_company_id
@@ -151,6 +151,17 @@ module Booqable
151
151
  Proc.new { }
152
152
  end
153
153
 
154
+ # Default `around_refresh_token` callable
155
+ #
156
+ # When non-nil, the OAuth middleware yields its read+check+refresh
157
+ # sequence to this callable so the host application can serialize
158
+ # concurrent refreshes (e.g. with an advisory lock).
159
+ #
160
+ # @return [Proc, nil]
161
+ def around_refresh_token
162
+ nil
163
+ end
164
+
154
165
  # Default API key from ENV
155
166
  # @return [String, nil] API key for authentication
156
167
  def api_key
@@ -24,6 +24,10 @@ module Booqable
24
24
  # @option options [String] :api_endpoint API endpoint URL for the OAuth provider
25
25
  # @option options [Proc] :read_token Proc to read stored token
26
26
  # @option options [Proc] :write_token Proc to store new token
27
+ # @option options [Proc, nil] :around_refresh_token Optional callable
28
+ # invoked with a block around the read+check+refresh sequence. The
29
+ # host application can use it to serialize concurrent refreshes
30
+ # (e.g. wrap the block in a database transaction + advisory lock).
27
31
  # @raise [KeyError] If required options are not provided
28
32
  def initialize(app, options = {})
29
33
  super(app)
@@ -33,6 +37,7 @@ module Booqable
33
37
  @api_endpoint = options.fetch(:api_endpoint)
34
38
  @read_token = options.fetch(:read_token)
35
39
  @write_token = options.fetch(:write_token)
40
+ @around_refresh_token = options[:around_refresh_token]
36
41
 
37
42
  @client = OAuthClient.new(
38
43
  client_id: @client_id,
@@ -50,10 +55,12 @@ module Booqable
50
55
  # @param env [Faraday::Env] The request environment
51
56
  # @return [Faraday::Response] The response from the next middleware
52
57
  def call(env)
53
- @token = @client.get_access_token_from_hash(@read_token.call)
58
+ around_refresh_token do
59
+ @token = @client.get_access_token_from_hash(@read_token.call)
54
60
 
55
- if @token.expired? || @token.expires_at.nil?
56
- @token = refresh_token!
61
+ if @token.expired? || @token.expires_at.nil?
62
+ @token = refresh_token!
63
+ end
57
64
  end
58
65
 
59
66
  env.request_headers["Authorization"] ||= "Bearer #{@token.token}"
@@ -63,6 +70,16 @@ module Booqable
63
70
 
64
71
  private
65
72
 
73
+ # Yield to the configured around-callback, if any
74
+ #
75
+ # When a host application provides one (e.g. an advisory lock), the
76
+ # read+check+refresh sequence runs inside it so concurrent callers
77
+ # cannot interleave a read with another caller's refresh.
78
+ def around_refresh_token(&block)
79
+ return yield unless @around_refresh_token
80
+ @around_refresh_token.call(&block)
81
+ end
82
+
66
83
  # Refresh the expired OAuth token
67
84
  #
68
85
  # Uses the refresh token to obtain a new access token and stores it
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Booqable
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: booqable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hrvoje Šimić
@@ -86,6 +86,9 @@ dependencies:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
88
  version: '2.0'
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 2.0.22
89
92
  type: :runtime
90
93
  prerelease: false
91
94
  version_requirements: !ruby/object:Gem::Requirement
@@ -93,6 +96,9 @@ dependencies:
93
96
  - - "~>"
94
97
  - !ruby/object:Gem::Version
95
98
  version: '2.0'
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: 2.0.22
96
102
  - !ruby/object:Gem::Dependency
97
103
  name: jwt
98
104
  requirement: !ruby/object:Gem::Requirement
@@ -166,7 +172,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
166
172
  - !ruby/object:Gem::Version
167
173
  version: '0'
168
174
  requirements: []
169
- rubygems_version: 3.6.9
175
+ rubygems_version: 4.0.10
170
176
  specification_version: 4
171
177
  summary: Official Booqable API client for Ruby.
172
178
  test_files: []