rodauth-oauth 1.6.5 → 1.6.6

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: cc6a614c797762b13c8b97dc51abf5191dc09ef919e27fd394f46504b62410c7
4
- data.tar.gz: 3c6003d4131e03514a5bf8acd674cc1b7d8fcacd9eada7ab98044fc3f037fc95
3
+ metadata.gz: d114c188d7d68d73ec35837c57c6491022be90288a56aecc7dd61b26949d2509
4
+ data.tar.gz: d6d55020809cc6397d23b00f85b89fd948e0b79ffd7970a1956b1b938419ad3d
5
5
  SHA512:
6
- metadata.gz: 997c1e682fc4f482d0ea367a918c4db15130535294db13f19c52c558f152eca78fd197461d74ce269e77a56b36cf465b8b7e9d2424f3b999cb6491782c8d69f6
7
- data.tar.gz: 2109d08b8911e33697e08e528899d45535de16c1cc994d663324c51161fed1f6e35386849413adb6dadbb526b2b75c64522865cbdf90798042ca8e68767362cf
6
+ metadata.gz: 546dd06b480c261c6818a7852cb85c4366011631190c4ee749ef59e33a8cb11b3fd1fedca8e7a7bf79e100f1ee406a3f8e71a895e1b0e4fe66abc63d0ba83efa
7
+ data.tar.gz: 8239900aebd23ba93d91311a06bb66d88a0a732d70772bc6ef6b0b21d01c91225e1cdc5b76d0a14de3400aa3fd02cbb7d0a406ed2b9a43693f5008d4563c40e5
data/README.md CHANGED
@@ -183,7 +183,15 @@ If you're starting from scratch though, the recommendation is to stick to the de
183
183
 
184
184
  You'll have to generate HTML templates for the Oauth Authorization form.
185
185
 
186
- The rodauth default setup expects the roda `render` plugin to be activated; by default, it expects a `views` directory to be defined in the project root folder. The Oauth Authorization template must be therefore defined there, and it should be called `oauth_authorize.(erb|str|...)` (read the [roda `render` plugin documentation](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Render.html) for more info about HTML templating).
186
+ The rodauth default setup expects the roda `render` plugin to be activated; by default, it expects a `views` directory to be defined in the project root folder. The Oauth Authorization template must be therefore defined there, and it should be called `authorize.(erb|str|...)` (read the [roda `render` plugin documentation](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Render.html) for more info about HTML templating).
187
+
188
+ Start it from [the bundled template](templates/authorize.str), which is written for the `str` engine, rather than from a blank file:
189
+
190
+ ```
191
+ cp "$(bundle info rodauth-oauth --path)/templates/authorize.str" views/authorize.str
192
+ ```
193
+
194
+ The form posts back to the authorize endpoint, so every authorization request parameter the grant depends on has to be carried over into it. A parameter left out is dropped from the grant silently, and only surfaces later as an error at the token endpoint, which is why adapting the bundled template beats writing one from scratch. See [the authorize form docs](doc/oauth_authorize_base.rdoc) for the parts which are easiest to lose.
187
195
 
188
196
  ### OAuth applications management
189
197
 
@@ -0,0 +1,25 @@
1
+ ### 1.6.6
2
+
3
+ #### Improvements
4
+
5
+ * Authorization form params will now be exposed to the HTML rendering via the `rodauth.authorize_form_params`; this not only reduces the size of the bundled forms, but also makes it much easier to add or remove features with their own required authorize params.
6
+ * `:oauth_jwt_base` feature: new `:oauth_jwt_iat_leeway` option (defaults to 30, as in seconds), is used now to make the JWT claim `iat` claim tolerant to clock skew. This option is used in both the `jwt` and `json/jwt` gem integrations.
7
+ * `:oauth_pkce` feature: new `:oauth_pkce_allow_plain_method` option, which may disallow the usage of `"plain"` as a PKCE challenge method
8
+ * defaults to `true` in order not to break backwards compatibility, but this is going to change in a future version, so you are recommended to set it explicitly in order not to break your application in a future upgrade.
9
+
10
+ #### Bugfixes
11
+
12
+ * (critical) `:oauth_dynamic_client_registration` feature: `PUT /register` was incorrectly authenticating the request (responding with 200 when secret didn't match), thereby allowing anyone (else) to update application parameters.
13
+ * (critical) `:oauth_dynamic_client_registration` feature: `PUT /register` was not hashing the client secret when hashing on the client secret was enforced.
14
+ * `:oauth_authorization_code_grant` feature: escape untrusted values in the authorize form (hidden request params, the `"state"` in the cancel link, the client application name), in the authorize form post POST response form, and in the device verification template.
15
+ * token endpoint: enforce 4XX HTTP responses with JSON encoded body when there are errors (instead of making it rely on the "accept" HTTP header).
16
+ * client authentication: fallback to `oauth_default_token_endpoint_auth_methods` when the application being authentication has no `token_endpoint_auth_method` set (instead of assuming it as "none").
17
+ * `:oauth_dynamic_client_registration` feature: client metadata which the server does not understand is now ignored (RFC 7591 section 3.1), instead of failing the registration with an `"invalid_client_metadata"` error.
18
+ * `:oauth_dynamic_client_registration` feature: fix typo which was exposing `client_secret` in the registration response in cases where it shouldn't.
19
+ * `:oauth_dynamic_client_registration` feature: emit `"client_id_issued_at"` as epoch seconds instead of an ISO8601 string, as per [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1).
20
+ * `:oauth_dynamic_client_registration` feature: remove "registration_access_token" and "registration_client_uri" parameters from the payload (these are only valid for the OIDC counterpart).
21
+ * `json/jwt`: decoding a JWT was bypassing claims verification.
22
+
23
+ #### Security
24
+
25
+ * Using `rodauth` `timing_safe_eql?` helper method, a string comparison function which is safe against timing attacks, in client secret matching and PKCE verification.
@@ -3,7 +3,7 @@
3
3
  <%= image_tag rodauth.oauth_application[rodauth.oauth_applications_logo_uri_column] %>
4
4
  <% end %>
5
5
  <% application_uri = rodauth.oauth_application[rodauth.oauth_applications_homepage_url_column] %>
6
- <% application_name = application_uri ? link_to(rodauth.oauth_application[rodauth.oauth_applications_name_column], application_uri) : rodauth.oauth_application[rodauth.oauth_applications_name_column] %>
6
+ <% application_name = application_uri ? link_to(rodauth.oauth_application[rodauth.oauth_applications_name_column], application_uri) : h(rodauth.oauth_application[rodauth.oauth_applications_name_column]) %>
7
7
  <p class="lead"><%= rodauth.authorize_page_lead(name: application_name).html_safe %></p>
8
8
 
9
9
  <div class="list-group">
@@ -37,56 +37,14 @@
37
37
  </div>
38
38
  <% end %>
39
39
  <% end %>
40
- <%= hidden_field_tag :client_id, rodauth.param_or_nil("client_id") %>
41
- <% %w[access_type response_type response_mode state redirect_uri].each do |oauth_param| %>
42
- <% if rodauth.param_or_nil(oauth_param) %>
43
- <%= hidden_field_tag oauth_param, rodauth.param_or_nil(oauth_param) %>
44
- <% end %>
45
- <% end %>
46
- <% if rodauth.features.include?(:oauth_resource_indicators) && rodauth.resource_indicators %>
47
- <% rodauth.resource_indicators.each do |resource| %>
48
- <%= hidden_field_tag "resource", resource %>
49
- <% end %>
50
- <% end %>
51
- <% if rodauth.features.include?(:oauth_pkce) %>
52
- <% if rodauth.param_or_nil("code_challenge") %>
53
- <%= hidden_field_tag :code_challenge, rodauth.param_or_nil("code_challenge") %>
54
- <% end %>
55
- <% if rodauth.param_or_nil("code_challenge_method") %>
56
- <%= hidden_field_tag :code_challenge_method, rodauth.param_or_nil("code_challenge_method") %>
57
- <% end %>
58
- <% end %>
59
- <% if rodauth.features.include?(:oidc) %>
60
- <% if rodauth.param_or_nil("prompt") %>
61
- <%= hidden_field_tag :prompt, rodauth.param_or_nil("prompt") %>
62
- <% end %>
63
- <% if rodauth.param_or_nil("nonce") %>
64
- <%= hidden_field_tag :nonce, rodauth.param_or_nil("nonce") %>
65
- <% end %>
66
- <% if rodauth.param_or_nil("ui_locales") %>
67
- <%= hidden_field_tag :ui_locales, rodauth.param_or_nil("ui_locales") %>
68
- <% end %>
69
- <% if rodauth.param_or_nil("claims_locales") %>
70
- <%= hidden_field_tag :claims_locales, rodauth.param_or_nil("claims_locales") %>
71
- <% end %>
72
- <% if rodauth.param_or_nil("claims") %>
73
- <%= hidden_field_tag :claims, sanitize(rodauth.param_or_nil("claims")) %>
74
- <% end %>
75
- <% if rodauth.param_or_nil("acr_values") %>
76
- <%= hidden_field_tag :acr_values, rodauth.param_or_nil("acr_values") %>
77
- <% end %>
78
- <% if rodauth.param_or_nil("registration") %>
79
- <%= hidden_field_tag :registration, rodauth.param_or_nil("registration") %>
80
- <% end %>
81
- <% end %>
82
- <% if rodauth.features.include?(:oauth_dpop) %>
83
- <% if rodauth.param_or_nil("dpop_jkt") %>
84
- <%= hidden_field_tag :dpop_jkt, rodauth.param_or_nil("dpop_jkt") %>
85
- <% end %>
40
+ <% rodauth.authorize_form_params.each do |field| %>
41
+ <%= tag(:input, type: field["type"], name: field["name"], value: field["value"]) %>
86
42
  <% end %>
87
43
  </div>
88
44
  <p class="text-center">
89
45
  <%= submit_tag rodauth.oauth_authorize_button, class: "btn btn-outline-primary" %>
90
- <%= link_to rodauth.oauth_cancel_button, "#{rodauth.redirect_uri}?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request#{"&state=\#{CGI.escape(rodauth.state)}" if rodauth.param_or_nil("state") }", class: "btn btn-outline-danger" %>
46
+ <% cancel_url = "#{rodauth.redirect_uri}?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request" %>
47
+ <% cancel_url += "&state=#{CGI.escape(rodauth.param("state"))}" if rodauth.param_or_nil("state") %>
48
+ <%= link_to rodauth.oauth_cancel_button, cancel_url, class: "btn btn-outline-danger" %>
91
49
  </p>
92
50
  <% end %>
@@ -116,7 +116,7 @@ module Rodauth
116
116
  <html>
117
117
  <head><title>Authorized</title></head>
118
118
  <body onload="javascript:document.forms[0].submit()">
119
- <form method="post" action="#{url}">
119
+ <form method="post" action="#{scope.h(url)}">
120
120
  #{yield}
121
121
  <input type="submit" class="btn btn-outline-primary" value="#{scope.h(oauth_authorize_post_button)}"/>
122
122
  </form>
@@ -130,7 +130,7 @@ module Rodauth
130
130
  <html>
131
131
  <head><title></title></head>
132
132
  <body onload="javascript:document.forms[0].submit()">
133
- <form method="post" action="#{url}">
133
+ <form method="post" action="#{scope.h(url)}">
134
134
  #{yield}
135
135
  </form>
136
136
  </body>
@@ -30,7 +30,8 @@ module Rodauth
30
30
 
31
31
  auth_methods(
32
32
  :resource_owner_params,
33
- :oauth_grants_resource_owner_columns
33
+ :oauth_grants_resource_owner_columns,
34
+ :authorize_form_params
34
35
  )
35
36
 
36
37
  OAUTH_ACCESS_TYPES = %w[offline online].freeze
@@ -73,6 +74,24 @@ module Rodauth
73
74
  end
74
75
  end
75
76
 
77
+ # The authorization request is validated on the GET which renders the form, and again on the
78
+ # POST which the resource owner submits, and the latter is a fresh request. These are the
79
+ # params of the authorization request which the form has to carry over into it, one entry per
80
+ # field, each with the "name", "value" and "type" it is rendered with. Features which introduce
81
+ # authorization request params of their own extend it, so a view which renders all of them
82
+ # keeps working as features get enabled.
83
+ def authorize_form_params
84
+ params = [{ "name" => "client_id", "value" => param("client_id"), "type" => "hidden" }]
85
+
86
+ %w[access_type response_type response_mode state redirect_uri].each do |param_name|
87
+ if (param_value = param_or_nil(param_name))
88
+ params << { "name" => param_name, "value" => param_value, "type" => "hidden" }
89
+ end
90
+ end
91
+
92
+ params
93
+ end
94
+
76
95
  private
77
96
 
78
97
  def validate_authorize_params
@@ -34,6 +34,15 @@ module Rodauth
34
34
  auth_value_method :oauth_unique_id_generation_retries, 3
35
35
 
36
36
  auth_value_method :oauth_token_endpoint_auth_methods_supported, %w[client_secret_basic client_secret_post]
37
+ # An application which has no auth method of its own falls back to these, and not to every
38
+ # method the server supports: enabling a new method server-wide (such as "none", which requires
39
+ # no client authentication at all) must not turn existing confidential clients into public ones.
40
+ #
41
+ # TODO: change this to "client_secret_basic" only in a future major version, as per
42
+ # https://datatracker.ietf.org/doc/html/rfc7591#section-2, which defaults an omitted
43
+ # "token_endpoint_auth_method" to it. Doing so now would lock out "client_secret_post" clients
44
+ # whose application row has no auth method set.
45
+ auth_value_method :oauth_default_token_endpoint_auth_methods, %w[client_secret_basic client_secret_post]
37
46
  auth_value_method :oauth_grant_types_supported, %w[refresh_token]
38
47
  auth_value_method :oauth_response_types_supported, []
39
48
  auth_value_method :oauth_response_modes_supported, []
@@ -188,6 +197,11 @@ module Rodauth
188
197
  def accepts_json?
189
198
  return true if only_json?
190
199
 
200
+ # the token endpoint responds in json no matter what the client asks for, including its
201
+ # errors, which are never bounced off the client's redirect uri.
202
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
203
+ return true if request.path == token_path
204
+
191
205
  (accept = request.env["HTTP_ACCEPT"]) && accept =~ json_request_regexp
192
206
  end
193
207
 
@@ -404,10 +418,10 @@ module Rodauth
404
418
  def supports_auth_method?(oauth_application, auth_method)
405
419
  return false unless oauth_application
406
420
 
407
- supported_auth_methods = if oauth_application[oauth_applications_token_endpoint_auth_method_column]
408
- oauth_application[oauth_applications_token_endpoint_auth_method_column].split(/ +/)
421
+ supported_auth_methods = if (auth_methods = oauth_application[oauth_applications_token_endpoint_auth_method_column])
422
+ auth_methods.split(/ +/)
409
423
  else
410
- oauth_token_endpoint_auth_methods_supported
424
+ oauth_default_token_endpoint_auth_methods
411
425
  end
412
426
 
413
427
  supported_auth_methods.include?(auth_method)
@@ -431,7 +445,11 @@ module Rodauth
431
445
  if oauth_applications_client_secret_hash_column
432
446
  BCrypt::Password.new(oauth_application[oauth_applications_client_secret_hash_column]) == secret
433
447
  else
434
- oauth_application[oauth_applications_client_secret_column] == secret
448
+ stored_secret = oauth_application[oauth_applications_client_secret_column]
449
+ # A missing submitted or stored secret must never match. Without these guards, .to_s would
450
+ # coerce nil to "", letting an absent secret authenticate a client whose stored secret is
451
+ # empty/NULL. The real comparison stays constant-time via timing_safe_eql?.
452
+ !secret.nil? && !stored_secret.nil? && timing_safe_eql?(secret.to_s, stored_secret.to_s)
435
453
  end
436
454
  end
437
455
 
@@ -85,6 +85,14 @@ module Rodauth
85
85
  super
86
86
  end
87
87
 
88
+ def authorize_form_params
89
+ super.tap do |params|
90
+ if (param_value = param_or_nil("dpop_jkt"))
91
+ params << { "name" => "dpop_jkt", "value" => param_value, "type" => "hidden" }
92
+ end
93
+ end
94
+ end
95
+
88
96
  private
89
97
 
90
98
  def validate_token_params
@@ -12,7 +12,12 @@ module Rodauth
12
12
  auth_value_method :oauth_applications_registration_access_token_column, :registration_access_token
13
13
  auth_value_method :registration_client_uri_route, "register"
14
14
 
15
- PROTECTED_APPLICATION_ATTRIBUTES = %w[account_id client_id].freeze
15
+ PROTECTED_APPLICATION_CREATE_ATTRIBUTES = %w[account_id client_id].freeze
16
+
17
+ PROTECTED_APPLICATION_UPDATE_ATTRIBUTES = %w[account_id client_id registration_access_token registration_client_uri
18
+ client_secret_expires_at client_id_issued_at].freeze
19
+
20
+ HIDDEN_APPLICATION_ATTRIBUTES = %w[id account_id client_id client_secret client_secret_hash].freeze
16
21
 
17
22
  def load_registration_client_uri_routes
18
23
  request.on(registration_client_uri_route) do
@@ -34,19 +39,14 @@ module Rodauth
34
39
  json_response_oauth_application(oauth_application)
35
40
  end
36
41
  request.on method: :put do
37
- %w[client_id registration_access_token registration_client_uri client_secret_expires_at
38
- client_id_issued_at].each do |prohibited_param|
39
- if request.params.key?(prohibited_param)
40
- register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(prohibited_param))
41
- end
42
- end
43
- validate_client_registration_params
42
+ validate_client_registration_params(request.params, PROTECTED_APPLICATION_UPDATE_ATTRIBUTES)
44
43
 
45
- # if the client includes the "client_secret" field in the request, the value of this field MUST match the currently
46
- # issued client secret for that client. The client MUST NOT be allowed to overwrite its existing client secret with
47
- # its own chosen value.
48
- authorization_required if request.params.key?("client_secret") && secret_matches?(oauth_application,
49
- request.params["client_secret"])
44
+ if request.params.key?("client_secret") && !secret_matches?(oauth_application, request.params["client_secret"])
45
+ # if the client includes the "client_secret" field in the request, the value of this field MUST match the currently
46
+ # issued client secret for that client. The client MUST NOT be allowed to overwrite its existing client secret with
47
+ # its own chosen value.
48
+ authorization_required
49
+ end
50
50
 
51
51
  oauth_application = transaction do
52
52
  applications_ds = db[oauth_applications_table]
@@ -80,7 +80,7 @@ module Rodauth
80
80
  end
81
81
  end
82
82
 
83
- validate_client_registration_params
83
+ validate_client_registration_params(request.params, PROTECTED_APPLICATION_CREATE_ATTRIBUTES)
84
84
 
85
85
  response_params = transaction do
86
86
  before_register
@@ -119,8 +119,14 @@ module Rodauth
119
119
  }
120
120
  end
121
121
 
122
- def validate_client_registration_params(request_params = request.params)
122
+ def validate_client_registration_params(request_params, protected_attributes = PROTECTED_APPLICATION_CREATE_ATTRIBUTES)
123
+ @oauth_application_unrecognized_params = []
123
124
  @oauth_application_params = request_params.each_with_object({}) do |(key, value), params|
125
+ if protected_attributes.include?(key)
126
+ register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(key))
127
+ next
128
+ end
129
+
124
130
  case key
125
131
  when "redirect_uris"
126
132
  if value.is_a?(Array)
@@ -179,6 +185,10 @@ module Rodauth
179
185
  end
180
186
  end
181
187
  key = __send__(:"oauth_applications_#{key}_column")
188
+ when "client_secret"
189
+ # use internal routine which may hash the secret
190
+ set_client_secret(params, value)
191
+ next
182
192
  when "jwks"
183
193
  register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(value)) unless value.is_a?(Hash)
184
194
  if request_params.key?("jwks_uri")
@@ -248,13 +258,14 @@ module Rodauth
248
258
  key = __send__(:"oauth_applications_#{key}_column")
249
259
  else
250
260
  if respond_to?(:"oauth_applications_#{key}_column")
251
- if PROTECTED_APPLICATION_ATTRIBUTES.include?(key)
252
- register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(key))
253
- end
254
261
  property = :"oauth_applications_#{key}_column"
255
262
  key = __send__(property)
256
263
  elsif !db[oauth_applications_table].columns.include?(key.to_sym)
257
- register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(key))
264
+ # https://datatracker.ietf.org/doc/html/rfc7591#section-3.1
265
+ # "The authorization server MUST ignore any client metadata sent by the client that it
266
+ # does not understand".
267
+ @oauth_application_unrecognized_params << key
268
+ next
258
269
  end
259
270
  end
260
271
  params[key] = value
@@ -285,6 +296,9 @@ module Rodauth
285
296
  applications_ds = db[oauth_applications_table]
286
297
  application_columns = applications_ds.columns
287
298
 
299
+ # ignored metadata is not part of the registration, so it does not belong in the response either
300
+ Array(@oauth_application_unrecognized_params).each { |key| return_params.delete(key) }
301
+
288
302
  # set defaults
289
303
  create_params = @oauth_application_params
290
304
 
@@ -311,7 +325,7 @@ module Rodauth
311
325
  "code"
312
326
  end
313
327
  rescue_from_uniqueness_error do
314
- initialize_register_params(create_params, return_params)
328
+ initialize_create_params(create_params, return_params)
315
329
  create_params.delete_if { |k, _| !application_columns.include?(k) }
316
330
  applications_ds.insert(create_params)
317
331
  end
@@ -319,19 +333,15 @@ module Rodauth
319
333
  return_params
320
334
  end
321
335
 
322
- def initialize_register_params(create_params, return_params)
336
+ def initialize_create_params(create_params, return_params)
323
337
  client_id = oauth_unique_id_generator
324
338
  create_params[oauth_applications_client_id_column] = client_id
325
339
  return_params["client_id"] = client_id
326
- return_params["client_id_issued_at"] = Time.now.utc.iso8601
327
-
328
- registration_access_token = oauth_unique_id_generator
329
- create_params[oauth_applications_registration_access_token_column] = secret_hash(registration_access_token)
330
- return_params["registration_access_token"] = registration_access_token
331
- return_params["registration_client_uri"] = "#{base_url}/#{registration_client_uri_route}/#{return_params['client_id']}"
340
+ # https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1
341
+ # "client_id_issued_at" is a number, expressed as seconds since 1970-01-01T00:00:00Z.
342
+ return_params["client_id_issued_at"] = Time.now.to_i
332
343
 
333
344
  if create_params.key?(oauth_applications_client_secret_column)
334
- set_client_secret(create_params, create_params[oauth_applications_client_secret_column])
335
345
  return_params.delete("client_secret")
336
346
  else
337
347
  client_secret = oauth_unique_id_generator
@@ -404,7 +414,7 @@ module Rodauth
404
414
  params = methods.map { |k| k.to_s[/\Aoauth_applications_(\w+)_column\z/, 1] }.compact
405
415
 
406
416
  body = params.each_with_object({}) do |k, hash|
407
- next if %w[id account_id client_id client_secret cliennt_secret_hash].include?(k)
417
+ next if HIDDEN_APPLICATION_ATTRIBUTES.include?(k)
408
418
 
409
419
  value = oauth_application[__send__(:"oauth_applications_#{k}_column")]
410
420
 
@@ -18,6 +18,11 @@ module Rodauth
18
18
 
19
19
  auth_value_method :oauth_jwt_jwe_copyright, nil
20
20
 
21
+ # Clock-skew leeway, in seconds, tolerated when rejecting a JWT whose iat (issued-at) is in the
22
+ # future. iat is informational per RFC 7519 section 4.1.6, so this only guards against tokens
23
+ # issued well ahead of now. Applies to the json-jwt verification path (see jwt_decode).
24
+ auth_value_method :oauth_jwt_iat_leeway, 30
25
+
21
26
  auth_methods(
22
27
  :jwt_encode,
23
28
  :jwt_decode,
@@ -266,15 +271,28 @@ module Rodauth
266
271
  end
267
272
 
268
273
  now = Time.now
269
- if verify_claims &&
270
- (!claims[:exp] || Time.at(claims[:exp]) < now) &&
271
- claims[:nbf] && Time.at(claims[:nbf]) < now &&
272
- claims[:iat] && Time.at(claims[:iat]) < now &&
273
- verify_iss && claims[:iss] != oauth_jwt_issuer &&
274
- verify_aud && !verify_aud(claims[:aud], claims[:client_id]) &&
275
- verify_jti && !verify_jti(claims[:jti], claims)
276
-
277
- return
274
+ if verify_claims
275
+ # Each line asserts that a claim is acceptable: absent / not checked, or valid. The token
276
+ # is rejected unless all of them hold. These are deliberately PASS conditions joined with
277
+ # `&&` -- the inverse (failure conditions joined with `&&`) only rejects when every check
278
+ # fails at once, which was the original verification bug. exp/nbf/iat are optional and
279
+ # only enforced when present (e.g. DPoP proofs carry no exp).
280
+ #
281
+ # iat is informational per RFC 7519 section 4.1.6 (RFC 9068 section 4 requires it be present,
282
+ # not verified), so we only reject tokens issued in the future, tolerating
283
+ # oauth_jwt_iat_leeway seconds of clock skew. Both backends now honor oauth_jwt_iat_leeway:
284
+ # the ruby-jwt path disables verify_iat (which removed iat_leeway in 2.2.0, jwt/ruby-jwt#319)
285
+ # and performs the same leeway'd future-iat check manually, so behavior is unified.
286
+ # A non-numeric iat is malformed (NumericDate per RFC 7519); reject it rather than letting
287
+ # Time.at raise, mirroring the ruby-jwt path so both backends reject malformed iat alike.
288
+ claims_valid = (!claims[:exp] || Time.at(claims[:exp]) >= now) &&
289
+ (!claims[:nbf] || Time.at(claims[:nbf]) <= now) &&
290
+ (!claims[:iat] || (claims[:iat].is_a?(Numeric) && Time.at(claims[:iat]) <= now + oauth_jwt_iat_leeway)) &&
291
+ (!verify_iss || claims[:iss] == oauth_jwt_issuer) &&
292
+ (!verify_aud || verify_aud(claims[:aud], claims[:client_id])) &&
293
+ (!verify_jti || verify_jti(claims[:jti], claims))
294
+
295
+ return unless claims_valid
278
296
  end
279
297
 
280
298
  return if verify_headers && !verify_headers.call(claims.header)
@@ -413,7 +431,12 @@ module Rodauth
413
431
  #
414
432
  # issuer: check that server generated the token
415
433
  # aud: check the audience field (client is who he says he is)
416
- # iat: check that the token didn't expire
434
+ # exp: check that the token hasn't expired
435
+ # iat: informational per RFC 7519 section 4.1.6, so we only reject a future iat, tolerating
436
+ # oauth_jwt_iat_leeway seconds of clock skew. ruby-jwt's verify_iat offers no leeway knob
437
+ # (iat_leeway was removed in 2.2.0, jwt/ruby-jwt#319), so verify_iat is disabled below and
438
+ # iat is checked manually -- unifying behavior with the json-jwt path, which honors the
439
+ # same oauth_jwt_iat_leeway.
417
440
  #
418
441
  # subject can't be verified automatically without having access to the account id,
419
442
  # which we don't because that's the whole point.
@@ -425,7 +448,9 @@ module Rodauth
425
448
  # can't use stock aud verification, as it's dependent on the client application id
426
449
  verify_aud: false,
427
450
  verify_jti: (verify_jti ? method(:verify_jti) : false),
428
- verify_iat: true
451
+ # iat is verified manually below (with leeway) rather than via
452
+ # ruby-jwt's verify_iat, which rejects a future iat with no leeway.
453
+ verify_iat: false
429
454
  }
430
455
  else
431
456
  {}
@@ -456,6 +481,16 @@ module Rodauth
456
481
  JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms, **verify_claims_params)
457
482
  end
458
483
 
484
+ # iat is only rejected when issued in the future, tolerating oauth_jwt_iat_leeway seconds of
485
+ # clock skew -- mirroring the json-jwt path so both backends behave identically. ruby-jwt
486
+ # claims are string-keyed. Since verify_iat is disabled above, ruby-jwt no longer validates
487
+ # that iat is a NumericDate, so reject a non-numeric iat here rather than letting Time.at
488
+ # raise an error the JWT::DecodeError handler would not catch.
489
+ if verify_claims && (iat = claims["iat"])
490
+ return unless iat.is_a?(Numeric)
491
+ return if Time.at(iat) > Time.now + oauth_jwt_iat_leeway
492
+ end
493
+
459
494
  return if verify_claims && verify_aud && !verify_aud(claims["aud"], claims["client_id"])
460
495
 
461
496
  return if verify_headers && !verify_headers.call(headers)
@@ -496,7 +531,7 @@ module Rodauth
496
531
  JWT.decode(token, nil, false)
497
532
  end
498
533
  else
499
- # :nocov:
534
+ # simplecov:enable
500
535
  def jwk_export(_key)
501
536
  raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
502
537
  end
@@ -520,7 +555,7 @@ module Rodauth
520
555
  def private_jwk?(_jwk)
521
556
  raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
522
557
  end
523
- # :nocov:
558
+ # simplecov:disable
524
559
  end
525
560
  end
526
561
  end
@@ -9,6 +9,11 @@ module Rodauth
9
9
  auth_value_method :oauth_require_pkce, true
10
10
  auth_value_method :oauth_pkce_challenge_method, "S256"
11
11
 
12
+ # TODO: change to false in a major version bump
13
+ # RFC 7636 §4.2 discourages the "plain" transform: it transmits the raw code_verifier,
14
+ # so an intercepted authorization request exposes it and defeats PKCE.
15
+ auth_value_method :oauth_pkce_allow_plain_method, true
16
+
12
17
  auth_value_method :oauth_grants_code_challenge_column, :code_challenge
13
18
  auth_value_method :oauth_grants_code_challenge_method_column, :code_challenge_method
14
19
 
@@ -17,6 +22,16 @@ module Rodauth
17
22
  auth_value_method :oauth_unsupported_transform_algorithm_error_code, "invalid_request"
18
23
  translatable_method :oauth_unsupported_transform_algorithm_message, "transform algorithm not supported"
19
24
 
25
+ def authorize_form_params
26
+ super.tap do |params|
27
+ %w[code_challenge code_challenge_method].each do |param_name|
28
+ if (param_value = param_or_nil(param_name))
29
+ params << { "name" => param_name, "value" => param_value, "type" => "hidden" }
30
+ end
31
+ end
32
+ end
33
+ end
34
+
20
35
  private
21
36
 
22
37
  def supports_auth_method?(oauth_application, auth_method)
@@ -61,7 +76,13 @@ module Rodauth
61
76
  if param_or_nil("code_challenge")
62
77
 
63
78
  challenge_method = param_or_nil("code_challenge_method")
64
- redirect_response_error("code_challenge_required") unless oauth_pkce_challenge_method == challenge_method
79
+
80
+ # Reject the weak "plain" transform unless it has been explicitly opted into. This runs
81
+ # before the supported-method check so the disabled method surfaces a meaningful error
82
+ # instead of the generic "code challenge required".
83
+ redirect_response_error("unsupported_transform_algorithm") if challenge_method == "plain" && !oauth_pkce_allow_plain_method
84
+
85
+ redirect_response_error("code_challenge_required") unless oauth_pkce_challenge_methods.include?(challenge_method)
65
86
  else
66
87
  return unless oauth_require_pkce
67
88
 
@@ -74,19 +95,29 @@ module Rodauth
74
95
 
75
96
  case grant[oauth_grants_code_challenge_method_column]
76
97
  when "plain"
77
- challenge == verifier
98
+ # A grant stored with the weak "plain" method must not be redeemable unless the
99
+ # method has been explicitly enabled, otherwise PKCE is silently downgraded.
100
+ redirect_response_error("unsupported_transform_algorithm") unless oauth_pkce_allow_plain_method
101
+ timing_safe_eql?(verifier.to_s, challenge.to_s)
78
102
  when "S256"
79
103
  generated_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
80
104
 
81
- challenge == generated_challenge
105
+ timing_safe_eql?(generated_challenge, challenge.to_s)
82
106
  else
83
107
  redirect_response_error("unsupported_transform_algorithm")
84
108
  end
85
109
  end
86
110
 
111
+ # The PKCE transform methods the server accepts. "S256" is always supported; the weak "plain"
112
+ # transform is only included when explicitly opted into via oauth_pkce_allow_plain_method.
113
+ def oauth_pkce_challenge_methods
114
+ oauth_pkce_allow_plain_method ? %w[S256 plain] : %w[S256]
115
+ end
116
+
87
117
  def oauth_server_metadata_body(*)
88
118
  super.tap do |data|
89
- data[:code_challenge_methods_supported] = oauth_pkce_challenge_method
119
+ # RFC 8414: code_challenge_methods_supported is a JSON array of the supported methods.
120
+ data[:code_challenge_methods_supported] = oauth_pkce_challenge_methods
90
121
  end
91
122
  end
92
123
  end
@@ -46,6 +46,15 @@ module Rodauth
46
46
  authorization_required unless token_indicators.any? { |resource| base_url.start_with?(resource) }
47
47
  end
48
48
 
49
+ def authorize_form_params
50
+ super.tap do |params|
51
+ # the "resource" param may be repeated, so it carries one entry per resource indicator
52
+ Array(resource_indicators).each do |resource|
53
+ params << { "name" => "resource", "value" => resource, "type" => "hidden" }
54
+ end
55
+ end
56
+ end
57
+
49
58
  private
50
59
 
51
60
  def validate_token_params
@@ -235,6 +235,16 @@ module Rodauth
235
235
  super unless subject_type == "pairwise"
236
236
  end
237
237
 
238
+ def authorize_form_params
239
+ super.tap do |params|
240
+ %w[prompt nonce ui_locales claims_locales claims acr_values].each do |param_name|
241
+ if (param_value = param_or_nil(param_name))
242
+ params << { "name" => param_name, "value" => param_value, "type" => "hidden" }
243
+ end
244
+ end
245
+ end
246
+ end
247
+
238
248
  private
239
249
 
240
250
  if defined?(::I18n)
@@ -287,7 +287,7 @@ module Rodauth
287
287
  "The application type '#{application_type}' is not allowed."
288
288
  end
289
289
 
290
- def initialize_register_params(create_params, return_params)
290
+ def initialize_create_params(create_params, return_params)
291
291
  super
292
292
  registration_access_token = oauth_unique_id_generator
293
293
  create_params[oauth_applications_registration_access_token_column] = secret_hash(registration_access_token)
@@ -2,10 +2,10 @@
2
2
 
3
3
  require "rodauth/oauth"
4
4
 
5
- # :nocov:
5
+ # simplecov:enable
6
6
  raise LoadError, "the `:oidc_frontchannel_logout` requires rodauth 2.32.0 or higher" if Rodauth::VERSION < "2.32.0"
7
7
 
8
- # :nocov:
8
+ # simplecov:disable
9
9
 
10
10
  module Rodauth
11
11
  Feature.define(:oidc_frontchannel_logout, :OidFrontchannelLogout) do
@@ -38,6 +38,14 @@ module Rodauth
38
38
  @oauth_application = client_params
39
39
  end
40
40
 
41
+ def authorize_form_params
42
+ super.tap do |params|
43
+ if (param_value = param_or_nil("registration"))
44
+ params << { "name" => "registration", "value" => param_value, "type" => "hidden" }
45
+ end
46
+ end
47
+ end
48
+
41
49
  private
42
50
 
43
51
  def oauth_response_types_supported
@@ -23,7 +23,7 @@ class Rodauth::OAuth::TtlStore
23
23
  def set(key, &block)
24
24
  @store_mutex.synchronize do
25
25
  # short circuit
26
- return @store[key][:payload] if @store[key] && @store[key][:ttl] < now
26
+ return @store[key][:payload] if @store[key] && @store[key][:ttl] > now
27
27
  end
28
28
 
29
29
  payload, ttl = block.call
@@ -33,9 +33,9 @@ class Rodauth::OAuth::TtlStore
33
33
  @store_mutex.synchronize do
34
34
  # given that the block call triggers network, and two requests for the same key be processed
35
35
  # at the same time, this ensures the first one wins.
36
- return @store[key][:payload] if @store[key] && @store[key][:ttl] < now
36
+ return @store[key][:payload] if @store[key] && @store[key][:ttl] > now
37
37
 
38
- @store[key] = { payload: payload, ttl: ttl || (now + DEFAULT_TTL) }
38
+ @store[key] = { payload: payload, ttl: now + (ttl || DEFAULT_TTL) }
39
39
  end
40
40
  @store[key][:payload]
41
41
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "1.6.5"
5
+ VERSION = "1.6.6"
6
6
  end
7
7
  end
@@ -10,7 +10,7 @@
10
10
  <p class="lead">
11
11
  #{
12
12
  application_uri = rodauth.oauth_application[rodauth.oauth_applications_homepage_url_column]
13
- application_name = application_uri ? (<<-LINK) : rodauth.oauth_application[rodauth.oauth_applications_name_column]
13
+ application_name = application_uri ? (<<-LINK) : h(rodauth.oauth_application[rodauth.oauth_applications_name_column])
14
14
  <a target="_blank" href="#{h(application_uri)}">
15
15
  #{h(rodauth.oauth_application[rodauth.oauth_applications_name_column])}
16
16
  </a>
@@ -62,7 +62,7 @@
62
62
  #{
63
63
  rodauth.authorize_scopes.map do |scope|
64
64
  if rodauth.features.include?(:oidc) && scope == "offline_access"
65
- "<input type=\"hidden\" name=\"scope[]\" value=\"#{scope}\" />"
65
+ "<input type=\"hidden\" name=\"scope[]\" value=\"#{h(scope)}\" />"
66
66
  else
67
67
  <<-HTML
68
68
  <div class="form-check">
@@ -74,34 +74,16 @@
74
74
  end.join
75
75
  }
76
76
 
77
- <input type="hidden" name="client_id" value="#{rodauth.param("client_id")}"/>
78
- #{"<input type=\"hidden\" name=\"access_type\" value=\"#{rodauth.param("access_type")}\"/>" if rodauth.param_or_nil("access_type")}
79
- #{"<input type=\"hidden\" name=\"response_type\" value=\"#{rodauth.param("response_type")}\"/>" if rodauth.param_or_nil("response_type")}
80
- #{"<input type=\"hidden\" name=\"response_mode\" value=\"#{rodauth.param("response_mode")}\"/>" if rodauth.param_or_nil("response_mode")}
81
- #{"<input type=\"hidden\" name=\"state\" value=\"#{rodauth.param("state")}\"/>" if rodauth.param_or_nil("state")}
82
- #{"<input type=\"hidden\" name=\"redirect_uri\" value=\"#{rodauth.redirect_uri}\"/>" if rodauth.param_or_nil("redirect_uri")}
83
- #{"<input type=\"hidden\" name=\"code_challenge\" value=\"#{rodauth.param("code_challenge")}\"/>" if rodauth.features.include?(:oauth_pkce) && rodauth.param_or_nil("code_challenge")}
84
- #{"<input type=\"hidden\" name=\"code_challenge_method\" value=\"#{rodauth.param("code_challenge_method")}\"/>" if rodauth.features.include?(:oauth_pkce) && rodauth.param_or_nil("code_challenge_method")}
85
- #{"<input type=\"hidden\" name=\"prompt\" value=\"#{rodauth.param("prompt")}\"/>" if rodauth.features.include?(:oidc) && rodauth.param_or_nil("prompt")}
86
- #{"<input type=\"hidden\" name=\"nonce\" value=\"#{rodauth.param("nonce")}\"/>" if rodauth.features.include?(:oidc) && rodauth.param_or_nil("nonce")}
87
- #{"<input type=\"hidden\" name=\"ui_locales\" value=\"#{rodauth.param("ui_locales")}\"/>" if rodauth.features.include?(:oidc) && rodauth.param_or_nil("ui_locales")}
88
- #{"<input type=\"hidden\" name=\"claims_locales\" value=\"#{rodauth.param("claims_locales")}\"/>" if rodauth.features.include?(:oidc) && rodauth.param_or_nil("claims_locales")}
89
- #{"<input type=\"hidden\" name=\"claims\" value=\"#{h(rodauth.param("claims"))}\"/>" if rodauth.features.include?(:oidc) && rodauth.param_or_nil("claims")}
90
- #{"<input type=\"hidden\" name=\"acr_values\" value=\"#{rodauth.param("acr_values")}\"/>" if rodauth.features.include?(:oidc) && rodauth.param_or_nil("acr_values")}
91
- #{"<input type=\"hidden\" name=\"registration\" value=\"#{h(rodauth.param("registration"))}\"/>" if rodauth.features.include?(:oidc_self_issued) && rodauth.param_or_nil("registration")}
92
77
  #{
93
- if rodauth.features.include?(:oauth_resource_indicators) && rodauth.resource_indicators
94
- rodauth.resource_indicators.map do |resource|
95
- "<input type=\"hidden\" name=\"resource\" value=\"#{resource}\"/>"
96
- end.join
97
- end
98
- }
99
- #{"<input type=\"hidden\" name=\"dpop_jkt\" value=\"#{h(rodauth.param("dpop_jkt"))}\"/>" if rodauth.features.include?(:oauth_dpop) && rodauth.param_or_nil("dpop_jkt")}
78
+ rodauth.authorize_form_params.map do |field|
79
+ "<input type=\"#{h(field["type"])}\" name=\"#{h(field["name"])}\" value=\"#{h(field["value"])}\"/>"
80
+ end.join
81
+ }
100
82
  </div>
101
83
  <p class="text-center">
102
84
  <input type="submit" class="btn btn-outline-primary" value="#{h(rodauth.oauth_authorize_button)}"/>
103
- <a href="#{rodauth.redirect_uri}?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request#{ "&state=#{rodauth.param("state")}" if rodauth.param_or_nil("state")}" class="btn btn-outline-danger">
104
- #{rodauth.oauth_cancel_button}
85
+ <a href="#{h(rodauth.redirect_uri)}?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request#{ "&state=#{CGI.escape(rodauth.param("state"))}" if rodauth.param_or_nil("state")}" class="btn btn-outline-danger">
86
+ #{h(rodauth.oauth_cancel_button)}
105
87
  </a>
106
88
  </p>
107
89
  </form>
@@ -10,15 +10,15 @@
10
10
  scopes = @oauth_grant[rodauth.oauth_grants_scopes_column].split(rodauth.oauth_scope_separator)
11
11
  scopes.map do |scope|
12
12
  <<-HTML
13
- <li class="list-group-item">#{scope}</li>
13
+ <li class="list-group-item">#{h(scope)}</li>
14
14
  HTML
15
15
  end.join
16
16
  }
17
17
  </ul>
18
18
  </div>
19
- <input type="hidden" name="user_code" value="#{rodauth.param("user_code")}"/>
19
+ <input type="hidden" name="user_code" value="#{h(rodauth.param("user_code"))}"/>
20
20
  <p class="text-center">
21
21
  <input type="submit" class="btn btn-outline-primary" value="#{h(rodauth.oauth_device_verification_button)}"/>
22
- <a href="#{rodauth.device_path}?error=access_denied" class="btn btn-outline-danger">#{rodauth.oauth_cancel_button}</a>
22
+ <a href="#{rodauth.device_path}?error=access_denied" class="btn btn-outline-danger">#{h(rodauth.oauth_cancel_button)}</a>
23
23
  </p>
24
24
  </form>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rodauth-oauth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.5
4
+ version: 1.6.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tiago Cardoso
@@ -93,6 +93,7 @@ extra_rdoc_files:
93
93
  - doc/release_notes/1_6_3.md
94
94
  - doc/release_notes/1_6_4.md
95
95
  - doc/release_notes/1_6_5.md
96
+ - doc/release_notes/1_6_6.md
96
97
  files:
97
98
  - CHANGELOG.md
98
99
  - LICENSE.txt
@@ -144,6 +145,7 @@ files:
144
145
  - doc/release_notes/1_6_3.md
145
146
  - doc/release_notes/1_6_4.md
146
147
  - doc/release_notes/1_6_5.md
148
+ - doc/release_notes/1_6_6.md
147
149
  - lib/generators/rodauth/oauth/install_generator.rb
148
150
  - lib/generators/rodauth/oauth/templates/app/models/oauth_application.rb
149
151
  - lib/generators/rodauth/oauth/templates/app/models/oauth_grant.rb
@@ -244,7 +246,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
244
246
  - !ruby/object:Gem::Version
245
247
  version: '0'
246
248
  requirements: []
247
- rubygems_version: 3.6.9
249
+ rubygems_version: 4.0.16
248
250
  specification_version: 4
249
251
  summary: Implementation of the OAuth 2.0 protocol on top of rodauth.
250
252
  test_files: []