vpndetection 5.5.2 → 5.6.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 +17 -0
- data/lib/vpndetection/oauth_api.rb +81 -2
- 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: af30c794b9bdfb02657775e5e1fd768fa77855d97b1c4f8c2ea3ee4b9319380f
|
|
4
|
+
data.tar.gz: dab0d13a90c8f25adb6d6e2ecbafa524d62f1629bcb65e1e4cc55101fe81321d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3432eaf4c3bed7d1659898e61a2ce8d9918035e84a39c721e31958cafc97703cb3222c87ab0ed083f2ab02c422b0c737931da0a81b13950877fcb070e5c7df0f
|
|
7
|
+
data.tar.gz: 8152326e9e5b8e8e20d170be7dbd950b8ff5bd077eb6327857e4697898a4a87ebc642a5e104bdcd4d78d579ff785315bb7501f8001ecc3ccf91d042c80781f91
|
data/README.md
CHANGED
|
@@ -203,6 +203,23 @@ keyed = VPNDetection::Client.new(api_key: token.apikey)
|
|
|
203
203
|
|
|
204
204
|
`poll_device_token` raises `VPNDetection::OauthAccessDeniedError` when the person refuses and `VPNDetection::OauthExpiredTokenError` when the code expires first. Client IDs are issued on request from support@vpndetection.io, and `client.oauth.revoke('your-client-id', token.refresh_token)` signs the machine out.
|
|
205
205
|
|
|
206
|
+
### Sign in with OAuth (authorization code)
|
|
207
|
+
|
|
208
|
+
An app that can take a browser redirect signs the person in there instead, with a PKCE pair made for that one sign-in:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
client = VPNDetection::Client.new
|
|
212
|
+
redirect_uri = 'http://127.0.0.1:8765/callback'
|
|
213
|
+
pkce = client.oauth.create_pkce
|
|
214
|
+
|
|
215
|
+
url = client.oauth.authorization_url('your-client-id', redirect_uri, pkce.challenge,
|
|
216
|
+
scope: 'apikeys.use', state: 'your-state')
|
|
217
|
+
# Open url in the browser. Its redirect to redirect_uri carries code and state.
|
|
218
|
+
token = client.oauth.exchange_authorization_code('your-client-id', code, pkce.verifier, redirect_uri)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
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`.
|
|
222
|
+
|
|
206
223
|
### Absent is not false
|
|
207
224
|
|
|
208
225
|
Every field beyond `ip` and `is_vpn` 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".
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
3
6
|
module VPNDetection
|
|
7
|
+
# One sign-in's PKCE pair: `challenge` goes in the authorization URL, and
|
|
8
|
+
# `verifier` to {OauthApi#exchange_authorization_code}.
|
|
9
|
+
# `code_challenge_method` is always `S256`, the only method the server
|
|
10
|
+
# accepts; a reader called `method` would hide Object#method. The verifier is
|
|
11
|
+
# left out of `inspect`, so logging the pair does not leak it.
|
|
12
|
+
Pkce = Data.define(:verifier, :challenge, :code_challenge_method) do
|
|
13
|
+
def inspect
|
|
14
|
+
"#<data VPNDetection::Pkce challenge=#{challenge.inspect} code_challenge_method=#{code_challenge_method.inspect}>"
|
|
15
|
+
end
|
|
16
|
+
alias_method :to_s, :inspect
|
|
17
|
+
end
|
|
18
|
+
|
|
4
19
|
# Signing a person in with OAuth, reached as `client.oauth`.
|
|
5
20
|
#
|
|
6
21
|
# A program on the person's own machine starts a device sign-in, shows them a
|
|
@@ -9,13 +24,18 @@ module VPNDetection
|
|
|
9
24
|
# client ID works; they are issued on request from support@vpndetection.io.
|
|
10
25
|
#
|
|
11
26
|
# 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
|
|
13
|
-
# `timeout:`, seconds per attempt, for that call alone.
|
|
27
|
+
# client built without one works exactly the same. Every method that makes a
|
|
28
|
+
# request takes `timeout:`, seconds per attempt, for that call alone.
|
|
29
|
+
#
|
|
30
|
+
# An app that can take a browser redirect signs the person in with the
|
|
31
|
+
# authorization code flow instead: {#authorization_url} with a {#create_pkce}
|
|
32
|
+
# pair, then {#exchange_authorization_code}.
|
|
14
33
|
class OauthApi
|
|
15
34
|
METADATA_PATH = '/.well-known/oauth-authorization-server'
|
|
16
35
|
DEVICE_AUTHORIZATION_PATH = '/oauth/device_authorization'
|
|
17
36
|
TOKEN_PATH = '/oauth/token'
|
|
18
37
|
REVOKE_PATH = '/oauth/revoke'
|
|
38
|
+
AUTHORIZE_PATH = '/oauth/authorize'
|
|
19
39
|
DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
20
40
|
# The longest single `sleep` the poll asks Ruby for, in seconds.
|
|
21
41
|
LONGEST_SLEEP = 2**31 - 1
|
|
@@ -82,6 +102,55 @@ module VPNDetection
|
|
|
82
102
|
exchange(form, timeout)
|
|
83
103
|
end
|
|
84
104
|
|
|
105
|
+
# Exchange the `code` a sign-in's redirect brought back for tokens.
|
|
106
|
+
# `code_verifier` is the PKCE verifier whose challenge went into the
|
|
107
|
+
# authorization URL, and `redirect_uri` that URL's, exactly.
|
|
108
|
+
#
|
|
109
|
+
# Never retried: the server spends the code on first read, before it checks
|
|
110
|
+
# the verifier, so a retry could only be refused.
|
|
111
|
+
#
|
|
112
|
+
# @return [TokenResponse]
|
|
113
|
+
def exchange_authorization_code(client_id, code, code_verifier, redirect_uri, timeout: nil)
|
|
114
|
+
form = {
|
|
115
|
+
'grant_type' => 'authorization_code', 'code' => code, 'redirect_uri' => redirect_uri,
|
|
116
|
+
'client_id' => client_id, 'code_verifier' => code_verifier
|
|
117
|
+
}
|
|
118
|
+
exchange(form, timeout)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The URL to open in the person's browser for the authorization code flow.
|
|
122
|
+
# Makes no request. Once they decide, the server redirects to `redirect_uri`
|
|
123
|
+
# with a `code` for {#exchange_authorization_code} (and `state`, when one was
|
|
124
|
+
# given), or with an `error`. An option given empty is left out; a required
|
|
125
|
+
# value that is empty or not UTF-8 raises ArgumentError.
|
|
126
|
+
#
|
|
127
|
+
# @return [String]
|
|
128
|
+
def authorization_url(client_id, redirect_uri, code_challenge, scope: nil, state: nil, resource: nil)
|
|
129
|
+
required = { 'client_id' => client_id, 'redirect_uri' => redirect_uri, 'code_challenge' => code_challenge }
|
|
130
|
+
required.each do |name, value|
|
|
131
|
+
raise ArgumentError, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?
|
|
132
|
+
end
|
|
133
|
+
optional = { 'scope' => scope, 'state' => state, 'resource' => resource }.reject { |_, v| v.nil? || v.empty? }
|
|
134
|
+
params = { 'response_type' => 'code', **required, 'code_challenge_method' => 'S256', **optional }
|
|
135
|
+
query = params.map { |name, value| "#{name}=#{percent_encode(name, value)}" }.join('&')
|
|
136
|
+
"#{@transport.config.base_url}#{AUTHORIZE_PATH}?#{query}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A fresh PKCE pair for one sign-in, from 32 bytes of SecureRandom.
|
|
140
|
+
#
|
|
141
|
+
# @return [Pkce]
|
|
142
|
+
def create_pkce
|
|
143
|
+
verifier = SecureRandom.urlsafe_base64(32)
|
|
144
|
+
Pkce.new(verifier: verifier, challenge: pkce_challenge(verifier), code_challenge_method: 'S256')
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url.
|
|
148
|
+
#
|
|
149
|
+
# @return [String]
|
|
150
|
+
def pkce_challenge(verifier)
|
|
151
|
+
[Digest::SHA256.digest(verifier)].pack('m0').tr('+/', '-_').delete('=')
|
|
152
|
+
end
|
|
153
|
+
|
|
85
154
|
# Revoke an access or refresh token. A refresh token ends the whole grant and
|
|
86
155
|
# every token it issued, which is how a machine signs out.
|
|
87
156
|
#
|
|
@@ -147,6 +216,16 @@ module VPNDetection
|
|
|
147
216
|
end
|
|
148
217
|
end
|
|
149
218
|
|
|
219
|
+
# Every byte of the UTF-8 but A-Z a-z 0-9 - . _ ~ as %XX, so a space is %20
|
|
220
|
+
# and never +.
|
|
221
|
+
def percent_encode(name, value)
|
|
222
|
+
raise ArgumentError, "#{name} is not valid UTF-8" unless value.encode(Encoding::UTF_8).valid_encoding?
|
|
223
|
+
|
|
224
|
+
value.encode(Encoding::UTF_8).b.gsub(/[^A-Za-z0-9\-._~]/n) { |byte| format('%%%02X', byte.ord) }
|
|
225
|
+
rescue EncodingError
|
|
226
|
+
raise ArgumentError, "#{name} is not valid UTF-8"
|
|
227
|
+
end
|
|
228
|
+
|
|
150
229
|
def exchange(form, timeout)
|
|
151
230
|
decode(TokenResponse, @transport.oauth_request(:POST, TOKEN_PATH, form: form, timeout: timeout).run)
|
|
152
231
|
end
|
data/lib/vpndetection/version.rb
CHANGED