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 +4 -4
- data/README.md +11 -0
- data/lib/vpndetection/client.rb +2 -1
- data/lib/vpndetection/oauth_api.rb +39 -0
- data/lib/vpndetection/transport.rb +40 -5
- data/lib/vpndetection/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53a47c7a74fc9b0b6d904a2ff64d06fef0e877e93abc4af20b34f543d9c637f1
|
|
4
|
+
data.tar.gz: 203ddf3621a6e7083fe56998c8a5a1a417b56bf8d21a32ba96f8a2f0a4e52e29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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".
|
data/lib/vpndetection/client.rb
CHANGED
|
@@ -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
|
-
|
|
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 #{
|
|
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'
|
|
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)
|
data/lib/vpndetection/version.rb
CHANGED