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.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +353 -0
- data/LICENSE.txt +21 -0
- data/README.md +54 -0
- data/lib/x/core/app_only_authenticator.rb +198 -0
- data/lib/x/core/authenticator.rb +74 -0
- data/lib/x/core/authenticator_request.rb +56 -0
- data/lib/x/core/bearer_token_authenticator.rb +46 -0
- data/lib/x/core/built_response.rb +100 -0
- data/lib/x/core/client.rb +728 -0
- data/lib/x/core/client_app_only.rb +115 -0
- data/lib/x/core/client_credentials.rb +193 -0
- data/lib/x/core/client_internals.rb +315 -0
- data/lib/x/core/client_memo.rb +58 -0
- data/lib/x/core/client_settings.rb +166 -0
- data/lib/x/core/client_token_refresh.rb +281 -0
- data/lib/x/core/connection.rb +328 -0
- data/lib/x/core/connection_pool.rb +123 -0
- data/lib/x/core/connection_proxy.rb +151 -0
- data/lib/x/core/connection_request.rb +90 -0
- data/lib/x/core/credential_holder.rb +78 -0
- data/lib/x/core/credential_validator.rb +286 -0
- data/lib/x/core/errors/authorization_denied.rb +55 -0
- data/lib/x/core/errors/authorization_error.rb +41 -0
- data/lib/x/core/errors/bad_gateway.rb +9 -0
- data/lib/x/core/errors/bad_request.rb +10 -0
- data/lib/x/core/errors/callback_error.rb +88 -0
- data/lib/x/core/errors/client_error.rb +17 -0
- data/lib/x/core/errors/conflict.rb +9 -0
- data/lib/x/core/errors/error.rb +66 -0
- data/lib/x/core/errors/forbidden.rb +10 -0
- data/lib/x/core/errors/gateway_timeout.rb +9 -0
- data/lib/x/core/errors/gone.rb +9 -0
- data/lib/x/core/errors/http_error.rb +333 -0
- data/lib/x/core/errors/internal_server_error.rb +9 -0
- data/lib/x/core/errors/invalid_response.rb +96 -0
- data/lib/x/core/errors/method_not_allowed.rb +10 -0
- data/lib/x/core/errors/network_error.rb +59 -0
- data/lib/x/core/errors/not_acceptable.rb +10 -0
- data/lib/x/core/errors/not_found.rb +15 -0
- data/lib/x/core/errors/payload_too_large.rb +10 -0
- data/lib/x/core/errors/payment_required.rb +10 -0
- data/lib/x/core/errors/request_timeout.rb +9 -0
- data/lib/x/core/errors/server_error.rb +15 -0
- data/lib/x/core/errors/service_unavailable.rb +9 -0
- data/lib/x/core/errors/token_report_failed.rb +59 -0
- data/lib/x/core/errors/too_many_redirects.rb +53 -0
- data/lib/x/core/errors/too_many_requests.rb +80 -0
- data/lib/x/core/errors/unauthorized.rb +15 -0
- data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
- data/lib/x/core/errors/unprocessable_entity.rb +10 -0
- data/lib/x/core/errors/unsupported_format.rb +22 -0
- data/lib/x/core/errors/unsupported_media_type.rb +10 -0
- data/lib/x/core/errors/unsupported_operation.rb +18 -0
- data/lib/x/core/oauth1_authenticator.rb +142 -0
- data/lib/x/core/oauth2_authenticator.rb +296 -0
- data/lib/x/core/oauth2_authorization.rb +405 -0
- data/lib/x/core/oauth2_refresh.rb +272 -0
- data/lib/x/core/oauth2_tokens.rb +277 -0
- data/lib/x/core/origin.rb +95 -0
- data/lib/x/core/problem.rb +302 -0
- data/lib/x/core/proxy_setting.rb +22 -0
- data/lib/x/core/rate_limit.rb +137 -0
- data/lib/x/core/rate_limit_handler.rb +166 -0
- data/lib/x/core/redirect_handler.rb +208 -0
- data/lib/x/core/refresh_reporter.rb +92 -0
- data/lib/x/core/request_builder.rb +155 -0
- data/lib/x/core/request_context.rb +85 -0
- data/lib/x/core/request_encoding.rb +135 -0
- data/lib/x/core/response.rb +196 -0
- data/lib/x/core/response_headers.rb +31 -0
- data/lib/x/core/response_parser.rb +155 -0
- data/lib/x/core/retry_handler.rb +149 -0
- data/lib/x/core/setting_validator.rb +399 -0
- data/lib/x/core/stream_body.rb +77 -0
- data/lib/x/core/stream_response.rb +267 -0
- data/lib/x/core/token_endpoint.rb +238 -0
- data/lib/x/core/version.rb +25 -0
- data/lib/x/core.rb +6 -0
- data/sig/manifest.yaml +8 -0
- data/sig/x-core.rbs +396 -0
- 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
|