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 +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +32 -0
- data/README.md +116 -2
- data/Rakefile +2 -0
- data/Steepfile +3 -2
- data/lib/simple_oauth/encoding.rb +2 -0
- data/lib/simple_oauth/errors.rb +7 -2
- data/lib/simple_oauth/header/class_methods.rb +23 -25
- data/lib/simple_oauth/header/params.rb +7 -7
- data/lib/simple_oauth/header.rb +7 -6
- data/lib/simple_oauth/oauth2/authorization_response.rb +162 -0
- data/lib/simple_oauth/oauth2/client.rb +307 -0
- data/lib/simple_oauth/oauth2/error.rb +95 -0
- data/lib/simple_oauth/oauth2/pkce.rb +104 -0
- data/lib/simple_oauth/oauth2/request.rb +61 -0
- data/lib/simple_oauth/oauth2/response_body.rb +26 -0
- data/lib/simple_oauth/oauth2/token.rb +209 -0
- data/lib/simple_oauth/oauth2.rb +31 -0
- data/lib/simple_oauth/parser.rb +7 -1
- data/lib/simple_oauth/signature.rb +12 -7
- data/lib/simple_oauth/version.rb +4 -2
- data/lib/simple_oauth.rb +6 -7
- data/sig/simple_oauth/header/class_methods.rbs +3 -5
- data/sig/simple_oauth/oauth2.rbs +144 -0
- data/sig/simple_oauth/signature.rbs +1 -1
- data/sig/simple_oauth.rbs +7 -3
- metadata +13 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86b6ff6f62fce7e9cc8119852cbf8470c5a1b7f9ab4ecaaf929d2aba35121a2b
|
|
4
|
+
data.tar.gz: c40d43c6172cf63cca3d04f81291b8e9917062cea0c0b112943cb0a2428c488c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fafef85869f766c1d1c19ebceb4e8152741734ce438e3379ff0fd94d15b2a4437d7b19e74fe62506f0ff4214eaf37c64cfffaa979400df0fc49b765f8bc1c325
|
|
7
|
+
data.tar.gz: b95a19574fdd189c53f1e3d263515533991046f8e29e501f12c0a37c39158351f1376c851d58f751b7efda99b6d4a676be2ce243cc5540e52ab4c7d2ef18870e
|
data/.rubocop.yml
CHANGED
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
|
[](https://github.com/laserlemon/simple_oauth/actions/workflows/typecheck.yml)
|
|
8
8
|
[](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.
|
|
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
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
|
data/lib/simple_oauth/errors.rb
CHANGED
|
@@ -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 <
|
|
8
|
+
class ParseError < Error; end
|
|
4
9
|
|
|
5
10
|
# Error raised when invalid options are passed to Header
|
|
6
|
-
class InvalidOptionsError <
|
|
11
|
+
class InvalidOptionsError < Error; end
|
|
7
12
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
37
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/simple_oauth/header.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
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"
|
|
17
|
+
OAUTH_SCHEME = "OAuth"
|
|
17
18
|
|
|
18
19
|
# Prefix for OAuth parameters
|
|
19
|
-
OAUTH_PREFIX = "oauth_"
|
|
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"
|
|
23
|
+
FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
|
23
24
|
# Default signature method per RFC 5849
|
|
24
|
-
DEFAULT_SIGNATURE_METHOD = "HMAC-SHA1"
|
|
25
|
+
DEFAULT_SIGNATURE_METHOD = "HMAC-SHA1"
|
|
25
26
|
|
|
26
27
|
# OAuth version
|
|
27
|
-
OAUTH_VERSION = "1.0"
|
|
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
|