rodauth-oauth 0.2.0 → 0.4.3

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: 02d69464053b6809900da774b4c9957d642b003d0a0de7aa076e57a5eb8895bc
4
- data.tar.gz: e1dd94b69aa4bdf051b1c28d684a0ec2a1435da9f99ca6bf77da9d537474f9a6
3
+ metadata.gz: 96756ac8a30c904c5b832b64c47a00af9524810561d58c909b6f322da7348e8c
4
+ data.tar.gz: 965f6ff260bd86c2fcb7bbd2ba2bd131b453f04a39b73f99ef0860d2bc95b0e0
5
5
  SHA512:
6
- metadata.gz: cfe325a2e8daa96a72b4577566ae84772dcf114411ebbd9d0115f0f33f5b104f7c138a1b1ef7813d46f0fd2226c6560db5d974e51ec92b33ce7e393726005b2d
7
- data.tar.gz: df5866c1cd089c0d361a00d1ffd1db02df9b6551940dbf70e7390657fa8558ae0fb3c8eb2fcff6ec6fb9fd52406a99615574305d5a3bacdbd20f5fc22bacd63f
6
+ metadata.gz: e7e257a12204599a27d0917f2b31c32906f0d4c566d51ee6d4fde146e2340e36afb9a932cff8bf37872d59259f4d43d423d1c1266f3066063c70aa334f83e119
7
+ data.tar.gz: 07c0e564e7636893f736f6e05f634684cd7bc28e9d0acfb53ba518357fab198bc878792a68bde6b988b8c8ddf2d3e2bb4d4ecebcd9c4bf68d85f75178cdd0fdf
@@ -2,7 +2,75 @@
2
2
 
3
3
  ## master
4
4
 
5
- ### 0.2.0
5
+ ### 0.4.3 (09/12/2020)
6
+
7
+ * Introspection requests made to an Authorization Server in "resource server" mode are not correctly encoding the body using the "application/x-www-form-urlencoded" format.
8
+
9
+ ### 0.4.2 (24/11/2020)
10
+
11
+ ### Bugfixes
12
+
13
+ * database extensions were being run in resource server mode, when it's not expected that the oauth db tables are around.
14
+
15
+ ### 0.4.1 (24/11/2020)
16
+
17
+ ### Improvements
18
+
19
+ When in "Resource Server" mode, calling `rodauth.authorization_token` will now return an hash of the JSON payload that the Authorization Server responds, and which was already previously used to authorize access to protected resources.
20
+
21
+ ### Bugfixes
22
+
23
+ * An error occurred if the client passed an empty authorization header (`Authorization: ` or `Authorization: Bearer `), causing an unexpected error; It now responds with the proper `401 Unauthorized` status code.
24
+
25
+ ### 0.4.0 (13/11/2020)
26
+
27
+ ### Features
28
+
29
+ * A new method, `get_additional_param(account, claim)`, is now exposed; this method will be called whenever non-OIDC scopes are requested in the emission of the ID token.
30
+
31
+ * The `form_post` response is now supported, either by passing the `response_mode=form_post` request param in the authorization URL, or by setting `oauth_response_mode "form_post"` option. This improves the overall security of an Authorization server even more, as authorization codes are sent to client applications via a POST request to the redirect URI.
32
+
33
+
34
+ ### Improvements
35
+
36
+ * For the OIDC `address` scope, proper claims are now emitted as per the standard, i.e. the "formatted", "street_address", "locality", "region", "postal_code", "country". These will be the ones referenced in the `get_oidc_param` method.
37
+
38
+ ### Bugfixes
39
+
40
+ * The rails templates were missing declarations from a few params, which made some of the flows (the PKCE for example) not work out-of-the box;
41
+ * rails tests were silently not running in CI;
42
+ * The CI suite was revamped, so that all Oauth tests would be run under rails as well. All versions from rails equal or above 5.0 are now targeted;
43
+
44
+ ### 0.3.0 (8/10/2020)
45
+
46
+ #### Features
47
+
48
+ * `oauth_refresh_token_protection_policy` is a new option, which can be used to set a protection policy around usage of refresh tokens. By default it's `none`, for backwards-compatibility. However, when set to `rotation`, refresh tokens will be "use-once", i.e. a token refresh request will generate a new refresh token. Also, refresh token requests performed with already-used refresh tokens will be interpreted as a security breach, i.e. all tokens linked to the compromised refresh token will be revoked.
49
+
50
+ #### Improvements
51
+
52
+
53
+ * Support for the OIDC authorize [`prompt` parameter](https://openid.net/specs/openid-connect-core-1_0.html) (sectionn 3.1.2.1). It supports the `none`, `login` and `consent` out-of-the-box, while providing support for `select-account` when paired with [rodauth-select-account, a rodauth feature to handle multiple accounts in the same session](https://gitlab.com/honeyryderchuck/rodauth-select-account).
54
+
55
+ * Refresh Tokens are now expirable. The refresh token expiration period is governed by the `oauth_refresh_token_expires_in` option (default: 1 year), and is the period for which a refresh token can be used after its respective access token expired.
56
+
57
+ #### Bugfixes
58
+
59
+ * Default Templates now being packaged, as a way to provide a default experience to the OAuth journeys.
60
+
61
+ * fixing metadata urls when plugin loaded with a prefix path (@ianks)
62
+
63
+ * All date/time-based calculations, such as determining an expiration date, or checking if a token has expired, are now performed using database arithmetic operations, using sequel's `date_arithmetic` plugin. This will eliminate subtle bugs, such as when the database timezone is different than the application OS timezone.
64
+
65
+ * OIDC configuration endpoint is now stricter, eliminating JSON metadata inherited from the Oauth metadata endpoint. (@ianks)
66
+
67
+ #### Chore
68
+
69
+ Use `rodauth.convert_timestamp` in the templates, whenever dates are displayed.
70
+
71
+ Set HTTP Cache headers for metadata responses, such as `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration`, so they can be stored at the edge. The cache will be valid for 1 day (this value isn't set by an option yet).
72
+
73
+ ### 0.2.0 (9/9/2020)
6
74
 
7
75
  #### Features
8
76
 
@@ -46,9 +114,7 @@ Fixed some mishandling of HTTP headers when in in resource-server mode.
46
114
  * 97.7% test coverage;
47
115
  * `rodauth-oauth` CI tests run against sqlite, postgresql and mysql.
48
116
 
49
- ### 0.1.0
50
-
51
- (31/7/2020)
117
+ ### 0.1.0 (31/7/2020)
52
118
 
53
119
  #### Features
54
120
 
@@ -94,9 +160,7 @@ URI schemes for client applications redirect URIs have to be `https`. In order t
94
160
  * fixed trailing "/" in the "issuer" value in server metadata (`https://server.com/` -> `https://server.com`).
95
161
 
96
162
 
97
- ### 0.0.6
98
-
99
- (6/7/2020)
163
+ ### 0.0.6 (6/7/2020)
100
164
 
101
165
  #### Features
102
166
 
@@ -119,9 +183,7 @@ The `oauth_jwt` feature now supports JWT Secured Authorization Request (JAR) (se
119
183
  Removed React Javascript from example applications.
120
184
 
121
185
 
122
- ### 0.0.5
123
-
124
- (26/6/2020)
186
+ ### 0.0.5 (26/6/2020)
125
187
 
126
188
  #### Features
127
189
 
@@ -158,9 +220,7 @@ It **requires** the authorization to implement the server metadata endpoint (`/.
158
220
  * option `scopes_param` renamed to `scope_param`;
159
221
  *
160
222
 
161
- ## 0.0.4
162
-
163
- (13/6/2020)
223
+ ## 0.0.4 (13/6/2020)
164
224
 
165
225
  ### Features
166
226
 
@@ -197,9 +257,7 @@ The `oauth_jwt` feature now allows the usage of access tokens to authorize the g
197
257
 
198
258
  * Fixed scope claim of JWT ("scopes" -> "scope");
199
259
 
200
- ## 0.0.3
201
-
202
- (5/6/2020)
260
+ ## 0.0.3 (5/6/2020)
203
261
 
204
262
  ### Features
205
263
 
@@ -231,9 +289,7 @@ end
231
289
  * renamed the existing `use_oauth_implicit_grant_type` to `use_oauth_implicit_grant_type?`;
232
290
  * It's now usable as JSON API (small caveat: POST authorize will still redirect on success...);
233
291
 
234
- ## 0.0.2
235
-
236
- (29/5/2020)
292
+ ## 0.0.2 (29/5/2020)
237
293
 
238
294
  ### Features
239
295
 
@@ -249,8 +305,6 @@ end
249
305
 
250
306
  * usage of client secret for authorizing the generation of tokens, as the spec mandates (and refraining from them when doing PKCE).
251
307
 
252
- ## 0.0.1
253
-
254
- (14/5/2020)
308
+ ## 0.0.1 (14/5/2020)
255
309
 
256
310
  Initial implementation of the Oauth 2.0 framework, with an example app done using roda.
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Rodauth::Oauth
2
2
 
3
3
  [![pipeline status](https://gitlab.com/honeyryderchuck/rodauth-oauth/badges/master/pipeline.svg)](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/pipelines?page=1&ref=master)
4
- [![coverage report](https://gitlab.com/honeyryderchuck/rodauth-oauth/badges/master/coverage.svg)](https://honeyryderchuck.gitlab.io/rodauth-oauth/coverage/#_AllFiles)
4
+ [![coverage report](https://gitlab.com/honeyryderchuck/rodauth-oauth/badges/master/coverage.svg?job=coverage)](https://honeyryderchuck.gitlab.io/rodauth-oauth/coverage/#_AllFiles)
5
5
 
6
6
  This is an extension to the `rodauth` gem which implements the [OAuth 2.0 framework](https://tools.ietf.org/html/rfc6749) for an authorization server.
7
7
 
@@ -46,6 +46,8 @@ module Rodauth
46
46
 
47
47
  SCOPES = %w[profile.read].freeze
48
48
 
49
+ SERVER_METADATA = OAuth::TtlStore.new
50
+
49
51
  before "authorize"
50
52
  after "authorize"
51
53
 
@@ -75,12 +77,14 @@ module Rodauth
75
77
 
76
78
  auth_value_method :oauth_grant_expires_in, 60 * 5 # 5 minutes
77
79
  auth_value_method :oauth_token_expires_in, 60 * 60 # 60 minutes
80
+ auth_value_method :oauth_refresh_token_expires_in, 60 * 60 * 24 * 360 # 1 year
78
81
  auth_value_method :use_oauth_implicit_grant_type?, false
79
82
  auth_value_method :use_oauth_pkce?, true
80
83
  auth_value_method :use_oauth_access_type?, true
81
84
 
82
85
  auth_value_method :oauth_require_pkce, false
83
86
  auth_value_method :oauth_pkce_challenge_method, "S256"
87
+ auth_value_method :oauth_response_mode, "query"
84
88
 
85
89
  auth_value_method :oauth_valid_uri_schemes, %w[https]
86
90
 
@@ -97,6 +101,7 @@ module Rodauth
97
101
  button "Register", "oauth_application"
98
102
  button "Authorize", "oauth_authorize"
99
103
  button "Revoke", "oauth_token_revoke"
104
+ button "Back to Client Application", "oauth_authorize_post"
100
105
 
101
106
  # OAuth Token
102
107
  auth_value_method :oauth_tokens_path, "oauth-tokens"
@@ -149,9 +154,11 @@ module Rodauth
149
154
  auth_value_method :"oauth_applications_#{column}_column", column
150
155
  end
151
156
 
157
+ # Feature options
152
158
  auth_value_method :oauth_application_default_scope, SCOPES.first
153
159
  auth_value_method :oauth_application_scopes, SCOPES
154
160
  auth_value_method :oauth_token_type, "bearer"
161
+ auth_value_method :oauth_refresh_token_protection_policy, "none" # can be: none, sender_constrained, rotation
155
162
 
156
163
  auth_value_method :invalid_client_message, "Invalid client"
157
164
  auth_value_method :invalid_grant_type_message, "Invalid grant type"
@@ -200,7 +207,214 @@ module Rodauth
200
207
 
201
208
  auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i
202
209
 
203
- SERVER_METADATA = OAuth::TtlStore.new
210
+ # /token
211
+ route(:token) do |r|
212
+ next unless is_authorization_server?
213
+
214
+ before_token_route
215
+ require_oauth_application
216
+
217
+ r.post do
218
+ catch_error do
219
+ validate_oauth_token_params
220
+
221
+ oauth_token = nil
222
+ transaction do
223
+ before_token
224
+ oauth_token = create_oauth_token
225
+ end
226
+
227
+ json_response_success(json_access_token_payload(oauth_token))
228
+ end
229
+
230
+ throw_json_response_error(invalid_oauth_response_status, "invalid_request")
231
+ end
232
+ end
233
+
234
+ # /introspect
235
+ route(:introspect) do |r|
236
+ next unless is_authorization_server?
237
+
238
+ before_introspect_route
239
+
240
+ r.post do
241
+ catch_error do
242
+ validate_oauth_introspect_params
243
+
244
+ before_introspect
245
+ oauth_token = case param("token_type_hint")
246
+ when "access_token"
247
+ oauth_token_by_token(param("token"))
248
+ when "refresh_token"
249
+ oauth_token_by_refresh_token(param("token"))
250
+ else
251
+ oauth_token_by_token(param("token")) || oauth_token_by_refresh_token(param("token"))
252
+ end
253
+
254
+ if oauth_application
255
+ redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application)
256
+ elsif oauth_token
257
+ @oauth_application = db[oauth_applications_table].where(oauth_applications_id_column =>
258
+ oauth_token[oauth_tokens_oauth_application_id_column]).first
259
+ end
260
+
261
+ json_response_success(json_token_introspect_payload(oauth_token))
262
+ end
263
+
264
+ throw_json_response_error(invalid_oauth_response_status, "invalid_request")
265
+ end
266
+ end
267
+
268
+ # /revoke
269
+ route(:revoke) do |r|
270
+ next unless is_authorization_server?
271
+
272
+ before_revoke_route
273
+ require_oauth_application
274
+
275
+ r.post do
276
+ catch_error do
277
+ validate_oauth_revoke_params
278
+
279
+ oauth_token = nil
280
+ transaction do
281
+ before_revoke
282
+ oauth_token = revoke_oauth_token
283
+ after_revoke
284
+ end
285
+
286
+ if accepts_json?
287
+ json_response_success \
288
+ "token" => oauth_token[oauth_tokens_token_column],
289
+ "refresh_token" => oauth_token[oauth_tokens_refresh_token_column],
290
+ "revoked_at" => convert_timestamp(oauth_token[oauth_tokens_revoked_at_column])
291
+ else
292
+ set_notice_flash revoke_oauth_token_notice_flash
293
+ redirect request.referer || "/"
294
+ end
295
+ end
296
+
297
+ redirect_response_error("invalid_request", request.referer || "/")
298
+ end
299
+ end
300
+
301
+ # /authorize
302
+ route(:authorize) do |r|
303
+ next unless is_authorization_server?
304
+
305
+ before_authorize_route
306
+ require_authorizable_account
307
+
308
+ validate_oauth_grant_params
309
+ try_approval_prompt if use_oauth_access_type? && request.get?
310
+
311
+ r.get do
312
+ authorize_view
313
+ end
314
+
315
+ r.post do
316
+ redirect_url = URI.parse(redirect_uri)
317
+
318
+ params, mode = transaction do
319
+ before_authorize
320
+ do_authorize
321
+ end
322
+
323
+ case mode
324
+ when "query"
325
+ params = params.map { |k, v| "#{k}=#{v}" }
326
+ params << redirect_url.query if redirect_url.query
327
+ redirect_url.query = params.join("&")
328
+ redirect(redirect_url.to_s)
329
+ when "fragment"
330
+ params = params.map { |k, v| "#{k}=#{v}" }
331
+ params << redirect_url.query if redirect_url.query
332
+ redirect_url.fragment = params.join("&")
333
+ redirect(redirect_url.to_s)
334
+ when "form_post"
335
+ scope.view layout: false, inline: <<-FORM
336
+ <html>
337
+ <head><title>Authorized</title></head>
338
+ <body onload="javascript:document.forms[0].submit()">
339
+ <form method="post" action="#{redirect_uri}">
340
+ #{
341
+ params.map do |name, value|
342
+ "<input type=\"hidden\" name=\"#{name}\" value=\"#{scope.h(value)}\" />"
343
+ end.join
344
+ }
345
+ <input type="submit" class="btn btn-outline-primary" value="#{scope.h(oauth_authorize_post_button)}"/>
346
+ </form>
347
+ </body>
348
+ </html>
349
+ FORM
350
+ when "none"
351
+ redirect(redirect_url.to_s)
352
+ end
353
+ end
354
+ end
355
+
356
+ def oauth_server_metadata(issuer = nil)
357
+ request.on(".well-known") do
358
+ request.on("oauth-authorization-server") do
359
+ request.get do
360
+ json_response_success(oauth_server_metadata_body(issuer), true)
361
+ end
362
+ end
363
+ end
364
+ end
365
+
366
+ # /oauth-applications routes
367
+ def oauth_applications
368
+ request.on(oauth_applications_path) do
369
+ require_account
370
+
371
+ request.get "new" do
372
+ new_oauth_application_view
373
+ end
374
+
375
+ request.on(oauth_applications_id_pattern) do |id|
376
+ oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => id).first
377
+ next unless oauth_application
378
+
379
+ scope.instance_variable_set(:@oauth_application, oauth_application)
380
+
381
+ request.is do
382
+ request.get do
383
+ oauth_application_view
384
+ end
385
+ end
386
+
387
+ request.on(oauth_tokens_path) do
388
+ oauth_tokens = db[oauth_tokens_table].where(oauth_tokens_oauth_application_id_column => id)
389
+ scope.instance_variable_set(:@oauth_tokens, oauth_tokens)
390
+ request.get do
391
+ oauth_tokens_view
392
+ end
393
+ end
394
+ end
395
+
396
+ request.get do
397
+ scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table])
398
+ oauth_applications_view
399
+ end
400
+
401
+ request.post do
402
+ catch_error do
403
+ validate_oauth_application_params
404
+
405
+ transaction do
406
+ before_create_oauth_application
407
+ id = create_oauth_application
408
+ after_create_oauth_application
409
+ set_notice_flash create_oauth_application_notice_flash
410
+ redirect "#{request.path}/#{id}"
411
+ end
412
+ end
413
+ set_error_flash create_oauth_application_error_flash
414
+ new_oauth_application_view
415
+ end
416
+ end
417
+ end
204
418
 
205
419
  def check_csrf?
206
420
  case request.path
@@ -275,13 +489,13 @@ module Rodauth
275
489
  def fetch_access_token
276
490
  value = request.env["HTTP_AUTHORIZATION"]
277
491
 
278
- return unless value
492
+ return unless value && !value.empty?
279
493
 
280
494
  scheme, token = value.split(" ", 2)
281
495
 
282
496
  return unless scheme.downcase == oauth_token_type
283
497
 
284
- return if token.empty?
498
+ return if token.nil? || token.empty?
285
499
 
286
500
  token
287
501
  end
@@ -294,101 +508,46 @@ module Rodauth
294
508
 
295
509
  return unless bearer_token
296
510
 
297
- # check if token has not expired
298
- # check if token has been revoked
299
- @authorization_token = oauth_token_by_token(bearer_token)
511
+ @authorization_token = if is_authorization_server?
512
+ # check if token has not expired
513
+ # check if token has been revoked
514
+ oauth_token_by_token(bearer_token)
515
+ else
516
+ # where in resource server, NOT the authorization server.
517
+ payload = introspection_request("access_token", bearer_token)
518
+
519
+ return unless payload["active"]
520
+
521
+ payload
522
+ end
300
523
  end
301
524
 
302
525
  def require_oauth_authorization(*scopes)
303
- token_scopes = if is_authorization_server?
304
- authorization_required unless authorization_token
526
+ authorization_required unless authorization_token
305
527
 
306
- scopes << oauth_application_default_scope if scopes.empty?
528
+ scopes << oauth_application_default_scope if scopes.empty?
307
529
 
530
+ token_scopes = if is_authorization_server?
308
531
  authorization_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
309
532
  else
310
- bearer_token = fetch_access_token
311
-
312
- authorization_required unless bearer_token
313
-
314
- scopes << oauth_application_default_scope if scopes.empty?
315
-
316
- # where in resource server, NOT the authorization server.
317
- payload = introspection_request("access_token", bearer_token)
318
-
319
- authorization_required unless payload["active"]
320
-
321
- payload["scope"].split(oauth_scope_separator)
533
+ aux_scopes = authorization_token["scope"]
534
+ if aux_scopes
535
+ aux_scopes.split(oauth_scope_separator)
536
+ else
537
+ []
538
+ end
322
539
  end
323
540
 
324
541
  authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
325
542
  end
326
543
 
327
- # /oauth-applications routes
328
- def oauth_applications
329
- request.on(oauth_applications_path) do
330
- require_account
331
-
332
- request.get "new" do
333
- new_oauth_application_view
334
- end
335
-
336
- request.on(oauth_applications_id_pattern) do |id|
337
- oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => id).first
338
- next unless oauth_application
339
-
340
- scope.instance_variable_set(:@oauth_application, oauth_application)
341
-
342
- request.is do
343
- request.get do
344
- oauth_application_view
345
- end
346
- end
347
-
348
- request.on(oauth_tokens_path) do
349
- oauth_tokens = db[oauth_tokens_table].where(oauth_tokens_oauth_application_id_column => id)
350
- scope.instance_variable_set(:@oauth_tokens, oauth_tokens)
351
- request.get do
352
- oauth_tokens_view
353
- end
354
- end
355
- end
356
-
357
- request.get do
358
- scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table])
359
- oauth_applications_view
360
- end
361
-
362
- request.post do
363
- catch_error do
364
- validate_oauth_application_params
365
-
366
- transaction do
367
- before_create_oauth_application
368
- id = create_oauth_application
369
- after_create_oauth_application
370
- set_notice_flash create_oauth_application_notice_flash
371
- redirect "#{request.path}/#{id}"
372
- end
373
- end
374
- set_error_flash create_oauth_application_error_flash
375
- new_oauth_application_view
376
- end
377
- end
378
- end
379
-
380
- def oauth_server_metadata(issuer = nil)
381
- request.on(".well-known") do
382
- request.on("oauth-authorization-server") do
383
- request.get do
384
- json_response_success(oauth_server_metadata_body(issuer))
385
- end
386
- end
387
- end
388
- end
389
-
390
544
  def post_configure
391
545
  super
546
+
547
+ # all of the extensions below involve DB changes. Resource server mode doesn't use
548
+ # database functions for OAuth though.
549
+ return unless is_authorization_server?
550
+
392
551
  self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db))
393
552
 
394
553
  # Check whether we can reutilize db entries for the same account / application pair
@@ -401,6 +560,10 @@ module Rodauth
401
560
  self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account }
402
561
  end
403
562
 
563
+ def use_date_arithmetic?
564
+ true
565
+ end
566
+
404
567
  private
405
568
 
406
569
  def rescue_from_uniqueness_error(&block)
@@ -446,9 +609,9 @@ module Rodauth
446
609
  # time-to-live
447
610
  ttl = if response.key?("cache-control")
448
611
  cache_control = response["cache-control"]
449
- cache_control[/max-age=(\d+)/, 1]
612
+ cache_control[/max-age=(\d+)/, 1].to_i
450
613
  elsif response.key?("expires")
451
- DateTime.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
614
+ Time.parse(response["expires"]).to_i - Time.now.to_i
452
615
  end
453
616
 
454
617
  [JSON.parse(response.body, symbolize_names: true), ttl]
@@ -461,9 +624,9 @@ module Rodauth
461
624
  http.use_ssl = auth_url.scheme == "https"
462
625
 
463
626
  request = Net::HTTP::Post.new(introspect_path)
464
- request["content-type"] = json_response_content_type
627
+ request["content-type"] = "application/x-www-form-urlencoded"
465
628
  request["accept"] = json_response_content_type
466
- request.body = JSON.dump({ "token_type_hint" => token_type_hint, "token" => token })
629
+ request.set_form_data({ "token_type_hint" => token_type_hint, "token" => token })
467
630
 
468
631
  before_introspection_request(request)
469
632
  response = http.request(request)
@@ -516,7 +679,7 @@ module Rodauth
516
679
  end
517
680
 
518
681
  def oauth_unique_id_generator
519
- SecureRandom.hex(32)
682
+ SecureRandom.urlsafe_base64(32)
520
683
  end
521
684
 
522
685
  def generate_token_hash(token)
@@ -537,7 +700,7 @@ module Rodauth
537
700
 
538
701
  def generate_oauth_token(params = {}, should_generate_refresh_token = true)
539
702
  create_params = {
540
- oauth_grants_expires_in_column => Time.now + oauth_token_expires_in
703
+ oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in)
541
704
  }.merge(params)
542
705
 
543
706
  rescue_from_uniqueness_error do
@@ -597,26 +760,37 @@ module Rodauth
597
760
  end
598
761
  end
599
762
 
600
- def oauth_token_by_token(token, dataset = db[oauth_tokens_table])
763
+ def oauth_token_by_token(token)
764
+ ds = db[oauth_tokens_table]
765
+
601
766
  ds = if oauth_tokens_token_hash_column
602
- dataset.where(oauth_tokens_token_hash_column => generate_token_hash(token))
767
+ ds.where(oauth_tokens_token_hash_column => generate_token_hash(token))
603
768
  else
604
- dataset.where(oauth_tokens_token_column => token)
769
+ ds.where(oauth_tokens_token_column => token)
605
770
  end
606
771
 
607
772
  ds.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
608
773
  .where(oauth_tokens_revoked_at_column => nil).first
609
774
  end
610
775
 
611
- def oauth_token_by_refresh_token(token, dataset = db[oauth_tokens_table])
776
+ def oauth_token_by_refresh_token(token, revoked: false)
777
+ ds = db[oauth_tokens_table]
778
+ #
779
+ # filter expired refresh tokens out.
780
+ # an expired refresh token is a token whose access token expired for a period longer than the
781
+ # refresh token expiration period.
782
+ #
783
+ ds = ds.where(Sequel.date_add(oauth_tokens_expires_in_column, seconds: oauth_refresh_token_expires_in) >= Sequel::CURRENT_TIMESTAMP)
784
+
612
785
  ds = if oauth_tokens_refresh_token_hash_column
613
- dataset.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token))
786
+ ds.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token))
614
787
  else
615
- dataset.where(oauth_tokens_refresh_token_column => token)
788
+ ds.where(oauth_tokens_refresh_token_column => token)
616
789
  end
617
790
 
618
- ds.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
619
- .where(oauth_tokens_revoked_at_column => nil).first
791
+ ds = ds.where(oauth_tokens_revoked_at_column => nil) unless revoked
792
+
793
+ ds.first
620
794
  end
621
795
 
622
796
  def json_access_token_payload(oauth_token)
@@ -714,6 +888,9 @@ module Rodauth
714
888
  end
715
889
  redirect_response_error("invalid_scope") unless check_valid_scopes?
716
890
 
891
+ if (response_mode = param_or_nil("response_mode")) && response_mode != "form_post"
892
+ redirect_response_error("invalid_request")
893
+ end
717
894
  validate_pkce_challenge_params if use_oauth_pkce?
718
895
  end
719
896
 
@@ -731,7 +908,6 @@ module Rodauth
731
908
  ).count.zero?
732
909
 
733
910
  # if there's a previous oauth grant for the params combo, it means that this user has approved before.
734
-
735
911
  request.env["REQUEST_METHOD"] = "POST"
736
912
  end
737
913
 
@@ -740,7 +916,7 @@ module Rodauth
740
916
  oauth_grants_account_id_column => account_id,
741
917
  oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
742
918
  oauth_grants_redirect_uri_column => redirect_uri,
743
- oauth_grants_expires_in_column => Time.now + oauth_grant_expires_in,
919
+ oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_grant_expires_in),
744
920
  oauth_grants_scopes_column => scopes.join(oauth_scope_separator)
745
921
  )
746
922
 
@@ -766,28 +942,26 @@ module Rodauth
766
942
  create_params[oauth_grants_code_column]
767
943
  end
768
944
 
769
- def do_authorize(redirect_url, query_params = [], fragment_params = [])
945
+ def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
770
946
  case param("response_type")
771
947
  when "token"
772
948
  redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
773
949
 
774
- fragment_params.replace(_do_authorize_token.map { |k, v| "#{k}=#{v}" })
775
- when "code", "", nil
776
- query_params.replace(_do_authorize_code.map { |k, v| "#{k}=#{v}" })
777
- end
778
-
779
- if param_or_nil("state")
780
- if !fragment_params.empty?
781
- fragment_params << "state=#{param('state')}"
782
- else
783
- query_params << "state=#{param('state')}"
784
- end
950
+ response_mode ||= "fragment"
951
+ response_params.replace(_do_authorize_token)
952
+ when "code"
953
+ response_mode ||= "query"
954
+ response_params.replace(_do_authorize_code)
955
+ when "none"
956
+ response_mode ||= "none"
957
+ when "", nil
958
+ response_mode ||= oauth_response_mode
959
+ response_params.replace(_do_authorize_code)
785
960
  end
786
961
 
787
- query_params << redirect_url.query if redirect_url.query
962
+ response_params["state"] = param("state") if param_or_nil("state")
788
963
 
789
- redirect_url.query = query_params.join("&") unless query_params.empty?
790
- redirect_url.fragment = fragment_params.join("&") unless fragment_params.empty?
964
+ [response_params, response_mode]
791
965
  end
792
966
 
793
967
  def _do_authorize_code
@@ -846,14 +1020,30 @@ module Rodauth
846
1020
  }
847
1021
  create_oauth_token_from_authorization_code(oauth_grant, create_params)
848
1022
  when "refresh_token"
849
- # fetch oauth token
850
- oauth_token = oauth_token_by_refresh_token(param("refresh_token"))
851
-
852
- redirect_response_error("invalid_grant") unless oauth_token
1023
+ # fetch potentially revoked oauth token
1024
+ oauth_token = oauth_token_by_refresh_token(param("refresh_token"), revoked: true)
1025
+
1026
+ if !oauth_token
1027
+ redirect_response_error("invalid_grant")
1028
+ elsif oauth_token[oauth_tokens_revoked_at_column]
1029
+ if oauth_refresh_token_protection_policy == "rotation"
1030
+ # https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00#section-6.1
1031
+ #
1032
+ # If a refresh token is compromised and subsequently used by both the attacker and the legitimate
1033
+ # client, one of them will present an invalidated refresh token, which will inform the authorization
1034
+ # server of the breach. The authorization server cannot determine which party submitted the invalid
1035
+ # refresh token, but it will revoke the active refresh token. This stops the attack at the cost of
1036
+ # forcing the legitimate client to obtain a fresh authorization grant.
1037
+
1038
+ db[oauth_tokens_table].where(oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column])
1039
+ .update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
1040
+ end
1041
+ redirect_response_error("invalid_grant")
1042
+ end
853
1043
 
854
1044
  update_params = {
855
1045
  oauth_tokens_oauth_application_id_column => oauth_token[oauth_grants_oauth_application_id_column],
856
- oauth_tokens_expires_in_column => Time.now + oauth_token_expires_in
1046
+ oauth_tokens_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in)
857
1047
  }
858
1048
  create_oauth_token_from_token(oauth_token, update_params)
859
1049
  end
@@ -885,6 +1075,7 @@ module Rodauth
885
1075
  redirect_response_error("invalid_grant") unless token_from_application?(oauth_token, oauth_application)
886
1076
 
887
1077
  rescue_from_uniqueness_error do
1078
+ oauth_tokens_ds = db[oauth_tokens_table]
888
1079
  token = oauth_unique_id_generator
889
1080
 
890
1081
  if oauth_tokens_token_hash_column
@@ -893,9 +1084,25 @@ module Rodauth
893
1084
  update_params[oauth_tokens_token_column] = token
894
1085
  end
895
1086
 
896
- ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
1087
+ oauth_token = if oauth_refresh_token_protection_policy == "rotation"
1088
+ insert_params = {
1089
+ **update_params,
1090
+ oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column],
1091
+ oauth_tokens_scopes_column => oauth_token[oauth_tokens_scopes_column]
1092
+ }
1093
+
1094
+ # revoke the refresh token
1095
+ oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
1096
+ .update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
1097
+
1098
+ insert_params[oauth_tokens_oauth_token_id_column] = oauth_token[oauth_tokens_id_column]
1099
+ __insert_and_return__(oauth_tokens_ds, oauth_tokens_id_column, insert_params)
1100
+ else
1101
+ # includes none
1102
+ ds = oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
1103
+ __update_and_return__(ds, update_params)
1104
+ end
897
1105
 
898
- oauth_token = __update_and_return__(ds, update_params)
899
1106
  oauth_token[oauth_tokens_token_column] = token
900
1107
  oauth_token
901
1108
  end
@@ -1000,9 +1207,17 @@ module Rodauth
1000
1207
  end
1001
1208
  end
1002
1209
 
1003
- def json_response_success(body)
1210
+ def json_response_success(body, cache = false)
1004
1211
  response.status = 200
1005
1212
  response["Content-Type"] ||= json_response_content_type
1213
+ if cache
1214
+ # defaulting to 1-day for everyone, for now at least
1215
+ max_age = 60 * 60 * 24
1216
+ response["Cache-Control"] = "private, max-age=#{max_age}"
1217
+ else
1218
+ response["Cache-Control"] = "no-store"
1219
+ response["Pragma"] = "no-cache"
1220
+ end
1006
1221
  json_payload = _json_response_body(body)
1007
1222
  response.write(json_payload)
1008
1223
  request.halt
@@ -1122,7 +1337,7 @@ module Rodauth
1122
1337
  issuer += "/#{path}" if path
1123
1338
 
1124
1339
  responses_supported = %w[code]
1125
- response_modes_supported = %w[query]
1340
+ response_modes_supported = %w[query form_post]
1126
1341
  grant_types_supported = %w[authorization_code]
1127
1342
 
1128
1343
  if use_oauth_implicit_grant_type?
@@ -1130,11 +1345,12 @@ module Rodauth
1130
1345
  response_modes_supported << "fragment"
1131
1346
  grant_types_supported << "implicit"
1132
1347
  end
1348
+
1133
1349
  {
1134
1350
  issuer: issuer,
1135
1351
  authorization_endpoint: authorize_url,
1136
1352
  token_endpoint: token_url,
1137
- registration_endpoint: "#{base_url}/#{oauth_applications_path}",
1353
+ registration_endpoint: route_url(oauth_applications_path),
1138
1354
  scopes_supported: oauth_application_scopes,
1139
1355
  response_types_supported: responses_supported,
1140
1356
  response_modes_supported: response_modes_supported,
@@ -1151,121 +1367,5 @@ module Rodauth
1151
1367
  code_challenge_methods_supported: (use_oauth_pkce? ? oauth_pkce_challenge_method : nil)
1152
1368
  }
1153
1369
  end
1154
-
1155
- # /token
1156
- route(:token) do |r|
1157
- next unless is_authorization_server?
1158
-
1159
- before_token_route
1160
- require_oauth_application
1161
-
1162
- r.post do
1163
- catch_error do
1164
- validate_oauth_token_params
1165
-
1166
- oauth_token = nil
1167
- transaction do
1168
- before_token
1169
- oauth_token = create_oauth_token
1170
- end
1171
-
1172
- json_response_success(json_access_token_payload(oauth_token))
1173
- end
1174
-
1175
- throw_json_response_error(invalid_oauth_response_status, "invalid_request")
1176
- end
1177
- end
1178
-
1179
- # /introspect
1180
- route(:introspect) do |r|
1181
- next unless is_authorization_server?
1182
-
1183
- before_introspect_route
1184
-
1185
- r.post do
1186
- catch_error do
1187
- validate_oauth_introspect_params
1188
-
1189
- before_introspect
1190
- oauth_token = case param("token_type_hint")
1191
- when "access_token"
1192
- oauth_token_by_token(param("token"))
1193
- when "refresh_token"
1194
- oauth_token_by_refresh_token(param("token"))
1195
- else
1196
- oauth_token_by_token(param("token")) || oauth_token_by_refresh_token(param("token"))
1197
- end
1198
-
1199
- if oauth_application
1200
- redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application)
1201
- elsif oauth_token
1202
- @oauth_application = db[oauth_applications_table].where(oauth_applications_id_column =>
1203
- oauth_token[oauth_tokens_oauth_application_id_column]).first
1204
- end
1205
-
1206
- json_response_success(json_token_introspect_payload(oauth_token))
1207
- end
1208
-
1209
- throw_json_response_error(invalid_oauth_response_status, "invalid_request")
1210
- end
1211
- end
1212
-
1213
- # /revoke
1214
- route(:revoke) do |r|
1215
- next unless is_authorization_server?
1216
-
1217
- before_revoke_route
1218
- require_oauth_application
1219
-
1220
- r.post do
1221
- catch_error do
1222
- validate_oauth_revoke_params
1223
-
1224
- oauth_token = nil
1225
- transaction do
1226
- before_revoke
1227
- oauth_token = revoke_oauth_token
1228
- after_revoke
1229
- end
1230
-
1231
- if accepts_json?
1232
- json_response_success \
1233
- "token" => oauth_token[oauth_tokens_token_column],
1234
- "refresh_token" => oauth_token[oauth_tokens_refresh_token_column],
1235
- "revoked_at" => oauth_token[oauth_tokens_revoked_at_column]
1236
- else
1237
- set_notice_flash revoke_oauth_token_notice_flash
1238
- redirect request.referer || "/"
1239
- end
1240
- end
1241
-
1242
- redirect_response_error("invalid_request", request.referer || "/")
1243
- end
1244
- end
1245
-
1246
- # /authorize
1247
- route(:authorize) do |r|
1248
- next unless is_authorization_server?
1249
-
1250
- before_authorize_route
1251
- require_authorizable_account
1252
-
1253
- validate_oauth_grant_params
1254
- try_approval_prompt if use_oauth_access_type? && request.get?
1255
-
1256
- r.get do
1257
- authorize_view
1258
- end
1259
-
1260
- r.post do
1261
- redirect_url = URI.parse(redirect_uri)
1262
-
1263
- transaction do
1264
- before_authorize
1265
- do_authorize(redirect_url)
1266
- end
1267
- redirect(redirect_url.to_s)
1268
- end
1269
- end
1270
1370
  end
1271
1371
  end