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,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "simple_oauth"
5
+ require "uri"
6
+ require_relative "client"
7
+ require_relative "connection"
8
+ require_relative "credential_holder"
9
+ require_relative "credential_validator"
10
+ require_relative "errors/authorization_denied"
11
+ require_relative "errors/authorization_error"
12
+ require_relative "errors/token_report_failed"
13
+ require_relative "oauth2_authenticator"
14
+ require_relative "oauth2_tokens"
15
+ require_relative "setting_validator"
16
+ require_relative "token_endpoint"
17
+
18
+ module X
19
+ module Core
20
+ # Authorizes an app to act for a user with the OAuth 2.0 authorization code flow and PKCE
21
+ #
22
+ # The flow takes two requests to the app: one that sends the user to X to authorize it, and one that X redirects
23
+ # the user back to. The state and code verifier of the first must reach the second, so store them, as in the
24
+ # session, and build the authorization again with them when X redirects back.
25
+ #
26
+ # @api public
27
+ class ::X::OAuth2Authorization
28
+ include CredentialHolder
29
+
30
+ # The page that asks a user to authorize an app
31
+ AUTHORIZATION_URL = "https://x.com/i/oauth2/authorize"
32
+ # The endpoint that exchanges an authorization code for tokens, at X, whose path is requested at the origin of
33
+ # the base URL of the authorization, or of the client it builds
34
+ TOKEN_URL = "https://api.x.com/2/oauth2/token"
35
+ private_constant :TOKEN_URL
36
+ # The scopes that read posts and users, and keep a refresh token to act for the user after the access token expires
37
+ DEFAULT_SCOPES = %w[tweet.read users.read offline.access].freeze
38
+ # The number of random bytes in a generated state
39
+ STATE_BYTES = 32
40
+ private_constant :STATE_BYTES
41
+ # The message raised when X describes no reason for a failed authorization
42
+ DEFAULT_ERROR_MESSAGE = "Authorization failed"
43
+ private_constant :DEFAULT_ERROR_MESSAGE
44
+ # The message raised for a redirect back from X that is not a valid URL
45
+ INVALID_CALLBACK_MESSAGE = "The redirect back from X is not a valid URL"
46
+ private_constant :INVALID_CALLBACK_MESSAGE
47
+ # The options of Client#initialize that the exchange of the code gives the client of #client
48
+ CREDENTIALS = %i[api_key api_key_secret access_token access_token_secret bearer_token client_id client_secret
49
+ refresh_token expires_at scopes authenticator].freeze
50
+ private_constant :CREDENTIALS
51
+ # The message raised for credentials given to #client, which the exchange of the code gives the client
52
+ CREDENTIALS_GIVEN_MESSAGE = "The client of an authorization authenticates with the tokens X exchanges the code " \
53
+ "for, so it cannot be given %s"
54
+ private_constant :CREDENTIALS_GIVEN_MESSAGE
55
+ # The message raised for a redirect URI that is not a String of more than whitespace
56
+ INVALID_REDIRECT_URI = "redirect_uri must be a String that is not empty; pass the URL X redirects the user back to, as " \
57
+ "registered for the app"
58
+ private_constant :INVALID_REDIRECT_URI
59
+ # The message raised for scopes that are not an Array of Strings
60
+ INVALID_SCOPES = "scopes must be an Array of Strings that each name a scope, such as %%w[tweet.read users.read offline.access], not %s"
61
+ private_constant :INVALID_SCOPES
62
+ # The message raised for a state that is not a String, or is empty
63
+ INVALID_STATE = "state must be a String that is not empty; pass the state stored when the user was sent to X"
64
+ private_constant :INVALID_STATE
65
+
66
+ # The OAuth 2.0 client ID of the app
67
+ # @api public
68
+ # @return [String] the client ID
69
+ # @example Get the client ID
70
+ # authorization.client_id
71
+ attr_reader :client_id
72
+
73
+ # The URL X redirects the user back to, as registered for the app
74
+ # @api public
75
+ # @return [String] the redirect URI
76
+ # @example Get the redirect URI
77
+ # authorization.redirect_uri
78
+ attr_reader :redirect_uri
79
+
80
+ # The scopes the app asks the user for
81
+ #
82
+ # They are a frozen copy of the ones given, so that changing the Array the authorization was given never
83
+ # changes what it asks for, or the scopes of the tokens it builds when X names none.
84
+ #
85
+ # @api public
86
+ # @return [Array<String>] the scopes, frozen
87
+ # @example Get the scopes
88
+ # authorization.scopes # => ["tweet.read", "users.read", "offline.access"]
89
+ attr_reader :scopes
90
+
91
+ # The value that ties the redirect back from X to this authorization
92
+ # @api public
93
+ # @return [String] the state
94
+ # @example Store the state in the session
95
+ # session[:state] = authorization.state
96
+ attr_reader :state
97
+
98
+ # The PKCE code verifier, to store until X redirects back
99
+ # @api public
100
+ # @return [String] the code verifier
101
+ # @example Store the code verifier in the session
102
+ # session[:code_verifier] = authorization.code_verifier
103
+ attr_reader :code_verifier
104
+
105
+ # Initialize an authorization
106
+ #
107
+ # A new state and code verifier are generated unless they are given. The authorization code is exchanged for
108
+ # tokens at the origin of the base URL given, with the proxy, timeouts, keep-alive timeout, debug output, and
109
+ # headers given, which a client built with {#client} is given too.
110
+ #
111
+ # @api public
112
+ # @param client_id [String] the OAuth 2.0 client ID of the app
113
+ # @param redirect_uri [String] the URL X redirects the user back to, as registered for the app
114
+ # @param client_secret [String, nil] the client secret of a confidential app, or nil for a public client
115
+ # @param scopes [Array<String>] the scopes to ask the user for; offline.access keeps a refresh token
116
+ # @param state [String] the state, as stored when the user was sent to X
117
+ # @param code_verifier [String] the PKCE code verifier, as stored when the user was sent to X
118
+ # @param base_url [String] the base URL of the client the authorization builds, at whose origin the code is
119
+ # exchanged, as the client refreshes its tokens, so that an authorization pointed at another host, such as a test
120
+ # server, exchanges the code there
121
+ # @param proxy_url [String, URI::Generic, nil] the proxy URL for the token request
122
+ # @param open_timeout [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none
123
+ # @param read_timeout [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none
124
+ # @param write_timeout [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none
125
+ # @param keep_alive_timeout [Integer, Float] the time to keep a connection open for the next request to the same
126
+ # host, in seconds, which a proxy that closes idle connections sooner than X does may need lowered
127
+ # @param debug_output [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<,
128
+ # such as a StringIO. It is written every request and response whole, in the clear: the Authorization header,
129
+ # the client secret a token request sends, and the tokens a token response holds. Send it to a file you
130
+ # control while debugging, never to a log that is shipped elsewhere, and leave it nil in production.
131
+ # @param headers [Hash{String, Symbol => String}] the headers the code is exchanged with, beside the User-Agent of
132
+ # the gem, which one of them of that name replaces, as the headers of a client are sent, such as one a gateway
133
+ # the base URL names requires; an Authorization header is not sent, since the exchange carries its own
134
+ # credentials
135
+ # @return [OAuth2Authorization] a new authorization
136
+ # @raise [ArgumentError] if the client ID or redirect URI is nil, empty, or not a String, or the client secret is
137
+ # empty or not a String, which would send the user to X with a URL it refuses
138
+ # @raise [ArgumentError] if the scopes are not an Array of Strings that each name a scope, as a String that holds
139
+ # a space, which names two, does not
140
+ # @raise [ArgumentError] if the state is not a String, as nil is not, or is empty, which would accept the
141
+ # redirect of any authorization
142
+ # @raise [ArgumentError] if the code verifier is not 43 to 128 unreserved characters
143
+ # @raise [ArgumentError] if the base URL is not an absolute http or https URL with no user, password, query, or
144
+ # fragment
145
+ # @raise [ArgumentError] if a timeout is neither a finite number of seconds of at least 0 nor nil, or the
146
+ # keep-alive timeout is not a finite number of seconds of at least 0
147
+ # @raise [ArgumentError] if the debug output is a String, as the name of a file is, or is neither nil nor takes
148
+ # a String with <<
149
+ # @raise [ArgumentError] if the headers are not a Hash that names each header with a String or a Symbol and gives
150
+ # it a String
151
+ # @example Start an authorization
152
+ # authorization = X::OAuth2Authorization.new(client_id: "id", redirect_uri: "https://example.com/callback")
153
+ def initialize(client_id:, redirect_uri:, client_secret: nil, scopes: DEFAULT_SCOPES, state: SecureRandom.urlsafe_base64(STATE_BYTES),
154
+ code_verifier: SimpleOAuth::OAuth2::PKCE.generate.verifier, base_url: Client::DEFAULT_BASE_URL, proxy_url: nil,
155
+ open_timeout: Client::DEFAULT_OPEN_TIMEOUT, read_timeout: Client::DEFAULT_READ_TIMEOUT,
156
+ write_timeout: Client::DEFAULT_WRITE_TIMEOUT, keep_alive_timeout: Client::DEFAULT_KEEP_ALIVE_TIMEOUT, debug_output: nil, headers: {})
157
+ validate!(client_id:, redirect_uri:, client_secret:, scopes:, state:)
158
+ @client_id, @client_secret, @redirect_uri = SettingValidator.frozen(client_id), SettingValidator.frozen(client_secret), SettingValidator.frozen(redirect_uri)
159
+ @scopes = CredentialValidator.frozen_scopes(scopes)
160
+ @state = state
161
+ @pkce = SimpleOAuth::OAuth2::PKCE.new(verifier: code_verifier)
162
+ @code_verifier = code_verifier
163
+ @settings = {base_url: SettingValidator.base_url!(base_url), proxy_url: proxy_url&.then { |url| -String(url) }, open_timeout:, read_timeout:, write_timeout:, keep_alive_timeout:, debug_output:, headers: SettingValidator.headers!(headers)}
164
+ @connection = Connection.new(**@settings.except(:base_url, :headers))
165
+ end
166
+
167
+ # Summarize the authorization for the console without revealing its secrets
168
+ #
169
+ # @api public
170
+ # @return [String] the class name, client ID, redirect URI, and scopes
171
+ # @example Inspect an authorization
172
+ # authorization.inspect # => #<X::OAuth2Authorization client_id="id" redirect_uri="https://example.com/callback" ...>
173
+ def inspect
174
+ "#<#{self.class} client_id=#{client_id.inspect} redirect_uri=#{redirect_uri.inspect} scopes=#{scopes}>"
175
+ end
176
+
177
+ # The page on X that asks the user to authorize the app, to redirect the user to
178
+ #
179
+ # @api public
180
+ # @return [String] the authorization URL, with the state and PKCE code challenge
181
+ # @example Send the user to X
182
+ # redirect_to authorization.url
183
+ def url
184
+ oauth2_client(base_url).authorization_url(redirect_uri:, pkce: @pkce, state:, scope: scopes)
185
+ end
186
+
187
+ # Exchange the code of the redirect back from X for the tokens of the user
188
+ #
189
+ # An authorization code works once, so call this, or {#client}, once for each redirect.
190
+ #
191
+ # The tokens are the OAuth2Tokens a client passes save_tokens and reads from load_tokens, which are stored for
192
+ # each user, so they leave out the client ID, and the client secret of a confidential client, which are the
193
+ # app's and kept once, apart from them; pass them beside the tokens to a client built of them, which refreshes
194
+ # the access token with them.
195
+ #
196
+ # @api public
197
+ # @param callback [String, Hash] the redirect back from X: its URL, its query string, or its query parameters
198
+ # @return [OAuth2Tokens] the tokens: the access token, the refresh token, and the expiration time; an
199
+ # authorization without offline.access issues no refresh token, so its tokens hold none, and a client built of
200
+ # them acts for the user until the access token expires, and cannot authenticate as the app
201
+ # @raise [AuthorizationDenied] if the user denied the app, the state does not match, or the redirect is not a
202
+ # valid URL
203
+ # @raise [AuthorizationError] if X refuses the code, with the response that refused it
204
+ # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
205
+ # as a server error, a redirect, or the page of a proxy says
206
+ # @example Store the tokens of the user
207
+ # store.save(authorization.tokens(request.url))
208
+ # @example Build the client of a confidential app from the tokens it stored
209
+ # X::Client.new(client_id: ENV.fetch("X_CLIENT_ID"), client_secret: ENV.fetch("X_CLIENT_SECRET"), **store.load.to_h)
210
+ def tokens(callback) = tokens_from(exchange(callback, base_url, @settings.fetch(:headers)))
211
+
212
+ # Exchange the code of the redirect back from X for a client
213
+ #
214
+ # The options are checked before the code is exchanged, since X accepts it once, so an option the client refuses,
215
+ # such as a misspelled keyword, raises before the code is spent rather than after, with the tokens it was
216
+ # exchanged for lost.
217
+ #
218
+ # The save_tokens of the client is passed the OAuth2Tokens of the exchange before the client is returned, as
219
+ # it is passed those of each refresh after, so that a callable that stores them stores every refresh token X
220
+ # issues, the first among them; a refresh token held by the client alone would be lost with it, and the user would
221
+ # have to authorize the app again. Without offline.access, which issues no refresh token, it is passed tokens whose
222
+ # refresh token is nil, so it stores the access token that acts for the user until it expires. A
223
+ # callable that raises, as one whose storage is briefly down may, raises TokenReportFailed, which holds the client
224
+ # and the tokens, so neither is lost with the code.
225
+ #
226
+ # The code is exchanged at the origin of the base URL of the client, the base_url of the options or else that of
227
+ # the authorization, as the client refreshes its tokens there, and through the proxy, with the timeouts,
228
+ # keep-alive timeout, debug output, and headers of the client, so a client given a proxy reaches X through it
229
+ # from the first request of its tokens.
230
+ #
231
+ # @api public
232
+ # @param callback [String, Hash] the redirect back from X: its URL, its query string, or its query parameters
233
+ # @param options [Hash] other options of Client#initialize, such as save_tokens, which it is built with
234
+ # beside the base URL, proxy, timeouts, keep-alive timeout, debug output, and headers of the authorization, and
235
+ # in place of them
236
+ # @return [Client] a client with the user's credentials
237
+ # @raise [ArgumentError] if an option is one Client#initialize refuses, or a credential or an authenticator,
238
+ # which the client is given by the exchange of the code
239
+ # @raise [AuthorizationDenied] if the user denied the app, the state does not match, or the redirect is not a
240
+ # valid URL
241
+ # @raise [AuthorizationError] if X refuses the code, with the response that refused it
242
+ # @raise [HTTPError, InvalidResponse] if the token endpoint limits the rate of the request or fails to answer,
243
+ # as a server error, a redirect, or the page of a proxy says
244
+ # @raise [TokenReportFailed] if save_tokens raises for the tokens of the exchange, with the client and tokens
245
+ # @example Act for the user who authorized the app, storing the refresh token of the exchange and of each refresh
246
+ # client = authorization.client(request.url, save_tokens: ->(tokens) { store.save(tokens.refresh_token) })
247
+ def client(callback, **options) # steep:ignore DifferentMethodParameterKind
248
+ given = options.keys & CREDENTIALS
249
+ raise ArgumentError, format(CREDENTIALS_GIVEN_MESSAGE, given.join(", ")) unless given.empty?
250
+
251
+ checked = Client.new(**@settings, **options) # refuses an option before the code, which X accepts once, is spent
252
+ tokens = tokens_from(exchange(callback, checked.base_url, checked.headers, connection_for(options)))
253
+ client_of(tokens, options).tap { |client| report_exchange(client, tokens) }
254
+ end
255
+
256
+ private
257
+
258
+ # Raise for arguments an authorization cannot be started or finished with
259
+ #
260
+ # They are read only once the URL is built, or the code exchanged, where a client ID or redirect URI that is nil
261
+ # or empty would build a URL X refuses, rather than raise where the authorization was given them. Each is a
262
+ # String, rather than what converts to one: a state that is an Integer would raise a NoMethodError once the URL
263
+ # is built.
264
+ #
265
+ # @api private
266
+ # @param client_id [Object] the OAuth 2.0 client ID of the app
267
+ # @param redirect_uri [Object] the URL X redirects the user back to
268
+ # @param client_secret [Object] the client secret of a confidential app, or nil for a public client
269
+ # @param scopes [Object] the scopes to ask the user for
270
+ # @param state [Object] the state
271
+ # @return [void]
272
+ # @raise [ArgumentError] if an argument is not one the authorization takes
273
+ def validate!(client_id:, redirect_uri:, client_secret:, scopes:, state:)
274
+ CredentialValidator.validate_required!({client_id:}, {client_secret:})
275
+ raise ArgumentError, INVALID_REDIRECT_URI unless String === redirect_uri && redirect_uri.match?(/\S/)
276
+ raise ArgumentError, format(INVALID_SCOPES, scopes.inspect) unless CredentialValidator.scopes?(scopes)
277
+ raise ArgumentError, INVALID_STATE unless String === state && !state.empty?
278
+ end
279
+
280
+ # The OAuth 2.0 client secret of a confidential app
281
+ #
282
+ # It is private, as the secrets of a client and its authenticators are, and inspect leaves it out. The credentials
283
+ # of an authorization hold it, since a client is built from them.
284
+ #
285
+ # @api private
286
+ # @return [String, nil] the client secret, or nil for a public client
287
+ attr_reader :client_secret
288
+
289
+ # The base URL of the client the authorization builds
290
+ #
291
+ # The code is exchanged at its origin, unless the client is given another.
292
+ #
293
+ # @api private
294
+ # @return [String] the base URL
295
+ def base_url = @settings.fetch(:base_url)
296
+
297
+ # The connection that exchanges the authorization code for tokens
298
+ # @api private
299
+ # @return [Core::Connection] the connection
300
+ attr_reader :connection
301
+
302
+ # The client for the authorization page and token endpoint
303
+ #
304
+ # The token endpoint is the one at the origin of the base URL of the client the code is exchanged for.
305
+ #
306
+ # @api private
307
+ # @param base_url [String] the base URL of the client the code is exchanged for
308
+ # @return [SimpleOAuth::OAuth2::Client] the OAuth 2.0 client
309
+ def oauth2_client(base_url)
310
+ SimpleOAuth::OAuth2::Client.new(client_id:, client_secret:, authorization_endpoint: AUTHORIZATION_URL,
311
+ token_endpoint: TokenEndpoint.url_at(base_url, TOKEN_URL))
312
+ end
313
+
314
+ # The connection that exchanges the code for a client, built with its settings
315
+ #
316
+ # It has the proxy, timeouts, keep-alive timeout, and debug output of the options of the client, and else those of
317
+ # the authorization, as the client does.
318
+ #
319
+ # @api private
320
+ # @param options [Hash] the options of Client#initialize the client is built with
321
+ # @return [Core::Connection] the connection
322
+ def connection_for(options) = Connection.new(**@settings.merge(options.slice(*@settings.keys)).except(:base_url, :headers))
323
+
324
+ # Exchange the code of the redirect back from X for a token
325
+ #
326
+ # The connection is closed once the code is exchanged, whether it is the authorization's own or one built for the
327
+ # exchange of a client, rather than left open for requests the authorization never sends, since a code is
328
+ # exchanged once; an authorization that exchanges another code, of another redirect, opens it again.
329
+ #
330
+ # @api private
331
+ # @param callback [String, Hash] the redirect back from X: its URL, its query string, or its query parameters
332
+ # @param base_url [String] the base URL of the client the code is exchanged for, at whose origin it is exchanged
333
+ # @param headers [Hash{String => String}] the headers of the client the code is exchanged for, which the token
334
+ # request is sent with
335
+ # @param over [Core::Connection] the connection to send the token request over
336
+ # @return [SimpleOAuth::OAuth2::Token] the token
337
+ # @raise [AuthorizationDenied] if the user denied the app, the state does not match, or the redirect is not a
338
+ # valid URL
339
+ # @raise [AuthorizationError] if X refuses the code, with the response that refused it
340
+ def exchange(callback, base_url, headers, over = connection)
341
+ token_request = oauth2_client(base_url).authorization_code_request(code: code_of(callback), redirect_uri:, code_verifier:)
342
+ TokenEndpoint.fetch(token_request, connection: over, refusal: DEFAULT_ERROR_MESSAGE, headers:)
343
+ ensure
344
+ over.close
345
+ end
346
+
347
+ # The authorization code of the redirect back from X
348
+ #
349
+ # The redirect is its URL, whose query is read, or else its query string or query parameters.
350
+ #
351
+ # @api private
352
+ # @param callback [String, Hash] the redirect back from X: its URL, its query string, or its query parameters
353
+ # @return [String] the code
354
+ # @raise [AuthorizationDenied] if the user denied the app, the state does not match, or the redirect is not a
355
+ # valid URL
356
+ def code_of(callback)
357
+ query = String.try_convert(callback)&.then { |url| URI(url).query } || callback
358
+ SimpleOAuth::OAuth2::AuthorizationResponse.parse(query, state:).code
359
+ rescue URI::InvalidURIError
360
+ raise AuthorizationDenied.new(INVALID_CALLBACK_MESSAGE), cause: nil
361
+ rescue SimpleOAuth::OAuth2::Error => e
362
+ raise AuthorizationDenied.__send__(:from, e, DEFAULT_ERROR_MESSAGE), cause: nil
363
+ end
364
+
365
+ # The client of the tokens of the exchange, built with the options given
366
+ # @api private
367
+ # @param tokens [OAuth2Tokens] the tokens of the exchange
368
+ # @param options [Hash] other options of Client#initialize, in place of those of the authorization
369
+ # @return [Client] the client
370
+ def client_of(tokens, options) = Client.new(client_id:, client_secret:, **tokens.to_h, **@settings, **options)
371
+
372
+ # Pass the tokens of the exchange to the save_tokens of the client
373
+ # @api private
374
+ # @param client [Client] the client
375
+ # @param tokens [OAuth2Tokens] the tokens of the exchange
376
+ # @return [void]
377
+ # @raise [TokenReportFailed] if save_tokens raises, with the client and tokens
378
+ def report_exchange(client, tokens)
379
+ hook = client.save_tokens or return
380
+
381
+ begin
382
+ hook.call(tokens)
383
+ rescue
384
+ raise TokenReportFailed.new(client:, tokens:)
385
+ end
386
+ end
387
+
388
+ # The tokens of a user from the token X returned
389
+ #
390
+ # A client is built of them as OAuth 2.0 credentials whether or not X issued a refresh token, since the access
391
+ # token acts for the user either way: one given as a bearer token would be taken for the app's, and sent to the
392
+ # endpoints that take app-only authentication, which refuse it. A token issued without offline.access has no
393
+ # refresh token, so the tokens hold nil in its place. A token that names no scopes was granted those the app
394
+ # asked for, as OAuth 2.0 has it, so the tokens hold those.
395
+ #
396
+ # @api private
397
+ # @param token [SimpleOAuth::OAuth2::Token] the token
398
+ # @return [OAuth2Tokens] the tokens
399
+ def tokens_from(token)
400
+ OAuth2Tokens.new(access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token.expires_at,
401
+ scopes: TokenEndpoint.scopes_of(token) || scopes)
402
+ end
403
+ end
404
+ end
405
+ end