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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -3
  3. data/lib/vpndetection/api/account_wire_api.rb +1 -1
  4. data/lib/vpndetection/api/api_keys_wire_api.rb +1 -1
  5. data/lib/vpndetection/api/authorization_wire_api.rb +19 -17
  6. data/lib/vpndetection/api/database_wire_api.rb +7 -3
  7. data/lib/vpndetection/api/entitlement_wire_api.rb +1 -1
  8. data/lib/vpndetection/api/lookup_wire_api.rb +1 -1
  9. data/lib/vpndetection/api/organization_wire_api.rb +1 -1
  10. data/lib/vpndetection/api_client.rb +1 -1
  11. data/lib/vpndetection/api_error.rb +1 -1
  12. data/lib/vpndetection/api_model_base.rb +1 -1
  13. data/lib/vpndetection/client.rb +29 -13
  14. data/lib/vpndetection/configuration.rb +1 -1
  15. data/lib/vpndetection/database_api.rb +18 -10
  16. data/lib/vpndetection/errors.rb +54 -13
  17. data/lib/vpndetection/models/account_create_apikey_request.rb +1 -1
  18. data/lib/vpndetection/models/account_created_apikey.rb +1 -1
  19. data/lib/vpndetection/models/account_org.rb +1 -1
  20. data/lib/vpndetection/models/account_org_ref.rb +1 -1
  21. data/lib/vpndetection/models/account_org_wrap.rb +1 -1
  22. data/lib/vpndetection/models/account_rc.rb +1 -1
  23. data/lib/vpndetection/models/account_revealed_apikey.rb +1 -1
  24. data/lib/vpndetection/models/account_user.rb +1 -1
  25. data/lib/vpndetection/models/apikey_detail.rb +1 -1
  26. data/lib/vpndetection/models/apikey_list.rb +1 -1
  27. data/lib/vpndetection/models/batch_lookup_error.rb +1 -1
  28. data/lib/vpndetection/models/batch_lookup_request.rb +1 -1
  29. data/lib/vpndetection/models/batch_lookup_response.rb +1 -1
  30. data/lib/vpndetection/models/class_detail.rb +1 -1
  31. data/lib/vpndetection/models/database.rb +1 -1
  32. data/lib/vpndetection/models/database_checksums_response.rb +1 -1
  33. data/lib/vpndetection/models/database_format.rb +1 -1
  34. data/lib/vpndetection/models/database_format_size.rb +1 -1
  35. data/lib/vpndetection/models/database_list.rb +1 -1
  36. data/lib/vpndetection/models/database_metadata.rb +1 -1
  37. data/lib/vpndetection/models/database_metadata_column.rb +1 -1
  38. data/lib/vpndetection/models/database_version.rb +1 -1
  39. data/lib/vpndetection/models/db_checksums.rb +1 -1
  40. data/lib/vpndetection/models/device_authorization.rb +2 -1
  41. data/lib/vpndetection/models/download.rb +1 -1
  42. data/lib/vpndetection/models/download_list.rb +1 -1
  43. data/lib/vpndetection/models/entitlement.rb +1 -1
  44. data/lib/vpndetection/models/entitlement_apikey.rb +1 -1
  45. data/lib/vpndetection/models/entitlement_error.rb +1 -1
  46. data/lib/vpndetection/models/entitlement_plan.rb +1 -1
  47. data/lib/vpndetection/models/entitlement_usage.rb +1 -1
  48. data/lib/vpndetection/models/error_envelope.rb +1 -1
  49. data/lib/vpndetection/models/identity.rb +1 -1
  50. data/lib/vpndetection/models/lookup_error.rb +1 -1
  51. data/lib/vpndetection/models/lookup_response.rb +1 -1
  52. data/lib/vpndetection/models/oauth_error.rb +1 -1
  53. data/lib/vpndetection/models/oauth_metadata.rb +30 -9
  54. data/lib/vpndetection/models/proxy_detail.rb +1 -1
  55. data/lib/vpndetection/models/standing.rb +1 -1
  56. data/lib/vpndetection/models/token_response.rb +28 -5
  57. data/lib/vpndetection/models/vpn_detail.rb +1 -1
  58. data/lib/vpndetection/oauth_api.rb +166 -0
  59. data/lib/vpndetection/retries.rb +4 -2
  60. data/lib/vpndetection/transport.rb +58 -5
  61. data/lib/vpndetection/version.rb +1 -1
  62. data/lib/vpndetection.rb +1 -0
  63. 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
@@ -11,12 +11,14 @@ module VPNDetection
11
11
 
12
12
  module_function
13
13
 
14
- def with_retries(retries)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VPNDetection
4
- VERSION = '5.1.0'
4
+ VERSION = '5.2.0'
5
5
  end
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.1.0
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