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,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "simple_oauth"
|
|
4
|
+
require_relative "errors/authorization_error"
|
|
5
|
+
require_relative "errors/unauthorized"
|
|
6
|
+
require_relative "oauth2_tokens"
|
|
7
|
+
require_relative "origin"
|
|
8
|
+
require_relative "refresh_reporter"
|
|
9
|
+
require_relative "setting_validator"
|
|
10
|
+
require_relative "token_endpoint"
|
|
11
|
+
|
|
12
|
+
module X
|
|
13
|
+
module Core
|
|
14
|
+
# How an OAuth2Authenticator refreshes its tokens, before a request and after a rejection, included into it
|
|
15
|
+
#
|
|
16
|
+
# Processes that share the tokens of a user read the store they share with load_tokens before a refresh, and take
|
|
17
|
+
# the tokens there in place of their own when those hold another refresh token, which another process issued
|
|
18
|
+
# by a refresh of its own. The tokens taken are copied, and never recorded as the latest a refresh issued, so the
|
|
19
|
+
# RefreshReporter passes them to no save_tokens: they came from the store.
|
|
20
|
+
#
|
|
21
|
+
# Internal to x-core: the methods are private, and a client calls retrying_rejected_token with __send__.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
module OAuth2Refresh
|
|
25
|
+
# The message raised when the token endpoint describes no reason for the failure
|
|
26
|
+
DEFAULT_ERROR_MESSAGE = "Token refresh failed"
|
|
27
|
+
private_constant :DEFAULT_ERROR_MESSAGE
|
|
28
|
+
# Seconds after a refresh in which a rejection of the access token it issued refreshes nothing
|
|
29
|
+
FRESH_TOKEN_SECONDS = 60
|
|
30
|
+
private_constant :FRESH_TOKEN_SECONDS
|
|
31
|
+
# The message of the error raised for load_tokens that returns what is not tokens
|
|
32
|
+
NOT_TOKENS = "load_tokens must return an X::OAuth2Tokens, or nil for none in the store, not a %s. Build the " \
|
|
33
|
+
"tokens from what the store holds with X::OAuth2Tokens.new"
|
|
34
|
+
private_constant :NOT_TOKENS
|
|
35
|
+
# The error codes of a refresh X refuses for a refresh token it no longer accepts, as one another process spent
|
|
36
|
+
REFUSED_REFRESH_TOKEN = %w[invalid_request invalid_grant].freeze
|
|
37
|
+
private_constant :REFUSED_REFRESH_TOKEN
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Initialize the lock, the reporter, and the loader of the refreshes
|
|
42
|
+
# @api private
|
|
43
|
+
# @param load_tokens [#call, nil] the callable that returns the OAuth2Tokens in the store, or nil for none
|
|
44
|
+
# @return [void]
|
|
45
|
+
# @raise [ArgumentError] if load_tokens is neither nil nor responds to call
|
|
46
|
+
def initialize_refresh(load_tokens)
|
|
47
|
+
@load_tokens = SettingValidator.callable!(:load_tokens, load_tokens)
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
@reporter = RefreshReporter.new
|
|
50
|
+
@spent_refresh_tokens = Set.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Refresh the access token if it has expired, over a connection
|
|
54
|
+
#
|
|
55
|
+
# An authenticator that holds no refresh token refreshes nothing, and sends the token it holds.
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
58
|
+
# @param connection [Core::Connection] the connection to send the refresh over
|
|
59
|
+
# @param client [Client, nil] the client whose request refreshes, whose headers it is sent with, or nil for none
|
|
60
|
+
# @return [void]
|
|
61
|
+
# @raise [AuthorizationError] if X refuses to refresh the token
|
|
62
|
+
# @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
|
|
63
|
+
# as a server error, a redirect, or the page of a proxy says
|
|
64
|
+
# @raise [TokenReportFailed] if save_tokens raises for the tokens of the refresh
|
|
65
|
+
def refresh_expired_token(connection, client = nil)
|
|
66
|
+
tokens = @mutex.synchronize { renew(connection, client) if refresh_token && token_expired? }
|
|
67
|
+
report_refresh(tokens, client) if tokens
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Refresh a rejected access token, unless it was already replaced or just issued
|
|
71
|
+
#
|
|
72
|
+
# Requests that were sent with the same token, and rejected together, refresh it once between them. A token
|
|
73
|
+
# issued by a refresh less than FRESH_TOKEN_SECONDS ago has not expired, so the API rejects it for another
|
|
74
|
+
# reason, which a refresh would not change: it is not refreshed, and the rejection is raised, rather than spend
|
|
75
|
+
# a refresh token on each request an endpoint that always rejects the token answers. An authenticator that holds no
|
|
76
|
+
# refresh token refreshes nothing, so the rejection is raised.
|
|
77
|
+
#
|
|
78
|
+
# @api private
|
|
79
|
+
# @param rejected_token [String] the access token the API rejected
|
|
80
|
+
# @param connection [Core::Connection] the connection to send the refresh over
|
|
81
|
+
# @param client [Client, nil] the client whose request the API rejected, whose headers the refresh is sent with,
|
|
82
|
+
# or nil for none
|
|
83
|
+
# @return [Boolean] true if the access token is no longer the one rejected
|
|
84
|
+
# @raise [AuthorizationError] if X refuses to refresh the token
|
|
85
|
+
# @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
|
|
86
|
+
# as a server error, a redirect, or the page of a proxy says
|
|
87
|
+
# @raise [TokenReportFailed] if save_tokens raises for the tokens of the refresh
|
|
88
|
+
def refresh_rejected_token!(rejected_token, connection, client = nil)
|
|
89
|
+
tokens, replaced = @mutex.synchronize do
|
|
90
|
+
[(renew(connection, client) if refresh_token && access_token.eql?(rejected_token) && !fresh?), !access_token.eql?(rejected_token)]
|
|
91
|
+
end
|
|
92
|
+
report_refresh(tokens, client) if tokens
|
|
93
|
+
replaced
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Run a request, again if the API rejects a token that a refresh replaces
|
|
97
|
+
#
|
|
98
|
+
# X rejects an expired access token with 401 Unauthorized, which an authenticator that does not know when
|
|
99
|
+
# its token expires learns only from the rejection. A 401 from another origin than the one the token is sent to
|
|
100
|
+
# answers a request that carried no token, whether it named that origin or was redirected there, so it refreshes
|
|
101
|
+
# nothing: X accepts a refresh token once, and a refresh would replace the tokens for a rejection of no token.
|
|
102
|
+
#
|
|
103
|
+
# A token that has expired is refreshed before the request, and one the API rejects after it, over the
|
|
104
|
+
# connection given, which is the one of the client that sends the request, with the headers of that client: the
|
|
105
|
+
# copies of a client share its authenticator, and a copy given another proxy, other timeouts, other debug output,
|
|
106
|
+
# or other headers refreshes the tokens it shares with them, as it sends its requests with them.
|
|
107
|
+
#
|
|
108
|
+
# Internal to x-core: Client runs each request it sends with an OAuth 2.0 authenticator through it, and calls it
|
|
109
|
+
# with __send__, since it is private.
|
|
110
|
+
#
|
|
111
|
+
# @api private
|
|
112
|
+
# @param origin [URI::Generic] a URI of the origin the token is sent to, such as the base URL of a client
|
|
113
|
+
# @param connection [Core::Connection] the connection to send a refresh over
|
|
114
|
+
# @param client [Client, nil] the client that sends the request, which TokenReportFailed holds, or nil for none
|
|
115
|
+
# @yield runs the request
|
|
116
|
+
# @return [Object] what the block returns
|
|
117
|
+
# @raise [Unauthorized] if the request is rejected again, or by another origin, or a refresh does not replace
|
|
118
|
+
# the access token
|
|
119
|
+
# @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh, with the tokens and client
|
|
120
|
+
def retrying_rejected_token(origin, connection, client = nil)
|
|
121
|
+
refresh_expired_token(connection, client)
|
|
122
|
+
token = access_token
|
|
123
|
+
begin
|
|
124
|
+
yield
|
|
125
|
+
rescue Unauthorized => e
|
|
126
|
+
raise unless Origin.answered?(e, origin) && refresh_rejected_token!(token, connection, client)
|
|
127
|
+
|
|
128
|
+
yield
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Take the tokens in the store, or else refresh, holding the lock
|
|
133
|
+
#
|
|
134
|
+
# Tokens taken from the store whose access token has not expired are sent as they are, and the others refreshed
|
|
135
|
+
# with the refresh token there.
|
|
136
|
+
#
|
|
137
|
+
# @api private
|
|
138
|
+
# @param connection [Core::Connection] the connection to send the refresh over
|
|
139
|
+
# @param client [Client, nil] the client whose request refreshes, or nil for none
|
|
140
|
+
# @return [OAuth2Tokens, nil] the tokens the refresh issued, or those it took from the store in place of a
|
|
141
|
+
# refusal, or nil for tokens taken from the store that need no refresh
|
|
142
|
+
# @raise [AuthorizationError] if X refuses to refresh the token
|
|
143
|
+
# @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
|
|
144
|
+
# as a server error, a redirect, or the page of a proxy says
|
|
145
|
+
def renew(connection, client)
|
|
146
|
+
refresh(connection, client) unless adopt_stored_tokens && !token_expired?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Refresh the access token, holding the lock
|
|
150
|
+
#
|
|
151
|
+
# It is called for an authenticator that holds a refresh token alone, which a refresh never takes away. A
|
|
152
|
+
# refresh X refuses for a refresh token it no longer accepts reads the store again, and takes the tokens there,
|
|
153
|
+
# if they hold another refresh token, in place of raising: another process spent the refresh token first.
|
|
154
|
+
#
|
|
155
|
+
# @api private
|
|
156
|
+
# @param connection [Core::Connection] the connection to send the refresh over
|
|
157
|
+
# @param client [Client, nil] the client whose request refreshes, or nil for none
|
|
158
|
+
# @return [OAuth2Tokens] the tokens the refresh issued, once the authenticator holds them, or those it took from
|
|
159
|
+
# the store in place of a refusal
|
|
160
|
+
# @raise [AuthorizationError] if X refuses to refresh the token, and the store holds no other
|
|
161
|
+
# @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
|
|
162
|
+
# as a server error, a redirect, or the page of a proxy says
|
|
163
|
+
def refresh(connection, client)
|
|
164
|
+
held = refresh_token #: String
|
|
165
|
+
request = oauth2_client(token_url(client)).refresh_token_request(refresh_token: held)
|
|
166
|
+
update_tokens(TokenEndpoint.fetch(request, connection:, refusal: DEFAULT_ERROR_MESSAGE, headers: token_headers(client)))
|
|
167
|
+
@spent_refresh_tokens << held
|
|
168
|
+
issued = refresh_token #: String
|
|
169
|
+
@reporter.issued(OAuth2Tokens.new(access_token:, refresh_token: issued, expires_at:, scopes:))
|
|
170
|
+
rescue AuthorizationError => e
|
|
171
|
+
adopt_in_place_of(e) or raise
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The headers a refresh is sent with
|
|
175
|
+
#
|
|
176
|
+
# A refresh a client's request makes is sent to the token endpoint at the origin of that client's base URL,
|
|
177
|
+
# with its headers, over its connection, and one made for no client, as refresh! and a request signed with the
|
|
178
|
+
# authenticator alone make, to the endpoint of the client that took the authenticator first, with its headers,
|
|
179
|
+
# over its connection.
|
|
180
|
+
#
|
|
181
|
+
# @api private
|
|
182
|
+
# @param client [Client, nil] the client whose request refreshes, or nil for none
|
|
183
|
+
# @return [Hash{String => String}] the headers
|
|
184
|
+
def token_headers(client) = client ? client.headers : @token_headers
|
|
185
|
+
|
|
186
|
+
# Take the stored tokens in place of a refused refresh
|
|
187
|
+
# @api private
|
|
188
|
+
# @param error [AuthorizationError] the refusal
|
|
189
|
+
# @return [OAuth2Tokens, nil] a copy of the tokens taken, or nil if X refused anything but the refresh token, or
|
|
190
|
+
# the store holds no other
|
|
191
|
+
def adopt_in_place_of(error) = (adopt_stored_tokens if REFUSED_REFRESH_TOKEN.include?(error.error_code))
|
|
192
|
+
|
|
193
|
+
# Take the stored tokens, if they hold another refresh token
|
|
194
|
+
#
|
|
195
|
+
# Tokens that hold no refresh token, as an authorization without offline.access stores them, are not taken, since
|
|
196
|
+
# they would take away the refresh token of an authenticator that refreshes, which a refresh never does. Tokens
|
|
197
|
+
# that hold the refresh token the authenticator holds, or one any of its refreshes spent, are its own, as the
|
|
198
|
+
# store holds them until save_tokens has stored the tokens of a later refresh, which it is passed once the lock
|
|
199
|
+
# is released, so they are not taken: refreshes on several threads while save_tokens is slow leave the store
|
|
200
|
+
# holding tokens older than the last refresh spent, whose refresh token X no longer accepts. The age of the access token taken is unknown, so it is not fresh.
|
|
201
|
+
#
|
|
202
|
+
# @api private
|
|
203
|
+
# @return [OAuth2Tokens, nil] a copy of the tokens taken, or nil if the store holds none, or none of another's
|
|
204
|
+
def adopt_stored_tokens
|
|
205
|
+
stored = stored_tokens or return
|
|
206
|
+
stored_refresh_token = stored.refresh_token
|
|
207
|
+
return if stored_refresh_token.nil? || stored_refresh_token.eql?(refresh_token) || @spent_refresh_tokens.include?(stored_refresh_token)
|
|
208
|
+
|
|
209
|
+
@refreshed_at = nil
|
|
210
|
+
@access_token = stored.access_token
|
|
211
|
+
@refresh_token = stored.refresh_token
|
|
212
|
+
@expires_at = stored.expires_at
|
|
213
|
+
@scopes = stored.scopes
|
|
214
|
+
OAuth2Tokens.new(**stored.to_h)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The tokens in the store the tokens of the user are shared through
|
|
218
|
+
#
|
|
219
|
+
# They are read with the load_tokens of the authenticator, or else with the load_tokens of a client that
|
|
220
|
+
# authenticates with it. The error names the class of what load_tokens returned, rather than inspect it, since a
|
|
221
|
+
# Hash of the tokens would show them.
|
|
222
|
+
#
|
|
223
|
+
# @api private
|
|
224
|
+
# @return [OAuth2Tokens, nil] the tokens, or nil for none in the store, or no load_tokens to read it with
|
|
225
|
+
# @raise [TypeError] if load_tokens returns neither OAuth2Tokens nor nil
|
|
226
|
+
def stored_tokens
|
|
227
|
+
load_tokens = @load_tokens || clients.keys.filter_map(&:load_tokens).first
|
|
228
|
+
stored = load_tokens&.call
|
|
229
|
+
raise TypeError, format(NOT_TOKENS, stored.class) unless stored.nil? || stored.is_a?(OAuth2Tokens)
|
|
230
|
+
|
|
231
|
+
stored
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Pass the tokens of a refresh to its callables, once the lock is released
|
|
235
|
+
#
|
|
236
|
+
# A callable can make a request of its own, such as looking up the user whose tokens it stores, which asks this
|
|
237
|
+
# authenticator for a header and so takes the lock again. The refreshes are reported in the order they were
|
|
238
|
+
# made, and one already replaced is not reported; see {Core::RefreshReporter}.
|
|
239
|
+
#
|
|
240
|
+
# @api private
|
|
241
|
+
# @param tokens [OAuth2Tokens] the tokens the refresh issued
|
|
242
|
+
# @param client [Client, nil] the client whose request refreshed, which TokenReportFailed holds, or nil for none
|
|
243
|
+
# @return [void]
|
|
244
|
+
# @raise [TokenReportFailed] if save_tokens raises for the tokens, with the tokens and client
|
|
245
|
+
def report_refresh(tokens, client) = @reporter.report(tokens, client)
|
|
246
|
+
|
|
247
|
+
# Update tokens from the response
|
|
248
|
+
# @api private
|
|
249
|
+
# @param token [SimpleOAuth::OAuth2::Token] the token the endpoint returned
|
|
250
|
+
# @return [void]
|
|
251
|
+
def update_tokens(token)
|
|
252
|
+
@refreshed_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
253
|
+
@access_token = SettingValidator.frozen(token.access_token)
|
|
254
|
+
@refresh_token = SettingValidator.frozen(token.refresh_token) if token.refresh_token
|
|
255
|
+
@expires_at = token.expires_at
|
|
256
|
+
@scopes = TokenEndpoint.scopes_of(token) || scopes
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Check whether a refresh issued the access token within FRESH_TOKEN_SECONDS
|
|
260
|
+
#
|
|
261
|
+
# A token the authenticator was given, rather than refreshed, may be of any age, so it is not fresh.
|
|
262
|
+
#
|
|
263
|
+
# @api private
|
|
264
|
+
# @return [Boolean] true if a refresh issued the token less than FRESH_TOKEN_SECONDS ago
|
|
265
|
+
def fresh?
|
|
266
|
+
refreshed_at = @refreshed_at
|
|
267
|
+
!refreshed_at.nil? && Process.clock_gettime(Process::CLOCK_MONOTONIC) - refreshed_at < FRESH_TOKEN_SECONDS
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
private_constant :OAuth2Refresh
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "credential_validator"
|
|
6
|
+
require_relative "setting_validator"
|
|
7
|
+
require_relative "errors/unsupported_format"
|
|
8
|
+
|
|
9
|
+
module X
|
|
10
|
+
module Core
|
|
11
|
+
# The OAuth 2.0 tokens one refresh issued, or the exchange of an authorization code, which save_tokens is
|
|
12
|
+
# passed to store
|
|
13
|
+
#
|
|
14
|
+
# It is frozen, and taken while the refresh holds its lock, so it holds the tokens of the refresh it reports,
|
|
15
|
+
# whatever refreshes follow on other threads, where the authenticator holds the tokens of the latest one.
|
|
16
|
+
#
|
|
17
|
+
# @api public
|
|
18
|
+
class ::X::OAuth2Tokens
|
|
19
|
+
# The number of the format of the state Marshal writes, which every release of 1.x writes
|
|
20
|
+
#
|
|
21
|
+
# A later release of 1.x adds to the state only what an earlier one ignores, parts after those it reads and keys of a
|
|
22
|
+
# Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later.
|
|
23
|
+
MARSHAL_FORMAT = 1
|
|
24
|
+
private_constant :MARSHAL_FORMAT
|
|
25
|
+
|
|
26
|
+
# The message of the error raised for a token that is not a String
|
|
27
|
+
NOT_A_STRING = "%s must be a String, not a %s"
|
|
28
|
+
private_constant :NOT_A_STRING
|
|
29
|
+
# The message of the error raised for a token that is neither a String nor nil
|
|
30
|
+
NOT_A_STRING_OR_NIL = "%s must be a String or nil, not a %s"
|
|
31
|
+
private_constant :NOT_A_STRING_OR_NIL
|
|
32
|
+
# The message of the error raised for JSON that is not an object
|
|
33
|
+
NOT_AN_OBJECT = "the JSON of X::OAuth2Tokens must be an object, not a %s"
|
|
34
|
+
private_constant :NOT_AN_OBJECT
|
|
35
|
+
# The message of the error raised for an expiration time in JSON that is neither a String nor nil
|
|
36
|
+
NOT_A_TIME_STRING = "the expires_at of the JSON of X::OAuth2Tokens must be an ISO 8601 String or nil, not a %s"
|
|
37
|
+
private_constant :NOT_A_TIME_STRING
|
|
38
|
+
# The digits of a second the JSON of an expiration time holds, as many as a Time holds, so it reads back equal
|
|
39
|
+
FRACTION_DIGITS = 9
|
|
40
|
+
private_constant :FRACTION_DIGITS
|
|
41
|
+
|
|
42
|
+
# Read tokens back from the JSON that to_json wrote, or the Hash that as_json gave
|
|
43
|
+
#
|
|
44
|
+
# A store that holds JSON, such as Redis or a JSON column, holds the tokens as as_json gives them: the number of
|
|
45
|
+
# their format, each token, and the expiration time as an ISO 8601 String, which this reads back as a Time. The
|
|
46
|
+
# Hash may be keyed by Symbol, as JSON.parse(json, symbolize_names: true) and many caches give it back.
|
|
47
|
+
#
|
|
48
|
+
# @api public
|
|
49
|
+
# @param json [String, Hash{String, Symbol => Object}] the JSON to_json wrote, or the Hash as_json gave
|
|
50
|
+
# @return [OAuth2Tokens] the frozen tokens
|
|
51
|
+
# @raise [JSON::ParserError] if the String is not JSON
|
|
52
|
+
# @raise [ArgumentError] if the JSON is not an object, its expiration time is not an ISO 8601 String or nil, or
|
|
53
|
+
# its tokens or scopes are not those the constructor takes
|
|
54
|
+
# @raise [UnsupportedFormat] if the JSON is of a format this release does not read
|
|
55
|
+
# @example Read tokens stored as JSON
|
|
56
|
+
# X::OAuth2Tokens.from_json(redis.get("tokens"))
|
|
57
|
+
def self.from_json(json)
|
|
58
|
+
state = json_state(json)
|
|
59
|
+
new(access_token: state["access_token"], refresh_token: state["refresh_token"],
|
|
60
|
+
expires_at: json_time(state["expires_at"]), scopes: state["scopes"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The Hash of the JSON of tokens, of the format this release reads
|
|
64
|
+
#
|
|
65
|
+
# @api private
|
|
66
|
+
# @param json [String, Hash{String, Symbol => Object}] the JSON to_json wrote, or the Hash as_json gave
|
|
67
|
+
# @return [Hash{String => Object}] the Hash, keyed by String
|
|
68
|
+
# @raise [ArgumentError] if the JSON is not an object
|
|
69
|
+
# @raise [UnsupportedFormat] if the JSON is of a format this release does not read
|
|
70
|
+
def self.json_state(json)
|
|
71
|
+
state = json.is_a?(String) ? JSON.parse(json) : json
|
|
72
|
+
raise ArgumentError, format(NOT_AN_OBJECT, state.class) unless state.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
state = state.transform_keys(&:to_s)
|
|
75
|
+
format = state["format"]
|
|
76
|
+
raise UnsupportedFormat, "#{self} reads format #{MARSHAL_FORMAT} of JSON, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format)
|
|
77
|
+
|
|
78
|
+
state
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The expiration time the JSON of tokens holds as an ISO 8601 String
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
# @param expires_at [String, nil] the expiration time as JSON holds it
|
|
85
|
+
# @return [Time, nil] the expiration time, or nil for tokens that hold none
|
|
86
|
+
# @raise [ArgumentError] if the expiration time is neither an ISO 8601 String nor nil
|
|
87
|
+
def self.json_time(expires_at)
|
|
88
|
+
return if expires_at.nil?
|
|
89
|
+
raise ArgumentError, format(NOT_A_TIME_STRING, expires_at.class) unless expires_at.is_a?(String)
|
|
90
|
+
|
|
91
|
+
Time.iso8601(expires_at)
|
|
92
|
+
end
|
|
93
|
+
private_class_method :json_state, :json_time
|
|
94
|
+
|
|
95
|
+
# The OAuth 2.0 access token the refresh issued
|
|
96
|
+
# @api public
|
|
97
|
+
# @return [String] the access token
|
|
98
|
+
# @example Get the access token
|
|
99
|
+
# tokens.access_token
|
|
100
|
+
attr_reader :access_token
|
|
101
|
+
|
|
102
|
+
# The OAuth 2.0 refresh token the refresh issued
|
|
103
|
+
#
|
|
104
|
+
# It is the refresh token the refresh was sent with when X issued none. An authorization without the
|
|
105
|
+
# offline.access scope issues none, so the tokens of its exchange hold nil, and the access token acts for the
|
|
106
|
+
# user until it expires, with nothing to refresh it.
|
|
107
|
+
#
|
|
108
|
+
# @api public
|
|
109
|
+
# @return [String, nil] the refresh token, or nil for tokens issued without one
|
|
110
|
+
# @example Get the refresh token
|
|
111
|
+
# tokens.refresh_token
|
|
112
|
+
attr_reader :refresh_token
|
|
113
|
+
|
|
114
|
+
# The time the access token expires, or nil when the refresh reported no lifetime
|
|
115
|
+
# @api public
|
|
116
|
+
# @return [Time, nil] the expiration time
|
|
117
|
+
# @example Get the expiration time
|
|
118
|
+
# tokens.expires_at
|
|
119
|
+
attr_reader :expires_at
|
|
120
|
+
|
|
121
|
+
# The scopes X granted the access token
|
|
122
|
+
#
|
|
123
|
+
# They are the scopes the user authorized, which may be fewer than the app asked for, since a user can leave
|
|
124
|
+
# some out on the consent screen, so an app that needs a scope checks for it here rather than learn of it from
|
|
125
|
+
# the 403 Forbidden of a request. A refresh that names no scopes keeps those it refreshed, as OAuth 2.0 has it.
|
|
126
|
+
#
|
|
127
|
+
# @api public
|
|
128
|
+
# @return [Array<String>, nil] the scopes, frozen, or nil when X named none and none were known
|
|
129
|
+
# @example Check that the user let the app post
|
|
130
|
+
# tokens.scopes.include?("tweet.write")
|
|
131
|
+
attr_reader :scopes
|
|
132
|
+
|
|
133
|
+
# Initialize the tokens of a refresh
|
|
134
|
+
#
|
|
135
|
+
# @api public
|
|
136
|
+
# @param access_token [String] the access token
|
|
137
|
+
# @param refresh_token [String, nil] the refresh token, or nil for tokens issued without one
|
|
138
|
+
# @param expires_at [Time, nil] the expiration time of the access token
|
|
139
|
+
# @param scopes [Array<String>, nil] the scopes X granted the access token, or nil when they are not known
|
|
140
|
+
# @return [OAuth2Tokens] the frozen tokens
|
|
141
|
+
# @raise [ArgumentError] if the access token is not a String, the refresh token neither a String nor nil, or a
|
|
142
|
+
# token is empty, or if the expiration time is neither a Time nor nil, as it is a String for tokens read back
|
|
143
|
+
# from JSON other than with from_json, or the scopes are neither an Array of Strings that each name a scope nor
|
|
144
|
+
# nil
|
|
145
|
+
# @example Build the tokens of a refresh
|
|
146
|
+
# X::OAuth2Tokens.new(access_token: "token", refresh_token: "refresh", expires_at: Time.now + 7200,
|
|
147
|
+
# scopes: %w[tweet.read users.read offline.access])
|
|
148
|
+
def initialize(access_token:, refresh_token: nil, expires_at: nil, scopes: nil)
|
|
149
|
+
raise ArgumentError, format(NOT_A_STRING, :access_token, access_token.class) if access_token.nil?
|
|
150
|
+
raise ArgumentError, format(NOT_A_STRING_OR_NIL, :refresh_token, refresh_token.class) unless refresh_token.nil? || refresh_token.is_a?(String)
|
|
151
|
+
|
|
152
|
+
CredentialValidator.validate_required!({access_token:}, {refresh_token:, expires_at:, scopes:})
|
|
153
|
+
@access_token = SettingValidator.frozen(access_token)
|
|
154
|
+
@refresh_token = SettingValidator.frozen(refresh_token)
|
|
155
|
+
@expires_at = expires_at
|
|
156
|
+
@scopes = CredentialValidator.frozen_scopes(scopes)
|
|
157
|
+
freeze
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The tokens as a Hash, to store, and to build a client of
|
|
161
|
+
#
|
|
162
|
+
# Its keys are keywords X::Client.new and X::OAuth2Authenticator.new take, so that the tokens build a client, as
|
|
163
|
+
# in X::Client.new(client_id:, **tokens.to_h), and a later release of 1.x adds a key to it only as both take it.
|
|
164
|
+
#
|
|
165
|
+
# @api public
|
|
166
|
+
# @return [Hash{Symbol => String, Time, Array<String>, nil}] the access token, refresh token, expiration time,
|
|
167
|
+
# and scopes
|
|
168
|
+
# @example Store the tokens
|
|
169
|
+
# store.save(**tokens.to_h)
|
|
170
|
+
def to_h = {access_token:, refresh_token:, expires_at:, scopes:}
|
|
171
|
+
|
|
172
|
+
# Check whether other tokens are the same tokens
|
|
173
|
+
#
|
|
174
|
+
# @api public
|
|
175
|
+
# @param other [Object] the other tokens
|
|
176
|
+
# @return [Boolean] true if the other tokens are of the same class, with the same values
|
|
177
|
+
# @example Compare tokens
|
|
178
|
+
# tokens == other
|
|
179
|
+
def ==(other) = other.instance_of?(self.class) && to_h.eql?(other.to_h)
|
|
180
|
+
alias_method :eql?, :==
|
|
181
|
+
|
|
182
|
+
# The hash of the tokens, for use as a Hash key
|
|
183
|
+
#
|
|
184
|
+
# @api public
|
|
185
|
+
# @return [Integer] the hash
|
|
186
|
+
# @example Get the hash
|
|
187
|
+
# tokens.hash
|
|
188
|
+
def hash = [self.class, to_h].hash
|
|
189
|
+
|
|
190
|
+
# The tokens as JSON writes them, led by the number of their format
|
|
191
|
+
#
|
|
192
|
+
# JSON holds no Time, so the expiration time is written as an ISO 8601 String in UTC, to the nanosecond, which
|
|
193
|
+
# from_json reads back as a Time equal to it. It holds the tokens themselves, since tokens are written as JSON to
|
|
194
|
+
# be stored, so what JSON wrote is kept as secret as the tokens are.
|
|
195
|
+
#
|
|
196
|
+
# @api public
|
|
197
|
+
# @return [Hash{String => Integer, String, Array<String>, nil}] the number of the format, the access token, refresh
|
|
198
|
+
# token, expiration time, and scopes
|
|
199
|
+
# @example Store the tokens of a refresh in Redis
|
|
200
|
+
# X::Client.new(**credentials, save_tokens: ->(tokens) { redis.set("tokens", tokens.to_json) })
|
|
201
|
+
def as_json(*)
|
|
202
|
+
{"format" => MARSHAL_FORMAT, "access_token" => access_token, "refresh_token" => refresh_token,
|
|
203
|
+
"expires_at" => expires_at&.getutc&.iso8601(FRACTION_DIGITS), "scopes" => scopes}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The tokens as JSON, which from_json reads back
|
|
207
|
+
#
|
|
208
|
+
# @api public
|
|
209
|
+
# @param state [JSON::State, nil] the state a JSON encoder passes, which the Hash of as_json is given
|
|
210
|
+
# @return [String] the tokens as a JSON object
|
|
211
|
+
# @example Store the tokens of a refresh in Redis
|
|
212
|
+
# X::Client.new(**credentials, save_tokens: ->(tokens) { redis.set("tokens", tokens.to_json) })
|
|
213
|
+
def to_json(state = nil) = as_json.to_json(state)
|
|
214
|
+
|
|
215
|
+
# Summarize the tokens for the console without revealing them
|
|
216
|
+
#
|
|
217
|
+
# @api public
|
|
218
|
+
# @return [String] the class name and expiration time
|
|
219
|
+
# @example Inspect tokens
|
|
220
|
+
# tokens.inspect # => #<X::OAuth2Tokens expires_at=nil>
|
|
221
|
+
def inspect = "#<#{self.class} expires_at=#{expires_at.inspect}>"
|
|
222
|
+
|
|
223
|
+
# The state Marshal writes
|
|
224
|
+
#
|
|
225
|
+
# What is written is plain data, led by the number of its format, so that tokens written by one release of 1.x are
|
|
226
|
+
# read by a later one: the tokens, their expiration time, and their scopes, as to_h gives them. It holds the tokens themselves,
|
|
227
|
+
# since tokens are marshalled to be stored, so what Marshal wrote is kept as secret as the tokens are.
|
|
228
|
+
#
|
|
229
|
+
# @api public
|
|
230
|
+
# @return [Array(Integer, Hash{Symbol => String, Time, Array<String>, nil})] the number of the format, then the
|
|
231
|
+
# tokens as a Hash
|
|
232
|
+
# @example Store the tokens of a refresh
|
|
233
|
+
# X::Client.new(**credentials, save_tokens: ->(tokens) { File.binwrite("tokens", Marshal.dump(tokens)) })
|
|
234
|
+
def marshal_dump = [MARSHAL_FORMAT, to_h]
|
|
235
|
+
|
|
236
|
+
# Restore tokens Marshal read, built as the constructor builds them, frozen
|
|
237
|
+
#
|
|
238
|
+
# @api public
|
|
239
|
+
# @param state [Array] the state Marshal wrote
|
|
240
|
+
# @return [void]
|
|
241
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
242
|
+
# @example Read stored tokens
|
|
243
|
+
# Marshal.load(File.binread("tokens")).expires_at
|
|
244
|
+
def marshal_load(state)
|
|
245
|
+
format, tokens = state #: [Integer, {access_token: String, refresh_token: String?, expires_at: Time?, scopes: Array[String]?}]
|
|
246
|
+
raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format)
|
|
247
|
+
|
|
248
|
+
initialize(**tokens.slice(:access_token, :refresh_token, :expires_at, :scopes)) # steep:ignore InsufficientKeywordArguments
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Write the state Marshal writes as YAML
|
|
252
|
+
#
|
|
253
|
+
# YAML would write the instance variables of the tokens, and read them back into tokens that are not frozen, so
|
|
254
|
+
# they say how they are written: the number of their format, then each of them, under the name to_h gives it.
|
|
255
|
+
#
|
|
256
|
+
# @api public
|
|
257
|
+
# @param coder [Psych::Coder] the coder YAML writes the tokens with
|
|
258
|
+
# @return [void]
|
|
259
|
+
# @example Write tokens as YAML
|
|
260
|
+
# YAML.dump(tokens)
|
|
261
|
+
def encode_with(coder)
|
|
262
|
+
coder["format"] = MARSHAL_FORMAT
|
|
263
|
+
to_h.each { |key, value| coder[key.to_s] = value }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Restore tokens YAML read, frozen, as Marshal restores them
|
|
267
|
+
#
|
|
268
|
+
# @api public
|
|
269
|
+
# @param coder [Psych::Coder] the coder YAML read the tokens with
|
|
270
|
+
# @return [void]
|
|
271
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
272
|
+
# @example Read tokens written as YAML
|
|
273
|
+
# YAML.unsafe_load(File.read("tokens.yml")).expires_at
|
|
274
|
+
def init_with(coder) = marshal_load([coder["format"], coder.map.transform_keys(&:to_sym)])
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "authenticator"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Core
|
|
8
|
+
# The origin a request is sent to, and the credentials it carries there
|
|
9
|
+
#
|
|
10
|
+
# Credentials are for the origin they were given for: the scheme, host, and port of the base URL of a client.
|
|
11
|
+
# A request to any other origin carries none of them, whether it was a redirect followed there or an endpoint
|
|
12
|
+
# that named a whole URL, since the credentials of the API are no business of another host. What is dropped is
|
|
13
|
+
# the authenticator, which signs the Authorization header of a request, and any Authorization, Cookie, or
|
|
14
|
+
# Proxy-Authorization header of the client or the request itself.
|
|
15
|
+
#
|
|
16
|
+
# Internal to x-core: Client and RedirectHandler decide with it what a request carries.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
module Origin
|
|
20
|
+
extend self
|
|
21
|
+
|
|
22
|
+
# The headers that carry credentials, which a request to another origin drops
|
|
23
|
+
CREDENTIAL_HEADERS = [Authenticator::AUTHENTICATION_HEADER, "Cookie", "Proxy-Authorization"].freeze
|
|
24
|
+
|
|
25
|
+
# The authenticator and headers a request carries to an origin
|
|
26
|
+
#
|
|
27
|
+
# The credentials that would leave the origin they were given for are dropped.
|
|
28
|
+
#
|
|
29
|
+
# @api private
|
|
30
|
+
# @param from [URI::Generic] the URI the credentials were given for, such as the base URL of a client
|
|
31
|
+
# @param to [URI::Generic] the URI the request is sent to
|
|
32
|
+
# @param authenticator [Authenticator] the authenticator of the request
|
|
33
|
+
# @param headers [Hash{String => String}] the headers of the request
|
|
34
|
+
# @return [Array(Authenticator, Hash{String, Symbol => String})] the authenticator that signs the request,
|
|
35
|
+
# and the headers it is sent with
|
|
36
|
+
# @example Keep the credentials of a request to the API
|
|
37
|
+
# X::Core::Origin.credentials_for(from: URI(client.base_url), to: uri, authenticator:, headers:)
|
|
38
|
+
def credentials_for(from:, to:, authenticator:, headers:)
|
|
39
|
+
return [authenticator, headers] if same?(from, to)
|
|
40
|
+
|
|
41
|
+
[Authenticator.new, without_credentials(headers)]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check whether two URIs share a scheme, host, and port
|
|
45
|
+
#
|
|
46
|
+
# @api private
|
|
47
|
+
# @param uri [URI::Generic] the one URI
|
|
48
|
+
# @param other [URI::Generic] the other URI
|
|
49
|
+
# @return [Boolean] true if both have the same origin
|
|
50
|
+
# @example Check whether an endpoint names the host of the base URL
|
|
51
|
+
# X::Core::Origin.same?(URI(client.base_url), uri)
|
|
52
|
+
def same?(uri, other) = of(uri).eql?(of(other))
|
|
53
|
+
|
|
54
|
+
# Check whether the response an error was raised for came from an origin
|
|
55
|
+
#
|
|
56
|
+
# A request answered by the origin its credentials were given for carried them, and one answered by another,
|
|
57
|
+
# after a redirect there, carried none, so only a rejection by that origin rejected them.
|
|
58
|
+
#
|
|
59
|
+
# @api private
|
|
60
|
+
# @param error [HTTPError] the error
|
|
61
|
+
# @param origin [URI::Generic] a URI of the origin
|
|
62
|
+
# @return [Boolean] true if the response came from the origin, and false for one that names no URI
|
|
63
|
+
# @example Check whether a rejection came from the base URL of a client
|
|
64
|
+
# X::Core::Origin.answered?(error, URI(client.base_url))
|
|
65
|
+
def answered?(error, origin)
|
|
66
|
+
uri = error.http_response.uri
|
|
67
|
+
!uri.nil? && same?(origin, uri)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# The scheme, host, and port of a URI, in lowercase
|
|
73
|
+
# @api private
|
|
74
|
+
# @param uri [URI::Generic] the URI
|
|
75
|
+
# @return [Array(String, String, Integer)] the origin
|
|
76
|
+
def of(uri)
|
|
77
|
+
normalized = uri.normalize
|
|
78
|
+
[normalized.scheme, normalized.host, normalized.port]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Headers without the ones that carry credentials
|
|
82
|
+
#
|
|
83
|
+
# Authorization, Cookie, and Proxy-Authorization are dropped, whatever their case, whether a String or a Symbol
|
|
84
|
+
# names them.
|
|
85
|
+
#
|
|
86
|
+
# @api private
|
|
87
|
+
# @param headers [Hash{String => String}] the headers
|
|
88
|
+
# @return [Hash{String => String}] the headers that carry no credentials
|
|
89
|
+
def without_credentials(headers)
|
|
90
|
+
headers.reject { |name, _| CREDENTIAL_HEADERS.any? { |header| name.casecmp?(header) } }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
private_constant :Origin
|
|
94
|
+
end
|
|
95
|
+
end
|