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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e04a226b5ebdcdaf6dc01178b331e40a3aa834facc43f289a844fe1c772aa687
4
- data.tar.gz: 995b7691fffb03bfbe5b66f95f9341842809f71c8a3f0d8ee53d8429abef0288
3
+ metadata.gz: 960ec5836038e70272ff2f5b29b23d2604fe320fb5a143f1647f72c0b66028ab
4
+ data.tar.gz: 26d3bfca65b7246fc6291b38ebefea5e4496cedba7bff94fe662a0a5e395d704
5
5
  SHA512:
6
- metadata.gz: 72f3070388c1b5feee4f5ac8e562c3fc853d46ee345cde838556ba784b49fbb27e96bb03ba997b9581f3bfa1894457eff692b808772388dc8d6f38cb1b57469e
7
- data.tar.gz: b047915dc0afa14375c651cc016b385a97f5bd24c3e8e09a0192e7dbdea2b9a91f987bd9ca1a0198d6202ffff03273afdaaa1d710dce1324ebb7f97391a25441
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 takes
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InternetData
4
- VERSION = '2.4.1'
4
+ VERSION = '2.5.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: internetdata
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.1
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mslm Dev