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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 680161f3f3fc8cf54f78c5111c1f0c4bcd6380b0d29022fb9ac5fd407805d8b7
4
- data.tar.gz: 6951095c0b1b04b7755e8857a977946825053a141e3fd4c1c3b7e10cade061fe
3
+ metadata.gz: 86b6ff6f62fce7e9cc8119852cbf8470c5a1b7f9ab4ecaaf929d2aba35121a2b
4
+ data.tar.gz: c40d43c6172cf63cca3d04f81291b8e9917062cea0c0b112943cb0a2428c488c
5
5
  SHA512:
6
- metadata.gz: f852606e3aa48e70a5856f0cfccbfafe4c9235a6a701faca692446186b5bf8f30781b316577b7ac15a164b07293f9b550ad5cdfe0db58df6e57be396de380cf6
7
- data.tar.gz: d938005d0ad30817b294e0e8143b272be0dafb794dfa4a57ee012fc8f162c7d895d85a5a2ae0a57f4bc3b9e50c372fde93ac22b31bc01579ead3deec968f2d2a
6
+ metadata.gz: fafef85869f766c1d1c19ebceb4e8152741734ce438e3379ff0fd94d15b2a4437d7b19e74fe62506f0ff4214eaf37c64cfffaa979400df0fc49b765f8bc1c325
7
+ data.tar.gz: b95a19574fdd189c53f1e3d263515533991046f8e29e501f12c0a37c39158351f1376c851d58f751b7efda99b6d4a676be2ce243cc5540e52ab4c7d2ef18870e
data/.rubocop.yml CHANGED
@@ -48,7 +48,7 @@ Style/Alias:
48
48
  EnforcedStyle: prefer_alias_method
49
49
 
50
50
  Style/FrozenStringLiteralComment:
51
- Enabled: false
51
+ Enabled: true
52
52
 
53
53
  Style/StringLiterals:
54
54
  Enabled: true
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## [1.0.0] - 2026-09-12
2
+
3
+ ### Changed
4
+
5
+ * Every file declares `frozen_string_literal: true`, and the explicit `freeze` calls on string literals it makes redundant are gone
6
+ * Drop the `base64` runtime dependency: `pack("m0")` and `unpack1("m")` encode and decode Base64 in core Ruby, so the gem now has no runtime dependencies at all
7
+ * Drop the `cgi` runtime dependency: query strings and form bodies are read with `URI.decode_www_form`, which Ruby ships in every supported version
8
+ * **Breaking**: raise `ParseError` when an Authorization header, form body, or query string repeats an OAuth protocol parameter, which RFC 5849 Section 3.2 does not allow; `Header.parse` previously kept the last value and `Header.parse_form_body` the first, so the two disagreed about which one a request carried
9
+ * **Breaking**: rename `Signature.methods` to `Signature.registered_methods`, so that `Signature.methods` is the module's own method list again
10
+ * **Breaking**: define `VERSION` in `SimpleOAuth`, the module the rest of the library uses, rather than in a second `SimpleOauth` module; `SimpleOauth::VERSION` is gone and `SimpleOAuth::VERSION` now resolves
11
+ * **Breaking**: `Signature.rsa?` raises `ArgumentError` for a signature method that is not registered, as `Signature.digest`, `sign`, and `verify` already do, rather than answering false
12
+ * `SimpleOAuth::Error` is the base of every error the library raises, so `rescue SimpleOAuth::Error` catches `ParseError`, `InvalidOptionsError`, and `OAuth2::Error` alike
13
+
14
+ ### Fixed
15
+
16
+ * Treat only `&` as a parameter separator when reading a query string or form body; a `;` is part of the value, as every current server reads it, so `?a=1;b=2` no longer signs two parameters where the server sees one
17
+
18
+ ### Added
19
+
20
+ * OAuth 2.0 request builders and response parsers in `SimpleOAuth::OAuth2`, which make no HTTP requests:
21
+ * `Client#authorization_url` for the authorization code flow, which names `pkce` so that a client either sends a challenge or says `pkce: nil`, as OAuth 2.1 asks every client for one; a `state` is optional alongside a challenge, and required without one
22
+ * `Client#authorization_code_request`, `#refresh_token_request`, and `#client_credentials_request` for the token endpoint
23
+ * `Client#revocation_request` for the revocation endpoint (RFC 7009)
24
+ * `client_secret_basic` and `client_secret_post` authentication for confidential clients, and public clients without a secret
25
+ * `PKCE` verifiers with `S256` and `plain` challenges (RFC 7636)
26
+ * `AuthorizationResponse.parse`, which reads the response an authorization server returns to the redirect URI: it raises the error the server reported, a `state` that is not the one the request sent, an `iss` that is not the expected issuer (RFC 9207), a repeated parameter, or a response with no code
27
+ * A `params` option on every request builder, for what an extension adds to a request, such as the resource indicator of RFC 8707
28
+ * `Token.from_response` and `Error.from_response` for token and error responses, rejecting a response whose access token is missing, null, or empty, or whose lifetime is not a number of seconds
29
+
1
30
  ## [0.5.1] - 2026-09-12
2
31
 
3
32
  ### Fixed
@@ -58,6 +87,9 @@
58
87
 
59
88
  ### Changed
60
89
 
90
+ * **Breaking**: `Signature.rsa?` raises `ArgumentError` for a signature method that is not registered, as `Signature.digest`, `sign`, and `verify` already do, rather than answering false
91
+ * `SimpleOAuth::Error` is the base of every error the library raises, so `rescue SimpleOAuth::Error` catches `ParseError`, `InvalidOptionsError`, and `OAuth2::Error` alike
92
+
61
93
  * Supports Ruby 3.2, 3.3, 3.4, and 4.0
62
94
  * Added `base64` and `cgi` as explicit runtime dependencies
63
95
  * Migrated test suite from RSpec to Minitest
data/README.md CHANGED
@@ -7,7 +7,9 @@
7
7
  [![Typecheck](https://github.com/laserlemon/simple_oauth/actions/workflows/typecheck.yml/badge.svg)](https://github.com/laserlemon/simple_oauth/actions/workflows/typecheck.yml)
8
8
  [![Yardstick](https://github.com/laserlemon/simple_oauth/actions/workflows/yardstick.yml/badge.svg)](https://github.com/laserlemon/simple_oauth/actions/workflows/yardstick.yml)
9
9
 
10
- Simply builds and verifies OAuth headers per [RFC 5849](https://tools.ietf.org/html/rfc5849)
10
+ Simply builds and verifies OAuth 1.0 headers per [RFC 5849](https://tools.ietf.org/html/rfc5849), and builds OAuth 2.0 requests per [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749), [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636), and [RFC 7009](https://www.rfc-editor.org/rfc/rfc7009).
11
+
12
+ Neither makes HTTP requests: you send what it builds with the HTTP client of your choice.
11
13
 
12
14
  ## Installation
13
15
 
@@ -100,7 +102,8 @@ end
100
102
 
101
103
  # Check registered methods
102
104
  SimpleOAuth::Signature.registered?("HMAC-SHA512") # => true
103
- SimpleOAuth::Signature.methods # => ["hmac_sha1", "hmac_sha256", "rsa_sha1", "rsa_sha256", "plaintext", "hmac_sha512"]
105
+ SimpleOAuth::Signature.registered_methods
106
+ # => ["hmac_sha1", "hmac_sha256", "rsa_sha1", "rsa_sha256", "plaintext", "hmac_sha512"]
104
107
  ```
105
108
 
106
109
  ### OAuth Request Body Hash
@@ -178,6 +181,117 @@ SimpleOAuth::Signature.register("RSA-SHA512", rsa: true,
178
181
  end
179
182
  ```
180
183
 
184
+ ## OAuth 2.0
185
+
186
+ `SimpleOAuth::OAuth2::Client` builds authorization URLs and the requests for its token and revocation endpoints. Each request is a `SimpleOAuth::OAuth2::Request` with a `method`, `url`, `headers`, and form-encoded `body`, ready to send with any HTTP client.
187
+
188
+ A client with a secret is confidential and authenticates with HTTP Basic, or in the request body with `auth_method: :client_secret_post`. A client without a secret is public and sends only its `client_id`.
189
+
190
+ `authorization_url` names `pkce` rather than defaulting it, because only the caller can keep the verifier to send with the code. [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) asks every client for a PKCE challenge, public and confidential alike, so leaving it out is a decision you make in writing:
191
+
192
+ ```ruby
193
+ # An OAuth 2.0 authorization server that rejects the challenge parameters
194
+ client.authorization_url(redirect_uri: "https://app.example/callback", pkce: nil, state: state)
195
+ ```
196
+
197
+ A PKCE challenge ties the authorization response to the request, which is what `state` does under OAuth 2.0. With a challenge the `state` is yours to use for application state, or to leave out; without one it is required.
198
+
199
+ ### Authorization Code Flow with PKCE
200
+
201
+ ```ruby
202
+ require "net/http"
203
+ require "simple_oauth"
204
+
205
+ client = SimpleOAuth::OAuth2::Client.new(
206
+ client_id: "client_id",
207
+ client_secret: "client_secret", # omit for a public client
208
+ authorization_endpoint: "https://x.com/i/oauth2/authorize",
209
+ token_endpoint: "https://api.x.com/2/oauth2/token",
210
+ revocation_endpoint: "https://api.x.com/2/oauth2/revoke"
211
+ )
212
+
213
+ # 1. Send the user to authorize the client
214
+ pkce = SimpleOAuth::OAuth2::PKCE.generate
215
+ state = SecureRandom.hex
216
+ redirect_to client.authorization_url(
217
+ redirect_uri: "https://app.example/callback",
218
+ pkce: pkce,
219
+ state: state,
220
+ scope: %w[tweet.read users.read offline.access]
221
+ )
222
+
223
+ # 2. Read the response the authorization server returned to the callback
224
+ response = SimpleOAuth::OAuth2::AuthorizationResponse.parse(request.query_string, state: state)
225
+
226
+ # 3. Exchange the code for a token
227
+ request = client.authorization_code_request(
228
+ code: response.code,
229
+ redirect_uri: "https://app.example/callback",
230
+ code_verifier: pkce.verifier
231
+ )
232
+ response = Net::HTTP.post(URI(request.url), request.body, request.headers)
233
+ token = SimpleOAuth::OAuth2::Token.from_response(status: response.code, body: response.body)
234
+
235
+ token.access_token # => "..."
236
+ token.refresh_token # => "..."
237
+ token.expires_at # => 2026-09-11 14:00:00 +0000
238
+ ```
239
+
240
+ `AuthorizationResponse.parse` makes the checks a client owes its own request, raising `SimpleOAuth::OAuth2::Error` rather than returning a code you cannot trust:
241
+
242
+ * the error the server reported, if it reported one ([RFC 6749 Section 4.1.2.1](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1))
243
+ * a `state` that is not the one the authorization URL sent, compared in constant time
244
+ * an `iss` that is not the expected issuer, when one is given ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207), which defends against a mix-up between authorization servers)
245
+ * a parameter the response repeats, which RFC 6749 Section 3.1 forbids
246
+ * a response carrying no code at all
247
+
248
+ ```ruby
249
+ SimpleOAuth::OAuth2::AuthorizationResponse.parse(
250
+ request.query_string,
251
+ state: session[:state], # omit when the request sent none
252
+ issuer: "https://server.example.com" # omit to make no issuer check
253
+ )
254
+ ```
255
+
256
+ It takes the query string, or the parameters a framework already parsed. `Token.from_response` raises `SimpleOAuth::OAuth2::Error` for an error response, with the endpoint's `code`, `description`, `uri`, and HTTP `status`.
257
+
258
+ ### Refreshing, Client Credentials, and Revocation
259
+
260
+ ```ruby
261
+ client.refresh_token_request(refresh_token: token.refresh_token)
262
+ client.client_credentials_request(scope: "read") # confidential clients only
263
+ client.revocation_request(token: token.refresh_token, token_type_hint: "refresh_token")
264
+
265
+ token.expired?(leeway: 30) # => true within 30 seconds of expiring
266
+ ```
267
+
268
+ Every request builder takes `params` for anything the extension you need adds to the request, such as the resource indicator of [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707):
269
+
270
+ ```ruby
271
+ client.authorization_code_request(
272
+ code: params[:code],
273
+ redirect_uri: "https://app.example/callback",
274
+ code_verifier: pkce.verifier,
275
+ params: {resource: "https://api.example/"}
276
+ )
277
+ ```
278
+
279
+ These override the parameters the client sends itself, whether their keys are Strings or Symbols.
280
+
281
+ A revocation endpoint answers 200 when the token is revoked. For any other response, `SimpleOAuth::OAuth2::Error.from_response(status:, body:)` describes the failure.
282
+
283
+ ## Errors
284
+
285
+ Every error the library raises descends from `SimpleOAuth::Error`, so one rescue covers all of them:
286
+
287
+ ```ruby
288
+ begin
289
+ token = SimpleOAuth::OAuth2::Token.from_response(status: response.code, body: response.body)
290
+ rescue SimpleOAuth::Error => error
291
+ # SimpleOAuth::OAuth2::Error, SimpleOAuth::ParseError, or SimpleOAuth::InvalidOptionsError
292
+ end
293
+ ```
294
+
181
295
  ## Contributing
182
296
 
183
297
  Bug reports and pull requests are welcome on GitHub at https://github.com/laserlemon/simple_oauth.
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bundler/gem_tasks"
2
4
  # Override release task to skip gem push (handled by GitHub Actions with attestations)
3
5
  Rake::Task["release"].clear
data/Steepfile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  D = Steep::Diagnostic
2
4
 
3
5
  target :lib do
@@ -5,11 +7,10 @@ target :lib do
5
7
 
6
8
  check "lib"
7
9
 
8
- library "base64"
9
10
  library "openssl"
10
11
  library "uri"
11
- library "cgi"
12
12
  library "securerandom"
13
+ library "json"
13
14
 
14
15
  configure_code_diagnostics(D::Ruby.strict) do |hash|
15
16
  # Allow FallbackAny warnings for variables in ensure blocks
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "uri"
2
4
 
3
5
  module SimpleOAuth
@@ -1,7 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SimpleOAuth
4
+ # The base of every error the library raises, so one rescue catches them all
5
+ class Error < StandardError; end
6
+
2
7
  # Error raised when parsing a malformed OAuth Authorization header
3
- class ParseError < StandardError; end
8
+ class ParseError < Error; end
4
9
 
5
10
  # Error raised when invalid options are passed to Header
6
- class InvalidOptionsError < StandardError; end
11
+ class InvalidOptionsError < Error; end
7
12
  end
@@ -1,5 +1,6 @@
1
- require "base64"
2
- require "cgi"
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
3
4
  require "openssl"
4
5
  require "securerandom"
5
6
 
@@ -37,7 +38,7 @@ module SimpleOAuth
37
38
  # SimpleOAuth::Header.body_hash('{"text": "Hello"}')
38
39
  # # => "aOjMoMwMP1RZ0hKa1HryYDlCKck="
39
40
  def body_hash(body, algorithm = "SHA1")
40
- encode_base64(OpenSSL::Digest.digest(algorithm, body || ""))
41
+ Signature.encode_base64(OpenSSL::Digest.digest(algorithm, body || ""))
41
42
  end
42
43
 
43
44
  # Parses an OAuth Authorization header string into a hash
@@ -71,7 +72,7 @@ module SimpleOAuth
71
72
  def from_request(request, oauth = {})
72
73
  uri = request.uri || raise(ArgumentError, "The request has no URI")
73
74
  body = request.body
74
- return new(request.method, uri, form_params(body), oauth) if form_encoded?(request)
75
+ return new(request.method, uri, form_pairs(body), oauth) if form_encoded?(request)
75
76
 
76
77
  no_params = {} #: Header::request_params
77
78
  new(request.method, uri, no_params, oauth, body)
@@ -85,6 +86,7 @@ module SimpleOAuth
85
86
  # @api public
86
87
  # @param body [String, #to_s] the form-encoded request body
87
88
  # @return [Hash] parsed OAuth attributes with symbol keys (only valid OAuth keys)
89
+ # @raise [SimpleOAuth::ParseError] if the body repeats a protocol parameter
88
90
  # @example
89
91
  # SimpleOAuth::Header.parse_form_body('oauth_consumer_key=key&oauth_signature=sig&status=hello')
90
92
  # # => {consumer_key: "key", signature: "sig"}
@@ -95,11 +97,14 @@ module SimpleOAuth
95
97
  valid_keys = PARSE_KEYS.map(&:to_s)
96
98
 
97
99
  result = {} #: Hash[Symbol, String]
98
- CGI.parse(body.to_s).each do |key, values|
100
+ form_pairs(body).each do |key, value|
99
101
  next unless key.start_with?(OAUTH_PREFIX)
100
102
 
101
103
  parsed_key = key.delete_prefix(OAUTH_PREFIX)
102
- result[parsed_key.to_sym] = values.first || "" if valid_keys.include?(parsed_key)
104
+ next unless valid_keys.include?(parsed_key)
105
+ raise ParseError, "Duplicate protocol parameter: #{key}" if result.key?(parsed_key.to_sym)
106
+
107
+ result[parsed_key.to_sym] = value
103
108
  end
104
109
  result
105
110
  end
@@ -115,23 +120,25 @@ module SimpleOAuth
115
120
  # # => {consumer_key: "key"}
116
121
  alias_method :parse_query, :parse_form_body
117
122
 
118
- private
119
-
120
- # Parses a form-encoded body into the parameter pairs to sign
123
+ # Parses a form-encoded query string or body into parameter pairs
121
124
  #
122
125
  # A parameter with no value, such as the "c2" of the RFC 5849 Section 3.4.1.3.1
123
- # example, is signed with an empty value rather than dropped.
126
+ # example, carries an empty value rather than being dropped. An empty segment, as in
127
+ # the "&&" of "a=1&&b=2", is no parameter at all. Only "&" separates parameters: a
128
+ # ";" is part of the value, as every current server reads it.
124
129
  #
125
130
  # @api private
126
- # @param body [String, nil] the form-encoded body
131
+ # @param form [String, #to_s, nil] the form-encoded query string or body
127
132
  # @return [Array<Array(String, String)>] the parameter pairs
128
- def form_params(body)
129
- CGI.parse(body.to_s).flat_map do |key, values|
130
- # A parameter with no value still makes one pair, carrying an empty value
131
- (values.empty? ? [""] : values).map { |value| [key, value] }
132
- end
133
+ # @example
134
+ # SimpleOAuth::Header.form_pairs("c2&a3=2+q")
135
+ # # => [["c2", ""], ["a3", "2 q"]]
136
+ def form_pairs(form)
137
+ URI.decode_www_form(form.to_s).reject { |key, value| key.empty? && value.empty? }
133
138
  end
134
139
 
140
+ private
141
+
135
142
  # Checks whether a request carries a form-encoded body
136
143
  #
137
144
  # @api private
@@ -160,15 +167,6 @@ module SimpleOAuth
160
167
  def generate_nonce
161
168
  SecureRandom.hex
162
169
  end
163
-
164
- # Encodes binary data as Base64 without newlines
165
- #
166
- # @api private
167
- # @param data [String] binary data to encode
168
- # @return [String] Base64-encoded string
169
- def encode_base64(data)
170
- Base64.strict_encode64(data)
171
- end
172
170
  end
173
171
  end
174
172
  end
@@ -1,4 +1,6 @@
1
- require "cgi"
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
2
4
 
3
5
  module SimpleOAuth
4
6
  class Header
@@ -33,16 +35,14 @@ module SimpleOAuth
33
35
 
34
36
  # Extracts query parameters from the request URL
35
37
  #
36
- # A parameter with no value, such as the "c2" of the RFC 5849 Section 3.4.1.3.1
37
- # example, is signed with an empty value rather than dropped.
38
+ # The pairs are left in the order the query string gave them, because
39
+ # {#normalized_params} sorts every parameter once they are all encoded, which is the
40
+ # order RFC 5849 Section 3.4.1.3.2 asks for.
38
41
  #
39
42
  # @api private
40
43
  # @return [Array<Array>] URL query parameters as key-value pairs
41
44
  def url_params
42
- CGI.parse(@uri.query || "").flat_map do |key, values|
43
- # A parameter with no value still makes one pair, carrying an empty value
44
- (values.empty? ? [""] : values.sort).map { |value| [key, value] }
45
- end
45
+ Header.form_pairs(@uri.query)
46
46
  end
47
47
 
48
48
  # Normalizes and sorts all request parameters for signing
@@ -1,4 +1,5 @@
1
- require "cgi"
1
+ # frozen_string_literal: true
2
+
2
3
  require "uri"
3
4
  require_relative "encoding"
4
5
  require_relative "errors"
@@ -13,18 +14,18 @@ module SimpleOAuth
13
14
  # @api public
14
15
  class Header
15
16
  # OAuth header scheme prefix
16
- OAUTH_SCHEME = "OAuth".freeze
17
+ OAUTH_SCHEME = "OAuth"
17
18
 
18
19
  # Prefix for OAuth parameters
19
- OAUTH_PREFIX = "oauth_".freeze
20
+ OAUTH_PREFIX = "oauth_"
20
21
 
21
22
  # The content type whose body parameters are signed, per RFC 5849 Section 3.4.1.3.1
22
- FORM_CONTENT_TYPE = "application/x-www-form-urlencoded".freeze
23
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
23
24
  # Default signature method per RFC 5849
24
- DEFAULT_SIGNATURE_METHOD = "HMAC-SHA1".freeze
25
+ DEFAULT_SIGNATURE_METHOD = "HMAC-SHA1"
25
26
 
26
27
  # OAuth version
27
- OAUTH_VERSION = "1.0".freeze
28
+ OAUTH_VERSION = "1.0"
28
29
 
29
30
  # Valid OAuth attribute keys that can be included in the header
30
31
  ATTRIBUTE_KEYS = %i[body_hash callback consumer_key nonce signature_method timestamp token verifier version].freeze
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "uri"
5
+ require_relative "error"
6
+
7
+ module SimpleOAuth
8
+ module OAuth2
9
+ # The response an authorization server returns to a client's redirect URI
10
+ #
11
+ # Parsing one makes the checks a client owes its own request before it sends the code
12
+ # anywhere: that the server reported no error, that the response answers the request this
13
+ # client made, and that it came from the authorization server the client expected.
14
+ #
15
+ # @api public
16
+ # @example Read the code out of a callback
17
+ # response = SimpleOAuth::OAuth2::AuthorizationResponse.parse(request.query_string,
18
+ # state: session[:state])
19
+ # token_request = client.authorization_code_request(code: response.code,
20
+ # redirect_uri: "https://app.example/cb", code_verifier: session[:verifier])
21
+ #
22
+ # @see https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2 RFC 6749 - Authorization Response
23
+ # @see https://www.rfc-editor.org/rfc/rfc9207 RFC 9207 - Authorization Server Issuer Identification
24
+ class AuthorizationResponse
25
+ # The description of a response whose state is not the one the request sent
26
+ STATE_MISMATCH = "The authorization response answers a different request"
27
+ # The description of a response from an authorization server other than the expected one
28
+ ISSUER_MISMATCH = "The authorization response is from a different authorization server"
29
+ # The description of a response carrying neither a code nor an error
30
+ NO_CODE = "The authorization response has no code"
31
+ # The description of a response that repeats a parameter, which RFC 6749 Section 3.1 forbids
32
+ DUPLICATE_PARAMETER = "The authorization response repeats a parameter"
33
+
34
+ # The authorization code, to exchange for a token
35
+ #
36
+ # @api public
37
+ # @return [String] the authorization code
38
+ # @example
39
+ # response.code # => "SplxlOBeZQQYbYS6WxSbIA"
40
+ attr_reader :code
41
+
42
+ # The state the authorization server returned
43
+ #
44
+ # @api public
45
+ # @return [String, nil] the state
46
+ # @example
47
+ # response.state # => "xyz"
48
+ attr_reader :state
49
+
50
+ # The issuer the authorization server identified itself with (RFC 9207)
51
+ #
52
+ # @api public
53
+ # @return [String, nil] the issuer
54
+ # @example
55
+ # response.issuer # => "https://server.example.com"
56
+ attr_reader :issuer
57
+
58
+ # Every parameter of the authorization response
59
+ #
60
+ # @api public
61
+ # @return [Hash{String => String}] the parameters
62
+ # @example
63
+ # response.params["code"] # => "SplxlOBeZQQYbYS6WxSbIA"
64
+ attr_reader :params
65
+
66
+ # Parse an authorization response, raising unless the client can trust and use it
67
+ #
68
+ # @api public
69
+ # @param query [String, Hash, nil] the query string of the redirect, or its parsed parameters
70
+ # @param state [String, nil] the state the authorization URL sent, which the response must
71
+ # carry; nil to make no such check, for a request that sent none
72
+ # @param issuer [String, nil] the issuer the server must identify itself with; nil to make
73
+ # no such check
74
+ # @return [AuthorizationResponse] the response
75
+ # @raise [Error] if the server reported an error, or the response cannot be trusted
76
+ # @example
77
+ # SimpleOAuth::OAuth2::AuthorizationResponse.parse("code=abc&state=xyz", state: "xyz")
78
+ def self.parse(query, state: nil, issuer: nil)
79
+ params = parameters(query)
80
+ raise reported_error(params) if params.key?("error")
81
+
82
+ reason = mismatch_reason(params, state, issuer)
83
+ raise Error.new(code: nil, description: reason) if reason
84
+
85
+ new(params)
86
+ end
87
+
88
+ # The parameters of an authorization response
89
+ #
90
+ # @api private
91
+ # @param query [String, Hash, nil] the query string of the redirect, or its parsed parameters
92
+ # @return [Hash{String => String}] the parameters
93
+ # @raise [Error] if a parameter is repeated, which RFC 6749 Section 3.1 forbids
94
+ # @example
95
+ # SimpleOAuth::OAuth2::AuthorizationResponse.parameters("code=abc") # => {"code" => "abc"}
96
+ def self.parameters(query)
97
+ return query.transform_keys(&:to_s) if query.is_a?(Hash)
98
+
99
+ pairs = URI.decode_www_form(query.to_s)
100
+ raise Error.new(code: nil, description: DUPLICATE_PARAMETER) if pairs.length > pairs.uniq(&:first).length
101
+
102
+ pairs.to_h
103
+ end
104
+
105
+ # The error the authorization server reported (RFC 6749 Section 4.1.2.1)
106
+ #
107
+ # @api private
108
+ # @param params [Hash] the response parameters
109
+ # @return [Error] the error
110
+ # @example
111
+ # SimpleOAuth::OAuth2::AuthorizationResponse.reported_error({"error" => "access_denied"})
112
+ def self.reported_error(params)
113
+ Error.new(code: params["error"], description: params["error_description"], uri: params["error_uri"])
114
+ end
115
+
116
+ # The reason a response cannot be trusted or used, if there is one
117
+ #
118
+ # @api private
119
+ # @param params [Hash] the response parameters
120
+ # @param state [String, nil] the state the request sent
121
+ # @param issuer [String, nil] the expected issuer
122
+ # @return [String, nil] the reason, or nil if the response is usable
123
+ # @example
124
+ # SimpleOAuth::OAuth2::AuthorizationResponse.mismatch_reason({"code" => "a"}, nil, nil) # => nil
125
+ def self.mismatch_reason(params, state, issuer)
126
+ return STATE_MISMATCH unless matches?(state, params["state"])
127
+ return ISSUER_MISMATCH unless matches?(issuer, params["iss"])
128
+
129
+ NO_CODE if params["code"].to_s.empty?
130
+ end
131
+
132
+ # Whether the response carries what the client expected, in constant time
133
+ #
134
+ # @api private
135
+ # @param expected [String, nil] what the client expects, or nil to expect anything
136
+ # @param actual [String, nil] what the response carried
137
+ # @return [Boolean] true if the response is acceptable
138
+ # @example
139
+ # SimpleOAuth::OAuth2::AuthorizationResponse.matches?("xyz", "xyz") # => true
140
+ def self.matches?(expected, actual)
141
+ return true if expected.nil?
142
+
143
+ !actual.nil? && OpenSSL.secure_compare(expected, actual)
144
+ end
145
+
146
+ # Initialize a response from the parameters of an authorization response
147
+ #
148
+ # @api public
149
+ # @param params [Hash] the response parameters
150
+ # @raise [KeyError] if the parameters have no code
151
+ # @example
152
+ # SimpleOAuth::OAuth2::AuthorizationResponse.new({"code" => "abc", "state" => "xyz"})
153
+ def initialize(params)
154
+ @params = params.transform_keys(&:to_s).freeze
155
+ @code = @params.fetch("code")
156
+ @state = @params["state"]
157
+ @issuer = @params["iss"]
158
+ freeze
159
+ end
160
+ end
161
+ end
162
+ end