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