x-core 1.0.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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +353 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +54 -0
  6. data/lib/x/core/app_only_authenticator.rb +198 -0
  7. data/lib/x/core/authenticator.rb +74 -0
  8. data/lib/x/core/authenticator_request.rb +56 -0
  9. data/lib/x/core/bearer_token_authenticator.rb +46 -0
  10. data/lib/x/core/built_response.rb +100 -0
  11. data/lib/x/core/client.rb +728 -0
  12. data/lib/x/core/client_app_only.rb +115 -0
  13. data/lib/x/core/client_credentials.rb +193 -0
  14. data/lib/x/core/client_internals.rb +315 -0
  15. data/lib/x/core/client_memo.rb +58 -0
  16. data/lib/x/core/client_settings.rb +166 -0
  17. data/lib/x/core/client_token_refresh.rb +281 -0
  18. data/lib/x/core/connection.rb +328 -0
  19. data/lib/x/core/connection_pool.rb +123 -0
  20. data/lib/x/core/connection_proxy.rb +151 -0
  21. data/lib/x/core/connection_request.rb +90 -0
  22. data/lib/x/core/credential_holder.rb +78 -0
  23. data/lib/x/core/credential_validator.rb +286 -0
  24. data/lib/x/core/errors/authorization_denied.rb +55 -0
  25. data/lib/x/core/errors/authorization_error.rb +41 -0
  26. data/lib/x/core/errors/bad_gateway.rb +9 -0
  27. data/lib/x/core/errors/bad_request.rb +10 -0
  28. data/lib/x/core/errors/callback_error.rb +88 -0
  29. data/lib/x/core/errors/client_error.rb +17 -0
  30. data/lib/x/core/errors/conflict.rb +9 -0
  31. data/lib/x/core/errors/error.rb +66 -0
  32. data/lib/x/core/errors/forbidden.rb +10 -0
  33. data/lib/x/core/errors/gateway_timeout.rb +9 -0
  34. data/lib/x/core/errors/gone.rb +9 -0
  35. data/lib/x/core/errors/http_error.rb +333 -0
  36. data/lib/x/core/errors/internal_server_error.rb +9 -0
  37. data/lib/x/core/errors/invalid_response.rb +96 -0
  38. data/lib/x/core/errors/method_not_allowed.rb +10 -0
  39. data/lib/x/core/errors/network_error.rb +59 -0
  40. data/lib/x/core/errors/not_acceptable.rb +10 -0
  41. data/lib/x/core/errors/not_found.rb +15 -0
  42. data/lib/x/core/errors/payload_too_large.rb +10 -0
  43. data/lib/x/core/errors/payment_required.rb +10 -0
  44. data/lib/x/core/errors/request_timeout.rb +9 -0
  45. data/lib/x/core/errors/server_error.rb +15 -0
  46. data/lib/x/core/errors/service_unavailable.rb +9 -0
  47. data/lib/x/core/errors/token_report_failed.rb +59 -0
  48. data/lib/x/core/errors/too_many_redirects.rb +53 -0
  49. data/lib/x/core/errors/too_many_requests.rb +80 -0
  50. data/lib/x/core/errors/unauthorized.rb +15 -0
  51. data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
  52. data/lib/x/core/errors/unprocessable_entity.rb +10 -0
  53. data/lib/x/core/errors/unsupported_format.rb +22 -0
  54. data/lib/x/core/errors/unsupported_media_type.rb +10 -0
  55. data/lib/x/core/errors/unsupported_operation.rb +18 -0
  56. data/lib/x/core/oauth1_authenticator.rb +142 -0
  57. data/lib/x/core/oauth2_authenticator.rb +296 -0
  58. data/lib/x/core/oauth2_authorization.rb +405 -0
  59. data/lib/x/core/oauth2_refresh.rb +272 -0
  60. data/lib/x/core/oauth2_tokens.rb +277 -0
  61. data/lib/x/core/origin.rb +95 -0
  62. data/lib/x/core/problem.rb +302 -0
  63. data/lib/x/core/proxy_setting.rb +22 -0
  64. data/lib/x/core/rate_limit.rb +137 -0
  65. data/lib/x/core/rate_limit_handler.rb +166 -0
  66. data/lib/x/core/redirect_handler.rb +208 -0
  67. data/lib/x/core/refresh_reporter.rb +92 -0
  68. data/lib/x/core/request_builder.rb +155 -0
  69. data/lib/x/core/request_context.rb +85 -0
  70. data/lib/x/core/request_encoding.rb +135 -0
  71. data/lib/x/core/response.rb +196 -0
  72. data/lib/x/core/response_headers.rb +31 -0
  73. data/lib/x/core/response_parser.rb +155 -0
  74. data/lib/x/core/retry_handler.rb +149 -0
  75. data/lib/x/core/setting_validator.rb +399 -0
  76. data/lib/x/core/stream_body.rb +77 -0
  77. data/lib/x/core/stream_response.rb +267 -0
  78. data/lib/x/core/token_endpoint.rb +238 -0
  79. data/lib/x/core/version.rb +25 -0
  80. data/lib/x/core.rb +6 -0
  81. data/sig/manifest.yaml +8 -0
  82. data/sig/x-core.rbs +396 -0
  83. metadata +162 -0
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "simple_oauth"
4
+ require_relative "authenticator"
5
+ require_relative "connection"
6
+ require_relative "credential_validator"
7
+ require_relative "setting_validator"
8
+ require_relative "errors/unsupported_operation"
9
+ require_relative "oauth2_refresh"
10
+
11
+ module X
12
+ module Core
13
+ # Handles OAuth 2.0 authentication, refreshing the access token when it expires
14
+ #
15
+ # X issues a new refresh token with each access token and accepts a refresh token once, so an authenticator
16
+ # refreshes under a lock, and the authenticator of a client passes the tokens each refresh issued, as OAuth2Tokens,
17
+ # to the save_tokens of that client and of each copy of it that shares the authenticator, so that they can be
18
+ # stored.
19
+ #
20
+ # Processes that share the tokens of a user, storing each refresh with save_tokens, read the store with
21
+ # load_tokens, which a refresh calls under its lock before it refreshes: X accepts a refresh token once, and a
22
+ # process that refreshed with the one another had already spent would be refused. The tokens a refresh takes from
23
+ # the store are not passed to save_tokens, since they came from it.
24
+ #
25
+ # X issues no refresh token for an authorization without the offline.access scope, so an authenticator built
26
+ # without one authenticates as the user until its access token expires, and refreshes nothing: a request sent
27
+ # with an access token that expired is sent as it is, for the API to reject with Unauthorized, and one the API
28
+ # rejects is not sent again.
29
+ #
30
+ # @api public
31
+ class ::X::OAuth2Authenticator < Authenticator
32
+ include OAuth2Refresh
33
+
34
+ # The endpoint that refreshes an access token, at X, whose path is requested at the origin of the base URL of
35
+ # the client that takes the authenticator
36
+ TOKEN_URL = "https://api.x.com/2/oauth2/token"
37
+ # Buffer time in seconds to account for clock skew and network latency
38
+ EXPIRATION_BUFFER = 30
39
+ private_constant :TOKEN_URL, :EXPIRATION_BUFFER
40
+ # The message raised for a refresh of an authenticator that holds no refresh token
41
+ NO_REFRESH_TOKEN = "The authenticator holds no refresh token, which X issues only for an authorization with the " \
42
+ "offline.access scope, so its access token cannot be refreshed. Ask the user to authorize the app again, with " \
43
+ "offline.access to refresh the token that authorization issues"
44
+ private_constant :NO_REFRESH_TOKEN
45
+
46
+ # The OAuth 2.0 client ID
47
+ # @api public
48
+ # @return [String] the client ID
49
+ # @example Get the client ID
50
+ # authenticator.client_id
51
+ attr_reader :client_id
52
+ # The expiration time of the access token
53
+ # @api public
54
+ # @return [Time, nil] the expiration time
55
+ # @example Get the expiration time
56
+ # authenticator.expires_at
57
+ attr_reader :expires_at
58
+ # The scopes X granted the access token, as last refreshed
59
+ #
60
+ # A refresh that names no scopes keeps those the authenticator held, as OAuth 2.0 has it.
61
+ #
62
+ # @api public
63
+ # @return [Array<String>, nil] the scopes, frozen, or nil if they are not known
64
+ # @example Check that the user let the app post
65
+ # authenticator.scopes&.include?("tweet.write")
66
+ attr_reader :scopes
67
+
68
+ # Initialize a new OAuth 2.0 authenticator
69
+ #
70
+ # @api public
71
+ # @param client_id [String] the OAuth 2.0 client ID
72
+ # @param client_secret [String, nil] the OAuth 2.0 client secret, or nil for a public client, which sends its
73
+ # client ID in the body of a refresh instead of authenticating with a secret
74
+ # @param access_token [String] the OAuth 2.0 access token
75
+ # @param refresh_token [String, nil] the OAuth 2.0 refresh token, or nil for an access token issued without the
76
+ # offline.access scope, which the authenticator cannot refresh
77
+ # @param expires_at [Time, nil] the expiration time of the access token
78
+ # @param scopes [Array<String>, nil] the scopes X granted the access token, or nil if they are not known
79
+ # @param load_tokens [#call, nil] a callable that takes no arguments and returns the OAuth2Tokens in the storage
80
+ # the tokens of the user are shared through, or nil for none there, which a refresh reads first, as the
81
+ # load_tokens of X::Client#initialize describes; nil reads the load_tokens of a client that authenticates with
82
+ # the authenticator instead
83
+ # @return [OAuth2Authenticator] a new authenticator instance
84
+ # @raise [ArgumentError] if the client ID or access token is nil or empty, the refresh token or client secret is
85
+ # empty, the expiration time is neither a Time nor nil, the scopes are neither an Array of Strings that each name
86
+ # a scope nor nil, or load_tokens is neither nil nor responds to call
87
+ # @example Create an authenticator
88
+ # authenticator = X::OAuth2Authenticator.new(
89
+ # client_id: "id",
90
+ # client_secret: "secret",
91
+ # access_token: "token",
92
+ # refresh_token: "refresh"
93
+ # )
94
+ # @example Share the tokens of a user among processes, storing each refresh and reading the store before one
95
+ # authenticator = X::OAuth2Authenticator.new(client_id: "id", **store.load(user).to_h,
96
+ # load_tokens: -> { store.load(user) })
97
+ # client = X::Client.new(authenticator:, save_tokens: ->(tokens) { store.save(user, tokens) })
98
+ def initialize(client_id:, access_token:, refresh_token: nil, client_secret: nil, expires_at: nil, scopes: nil, load_tokens: nil)
99
+ CredentialValidator.validate_required!({client_id:, access_token:}, {refresh_token:, client_secret:, expires_at:, scopes:})
100
+ initialize_refresh(load_tokens)
101
+ @client_id, @client_secret = SettingValidator.frozen(client_id), SettingValidator.frozen(client_secret)
102
+ @access_token, @refresh_token = SettingValidator.frozen(access_token), SettingValidator.frozen(refresh_token)
103
+ @expires_at, @scopes = expires_at, CredentialValidator.frozen_scopes(scopes)
104
+ @connection, @token_url, @token_headers = Connection.new, TOKEN_URL, {}
105
+ @clients = ObjectSpace::WeakMap.new
106
+ end
107
+
108
+ # Generate the authentication header, refreshing an expired token first
109
+ #
110
+ # An authenticator that holds no refresh token sends an access token that expired as it is, for the API to reject.
111
+ #
112
+ # @api public
113
+ # @param _request [#http_method, #uri, #body, #[], nil] the request, which a bearer token does not sign
114
+ # @return [Hash{String => String}] the authentication header
115
+ # @raise [AuthorizationError] if the token has expired and X refuses to refresh it
116
+ # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
117
+ # as a server error, a redirect, or the page of a proxy says
118
+ # @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh, with the tokens
119
+ # @example Get the header
120
+ # authenticator.headers(request)
121
+ def headers(_request)
122
+ refresh_expired_token(connection)
123
+ {AUTHENTICATION_HEADER => "Bearer #{access_token}"}
124
+ end
125
+
126
+ # Summarize the authenticator for the console without revealing credentials
127
+ #
128
+ # @api public
129
+ # @return [String] the class name, client ID, and expiration time
130
+ # @example Inspect an authenticator
131
+ # authenticator.inspect # => #<X::OAuth2Authenticator client_id="id" expires_at=nil>
132
+ def inspect = "#<#{self.class} client_id=#{client_id.inspect} expires_at=#{expires_at.inspect}>"
133
+
134
+ # Check if the access token has expired or will expire soon
135
+ #
136
+ # @api public
137
+ # @return [Boolean] true if the token has expired or will expire within the buffer period
138
+ # @example Check expiration
139
+ # authenticator.token_expired?
140
+ def token_expired?
141
+ return false if expires_at.nil?
142
+
143
+ Time.now >= expires_at - EXPIRATION_BUFFER
144
+ end
145
+
146
+ # Refresh the access token using the refresh token
147
+ #
148
+ # The authenticator holds the new tokens once it returns, and the authenticator of a client has passed them to the
149
+ # save_tokens of the clients that share it. The tokens it returns are those of this refresh, frozen, the
150
+ # same object save_tokens is passed, so they are a set that belongs together, whatever refreshes follow on
151
+ # other threads.
152
+ #
153
+ # A refresh reads the tokens in storage first, with load_tokens, and refreshes with the refresh token there when it
154
+ # is another. When X refuses the refresh for a refresh token another process spent, and the storage holds
155
+ # another, the tokens there are returned in place of an error, and are not passed to save_tokens.
156
+ #
157
+ # A save_tokens that raises, as one whose storage is briefly down may, raises TokenReportFailed once each
158
+ # has been passed the tokens, which holds them, since the refresh token they replaced is spent and the
159
+ # authenticator holds them alone, with the error save_tokens raised as its cause.
160
+ #
161
+ # @api public
162
+ # @return [OAuth2Tokens] the tokens the refresh issued, or those it took from storage in place of a refusal
163
+ # @raise [UnsupportedOperation] if the authenticator holds no refresh token, before any request
164
+ # @raise [AuthorizationError] if X refuses to refresh the token
165
+ # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
166
+ # as a server error, a redirect, or the page of a proxy says
167
+ # @raise [TokenReportFailed] if save_tokens raises for the tokens of the refresh, with the tokens
168
+ # @example Refresh the tokens and store them
169
+ # store.save(**authenticator.refresh!.to_h)
170
+ def refresh!
171
+ raise UnsupportedOperation, NO_REFRESH_TOKEN unless refresh_token
172
+
173
+ tokens = @mutex.synchronize do
174
+ adopt_stored_tokens
175
+ refresh(connection, nil)
176
+ end
177
+ report_refresh(tokens, nil)
178
+ tokens
179
+ end
180
+
181
+ private
182
+
183
+ # The OAuth 2.0 access token, as last refreshed
184
+ #
185
+ # It is a secret, so it is private, as the access token of a client is, since a client hands out its
186
+ # authenticator. The tokens of a refresh are passed to save_tokens, and returned by {#refresh!}. Internal to
187
+ # x-core: a client reads it with __send__.
188
+ #
189
+ # @api private
190
+ # @return [String] the access token
191
+ attr_reader :access_token
192
+
193
+ # The OAuth 2.0 refresh token, as last refreshed
194
+ #
195
+ # It is private for the reason {#access_token} is.
196
+ #
197
+ # @api private
198
+ # @return [String, nil] the refresh token, or nil for an authenticator that cannot refresh
199
+ attr_reader :refresh_token
200
+
201
+ # The OAuth 2.0 client secret, which authenticates a refresh
202
+ # @api private
203
+ # @return [String, nil] the client secret, or nil for a public client
204
+ # @example Refresh with the client secret
205
+ # client_secret
206
+ attr_reader :client_secret
207
+
208
+ # The connection for making token requests
209
+ #
210
+ # refresh! sends its request over it, as does a request signed with the authenticator alone, with the headers of
211
+ # the client that took the authenticator first. A client refreshes over its own connection, with its own headers,
212
+ # instead, so that the copies of a client that share its authenticator, but were given another proxy, other
213
+ # timeouts, other debug output, or other headers, refresh with those.
214
+ #
215
+ # @api private
216
+ # @return [Core::Connection] the connection
217
+ attr_reader :connection
218
+
219
+ # The clients that authenticate with the authenticator, held weakly
220
+ #
221
+ # Each refresh reaches the save_tokens of each of them. Internal to x-core: a client joins the clients of the
222
+ # authenticator it builds, shares with the client it was copied from, or is given, and reads them with __send__,
223
+ # since they are private.
224
+ #
225
+ # @api private
226
+ # @return [ObjectSpace::WeakMap] the clients, each held as a key
227
+ attr_reader :clients
228
+
229
+ # Send the token requests over the connection of the first client that takes it
230
+ #
231
+ # The token endpoint is requested at the scheme, host, and port of the base URL of the client, rather than of
232
+ # TOKEN_URL, so that a client pointed at another host sends the client credentials and refresh token there, as it
233
+ # sends its requests. A refresh a request of a later client makes goes to the origin of that client instead.
234
+ #
235
+ # The authenticator is shared by the copies of the client that takes it, and may be given to other clients
236
+ # besides, so a client that takes it after the first leaves it sending them over the connection of the first,
237
+ # as a copy of a client leaves the authenticator of that client. Internal to x-core: a client sends the token
238
+ # requests of the authenticator it builds, or is given, over its own connection, with its proxy, timeouts, debug
239
+ # output, and headers, and calls it with __send__, since it is private.
240
+ #
241
+ # @api private
242
+ # @param connection [Core::Connection] the connection to send the token requests over
243
+ # @param base_url [String] the base URL of the client, at whose origin the token endpoint is requested
244
+ # @param headers [Hash{String => String}] the headers of the client, which the token requests are sent with
245
+ # @return [OAuth2Authenticator] the authenticator
246
+ def token_requests_over(connection, base_url, headers)
247
+ @mutex.synchronize do
248
+ unless @taken
249
+ @connection = connection
250
+ @token_url = TokenEndpoint.url_at(base_url, TOKEN_URL)
251
+ @token_headers = headers
252
+ end
253
+ @taken = true
254
+ end
255
+ self
256
+ end
257
+
258
+ # Check whether the authenticator holds the credentials among some options
259
+ #
260
+ # The options are those of a client, and the credentials among them its OAuth 2.0 ones.
261
+ #
262
+ # Internal to x-core: a copy of a client shares the authenticator unless it was given a credential this does not
263
+ # hold, and calls it with __send__, since it is private.
264
+ #
265
+ # @api private
266
+ # @param options [Hash{Symbol => Object}] the options of a client, of which the others are ignored
267
+ # @return [Boolean] true if each client ID, client secret, access token, or refresh token among them is the one
268
+ # this holds
269
+ def holds?(options)
270
+ options.slice(:client_id, :client_secret, :access_token, :refresh_token) <= {client_id:, client_secret:, access_token:, refresh_token:}
271
+ end
272
+
273
+ # Pass each refresh to the callables another reads
274
+ #
275
+ # Internal to x-core: Client passes the refreshes of the authenticator it builds to the save_tokens of each
276
+ # client that shares it, and calls it with __send__, since it is private.
277
+ #
278
+ # @api private
279
+ # @param hooks [#call] a callable that returns the callables to pass each refresh, read at each refresh
280
+ # @return [#call] the callable
281
+ def report_refreshes_to(hooks) = @reporter.to(hooks)
282
+
283
+ # The client for the token endpoint
284
+ # @api private
285
+ # @param token_url [String] the URL of the token endpoint
286
+ # @return [SimpleOAuth::OAuth2::Client] the OAuth 2.0 client
287
+ def oauth2_client(token_url) = SimpleOAuth::OAuth2::Client.new(client_id:, client_secret:, token_endpoint: token_url)
288
+
289
+ # The URL of the token endpoint a refresh is sent to, as token_headers tells
290
+ # @api private
291
+ # @param client [Client, nil] the client whose request refreshes, or nil for none
292
+ # @return [String] the URL
293
+ def token_url(client) = client ? TokenEndpoint.url_at(client.base_url, TOKEN_URL) : @token_url
294
+ end
295
+ end
296
+ end