simple_oauth 0.5.1 → 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.
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "request"
5
+
6
+ module SimpleOAuth
7
+ module OAuth2
8
+ # An OAuth 2.0 client that builds authorization URLs and endpoint requests
9
+ #
10
+ # A client with a secret is confidential and authenticates to the token and revocation endpoints
11
+ # with HTTP Basic (client_secret_basic) or in the request body (client_secret_post). A client without
12
+ # a secret is public and identifies itself with its client_id alone.
13
+ #
14
+ # @api public
15
+ # @example Build the requests of an authorization code flow with PKCE
16
+ # client = SimpleOAuth::OAuth2::Client.new(client_id: "id",
17
+ # authorization_endpoint: "https://example.com/authorize", token_endpoint: "https://example.com/token")
18
+ # pkce = SimpleOAuth::OAuth2::PKCE.generate
19
+ # url = client.authorization_url(redirect_uri: "https://app.example/cb", state: "xyz", pkce: pkce)
20
+ # request = client.authorization_code_request(code: params[:code], redirect_uri: "https://app.example/cb",
21
+ # code_verifier: pkce.verifier)
22
+ class Client
23
+ # Client authentication methods for confidential clients (RFC 6749 Section 2.3.1)
24
+ AUTH_METHODS = %i[client_secret_basic client_secret_post].freeze
25
+ # The content type of every request body
26
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
27
+ # The error message for a state that is given but empty
28
+ EMPTY_STATE = "The state must not be empty"
29
+ # The error message for an authorization request that nothing ties to its response
30
+ UNPROTECTED = "Pass a pkce, or a state, so that the authorization response can be tied to this request"
31
+
32
+ # The client identifier
33
+ #
34
+ # @api public
35
+ # @return [String] the client identifier
36
+ # @example
37
+ # client.client_id # => "s6BhdRkqt3"
38
+ attr_reader :client_id
39
+
40
+ # The client secret, or nil for a public client
41
+ #
42
+ # @api public
43
+ # @return [String, nil] the client secret
44
+ # @example
45
+ # client.client_secret # => "gX1fBat3bV"
46
+ attr_reader :client_secret
47
+
48
+ # The authorization endpoint URL
49
+ #
50
+ # @api public
51
+ # @return [String, nil] the authorization endpoint
52
+ # @example
53
+ # client.authorization_endpoint # => "https://example.com/authorize"
54
+ attr_reader :authorization_endpoint
55
+
56
+ # The token endpoint URL
57
+ #
58
+ # @api public
59
+ # @return [String, nil] the token endpoint
60
+ # @example
61
+ # client.token_endpoint # => "https://example.com/token"
62
+ attr_reader :token_endpoint
63
+
64
+ # The revocation endpoint URL
65
+ #
66
+ # @api public
67
+ # @return [String, nil] the revocation endpoint
68
+ # @example
69
+ # client.revocation_endpoint # => "https://example.com/revoke"
70
+ attr_reader :revocation_endpoint
71
+
72
+ # How a confidential client authenticates with its secret
73
+ #
74
+ # @api public
75
+ # @return [Symbol] the authentication method
76
+ # @example
77
+ # client.auth_method # => :client_secret_basic
78
+ attr_reader :auth_method
79
+
80
+ # Initialize a new client
81
+ #
82
+ # @api public
83
+ # @param client_id [String] the client identifier
84
+ # @param client_secret [String, nil] the client secret, or nil for a public client; an empty
85
+ # secret is no secret, so a client given one is public
86
+ # @param authorization_endpoint [String, nil] the authorization endpoint URL
87
+ # @param token_endpoint [String, nil] the token endpoint URL
88
+ # @param revocation_endpoint [String, nil] the revocation endpoint URL
89
+ # @param auth_method [Symbol] how a confidential client authenticates: client_secret_basic or client_secret_post
90
+ # @raise [ArgumentError] if the authentication method is unknown
91
+ # @example A confidential client
92
+ # SimpleOAuth::OAuth2::Client.new(client_id: "s6BhdRkqt3", client_secret: "gX1fBat3bV",
93
+ # token_endpoint: "https://example.com/token")
94
+ def initialize(client_id:, client_secret: nil, authorization_endpoint: nil, token_endpoint: nil,
95
+ revocation_endpoint: nil, auth_method: :client_secret_basic)
96
+ raise ArgumentError, "Unknown auth_method: #{auth_method.inspect}" unless AUTH_METHODS.include?(auth_method)
97
+
98
+ @client_id = client_id
99
+ @client_secret = client_secret
100
+ @authorization_endpoint = authorization_endpoint
101
+ @token_endpoint = token_endpoint
102
+ @revocation_endpoint = revocation_endpoint
103
+ @auth_method = auth_method
104
+ freeze
105
+ end
106
+
107
+ # Check whether the client is public, meaning it has no secret
108
+ #
109
+ # A secret that is empty is no secret, so a client holding one cannot authenticate
110
+ # with it and identifies itself with its client_id alone.
111
+ #
112
+ # @api public
113
+ # @return [Boolean] true if the client has no secret
114
+ # @example
115
+ # client.public? # => false
116
+ def public?
117
+ client_secret.to_s.empty?
118
+ end
119
+
120
+ # Build the URL where the user authorizes the client (RFC 6749 Section 4.1.1)
121
+ #
122
+ # The pkce is named rather than defaulted because only the caller can keep the verifier
123
+ # to send with the code. OAuth 2.1 asks every client for one, so pass `pkce: nil` to
124
+ # leave it out, for an authorization server that rejects the challenge parameters.
125
+ #
126
+ # A state is what OAuth 2.0 ties the response to the request with. A PKCE challenge does
127
+ # that too, so with one the state is free to carry application state, or to be left out.
128
+ #
129
+ # @api public
130
+ # @param redirect_uri [String] where the authorization server returns the user
131
+ # @param pkce [PKCE, nil] the PKCE challenge to send, or nil to send none
132
+ # @param state [String, nil] an unguessable value the authorization server returns with
133
+ # the code, which {AuthorizationResponse.parse} checks
134
+ # @param scope [String, Array<String>, nil] the requested scope
135
+ # @param params [Hash] additional query parameters, which override the ones the client
136
+ # sends itself, whether their keys are Strings or Symbols
137
+ # @return [String] the authorization URL
138
+ # @raise [ArgumentError] if the state is given but empty, if neither a pkce nor a state
139
+ # is given, or if the client has no authorization endpoint
140
+ # @example
141
+ # client.authorization_url(redirect_uri: "https://app.example/cb", state: "xyz",
142
+ # scope: %w[tweet.read users.read], pkce: SimpleOAuth::OAuth2::PKCE.generate)
143
+ def authorization_url(redirect_uri:, pkce:, state: nil, scope: nil, params: {})
144
+ validate_protection!(pkce, state)
145
+ url = endpoint(authorization_endpoint, :authorization_endpoint)
146
+ query = {response_type: "code", client_id:, redirect_uri:, scope: scope_value(scope), state:,
147
+ code_challenge: pkce&.challenge, code_challenge_method: pkce&.challenge_method}
148
+ # Symbolize the caller's keys so that a String key overrides rather than repeating a parameter
149
+ query = query.merge(params.transform_keys(&:to_sym)).compact
150
+ "#{url}#{url.include?("?") ? "&" : "?"}#{URI.encode_www_form(query)}"
151
+ end
152
+
153
+ # Build the request that exchanges an authorization code for a token
154
+ #
155
+ # @api public
156
+ # @param code [String] the authorization code
157
+ # @param redirect_uri [String] the redirect URI sent in the authorization URL
158
+ # @param code_verifier [String, nil] the PKCE verifier, if the authorization URL sent a challenge
159
+ # @param params [Hash] additional form parameters, which override the ones the client sends itself
160
+ # @return [Request] the token request
161
+ # @raise [ArgumentError] if the client has no token endpoint
162
+ # @example
163
+ # client.authorization_code_request(code: "SplxlOBeZQQYbYS6WxSbIA", redirect_uri: "https://app.example/cb",
164
+ # code_verifier: pkce.verifier)
165
+ def authorization_code_request(code:, redirect_uri:, code_verifier: nil, params: {})
166
+ token_request({grant_type: "authorization_code", code:, redirect_uri:, code_verifier:}, params)
167
+ end
168
+
169
+ # Build the request that exchanges a refresh token for a new token
170
+ #
171
+ # @api public
172
+ # @param refresh_token [String] the refresh token
173
+ # @param scope [String, Array<String>, nil] a narrower scope to request
174
+ # @param params [Hash] additional form parameters, which override the ones the client sends itself
175
+ # @return [Request] the token request
176
+ # @raise [ArgumentError] if the client has no token endpoint
177
+ # @example
178
+ # client.refresh_token_request(refresh_token: "tGzv3JOkF0XG5Qx2TlKWIA")
179
+ def refresh_token_request(refresh_token:, scope: nil, params: {})
180
+ token_request({grant_type: "refresh_token", refresh_token:, scope: scope_value(scope)}, params)
181
+ end
182
+
183
+ # Build the request for a token that acts as the client itself
184
+ #
185
+ # @api public
186
+ # @param scope [String, Array<String>, nil] the requested scope
187
+ # @param params [Hash] additional form parameters, which override the ones the client sends itself
188
+ # @return [Request] the token request
189
+ # @raise [ArgumentError] if the client is public or has no token endpoint
190
+ # @example
191
+ # client.client_credentials_request
192
+ def client_credentials_request(scope: nil, params: {})
193
+ raise ArgumentError, "The client credentials grant requires a client secret" if public?
194
+
195
+ token_request({grant_type: "client_credentials", scope: scope_value(scope)}, params)
196
+ end
197
+
198
+ # Build the request that revokes an access or refresh token (RFC 7009 Section 2.1)
199
+ #
200
+ # @api public
201
+ # @param token [String] the token to revoke
202
+ # @param token_type_hint [String, nil] access_token or refresh_token
203
+ # @param params [Hash] additional form parameters, which override the ones the client sends itself
204
+ # @return [Request] the revocation request
205
+ # @raise [ArgumentError] if the client has no revocation endpoint
206
+ # @example
207
+ # client.revocation_request(token: "45ghiukldjahdnhzdauz", token_type_hint: "refresh_token")
208
+ def revocation_request(token:, token_type_hint: nil, params: {})
209
+ form_request(endpoint(revocation_endpoint, :revocation_endpoint), {token:, token_type_hint:}, params)
210
+ end
211
+
212
+ private
213
+
214
+ # Checks that something ties the authorization response to the request
215
+ #
216
+ # @api private
217
+ # @param pkce [PKCE, nil] the PKCE challenge to send, or nil to send none
218
+ # @param state [String, nil] the state to send, or nil to send none
219
+ # @return [void]
220
+ # @raise [ArgumentError] if the state is given but empty, or neither is given
221
+ def validate_protection!(pkce, state)
222
+ raise ArgumentError, EMPTY_STATE if !state.nil? && state.empty?
223
+ raise ArgumentError, UNPROTECTED if pkce.nil? && state.nil?
224
+ end
225
+
226
+ # Build a request to the token endpoint
227
+ #
228
+ # @api private
229
+ # @param params [Hash] the form parameters
230
+ # @param extra [Hash] the caller's own form parameters
231
+ # @return [Request] the request
232
+ def token_request(params, extra)
233
+ form_request(endpoint(token_endpoint, :token_endpoint), params, extra)
234
+ end
235
+
236
+ # Build an authenticated form POST
237
+ #
238
+ # The caller's own parameters are merged last, so that they can carry an extension such
239
+ # as the resource of RFC 8707, and can replace anything the client would send itself.
240
+ #
241
+ # @api private
242
+ # @param url [String] the endpoint URL
243
+ # @param params [Hash] the form parameters
244
+ # @param extra [Hash] the caller's own form parameters
245
+ # @return [Request] the request
246
+ def form_request(url, params, extra)
247
+ headers, params = authenticated(params)
248
+ # Symbolize the caller's keys so that a String key overrides rather than repeating a parameter
249
+ body = params.merge(extra.transform_keys(&:to_sym)).compact
250
+ Request.new(method: "POST", url:, headers:, body: URI.encode_www_form(body))
251
+ end
252
+
253
+ # The headers and form parameters that carry the client's credentials
254
+ #
255
+ # A confidential client authenticates with HTTP Basic or in the body, and a public client
256
+ # identifies itself with its client_id alone (RFC 6749 Section 2.3.1).
257
+ #
258
+ # @api private
259
+ # @param params [Hash] the form parameters
260
+ # @return [Array(Hash, Hash)] the headers and the form parameters
261
+ def authenticated(params)
262
+ headers = {"Content-Type" => FORM_CONTENT_TYPE, "Accept" => "application/json"}
263
+ secret = client_secret unless public?
264
+ return [headers, params.merge(client_id:)] if secret.nil?
265
+ return [headers, params.merge(client_id:, client_secret: secret)] if auth_method.eql?(:client_secret_post)
266
+
267
+ [headers.merge("Authorization" => basic_authorization(secret)), params]
268
+ end
269
+
270
+ # The HTTP Basic credentials, form-encoded first per RFC 6749 Section 2.3.1
271
+ #
272
+ # @api private
273
+ # @param secret [String] the client secret
274
+ # @return [String] the Authorization header value
275
+ def basic_authorization(secret)
276
+ credentials = [client_id, secret].map { |value| URI.encode_www_form_component(value) }.join(":")
277
+ # "m0" is Base64 with no line breaks
278
+ "Basic #{[credentials].pack("m0")}"
279
+ end
280
+
281
+ # Join a list of scopes with spaces
282
+ #
283
+ # RFC 6749 Appendix A.4 defines a scope as one or more characters, so an empty
284
+ # scope is omitted rather than sent as an empty parameter.
285
+ #
286
+ # @api private
287
+ # @param scope [String, Array<String>, nil] the scope
288
+ # @return [String, nil] the space-delimited scope, or nil if there is none
289
+ def scope_value(scope)
290
+ # Array#join flattens, so a String and an Array of Strings both join correctly, and nil joins to ""
291
+ value = [scope].join(" ")
292
+ value unless value.empty?
293
+ end
294
+
295
+ # An endpoint URL, which must be configured
296
+ #
297
+ # @api private
298
+ # @param url [String, nil] the endpoint URL
299
+ # @param name [Symbol] the endpoint name for the error message
300
+ # @return [String] the endpoint URL
301
+ # @raise [ArgumentError] if the endpoint is not configured
302
+ def endpoint(url, name)
303
+ url || raise(ArgumentError, "The client has no #{name}")
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "response_body"
5
+
6
+ module SimpleOAuth
7
+ module OAuth2
8
+ # Error returned by an OAuth 2.0 endpoint, per RFC 6749 Section 5.2
9
+ #
10
+ # @api public
11
+ # @example Raise the error described by a failed response
12
+ # raise SimpleOAuth::OAuth2::Error.from_response(status: 400, body: '{"error":"invalid_grant"}')
13
+ class Error < SimpleOAuth::Error
14
+ # The error message for a status that is not an HTTP status
15
+ INVALID_STATUS = "The status must be an Integer or a String of digits"
16
+
17
+ # The error code, such as invalid_grant, if the response included one
18
+ #
19
+ # @api public
20
+ # @return [String, nil] the error code
21
+ # @example
22
+ # error.code # => "invalid_grant"
23
+ attr_reader :code
24
+
25
+ # The human-readable description from the endpoint
26
+ #
27
+ # @api public
28
+ # @return [String, nil] the description
29
+ # @example
30
+ # error.description # => "The refresh token is invalid"
31
+ attr_reader :description
32
+
33
+ # The URI of a page describing the error
34
+ #
35
+ # @api public
36
+ # @return [String, nil] the error URI
37
+ # @example
38
+ # error.uri # => "https://example.com/errors/invalid_grant"
39
+ attr_reader :uri
40
+
41
+ # The HTTP status of the response
42
+ #
43
+ # @api public
44
+ # @return [Integer, nil] the HTTP status
45
+ # @example
46
+ # error.status # => 400
47
+ attr_reader :status
48
+
49
+ # The HTTP status of a response, as an Integer
50
+ #
51
+ # @api private
52
+ # @param value [Integer, String] the status of the response
53
+ # @return [Integer] the status
54
+ # @raise [ArgumentError] if the value is not an HTTP status
55
+ # @example
56
+ # SimpleOAuth::OAuth2::Error.http_status("400") # => 400
57
+ def self.http_status(value)
58
+ Integer(value, exception: false) || raise(ArgumentError, "#{INVALID_STATUS}: #{value.inspect}")
59
+ end
60
+
61
+ # Build the error described by an OAuth 2.0 error response
62
+ #
63
+ # @api public
64
+ # @param status [Integer, String] the HTTP status of the response
65
+ # @param body [String, nil] the response body
66
+ # @return [Error] the error
67
+ # @raise [ArgumentError] if the status is not an HTTP status
68
+ # @example
69
+ # SimpleOAuth::OAuth2::Error.from_response(status: 400, body: '{"error":"invalid_grant"}')
70
+ def self.from_response(status:, body:)
71
+ params = ResponseBody.parse(body)
72
+ new(code: params["error"], description: params["error_description"], uri: params["error_uri"],
73
+ status: http_status(status))
74
+ end
75
+
76
+ # Initialize a new error
77
+ #
78
+ # @api public
79
+ # @param code [String, nil] the error code
80
+ # @param description [String, nil] the human-readable description
81
+ # @param uri [String, nil] the URI of a page describing the error
82
+ # @param status [Integer, nil] the HTTP status of the response
83
+ # @example
84
+ # SimpleOAuth::OAuth2::Error.new(code: "invalid_grant", status: 400)
85
+ def initialize(code:, description: nil, uri: nil, status: nil)
86
+ @code = code
87
+ @description = description
88
+ @uri = uri
89
+ @status = status
90
+ details = [code, description].compact
91
+ super(details.empty? ? "OAuth 2.0 request failed with status #{status}" : details.join(": "))
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module SimpleOAuth
7
+ module OAuth2
8
+ # A Proof Key for Code Exchange verifier and challenge, per RFC 7636
9
+ #
10
+ # @api public
11
+ # @example Generate a verifier and use its challenge in an authorization URL
12
+ # pkce = SimpleOAuth::OAuth2::PKCE.generate
13
+ # client.authorization_url(redirect_uri: "https://app.example/cb", state: "xyz", pkce: pkce)
14
+ class PKCE
15
+ # Challenge method that hashes the verifier with SHA-256
16
+ S256 = "S256"
17
+ # Challenge method that sends the verifier itself
18
+ PLAIN = "plain"
19
+ # A valid verifier: 43 to 128 unreserved characters (RFC 7636 Section 4.1)
20
+ VERIFIER_PATTERN = /\A[A-Za-z0-9\-._~]{43,128}\z/
21
+ # The error message for an invalid verifier
22
+ INVALID_VERIFIER = "PKCE verifier must be 43 to 128 unreserved characters"
23
+ # Random bytes in a generated verifier, which encode to 64 characters
24
+ VERIFIER_BYTES = 48
25
+
26
+ # The code verifier, sent with the token request
27
+ #
28
+ # @api public
29
+ # @return [String] the code verifier
30
+ # @example
31
+ # pkce.verifier # => "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
32
+ attr_reader :verifier
33
+
34
+ # The challenge method: S256 or plain
35
+ #
36
+ # @api public
37
+ # @return [String] the challenge method
38
+ # @example
39
+ # pkce.challenge_method # => "S256"
40
+ attr_reader :challenge_method
41
+
42
+ # The code challenge, sent with the authorization request
43
+ #
44
+ # @api public
45
+ # @return [String] the code challenge
46
+ # @example
47
+ # pkce.challenge # => "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
48
+ attr_reader :challenge
49
+
50
+ # Generate a random verifier and its challenge
51
+ #
52
+ # @api public
53
+ # @param challenge_method [String] the challenge method: S256 or plain
54
+ # @return [PKCE] the verifier and challenge
55
+ # @example
56
+ # SimpleOAuth::OAuth2::PKCE.generate
57
+ def self.generate(challenge_method: S256)
58
+ new(verifier: SecureRandom.urlsafe_base64(VERIFIER_BYTES), challenge_method:)
59
+ end
60
+
61
+ # Initialize from an existing verifier
62
+ #
63
+ # @api public
64
+ # @param verifier [String] the code verifier
65
+ # @param challenge_method [String] the challenge method: S256 or plain
66
+ # @raise [ArgumentError] if the verifier or challenge method is invalid
67
+ # @example
68
+ # SimpleOAuth::OAuth2::PKCE.new(verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
69
+ def initialize(verifier:, challenge_method: S256)
70
+ raise ArgumentError, INVALID_VERIFIER unless VERIFIER_PATTERN.match?(verifier)
71
+
72
+ @verifier = verifier
73
+ @challenge_method = challenge_method
74
+ @challenge = compute_challenge
75
+ freeze
76
+ end
77
+
78
+ private
79
+
80
+ # Compute the challenge for the verifier with the challenge method
81
+ #
82
+ # @api private
83
+ # @return [String] the code challenge
84
+ # @raise [ArgumentError] if the challenge method is unknown
85
+ def compute_challenge
86
+ case challenge_method
87
+ when S256 then base64_url(OpenSSL::Digest.digest("SHA256", verifier))
88
+ when PLAIN then verifier
89
+ else raise ArgumentError, "Unknown PKCE challenge method: #{challenge_method}"
90
+ end
91
+ end
92
+
93
+ # Encodes data as base64url without padding (RFC 7636 Section 4.2)
94
+ #
95
+ # @api private
96
+ # @param data [String] the data to encode
97
+ # @return [String] the encoded data
98
+ def base64_url(data)
99
+ # "m0" is Base64 with no line breaks, which base64url then re-spells
100
+ [data].pack("m0").tr("+/", "-_").delete("=")
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleOAuth
4
+ module OAuth2
5
+ # An HTTP request to an OAuth 2.0 endpoint, built but not sent
6
+ #
7
+ # @api public
8
+ # @example Send a request with Net::HTTP
9
+ # response = Net::HTTP.post(URI(request.url), request.body, request.headers)
10
+ class Request
11
+ # The HTTP method
12
+ #
13
+ # @api public
14
+ # @return [String] the HTTP method
15
+ # @example
16
+ # request.method # => "POST"
17
+ attr_reader :method
18
+
19
+ # The endpoint URL
20
+ #
21
+ # @api public
22
+ # @return [String] the URL
23
+ # @example
24
+ # request.url # => "https://example.com/token"
25
+ attr_reader :url
26
+
27
+ # The request headers
28
+ #
29
+ # @api public
30
+ # @return [Hash{String => String}] the headers
31
+ # @example
32
+ # request.headers["Content-Type"] # => "application/x-www-form-urlencoded"
33
+ attr_reader :headers
34
+
35
+ # The form-encoded request body
36
+ #
37
+ # @api public
38
+ # @return [String] the body
39
+ # @example
40
+ # request.body # => "grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA"
41
+ attr_reader :body
42
+
43
+ # Initialize a new request
44
+ #
45
+ # @api public
46
+ # @param method [String] the HTTP method
47
+ # @param url [String] the endpoint URL
48
+ # @param headers [Hash{String => String}] the request headers
49
+ # @param body [String] the form-encoded request body
50
+ # @example
51
+ # SimpleOAuth::OAuth2::Request.new(method: "POST", url: "https://example.com/token", headers: {}, body: "")
52
+ def initialize(method:, url:, headers:, body:)
53
+ @method = method
54
+ @url = url
55
+ @headers = headers.dup.freeze
56
+ @body = body
57
+ freeze
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SimpleOAuth
6
+ module OAuth2
7
+ # Parses the JSON bodies returned by OAuth 2.0 endpoints
8
+ #
9
+ # @api private
10
+ module ResponseBody
11
+ # Parse a response body into a Hash, or an empty Hash if it is not a JSON object
12
+ #
13
+ # @api private
14
+ # @param body [String, nil] the response body
15
+ # @return [Hash{String => Object}] the parsed object
16
+ # @example
17
+ # SimpleOAuth::OAuth2::ResponseBody.parse('{"error":"invalid_grant"}')
18
+ # # => {"error" => "invalid_grant"}
19
+ def self.parse(body)
20
+ Hash.try_convert(JSON.parse(body.to_s)) || {}
21
+ rescue JSON::ParserError
22
+ {}
23
+ end
24
+ end
25
+ end
26
+ end