vpndetection 5.10.0 → 5.11.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: dedd11823619c5d0503009b84e5b77acf1975d5e1f0a4f3484683543b5b48554
4
- data.tar.gz: eab3545e7200ff9939caae8a5e42394d7973c92147b59d7e148a9ad8cbdccf41
3
+ metadata.gz: 53a47c7a74fc9b0b6d904a2ff64d06fef0e877e93abc4af20b34f543d9c637f1
4
+ data.tar.gz: 203ddf3621a6e7083fe56998c8a5a1a417b56bf8d21a32ba96f8a2f0a4e52e29
5
5
  SHA512:
6
- metadata.gz: 880e2eab30ca0651db6ae6954272ace94c3b3e6986824dc28a6498b40dce87a800513c037a1d6904a7040d5920d2ba7b55146e22d21bc7a69de96d4c4d4f6c77
7
- data.tar.gz: a832c58a56fb28b0d6637a4b0fb5b64459e2d23d369ccfd31034afb03407edf520a170e4d9cfb021b0bc55dfcb0b352f5045d06cd8ab8d1fc85502d8f39978e9
6
+ metadata.gz: 83466d57ff0b38c35bcbd8008b17b58675ee08b50e5b85f5183110554316bb3bbf743e16cb2aa6ecc4ff8ff7bc6423366f1d8c7bbf5f4dc54e2d17afbfd0fb28
7
+ data.tar.gz: 89f88adbe30de7547654930d45cb9884f9ce2d365fbb74baeada08dace99d1576824f3eedfdb2648e68faa1a4e149c8fad5e096357d500dcc1fdd2755cdcfe6a
data/README.md CHANGED
@@ -221,6 +221,17 @@ token = client.oauth.exchange_authorization_code('your-client-id', code, pkce.ve
221
221
 
222
222
  Check that `state` came back as you sent it before you exchange `code`, which works once. The client ID can also be the https URL of a client metadata document your app serves, and such an app is never handed a key, so `token.apikey` stays `nil`.
223
223
 
224
+ ### Register a client
225
+
226
+ An app with no client ID, and no client metadata document to use as one, registers itself, then signs in with the `client_id` it gets back as the client ID for `authorization_url` and `exchange_authorization_code`:
227
+
228
+ ```ruby
229
+ client = VPNDetection::Client.new
230
+ registration = client.oauth.register(['http://127.0.0.1:8765/callback'], client_name: 'Your App')
231
+ ```
232
+
233
+ Each redirect URI must be https on a public host name, or http on a loopback address, which then matches any port. Nothing is kept until a person approves the client, so an app with no client ID saved can simply register again.
234
+
224
235
  ### Absent is not false
225
236
 
226
237
  Without a key only `ip` and `is_vpn` come back, and a free key adds Tor, relay, `location` and `asn`. Every other field is present when your plan includes it and `nil` when it does not. `nil` means "not in your plan"; `false` means "we checked, and no".
@@ -25,7 +25,7 @@ module VPNDetection
25
25
 
26
26
  # @param api_key [String, nil] omit it entirely to use the free tier, which
27
27
  # answers `ip` and `is_vpn` and allows 1000 requests per day per source
28
- # address.
28
+ # address. It is trimmed, and a key of blanks alone is no key.
29
29
  # @param cache [Boolean] pass false to disable caching.
30
30
  # @param cache_ttl [Numeric] how long an answer stays fresh, in seconds.
31
31
  # @param concurrency [Integer] batch requests - chunks of up to 1000 addresses - in flight during a batch.
@@ -37,6 +37,7 @@ module VPNDetection
37
37
  # longer in total, and every call that takes `retries:` also takes
38
38
  # `timeout:` to override it. A dataset transfer bounds only its connect
39
39
  # phase with it.
40
+ # @raise [ArgumentError] for an `api_key` with a control character in it.
40
41
  # @raise [ArgumentError] for a `timeout`, here or on any call, that is
41
42
  # negative, not a finite number, or past 2147483.647 seconds (2**31 - 1 ms),
42
43
  # the longest curl holds.
@@ -36,6 +36,7 @@ module VPNDetection
36
36
  TOKEN_PATH = '/oauth/token'
37
37
  REVOKE_PATH = '/oauth/revoke'
38
38
  AUTHORIZE_PATH = '/oauth/authorize'
39
+ REGISTER_PATH = '/oauth/register'
39
40
  DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
40
41
  # The longest single `sleep` the poll asks Ruby for, in seconds.
41
42
  LONGEST_SLEEP = 2**31 - 1
@@ -45,6 +46,7 @@ module VPNDetection
45
46
  OauthMetadata => %i[issuer authorization_endpoint token_endpoint],
46
47
  DeviceAuthorization => %i[device_code user_code verification_uri expires_in interval],
47
48
  TokenResponse => %i[access_token token_type expires_in],
49
+ ClientRegistration => %i[client_id client_id_issued_at client_name redirect_uris token_endpoint_auth_method],
48
50
  }.freeze
49
51
 
50
52
  def initialize(transport, retries:)
@@ -151,6 +153,31 @@ module VPNDetection
151
153
  [Digest::SHA256.digest(verifier)].pack('m0').tr('+/', '-_').delete('=')
152
154
  end
153
155
 
156
+ # Register a client for the authorization code flow, for an app that cannot
157
+ # serve a Client ID Metadata Document, and get its `client_id` for
158
+ # {#authorization_url} and {#exchange_authorization_code}.
159
+ #
160
+ # Each redirect URI must be https on a public host name, or http on a
161
+ # loopback address, which then matches on any port; the server refuses any
162
+ # other with {OauthRequestError} coded `invalid_redirect_uri`. Nothing is kept
163
+ # until a person approves the client, so registering again costs nothing, and
164
+ # this is retried like a read. No URI at all, or one that is empty or not
165
+ # UTF-8, raises ArgumentError before anything is sent.
166
+ #
167
+ # @param client_name [String, nil] shown to the person as the client's own
168
+ # claim; left out when empty.
169
+ # @return [ClientRegistration]
170
+ def register(redirect_uris, client_name: nil, timeout: nil)
171
+ raise ArgumentError, 'redirect_uris must list at least one URI' unless redirect_uris.is_a?(Array) && !redirect_uris.empty?
172
+
173
+ body = { 'redirect_uris' => redirect_uris.each_with_index.map { |uri, i| utf8!("redirect_uris[#{i}]", uri) } }
174
+ name = client_name.nil? ? '' : utf8!('client_name', client_name, empty: true)
175
+ body['client_name'] = name unless name.empty?
176
+ Retries.with_retries(@retries) do
177
+ decode(ClientRegistration, @transport.oauth_request(:POST, REGISTER_PATH, json: body, timeout: timeout).run)
178
+ end
179
+ end
180
+
154
181
  # Revoke an access or refresh token. A refresh token ends the whole grant and
155
182
  # every token it issued, which is how a machine signs out.
156
183
  #
@@ -226,6 +253,18 @@ module VPNDetection
226
253
  raise ArgumentError, "#{name} is not valid UTF-8"
227
254
  end
228
255
 
256
+ def utf8!(name, value, empty: false)
257
+ raise ArgumentError, "#{name} must be a String" unless value.is_a?(String)
258
+ raise ArgumentError, "#{name} must not be empty" if value.empty? && !empty
259
+
260
+ utf8 = value.encode(Encoding::UTF_8)
261
+ raise ArgumentError, "#{name} is not valid UTF-8" unless utf8.valid_encoding?
262
+
263
+ utf8
264
+ rescue EncodingError
265
+ raise ArgumentError, "#{name} is not valid UTF-8"
266
+ end
267
+
229
268
  def exchange(form, timeout)
230
269
  decode(TokenResponse, @transport.oauth_request(:POST, TOKEN_PATH, form: form, timeout: timeout).run)
231
270
  end
@@ -34,6 +34,20 @@ module VPNDetection
34
34
  "timeout must be a number of seconds from 0 (no bound) to #{LONGEST_TIMEOUT}, not #{timeout.inspect}"
35
35
  end
36
36
 
37
+ # The key as sent: trimmed, and nil when nothing is left. Through 5.10.1 a key
38
+ # of blanks went out as `Authorization: Bearer` and a padded one padded, and
39
+ # curl passed a control character on, so a key ending in a CR LF and a line
40
+ # of its own wrote that line onto the wire as a header. Refused where it is
41
+ # set, without the key in the message, since the key is a secret.
42
+ def self.checked_key(api_key)
43
+ return nil if api_key.nil?
44
+
45
+ key = api_key.to_s.strip
46
+ raise ArgumentError, 'api_key must not contain a control character' if key.match?(/[[:cntrl:]]/)
47
+
48
+ key.empty? ? nil : key
49
+ end
50
+
37
51
  # The generated Configuration applies EVERY security scheme the spec lists,
38
52
  # so a keyless client would send `Authorization: Bearer `, an empty
39
53
  # `X-Api-Key` and an empty `apikey` query parameter. The API answers 401 to
@@ -45,17 +59,18 @@ module VPNDetection
45
59
  self.scheme = uri.scheme
46
60
  self.host = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
47
61
  self.base_path = uri.path
48
- self.access_token = api_key
62
+ self.access_token = Transport.checked_key(api_key)
49
63
  self.timeout = timeout.nil? ? nil : Transport.checked_timeout(timeout)
50
64
  end
51
65
 
52
66
  def auth_settings
53
- return {} if access_token.nil? || access_token.to_s.empty?
67
+ token = access_token.to_s.strip
68
+ return {} if token.empty?
54
69
 
55
70
  {
56
71
  'bearerAuth' => {
57
72
  type: 'bearer', in: 'header', key: 'Authorization',
58
- value: "Bearer #{access_token}"
73
+ value: "Bearer #{token}"
59
74
  }
60
75
  }
61
76
  end
@@ -164,11 +179,13 @@ module VPNDetection
164
179
  #
165
180
  # The form is encoded here rather than by curl, so a `+` in a value leaves as
166
181
  # `%2B` and never arrives as a space.
167
- def oauth_request(http_method, path, form: nil, timeout: nil)
182
+ def oauth_request(http_method, path, form: nil, json: nil, timeout: nil)
168
183
  headers = { 'Accept' => 'application/json' }
169
184
  headers['Content-Type'] = 'application/x-www-form-urlencoded' unless form.nil?
185
+ headers['Content-Type'] = 'application/json' unless json.nil?
170
186
  request = build_request(http_method, path, header_params: headers, auth_names: [], timeout: timeout)
171
187
  request.options[:body] = URI.encode_www_form(form) unless form.nil?
188
+ request.options[:body] = JSON.generate(json) unless json.nil?
172
189
  request
173
190
  end
174
191
 
@@ -317,6 +334,23 @@ module VPNDetection
317
334
  created],
318
335
  }.freeze
319
336
 
337
+ # RFC 3339's full-date, the spec's `date`, and its date-time, the spec's
338
+ # `date-time`, in the upper case the API sends. Anything else reached the
339
+ # generated reader's Date.parse or Time.parse, which read "monday" as this
340
+ # week's, "10:00" as today in the process's zone and 30 February as 2 March,
341
+ # or a lookup's detail handed it back as served (5.10.0, measured 2026-10-21).
342
+ RFC3339_DATE = /\A(\d{4})-(\d{2})-(\d{2})\z/
343
+ RFC3339_DATE_TIME = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))\z/
344
+
345
+ # A date in the form its type names, on a day and at a time that exist: an
346
+ # hour of 24 rolls over the way a missing day does.
347
+ def self.date?(value, type)
348
+ m = (type == 'Date' ? RFC3339_DATE : RFC3339_DATE_TIME).match(value)
349
+ return false unless m && Date.valid_date?(m[1].to_i, m[2].to_i, m[3].to_i)
350
+
351
+ type == 'Date' || (m[4].to_i < 24 && m[5].to_i < 60 && m[6].to_i < 60 && m[7].to_i < 24 && m[8].to_i < 60)
352
+ end
353
+
320
354
  # True when the parsed `value` has the generated `type`, a model's members
321
355
  # recursively; raises TypeError naming where it does not. No list entry or
322
356
  # map value the spec describes may be null: through 5.9.0 one came back as
@@ -327,7 +361,8 @@ module VPNDetection
327
361
 
328
362
  typed =
329
363
  case type
330
- when 'String', 'Date', 'Time' then value.is_a?(String)
364
+ when 'String' then value.is_a?(String)
365
+ when 'Date', 'Time' then value.is_a?(String) && date?(value, type)
331
366
  when 'Integer' then value.is_a?(Integer)
332
367
  when 'Float' then value.is_a?(Numeric)
333
368
  when 'Boolean' then [true, false].include?(value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VPNDetection
4
- VERSION = '5.10.0'
4
+ VERSION = '5.11.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vpndetection
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.10.0
4
+ version: 5.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mslm Dev