internetdata 2.4.1 → 2.5.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/internetdata/oauth_api.rb +81 -2
- data/lib/internetdata/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: 960ec5836038e70272ff2f5b29b23d2604fe320fb5a143f1647f72c0b66028ab
|
|
4
|
+
data.tar.gz: 26d3bfca65b7246fc6291b38ebefea5e4496cedba7bff94fe662a0a5e395d704
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa039d9cad48377cee2dccafc75220a202f1731078b091a5f70bcfefacdec9e2c1bfd121c83b41c384173a136dd23fe6e6de8f77440ca14c06fa65b6c8f84c13
|
|
7
|
+
data.tar.gz: a9d1ec2c94f7c1828cfb78f2c552090eefb2a183075a1f6b84f40613c6efe45f03cec1ff0b4d3b70e04f8ab17d027b6c573d9d25da11fc44e3b6494a30bbb22f
|
data/README.md
CHANGED
|
@@ -137,6 +137,23 @@ keyed = InternetData::Client.new(api_key: token.apikey)
|
|
|
137
137
|
|
|
138
138
|
`poll_device_token` raises `InternetData::OauthAccessDeniedError` when the person refuses and `InternetData::OauthExpiredTokenError` when the code expires first. Client IDs are issued on request from support@internetdata.io, and `client.oauth.revoke('your-client-id', token.refresh_token)` signs the machine out.
|
|
139
139
|
|
|
140
|
+
### Sign in with OAuth (authorization code)
|
|
141
|
+
|
|
142
|
+
An app that can take a browser redirect signs the person in there instead, with a PKCE pair made for that one sign-in:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
client = InternetData::Client.new
|
|
146
|
+
redirect_uri = 'http://127.0.0.1:8765/callback'
|
|
147
|
+
pkce = client.oauth.create_pkce
|
|
148
|
+
|
|
149
|
+
url = client.oauth.authorization_url('your-client-id', redirect_uri, pkce.challenge,
|
|
150
|
+
scope: 'apikeys.use', state: 'your-state')
|
|
151
|
+
# Open url in the browser. Its redirect to redirect_uri carries code and state.
|
|
152
|
+
token = client.oauth.exchange_authorization_code('your-client-id', code, pkce.verifier, redirect_uri)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
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`.
|
|
156
|
+
|
|
140
157
|
## Other Libraries
|
|
141
158
|
|
|
142
159
|
There are official InternetData client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/internetdata for more.
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
3
6
|
module InternetData
|
|
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 InternetData::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 InternetData
|
|
|
9
24
|
# client ID works; they are issued on request from support@internetdata.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 InternetData
|
|
|
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 InternetData
|
|
|
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/internetdata/version.rb
CHANGED