rodauth-oauth 0.2.0 → 0.4.3

Sign up to get free protection for your applications and to get access to all the features.
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