vpndetection 5.1.0 → 5.2.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 +30 -3
- data/lib/vpndetection/api/account_wire_api.rb +1 -1
- data/lib/vpndetection/api/api_keys_wire_api.rb +1 -1
- data/lib/vpndetection/api/authorization_wire_api.rb +19 -17
- data/lib/vpndetection/api/database_wire_api.rb +7 -3
- data/lib/vpndetection/api/entitlement_wire_api.rb +1 -1
- data/lib/vpndetection/api/lookup_wire_api.rb +1 -1
- data/lib/vpndetection/api/organization_wire_api.rb +1 -1
- data/lib/vpndetection/api_client.rb +1 -1
- data/lib/vpndetection/api_error.rb +1 -1
- data/lib/vpndetection/api_model_base.rb +1 -1
- data/lib/vpndetection/client.rb +29 -13
- data/lib/vpndetection/configuration.rb +1 -1
- data/lib/vpndetection/database_api.rb +18 -10
- data/lib/vpndetection/errors.rb +54 -13
- data/lib/vpndetection/models/account_create_apikey_request.rb +1 -1
- data/lib/vpndetection/models/account_created_apikey.rb +1 -1
- data/lib/vpndetection/models/account_org.rb +1 -1
- data/lib/vpndetection/models/account_org_ref.rb +1 -1
- data/lib/vpndetection/models/account_org_wrap.rb +1 -1
- data/lib/vpndetection/models/account_rc.rb +1 -1
- data/lib/vpndetection/models/account_revealed_apikey.rb +1 -1
- data/lib/vpndetection/models/account_user.rb +1 -1
- data/lib/vpndetection/models/apikey_detail.rb +1 -1
- data/lib/vpndetection/models/apikey_list.rb +1 -1
- data/lib/vpndetection/models/batch_lookup_error.rb +1 -1
- data/lib/vpndetection/models/batch_lookup_request.rb +1 -1
- data/lib/vpndetection/models/batch_lookup_response.rb +1 -1
- data/lib/vpndetection/models/class_detail.rb +1 -1
- data/lib/vpndetection/models/database.rb +1 -1
- data/lib/vpndetection/models/database_checksums_response.rb +1 -1
- data/lib/vpndetection/models/database_format.rb +1 -1
- data/lib/vpndetection/models/database_format_size.rb +1 -1
- data/lib/vpndetection/models/database_list.rb +1 -1
- data/lib/vpndetection/models/database_metadata.rb +1 -1
- data/lib/vpndetection/models/database_metadata_column.rb +1 -1
- data/lib/vpndetection/models/database_version.rb +1 -1
- data/lib/vpndetection/models/db_checksums.rb +1 -1
- data/lib/vpndetection/models/device_authorization.rb +2 -1
- data/lib/vpndetection/models/download.rb +1 -1
- data/lib/vpndetection/models/download_list.rb +1 -1
- data/lib/vpndetection/models/entitlement.rb +1 -1
- data/lib/vpndetection/models/entitlement_apikey.rb +1 -1
- data/lib/vpndetection/models/entitlement_error.rb +1 -1
- data/lib/vpndetection/models/entitlement_plan.rb +1 -1
- data/lib/vpndetection/models/entitlement_usage.rb +1 -1
- data/lib/vpndetection/models/error_envelope.rb +1 -1
- data/lib/vpndetection/models/identity.rb +1 -1
- data/lib/vpndetection/models/lookup_error.rb +1 -1
- data/lib/vpndetection/models/lookup_response.rb +1 -1
- data/lib/vpndetection/models/oauth_error.rb +1 -1
- data/lib/vpndetection/models/oauth_metadata.rb +30 -9
- data/lib/vpndetection/models/proxy_detail.rb +1 -1
- data/lib/vpndetection/models/standing.rb +1 -1
- data/lib/vpndetection/models/token_response.rb +28 -5
- data/lib/vpndetection/models/vpn_detail.rb +1 -1
- data/lib/vpndetection/oauth_api.rb +166 -0
- data/lib/vpndetection/retries.rb +4 -2
- data/lib/vpndetection/transport.rb +58 -5
- data/lib/vpndetection/version.rb +1 -1
- data/lib/vpndetection.rb +1 -0
- metadata +2 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VPNDetection
|
|
4
|
+
# Signing a person in with OAuth, reached as `client.oauth`.
|
|
5
|
+
#
|
|
6
|
+
# A program on the person's own machine starts a device sign-in, shows them a
|
|
7
|
+
# code to approve in their browser, and waits for the tokens, which can carry
|
|
8
|
+
# one of their API keys so nobody pastes a key by hand. Only a registered
|
|
9
|
+
# client ID works; they are issued on request from support@vpndetection.io.
|
|
10
|
+
#
|
|
11
|
+
# No request made here carries the API key this client was built with, and a
|
|
12
|
+
# client built without one works exactly the same. Every method takes
|
|
13
|
+
# `timeout:`, seconds per attempt, for that call alone.
|
|
14
|
+
class OauthApi
|
|
15
|
+
METADATA_PATH = '/.well-known/oauth-authorization-server'
|
|
16
|
+
DEVICE_AUTHORIZATION_PATH = '/oauth/device_authorization'
|
|
17
|
+
TOKEN_PATH = '/oauth/token'
|
|
18
|
+
REVOKE_PATH = '/oauth/revoke'
|
|
19
|
+
DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
20
|
+
|
|
21
|
+
# The members a 2xx must carry for its type to mean anything.
|
|
22
|
+
REQUIRED = {
|
|
23
|
+
OauthMetadata => %i[issuer authorization_endpoint token_endpoint],
|
|
24
|
+
DeviceAuthorization => %i[device_code user_code verification_uri expires_in interval],
|
|
25
|
+
TokenResponse => %i[access_token token_type expires_in],
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def initialize(transport, retries:)
|
|
29
|
+
@transport = transport
|
|
30
|
+
@retries = retries
|
|
31
|
+
# The poll's wait and its monotonic clock, which a test replaces together.
|
|
32
|
+
@wait = ->(seconds) { sleep(seconds) }
|
|
33
|
+
@now = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The authorization server's discovery document.
|
|
37
|
+
#
|
|
38
|
+
# @return [OauthMetadata]
|
|
39
|
+
def metadata(timeout: nil)
|
|
40
|
+
Retries.with_retries(@retries) do
|
|
41
|
+
decode(OauthMetadata, @transport.oauth_request(:GET, METADATA_PATH, timeout: timeout).run)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Start a device sign-in: show the person `user_code` and `verification_uri`,
|
|
46
|
+
# then {#poll_device_token}.
|
|
47
|
+
#
|
|
48
|
+
# @param scope [String, nil] space-delimited, sent as given; the server grants
|
|
49
|
+
# what this client may ask for and silently drops the rest.
|
|
50
|
+
# @param resource [String, nil] the API the tokens are for.
|
|
51
|
+
# @return [DeviceAuthorization]
|
|
52
|
+
def device_authorization(client_id, scope: nil, resource: nil, timeout: nil)
|
|
53
|
+
form = { 'client_id' => client_id, 'scope' => scope, 'resource' => resource }.compact
|
|
54
|
+
Retries.with_retries(@retries) do
|
|
55
|
+
request = @transport.oauth_request(:POST, DEVICE_AUTHORIZATION_PATH, form: form, timeout: timeout)
|
|
56
|
+
decode(DeviceAuthorization, request.run)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Exchange a device code for tokens, once. Until the person approves this
|
|
61
|
+
# raises {OauthRequestError} coded `authorization_pending`;
|
|
62
|
+
# {#poll_device_token} is the loop that waits for them.
|
|
63
|
+
#
|
|
64
|
+
# Never retried: the server spends the code when it answers, so a retry after
|
|
65
|
+
# a lost success could only fail and lose the tokens.
|
|
66
|
+
#
|
|
67
|
+
# @return [TokenResponse] with `apikey_id` and `apikey` when the person picked a key.
|
|
68
|
+
def exchange_device_code(client_id, device_code, timeout: nil)
|
|
69
|
+
form = { 'grant_type' => DEVICE_CODE_GRANT, 'device_code' => device_code, 'client_id' => client_id }
|
|
70
|
+
exchange(form, timeout)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Exchange a refresh token for a new pair. The token presented is spent, so
|
|
74
|
+
# keep the `refresh_token` this returns. Never retried, for the same reason as
|
|
75
|
+
# {#exchange_device_code}.
|
|
76
|
+
#
|
|
77
|
+
# @return [TokenResponse] which may name the key in `apikey_id`, but never carries `apikey`.
|
|
78
|
+
def exchange_refresh_token(client_id, refresh_token, timeout: nil)
|
|
79
|
+
form = { 'grant_type' => 'refresh_token', 'refresh_token' => refresh_token, 'client_id' => client_id }
|
|
80
|
+
exchange(form, timeout)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Revoke an access or refresh token. A refresh token ends the whole grant and
|
|
84
|
+
# every token it issued, which is how a machine signs out.
|
|
85
|
+
#
|
|
86
|
+
# @return [nil]
|
|
87
|
+
def revoke(client_id, token, timeout: nil)
|
|
88
|
+
form = { 'token' => token, 'client_id' => client_id }
|
|
89
|
+
Retries.with_retries(@retries) do
|
|
90
|
+
request = @transport.oauth_request(:POST, REVOKE_PATH, form: form, timeout: timeout)
|
|
91
|
+
Transport.oauth_success!(request.run)
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Wait for the person to approve a device sign-in, and return its tokens.
|
|
97
|
+
#
|
|
98
|
+
# Waits `device.interval` seconds before EVERY exchange, the first included,
|
|
99
|
+
# and five seconds longer for good each time the server answers `slow_down`.
|
|
100
|
+
# Raises {OauthAccessDeniedError} when the person refuses and
|
|
101
|
+
# {OauthExpiredTokenError} when the code expires, including locally, with no
|
|
102
|
+
# status, once `device.expires_in` seconds have passed since this call. Any
|
|
103
|
+
# other failure, a timeout or an outage included, ends the wait unchanged;
|
|
104
|
+
# calling again with the same device is safe until the code expires.
|
|
105
|
+
#
|
|
106
|
+
# There is no way to cancel it from outside: it blocks until one of those
|
|
107
|
+
# outcomes, so run it where blocking is acceptable.
|
|
108
|
+
#
|
|
109
|
+
# @param timeout [Numeric, nil] bounds each exchange, never the whole wait.
|
|
110
|
+
# @return [TokenResponse]
|
|
111
|
+
def poll_device_token(client_id, device, timeout: nil)
|
|
112
|
+
interval = device.interval >= 1 ? device.interval : 5
|
|
113
|
+
deadline = @now.call + device.expires_in
|
|
114
|
+
loop do
|
|
115
|
+
@wait.call(interval)
|
|
116
|
+
raise OauthExpiredTokenError, 'expired_token' if @now.call >= deadline
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
return exchange_device_code(client_id, device.device_code, timeout: timeout)
|
|
120
|
+
rescue OauthRequestError => e
|
|
121
|
+
case e.error_code
|
|
122
|
+
when 'slow_down' then interval += 5
|
|
123
|
+
when 'authorization_pending' then next
|
|
124
|
+
else raise
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def exchange(form, timeout)
|
|
133
|
+
decode(TokenResponse, @transport.oauth_request(:POST, TOKEN_PATH, form: form, timeout: timeout).run)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Only the members the type declares are read, each checked against the type
|
|
137
|
+
# the spec gives it, so an absent member stays nil and an empty `scope` stays
|
|
138
|
+
# an empty string. A 2xx that is not the type is the server's fault.
|
|
139
|
+
def decode(type, response)
|
|
140
|
+
body = Transport.oauth_object(response)
|
|
141
|
+
type.attribute_map.each do |member, wire|
|
|
142
|
+
value = body[wire.to_s]
|
|
143
|
+
if value.nil?
|
|
144
|
+
next unless REQUIRED.fetch(type).include?(member)
|
|
145
|
+
|
|
146
|
+
raise Error.new(:server_error, "the answer carried no #{wire}", status: response.code)
|
|
147
|
+
end
|
|
148
|
+
next if typed?(type.openapi_types.fetch(member), value)
|
|
149
|
+
|
|
150
|
+
raise Error.new(:server_error, "the answer's #{wire} is not a #{type.openapi_types[member]}",
|
|
151
|
+
status: response.code)
|
|
152
|
+
end
|
|
153
|
+
type.build_from_hash(body)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def typed?(type, value)
|
|
157
|
+
case type
|
|
158
|
+
when :String then value.is_a?(String)
|
|
159
|
+
when :Integer then value.is_a?(Integer)
|
|
160
|
+
when :Boolean then [true, false].include?(value)
|
|
161
|
+
when :'Array<String>' then value.is_a?(Array) && value.all?(String)
|
|
162
|
+
else raise ArgumentError, "no check for a member typed #{type}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/vpndetection/retries.rb
CHANGED
|
@@ -11,12 +11,14 @@ module VPNDetection
|
|
|
11
11
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# `retry_if`, when given, is asked after each retryable failure, and a false
|
|
15
|
+
# answer ends the attempts there.
|
|
16
|
+
def with_retries(retries, retry_if: nil)
|
|
15
17
|
attempt = 0
|
|
16
18
|
begin
|
|
17
19
|
yield
|
|
18
20
|
rescue Error => e
|
|
19
|
-
raise unless e.retryable? && attempt < retries
|
|
21
|
+
raise unless e.retryable? && attempt < retries && (retry_if.nil? || retry_if.call)
|
|
20
22
|
|
|
21
23
|
attempt += 1
|
|
22
24
|
sleep(delay_for(e, attempt))
|
|
@@ -53,9 +53,13 @@ module VPNDetection
|
|
|
53
53
|
# is true for every value it can be given, so the database download's 302
|
|
54
54
|
# would be chased and a multi-gigabyte dataset read into memory. Nothing
|
|
55
55
|
# this API serves is meant to be followed.
|
|
56
|
+
#
|
|
57
|
+
# `opts[:timeout]` is a per-call override of the configured bound, which the
|
|
58
|
+
# generated client would otherwise apply to every request it builds.
|
|
56
59
|
def build_request(http_method, path, opts = {})
|
|
57
60
|
request = super
|
|
58
61
|
request.options[:followlocation] = false
|
|
62
|
+
request.options[:timeout] = opts[:timeout] unless opts[:timeout].nil?
|
|
59
63
|
request
|
|
60
64
|
end
|
|
61
65
|
|
|
@@ -84,37 +88,56 @@ module VPNDetection
|
|
|
84
88
|
Typhoeus::Request.new(url, options)
|
|
85
89
|
end
|
|
86
90
|
|
|
87
|
-
def lookup_request(ip)
|
|
91
|
+
def lookup_request(ip, timeout: nil)
|
|
88
92
|
build_request(
|
|
89
93
|
:GET, LOOKUP_PATH.sub('{ip}', CGI.escape(ip.to_s)),
|
|
90
94
|
header_params: { 'Accept' => 'application/json' },
|
|
91
95
|
auth_names: %w[bearerAuth apiKeyHeader apiKeyQuery],
|
|
96
|
+
timeout: timeout,
|
|
92
97
|
)
|
|
93
98
|
end
|
|
94
99
|
|
|
95
|
-
def myip_request
|
|
100
|
+
def myip_request(timeout: nil)
|
|
96
101
|
build_request(
|
|
97
102
|
:GET, MYIP_PATH,
|
|
98
103
|
header_params: { 'Accept' => 'application/json' },
|
|
99
104
|
auth_names: %w[bearerAuth apiKeyHeader apiKeyQuery],
|
|
105
|
+
timeout: timeout,
|
|
100
106
|
)
|
|
101
107
|
end
|
|
102
108
|
|
|
103
|
-
def entitlement_request
|
|
109
|
+
def entitlement_request(timeout: nil)
|
|
104
110
|
build_request(
|
|
105
111
|
:GET, ENTITLEMENT_PATH,
|
|
106
112
|
header_params: { 'Accept' => 'application/json' },
|
|
107
113
|
auth_names: %w[bearerAuth apiKeyHeader apiKeyQuery],
|
|
114
|
+
timeout: timeout,
|
|
108
115
|
)
|
|
109
116
|
end
|
|
110
117
|
|
|
118
|
+
# A request to the authorization server, which carries NO credential whatever
|
|
119
|
+
# this client was built with: these endpoints have no use for the API key, and
|
|
120
|
+
# on the token endpoint an `Authorization` header reads as client
|
|
121
|
+
# authentication, which a public client does not have.
|
|
122
|
+
#
|
|
123
|
+
# The form is encoded here rather than by curl, so a `+` in a value leaves as
|
|
124
|
+
# `%2B` and never arrives as a space.
|
|
125
|
+
def oauth_request(http_method, path, form: nil, timeout: nil)
|
|
126
|
+
headers = { 'Accept' => 'application/json' }
|
|
127
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded' unless form.nil?
|
|
128
|
+
request = build_request(http_method, path, header_params: headers, auth_names: [], timeout: timeout)
|
|
129
|
+
request.options[:body] = URI.encode_www_form(form) unless form.nil?
|
|
130
|
+
request
|
|
131
|
+
end
|
|
132
|
+
|
|
111
133
|
# The one request with a body: the batch.
|
|
112
|
-
def batch_request(ips)
|
|
134
|
+
def batch_request(ips, timeout: nil)
|
|
113
135
|
build_request(
|
|
114
136
|
:POST, BATCH_PATH,
|
|
115
137
|
header_params: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' },
|
|
116
138
|
body: { ips: ips },
|
|
117
139
|
auth_names: %w[bearerAuth apiKeyHeader apiKeyQuery],
|
|
140
|
+
timeout: timeout,
|
|
118
141
|
)
|
|
119
142
|
end
|
|
120
143
|
|
|
@@ -144,6 +167,36 @@ module VPNDetection
|
|
|
144
167
|
}
|
|
145
168
|
end
|
|
146
169
|
|
|
170
|
+
# The JSON object a 2xx OAuth answer carries. A refusal the authorization
|
|
171
|
+
# server words as an RFC 6749 error raises that; anything else that is not a
|
|
172
|
+
# 2xx raises the ordinary error its status maps to.
|
|
173
|
+
def self.oauth_object(response)
|
|
174
|
+
oauth_success!(response)
|
|
175
|
+
parse_object(response)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.oauth_success!(response)
|
|
179
|
+
raise Error.from_transport(response) if transport_failure?(response)
|
|
180
|
+
return if response.success?
|
|
181
|
+
|
|
182
|
+
raise oauth_refusal(response) || Error.from_status(response.code, response.headers, response.body)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Only a 4xx whose body is a JSON object with a STRING `error` is the
|
|
186
|
+
# authorization server's own refusal. A 5xx is an outage whatever its body
|
|
187
|
+
# says, and a gateway's page names no OAuth code at all.
|
|
188
|
+
def self.oauth_refusal(response)
|
|
189
|
+
return nil unless (400..499).cover?(response.code)
|
|
190
|
+
|
|
191
|
+
body = JSON.parse(response.body.to_s)
|
|
192
|
+
return nil unless body.is_a?(Hash) && body['error'].is_a?(String)
|
|
193
|
+
|
|
194
|
+
description = body['error_description'].is_a?(String) ? body['error_description'] : nil
|
|
195
|
+
OauthRequestError.for_code(body['error'], description, status: response.code, headers: response.headers)
|
|
196
|
+
rescue JSON::ParserError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
147
200
|
def self.transport_failure?(response)
|
|
148
201
|
response.timed_out? || response.code.to_i.zero?
|
|
149
202
|
end
|
|
@@ -159,6 +212,6 @@ module VPNDetection
|
|
|
159
212
|
status: response.code)
|
|
160
213
|
end
|
|
161
214
|
|
|
162
|
-
private_class_method :transport_failure?, :parse_object
|
|
215
|
+
private_class_method :transport_failure?, :parse_object, :oauth_refusal
|
|
163
216
|
end
|
|
164
217
|
end
|
data/lib/vpndetection/version.rb
CHANGED
data/lib/vpndetection.rb
CHANGED
|
@@ -20,6 +20,7 @@ require 'vpndetection/cache'
|
|
|
20
20
|
require 'vpndetection/retries'
|
|
21
21
|
require 'vpndetection/transport'
|
|
22
22
|
require 'vpndetection/database_api'
|
|
23
|
+
require 'vpndetection/oauth_api'
|
|
23
24
|
require 'vpndetection/client'
|
|
24
25
|
|
|
25
26
|
# The official Ruby client library for the VPNDetection API.
|
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.
|
|
4
|
+
version: 5.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mslm Dev
|
|
@@ -109,6 +109,7 @@ files:
|
|
|
109
109
|
- lib/vpndetection/models/standing.rb
|
|
110
110
|
- lib/vpndetection/models/token_response.rb
|
|
111
111
|
- lib/vpndetection/models/vpn_detail.rb
|
|
112
|
+
- lib/vpndetection/oauth_api.rb
|
|
112
113
|
- lib/vpndetection/result.rb
|
|
113
114
|
- lib/vpndetection/retries.rb
|
|
114
115
|
- lib/vpndetection/transport.rb
|