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,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