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