rodauth-oauth 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3eac600d006a2c78509f608575db062b7ba6d67356b890c7d38414b9b82875f9
4
- data.tar.gz: c37fc18c093f546023481a88cc526c5a0b721b1a3bfeac827c21184e0583071b
3
+ metadata.gz: 02d69464053b6809900da774b4c9957d642b003d0a0de7aa076e57a5eb8895bc
4
+ data.tar.gz: e1dd94b69aa4bdf051b1c28d684a0ec2a1435da9f99ca6bf77da9d537474f9a6
5
5
  SHA512:
6
- metadata.gz: 0a04fdb5ab370ed5736208cbd4ccb1e6da801af52cd68004625a21008c4a10a04bc143d99c9e1a71bccb9fad882fc3cff27d9c0900689dbd5cf6c0616e4d43a0
7
- data.tar.gz: e6d5cb6e8ff31d64eb588fa39ad6a1e7bb1ae9416adac64e5f9a21bf451ffd4115a8c2d3bb8759f1a32192a88a4a401336e1baca7621a33934dc1b2a873c8402
6
+ metadata.gz: cfe325a2e8daa96a72b4577566ae84772dcf114411ebbd9d0115f0f33f5b104f7c138a1b1ef7813d46f0fd2226c6560db5d974e51ec92b33ce7e393726005b2d
7
+ data.tar.gz: df5866c1cd089c0d361a00d1ffd1db02df9b6551940dbf70e7390657fa8558ae0fb3c8eb2fcff6ec6fb9fd52406a99615574305d5a3bacdbd20f5fc22bacd63f
@@ -2,6 +2,50 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ### 0.2.0
6
+
7
+ #### Features
8
+
9
+ ##### SAML Assertion Grant Type
10
+
11
+ `rodauth-auth` now supports using a SAML Assertion to request for an Access token.In order to enable, you have to:
12
+
13
+ ```ruby
14
+ plugin :rodauth do
15
+ enable :oauth_saml
16
+ end
17
+ ```
18
+
19
+ For more info about integrating it, [check the wiki](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/wikis/SAML-Assertion-Access-Tokens).
20
+
21
+ ##### Supporting rotating keys
22
+
23
+ At some point, you'll want to replace the pkeys and algorithm used to generate and verify the JWT access tokens, but you want to keep validating previously-distributed JWT tokens, at least until they expire. Now you can, via two new options, `oauth_jwt_legacy_public_key` and `oauth_jwt_legacy_algorithm`, which will be declared in the JWKs URI and used to verify access tokens.
24
+
25
+
26
+ ##### Reuse access tokens
27
+
28
+ If the `oauth_reuse_access_token` is set, if there's already an existing valid access token, any new grant for the same application / account / scope will keep the same access token. This can be helpful in scenarios where one wants the same access token distributed across devices.
29
+
30
+ ##### require_authorizable_account
31
+
32
+ The method used to verify access to the authorize flow is called `require_authorizable_account`. By default, it checks if a user is logged in by using rodauth's own `require_account`. This is the method you'd want to redefine in order to augment these requirements, i.e. request 2fa authentication.
33
+
34
+ #### Improvements
35
+
36
+ Expired and revoked access tokens end up generating a lot of garbage, which will have to be periodically cleaned up. You can mitigate this now by setting a uniqueness index for a group of columns, i.e. if you set a uniqueness index for the `oauth_application_id/account_id/scopes` column, `rodauth-oauth` will transparently reuse the same db entry to store the new access token. If setting some other type of uniqueness index, make sure to update the option `oauth_tokens_unique_columns` (the array of columns from the uniqueness index).
37
+
38
+ #### Bugfixes
39
+
40
+ Calling `before_*_route` callbacks appropriately.
41
+
42
+ Fixed some mishandling of HTTP headers when in in resource-server mode.
43
+
44
+ #### Chore
45
+
46
+ * 97.7% test coverage;
47
+ * `rodauth-oauth` CI tests run against sqlite, postgresql and mysql.
48
+
5
49
  ### 0.1.0
6
50
 
7
51
  (31/7/2020)
data/README.md CHANGED
@@ -21,6 +21,7 @@ This gem implements the following RFCs and features of OAuth:
21
21
  * Access Type (Token refresh online and offline);
22
22
  * [MAC Authentication Scheme](https://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-02);
23
23
  * [JWT Acess Tokens](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-07);
24
+ * [SAML 2.0 Assertion Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-saml2-bearer-03);
24
25
  * [JWT Secured Authorization Requests](https://tools.ietf.org/html/draft-ietf-oauth-jwsreq-20);
25
26
  * OAuth application and token management dashboards;
26
27
 
@@ -43,14 +43,14 @@ class CreateRodauthOAuth < ActiveRecord::Migration<%= migration_version %>
43
43
  t.foreign_key :oauth_tokens, column: :oauth_token_id
44
44
  t.integer :oauth_application_id
45
45
  t.foreign_key :oauth_applications, column: :oauth_application_id
46
- t.string :token, null: false, token: true
46
+ t.string :token, null: false, token: true, unique: true
47
47
  # uncomment if setting oauth_tokens_token_hash_column
48
48
  # and delete the token column
49
- # t.string :token_hash, token: true
50
- t.string :refresh_token
49
+ # t.string :token_hash, token: true, unique: true
50
+ t.string :refresh_token, unique: true
51
51
  # uncomment if setting oauth_tokens_refresh_token_hash_column
52
52
  # and delete the refresh_token column
53
- # t.string :refresh_token_hash, token: true
53
+ # t.string :refresh_token_hash, token: true, unique: true
54
54
  t.datetime :expires_in, null: false
55
55
  t.datetime :revoked_at
56
56
  t.string :scopes, null: false
@@ -1,16 +1,21 @@
1
1
  # frozen-string-literal: true
2
2
 
3
+ require "time"
3
4
  require "base64"
4
5
  require "securerandom"
5
6
  require "net/http"
6
7
 
7
8
  require "rodauth/oauth/ttl_store"
9
+ require "rodauth/oauth/database_extensions"
8
10
 
9
11
  module Rodauth
10
12
  Feature.define(:oauth) do
11
13
  # RUBY EXTENSIONS
12
- # :nocov:
13
14
  unless Regexp.method_defined?(:match?)
15
+ # If you wonder why this is there: the oauth feature uses a refinement to enhance the
16
+ # Regexp class locally with #match? , but this is never tested, because ActiveSupport
17
+ # monkey-patches the same method... Please ActiveSupport, stop being so intrusive!
18
+ # :nocov:
14
19
  module RegexpExtensions
15
20
  refine(Regexp) do
16
21
  def match?(*args)
@@ -19,6 +24,7 @@ module Rodauth
19
24
  end
20
25
  end
21
26
  using(RegexpExtensions)
27
+ # :nocov:
22
28
  end
23
29
 
24
30
  unless String.method_defined?(:delete_suffix!)
@@ -37,7 +43,6 @@ module Rodauth
37
43
  end
38
44
  using(SuffixExtensions)
39
45
  end
40
- # :nocov:
41
46
 
42
47
  SCOPES = %w[profile.read].freeze
43
48
 
@@ -110,6 +115,8 @@ module Rodauth
110
115
  auth_value_method :oauth_tokens_token_hash_column, nil
111
116
  auth_value_method :oauth_tokens_refresh_token_hash_column, nil
112
117
 
118
+ # Access Token reuse
119
+ auth_value_method :oauth_reuse_access_token, false
113
120
  # OAuth Grants
114
121
  auth_value_method :oauth_grants_table, :oauth_grants
115
122
  auth_value_method :oauth_grants_id_column, :id
@@ -124,6 +131,7 @@ module Rodauth
124
131
 
125
132
  auth_value_method :authorization_required_error_status, 401
126
133
  auth_value_method :invalid_oauth_response_status, 400
134
+ auth_value_method :already_in_use_response_status, 409
127
135
 
128
136
  # OAuth Applications
129
137
  auth_value_method :oauth_applications_path, "oauth-applications"
@@ -155,6 +163,8 @@ module Rodauth
155
163
 
156
164
  auth_value_method :unique_error_message, "is already in use"
157
165
  auth_value_method :null_error_message, "is not filled"
166
+ auth_value_method :already_in_use_message, "error generating unique token"
167
+ auth_value_method :already_in_use_error_code, "invalid_request"
158
168
 
159
169
  # PKCE
160
170
  auth_value_method :code_challenge_required_error_code, "invalid_request"
@@ -172,6 +182,8 @@ module Rodauth
172
182
  # Only required to use if the plugin is to be used in a resource server
173
183
  auth_value_method :is_authorization_server?, true
174
184
 
185
+ auth_value_method :oauth_unique_id_generation_retries, 3
186
+
175
187
  auth_value_methods(
176
188
  :fetch_access_token,
177
189
  :oauth_unique_id_generator,
@@ -179,7 +191,9 @@ module Rodauth
179
191
  :secret_hash,
180
192
  :generate_token_hash,
181
193
  :authorization_server_url,
182
- :before_introspection_request
194
+ :before_introspection_request,
195
+ :require_authorizable_account,
196
+ :oauth_tokens_unique_columns
183
197
  )
184
198
 
185
199
  auth_value_methods(:only_json?)
@@ -213,14 +227,12 @@ module Rodauth
213
227
  end
214
228
 
215
229
  unless method_defined?(:json_request?)
216
- # :nocov:
217
230
  # copied from the jwt feature
218
231
  def json_request?
219
232
  return @json_request if defined?(@json_request)
220
233
 
221
234
  @json_request = request.content_type =~ json_request_regexp
222
235
  end
223
- # :nocov:
224
236
  end
225
237
 
226
238
  def initialize(scope)
@@ -343,7 +355,7 @@ module Rodauth
343
355
  end
344
356
 
345
357
  request.get do
346
- scope.instance_variable_set(:@oauth_applications, db[:oauth_applications])
358
+ scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table])
347
359
  oauth_applications_view
348
360
  end
349
361
 
@@ -375,8 +387,42 @@ module Rodauth
375
387
  end
376
388
  end
377
389
 
390
+ def post_configure
391
+ super
392
+ self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db))
393
+
394
+ # Check whether we can reutilize db entries for the same account / application pair
395
+ one_oauth_token_per_account = begin
396
+ db.indexes(oauth_tokens_table).values.any? do |definition|
397
+ definition[:unique] &&
398
+ definition[:columns] == oauth_tokens_unique_columns
399
+ end
400
+ end
401
+ self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account }
402
+ end
403
+
378
404
  private
379
405
 
406
+ def rescue_from_uniqueness_error(&block)
407
+ retries = oauth_unique_id_generation_retries
408
+ begin
409
+ transaction(savepoint: :only, &block)
410
+ rescue Sequel::UniqueConstraintViolation
411
+ redirect_response_error("already_in_use") if retries.zero?
412
+ retries -= 1
413
+ retry
414
+ end
415
+ end
416
+
417
+ # OAuth Token Unique/Reuse
418
+ def oauth_tokens_unique_columns
419
+ [
420
+ oauth_tokens_oauth_application_id_column,
421
+ oauth_tokens_account_id_column,
422
+ oauth_tokens_scopes_column
423
+ ]
424
+ end
425
+
380
426
  def authorization_server_url
381
427
  base_url
382
428
  end
@@ -399,10 +445,10 @@ module Rodauth
399
445
 
400
446
  # time-to-live
401
447
  ttl = if response.key?("cache-control")
402
- cache_control = response["cache_control"]
448
+ cache_control = response["cache-control"]
403
449
  cache_control[/max-age=(\d+)/, 1]
404
450
  elsif response.key?("expires")
405
- Time.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
451
+ DateTime.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
406
452
  end
407
453
 
408
454
  [JSON.parse(response.body, symbolize_names: true), ttl]
@@ -482,22 +528,11 @@ module Rodauth
482
528
  end
483
529
 
484
530
  unless method_defined?(:password_hash)
485
- # :nocov:
486
531
  # From login_requirements_base feature
487
- if ENV["RACK_ENV"] == "test"
488
- def password_hash_cost
489
- BCrypt::Engine::MIN_COST
490
- end
491
- else
492
- def password_hash_cost
493
- BCrypt::Engine::DEFAULT_COST
494
- end
495
- end
496
532
 
497
533
  def password_hash(password)
498
- BCrypt::Password.create(password, cost: password_hash_cost)
534
+ BCrypt::Password.create(password, cost: BCrypt::Engine::DEFAULT_COST)
499
535
  end
500
- # :nocov:
501
536
  end
502
537
 
503
538
  def generate_oauth_token(params = {}, should_generate_refresh_token = true)
@@ -505,43 +540,60 @@ module Rodauth
505
540
  oauth_grants_expires_in_column => Time.now + oauth_token_expires_in
506
541
  }.merge(params)
507
542
 
508
- token = oauth_unique_id_generator
509
- refresh_token = nil
543
+ rescue_from_uniqueness_error do
544
+ token = oauth_unique_id_generator
510
545
 
511
- if oauth_tokens_token_hash_column
512
- create_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
513
- else
514
- create_params[oauth_tokens_token_column] = token
515
- end
546
+ if oauth_tokens_token_hash_column
547
+ create_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
548
+ else
549
+ create_params[oauth_tokens_token_column] = token
550
+ end
516
551
 
517
- if should_generate_refresh_token
518
- refresh_token = oauth_unique_id_generator
552
+ refresh_token = nil
553
+ if should_generate_refresh_token
554
+ refresh_token = oauth_unique_id_generator
519
555
 
520
- if oauth_tokens_refresh_token_hash_column
521
- create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
522
- else
523
- create_params[oauth_tokens_refresh_token_column] = refresh_token
556
+ if oauth_tokens_refresh_token_hash_column
557
+ create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
558
+ else
559
+ create_params[oauth_tokens_refresh_token_column] = refresh_token
560
+ end
524
561
  end
562
+ oauth_token = _generate_oauth_token(create_params)
563
+ oauth_token[oauth_tokens_token_column] = token
564
+ oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token
565
+ oauth_token
525
566
  end
526
- oauth_token = _generate_oauth_token(create_params)
527
-
528
- oauth_token[oauth_tokens_token_column] = token
529
- oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token
530
- oauth_token
531
567
  end
532
568
 
533
569
  def _generate_oauth_token(params = {})
534
570
  ds = db[oauth_tokens_table]
535
571
 
536
- begin
537
- if ds.supports_returning?(:insert)
538
- ds.returning.insert(params).first
539
- else
540
- id = ds.insert(params)
541
- ds.where(oauth_tokens_id_column => id).first
572
+ if __one_oauth_token_per_account
573
+
574
+ token = __insert_or_update_and_return__(
575
+ ds,
576
+ oauth_tokens_id_column,
577
+ oauth_tokens_unique_columns,
578
+ params,
579
+ Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP,
580
+ ([oauth_tokens_token_column, oauth_tokens_refresh_token_column] if oauth_reuse_access_token)
581
+ )
582
+
583
+ # if the previous operation didn't return a row, it means that the conditions
584
+ # invalidated the update, and the existing token is still valid.
585
+ token || ds.where(
586
+ oauth_tokens_account_id_column => params[oauth_tokens_account_id_column],
587
+ oauth_tokens_oauth_application_id_column => params[oauth_tokens_oauth_application_id_column]
588
+ ).first
589
+ else
590
+ if oauth_reuse_access_token
591
+ unique_conds = Hash[oauth_tokens_unique_columns.map { |column| [column, params[column]] }]
592
+ valid_token = ds.where(Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP)
593
+ .where(unique_conds).first
594
+ return valid_token if valid_token
542
595
  end
543
- rescue Sequel::UniqueConstraintViolation
544
- retry
596
+ __insert_and_return__(ds, oauth_tokens_id_column, params)
545
597
  end
546
598
  end
547
599
 
@@ -633,7 +685,6 @@ module Rodauth
633
685
  # set client ID/secret pairs
634
686
 
635
687
  create_params.merge! \
636
- oauth_applications_client_id_column => oauth_unique_id_generator,
637
688
  oauth_applications_client_secret_column => \
638
689
  secret_hash(oauth_application_params[oauth_application_client_secret_param])
639
690
 
@@ -643,29 +694,14 @@ module Rodauth
643
694
  oauth_application_default_scope
644
695
  end
645
696
 
646
- id = nil
647
- raised = begin
648
- id = db[oauth_applications_table].insert(create_params)
649
- false
650
- rescue Sequel::ConstraintViolation => e
651
- e
652
- end
653
-
654
- if raised
655
- field = raised.message[/\.(.*)$/, 1]
656
- case raised
657
- when Sequel::UniqueConstraintViolation
658
- throw_error(field, unique_error_message)
659
- when Sequel::NotNullConstraintViolation
660
- throw_error(field, null_error_message)
661
- end
697
+ rescue_from_uniqueness_error do
698
+ create_params[oauth_applications_client_id_column] = oauth_unique_id_generator
699
+ db[oauth_applications_table].insert(create_params)
662
700
  end
663
-
664
- !raised && id
665
701
  end
666
702
 
667
703
  # Authorize
668
- def before_authorize
704
+ def require_authorizable_account
669
705
  require_account
670
706
  end
671
707
 
@@ -709,35 +745,25 @@ module Rodauth
709
745
  )
710
746
 
711
747
  # Access Type flow
712
- if use_oauth_access_type?
713
- if (access_type = param_or_nil("access_type"))
714
- create_params[oauth_grants_access_type_column] = access_type
715
- end
748
+ if use_oauth_access_type? && (access_type = param_or_nil("access_type"))
749
+ create_params[oauth_grants_access_type_column] = access_type
716
750
  end
717
751
 
718
752
  # PKCE flow
719
- if use_oauth_pkce?
753
+ if use_oauth_pkce? && (code_challenge = param_or_nil("code_challenge"))
754
+ code_challenge_method = param_or_nil("code_challenge_method")
720
755
 
721
- if (code_challenge = param_or_nil("code_challenge"))
722
- code_challenge_method = param_or_nil("code_challenge_method")
723
-
724
- create_params[oauth_grants_code_challenge_column] = code_challenge
725
- create_params[oauth_grants_code_challenge_method_column] = code_challenge_method
726
- elsif oauth_require_pkce
727
- redirect_response_error("code_challenge_required")
728
- end
756
+ create_params[oauth_grants_code_challenge_column] = code_challenge
757
+ create_params[oauth_grants_code_challenge_method_column] = code_challenge_method
729
758
  end
730
759
 
731
760
  ds = db[oauth_grants_table]
732
761
 
733
- begin
734
- authorization_code = oauth_unique_id_generator
735
- create_params[oauth_grants_code_column] = authorization_code
736
- ds.insert(create_params)
737
- authorization_code
738
- rescue Sequel::UniqueConstraintViolation
739
- retry
762
+ rescue_from_uniqueness_error do
763
+ create_params[oauth_grants_code_column] = oauth_unique_id_generator
764
+ __insert_and_return__(ds, oauth_grants_id_column, create_params)
740
765
  end
766
+ create_params[oauth_grants_code_column]
741
767
  end
742
768
 
743
769
  def do_authorize(redirect_url, query_params = [], fragment_params = [])
@@ -781,10 +807,6 @@ module Rodauth
781
807
 
782
808
  # Access Tokens
783
809
 
784
- def before_token
785
- require_oauth_application
786
- end
787
-
788
810
  def validate_oauth_token_params
789
811
  unless (grant_type = param_or_nil("grant_type"))
790
812
  redirect_response_error("invalid_request")
@@ -834,8 +856,6 @@ module Rodauth
834
856
  oauth_tokens_expires_in_column => Time.now + oauth_token_expires_in
835
857
  }
836
858
  create_oauth_token_from_token(oauth_token, update_params)
837
- else
838
- redirect_response_error("invalid_grant")
839
859
  end
840
860
  end
841
861
 
@@ -864,29 +884,21 @@ module Rodauth
864
884
  def create_oauth_token_from_token(oauth_token, update_params)
865
885
  redirect_response_error("invalid_grant") unless token_from_application?(oauth_token, oauth_application)
866
886
 
867
- token = oauth_unique_id_generator
868
-
869
- if oauth_tokens_token_hash_column
870
- update_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
871
- else
872
- update_params[oauth_tokens_token_column] = token
873
- end
887
+ rescue_from_uniqueness_error do
888
+ token = oauth_unique_id_generator
874
889
 
875
- ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
876
-
877
- oauth_token = begin
878
- if ds.supports_returning?(:update)
879
- ds.returning.update(update_params).first
890
+ if oauth_tokens_token_hash_column
891
+ update_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
880
892
  else
881
- ds.update(update_params)
882
- ds.first
893
+ update_params[oauth_tokens_token_column] = token
883
894
  end
884
- rescue Sequel::UniqueConstraintViolation
885
- retry
886
- end
887
895
 
888
- oauth_token[oauth_tokens_token_column] = token
889
- oauth_token
896
+ ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
897
+
898
+ oauth_token = __update_and_return__(ds, update_params)
899
+ oauth_token[oauth_tokens_token_column] = token
900
+ oauth_token
901
+ end
890
902
  end
891
903
 
892
904
  TOKEN_HINT_TYPES = %w[access_token refresh_token].freeze
@@ -895,8 +907,8 @@ module Rodauth
895
907
 
896
908
  def validate_oauth_introspect_params
897
909
  # check if valid token hint type
898
- if param_or_nil("token_type_hint")
899
- redirect_response_error("unsupported_token_type") unless TOKEN_HINT_TYPES.include?(param("token_type_hint"))
910
+ if param_or_nil("token_type_hint") && !TOKEN_HINT_TYPES.include?(param("token_type_hint"))
911
+ redirect_response_error("unsupported_token_type")
900
912
  end
901
913
 
902
914
  redirect_response_error("invalid_request") unless param_or_nil("token")
@@ -914,18 +926,12 @@ module Rodauth
914
926
  }
915
927
  end
916
928
 
917
- def before_introspect; end
918
-
919
929
  # Token revocation
920
930
 
921
- def before_revoke
922
- require_oauth_application
923
- end
924
-
925
931
  def validate_oauth_revoke_params
926
932
  # check if valid token hint type
927
- if param_or_nil("token_type_hint")
928
- redirect_response_error("unsupported_token_type") unless TOKEN_HINT_TYPES.include?(param("token_type_hint"))
933
+ if param_or_nil("token_type_hint") && !TOKEN_HINT_TYPES.include?(param("token_type_hint"))
934
+ redirect_response_error("unsupported_token_type")
929
935
  end
930
936
 
931
937
  redirect_response_error("invalid_request") unless param_or_nil("token")
@@ -942,23 +948,13 @@ module Rodauth
942
948
 
943
949
  redirect_response_error("invalid_request") unless oauth_token
944
950
 
945
- if oauth_application
946
- redirect_response_error("invalid_request") unless token_from_application?(oauth_token, oauth_application)
947
- else
948
- @oauth_application = db[oauth_applications_table].where(oauth_applications_id_column =>
949
- oauth_token[oauth_tokens_oauth_application_id_column]).first
950
- end
951
+ redirect_response_error("invalid_request") unless token_from_application?(oauth_token, oauth_application)
951
952
 
952
953
  update_params = { oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP }
953
954
 
954
955
  ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
955
956
 
956
- oauth_token = if ds.supports_returning?(:update)
957
- ds.returning.update(update_params).first
958
- else
959
- ds.update(update_params)
960
- ds.first
961
- end
957
+ oauth_token = __update_and_return__(ds, update_params)
962
958
 
963
959
  oauth_token[oauth_tokens_token_column] = token
964
960
  oauth_token
@@ -976,7 +972,13 @@ module Rodauth
976
972
 
977
973
  def redirect_response_error(error_code, redirect_url = redirect_uri || request.referer || default_redirect)
978
974
  if accepts_json?
979
- throw_json_response_error(invalid_oauth_response_status, error_code)
975
+ status_code = if respond_to?(:"#{error_code}_response_status")
976
+ send(:"#{error_code}_response_status")
977
+ else
978
+ invalid_oauth_response_status
979
+ end
980
+
981
+ throw_json_response_error(status_code, error_code)
980
982
  else
981
983
  redirect_url = URI.parse(redirect_url)
982
984
  query_params = []
@@ -1023,7 +1025,6 @@ module Rodauth
1023
1025
  end
1024
1026
 
1025
1027
  unless method_defined?(:_json_response_body)
1026
- # :nocov:
1027
1028
  def _json_response_body(hash)
1028
1029
  if request.respond_to?(:convert_to_json)
1029
1030
  request.send(:convert_to_json, hash)
@@ -1031,7 +1032,6 @@ module Rodauth
1031
1032
  JSON.dump(hash)
1032
1033
  end
1033
1034
  end
1034
- # :nocov:
1035
1035
  end
1036
1036
 
1037
1037
  def authorization_required
@@ -1156,7 +1156,8 @@ module Rodauth
1156
1156
  route(:token) do |r|
1157
1157
  next unless is_authorization_server?
1158
1158
 
1159
- before_token
1159
+ before_token_route
1160
+ require_oauth_application
1160
1161
 
1161
1162
  r.post do
1162
1163
  catch_error do
@@ -1164,6 +1165,7 @@ module Rodauth
1164
1165
 
1165
1166
  oauth_token = nil
1166
1167
  transaction do
1168
+ before_token
1167
1169
  oauth_token = create_oauth_token
1168
1170
  end
1169
1171
 
@@ -1178,12 +1180,13 @@ module Rodauth
1178
1180
  route(:introspect) do |r|
1179
1181
  next unless is_authorization_server?
1180
1182
 
1181
- before_introspect
1183
+ before_introspect_route
1182
1184
 
1183
1185
  r.post do
1184
1186
  catch_error do
1185
1187
  validate_oauth_introspect_params
1186
1188
 
1189
+ before_introspect
1187
1190
  oauth_token = case param("token_type_hint")
1188
1191
  when "access_token"
1189
1192
  oauth_token_by_token(param("token"))
@@ -1211,7 +1214,8 @@ module Rodauth
1211
1214
  route(:revoke) do |r|
1212
1215
  next unless is_authorization_server?
1213
1216
 
1214
- before_revoke
1217
+ before_revoke_route
1218
+ require_oauth_application
1215
1219
 
1216
1220
  r.post do
1217
1221
  catch_error do
@@ -1219,6 +1223,7 @@ module Rodauth
1219
1223
 
1220
1224
  oauth_token = nil
1221
1225
  transaction do
1226
+ before_revoke
1222
1227
  oauth_token = revoke_oauth_token
1223
1228
  after_revoke
1224
1229
  end
@@ -1242,12 +1247,12 @@ module Rodauth
1242
1247
  route(:authorize) do |r|
1243
1248
  next unless is_authorization_server?
1244
1249
 
1245
- require_account
1250
+ before_authorize_route
1251
+ require_authorizable_account
1252
+
1246
1253
  validate_oauth_grant_params
1247
1254
  try_approval_prompt if use_oauth_access_type? && request.get?
1248
1255
 
1249
- before_authorize
1250
-
1251
1256
  r.get do
1252
1257
  authorize_view
1253
1258
  end
@@ -1256,6 +1261,7 @@ module Rodauth
1256
1261
  redirect_url = URI.parse(redirect_uri)
1257
1262
 
1258
1263
  transaction do
1264
+ before_authorize
1259
1265
  do_authorize(redirect_url)
1260
1266
  end
1261
1267
  redirect(redirect_url.to_s)
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  Feature.define(:oauth_http_mac) do
5
- # :nocov:
6
5
  unless String.method_defined?(:delete_prefix)
7
6
  module PrefixExtensions
8
7
  refine(String) do
@@ -28,7 +27,6 @@ module Rodauth
28
27
  end
29
28
  using(PrefixExtensions)
30
29
  end
31
- # :nocov:
32
30
 
33
31
  depends :oauth
34
32
 
@@ -22,6 +22,10 @@ module Rodauth
22
22
  auth_value_method :oauth_jwt_jwe_algorithm, nil
23
23
  auth_value_method :oauth_jwt_jwe_encryption_method, nil
24
24
 
25
+ # values used for rotating keys
26
+ auth_value_method :oauth_jwt_legacy_public_key, nil
27
+ auth_value_method :oauth_jwt_legacy_algorithm, nil
28
+
25
29
  auth_value_method :oauth_jwt_jwe_copyright, nil
26
30
  auth_value_method :oauth_jwt_audience, nil
27
31
 
@@ -88,9 +92,7 @@ module Rodauth
88
92
  jws_jwk = if oauth_application[oauth_application_jws_jwk_column]
89
93
  jwk = oauth_application[oauth_application_jws_jwk_column]
90
94
 
91
- if jwk
92
- jwk = JSON.parse(jwk, symbolize_names: true) if jwk.is_a?(String)
93
- end
95
+ jwk = JSON.parse(jwk, symbolize_names: true) if jwk && jwk.is_a?(String)
94
96
  else
95
97
  redirect_response_error("invalid_request_object")
96
98
  end
@@ -105,8 +107,8 @@ module Rodauth
105
107
  # [RFC7519] specification. The value of "aud" should be the value of
106
108
  # the Authorization Server (AS) "issuer" as defined in RFC8414
107
109
  # [RFC8414].
108
- claims.delete(:iss)
109
- audience = claims.delete(:aud)
110
+ claims.delete("iss")
111
+ audience = claims.delete("aud")
110
112
 
111
113
  redirect_response_error("invalid_request_object") if audience && audience != authorization_server_url
112
114
 
@@ -119,11 +121,17 @@ module Rodauth
119
121
 
120
122
  # /token
121
123
 
122
- def before_token
124
+ def require_oauth_application
123
125
  # requset authentication optional for assertions
124
- return if param("grant_type") == "urn:ietf:params:oauth:grant-type:jwt-bearer"
126
+ return super unless param("grant_type") == "urn:ietf:params:oauth:grant-type:jwt-bearer"
125
127
 
126
- super
128
+ claims = jwt_decode(param("assertion"))
129
+
130
+ redirect_response_error("invalid_grant") unless claims
131
+
132
+ @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
133
+
134
+ authorization_required unless @oauth_application
127
135
  end
128
136
 
129
137
  def validate_oauth_token_params
@@ -145,10 +153,6 @@ module Rodauth
145
153
  def create_oauth_token_from_assertion
146
154
  claims = jwt_decode(param("assertion"))
147
155
 
148
- redirect_response_error("invalid_grant") unless claims
149
-
150
- @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
151
-
152
156
  account = account_ds(claims["sub"]).first
153
157
 
154
158
  redirect_response_error("invalid_client") unless oauth_application && account
@@ -167,17 +171,19 @@ module Rodauth
167
171
  oauth_grants_expires_in_column => Time.now + oauth_token_expires_in
168
172
  }.merge(params)
169
173
 
170
- if should_generate_refresh_token
171
- refresh_token = oauth_unique_id_generator
174
+ oauth_token = rescue_from_uniqueness_error do
175
+ if should_generate_refresh_token
176
+ refresh_token = oauth_unique_id_generator
172
177
 
173
- if oauth_tokens_refresh_token_hash_column
174
- create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
175
- else
176
- create_params[oauth_tokens_refresh_token_column] = refresh_token
178
+ if oauth_tokens_refresh_token_hash_column
179
+ create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
180
+ else
181
+ create_params[oauth_tokens_refresh_token_column] = refresh_token
182
+ end
177
183
  end
178
- end
179
184
 
180
- oauth_token = _generate_oauth_token(create_params)
185
+ _generate_oauth_token(create_params)
186
+ end
181
187
 
182
188
  claims = jwt_claims(oauth_token)
183
189
 
@@ -293,10 +299,10 @@ module Rodauth
293
299
 
294
300
  # time-to-live
295
301
  ttl = if response.key?("cache-control")
296
- cache_control = response["cache_control"]
302
+ cache_control = response["cache-control"]
297
303
  cache_control[/max-age=(\d+)/, 1]
298
304
  elsif response.key?("expires")
299
- Time.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
305
+ DateTime.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i
300
306
  end
301
307
 
302
308
  [JSON.parse(response.body, symbolize_names: true), ttl]
@@ -304,7 +310,6 @@ module Rodauth
304
310
  end
305
311
 
306
312
  if defined?(JSON::JWT)
307
- # :nocov:
308
313
 
309
314
  def jwk_import(data)
310
315
  JSON::JWK.new(data)
@@ -330,23 +335,27 @@ module Rodauth
330
335
  def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, **)
331
336
  token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
332
337
 
333
- @jwt_token = if jws_key
334
- JSON::JWT.decode(token, jws_key)
335
- elsif !is_authorization_server? && auth_server_jwks_set
336
- JSON::JWT.decode(token, JSON::JWK::Set.new(auth_server_jwks_set))
337
- end
338
+ if is_authorization_server?
339
+ if oauth_jwt_legacy_public_key
340
+ JSON::JWT.decode(token, JSON::JWK::Set.new({ keys: jwks_set }))
341
+ elsif jws_key
342
+ JSON::JWT.decode(token, jws_key)
343
+ end
344
+ elsif (jwks = auth_server_jwks_set)
345
+ JSON::JWT.decode(token, JSON::JWK::Set.new(jwks))
346
+ end
338
347
  rescue JSON::JWT::Exception
339
348
  nil
340
349
  end
341
350
 
342
351
  def jwks_set
343
- [
352
+ @jwks_set ||= [
344
353
  (JSON::JWK.new(oauth_jwt_public_key).merge(use: "sig", alg: oauth_jwt_algorithm) if oauth_jwt_public_key),
354
+ (JSON::JWK.new(oauth_jwt_legacy_public_key).merge(use: "sig", alg: oauth_jwt_legacy_algorithm) if oauth_jwt_legacy_public_key),
345
355
  (JSON::JWK.new(oauth_jwt_jwe_public_key).merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
346
356
  ].compact
347
357
  end
348
358
 
349
- # :nocov:
350
359
  elsif defined?(JWT)
351
360
 
352
361
  # ruby-jwt
@@ -391,21 +400,30 @@ module Rodauth
391
400
  def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, jws_algorithm: oauth_jwt_algorithm)
392
401
  # decrypt jwe
393
402
  token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
394
-
395
403
  # decode jwt
396
- @jwt_token = if jws_key
397
- JWT.decode(token, jws_key, true, algorithms: [jws_algorithm]).first
398
- elsif !is_authorization_server? && auth_server_jwks_set
399
- algorithms = auth_server_jwks_set[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
400
- JWT.decode(token, nil, true, jwks: auth_server_jwks_set, algorithms: algorithms).first
401
- end
404
+ if is_authorization_server?
405
+ if oauth_jwt_legacy_public_key
406
+ algorithms = jwks_set.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
407
+ JWT.decode(token, nil, true, jwks: { keys: jwks_set }, algorithms: algorithms).first
408
+ elsif jws_key
409
+ JWT.decode(token, jws_key, true, algorithms: [jws_algorithm]).first
410
+ end
411
+ elsif (jwks = auth_server_jwks_set)
412
+ algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
413
+ JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms).first
414
+ end
402
415
  rescue JWT::DecodeError, JWT::JWKError
403
416
  nil
404
417
  end
405
418
 
406
419
  def jwks_set
407
- [
420
+ @jwks_set ||= [
408
421
  (JWT::JWK.new(oauth_jwt_public_key).export.merge(use: "sig", alg: oauth_jwt_algorithm) if oauth_jwt_public_key),
422
+ (
423
+ if oauth_jwt_legacy_public_key
424
+ JWT::JWK.new(oauth_jwt_legacy_public_key).export.merge(use: "sig", alg: oauth_jwt_legacy_algorithm)
425
+ end
426
+ ),
409
427
  (JWT::JWK.new(oauth_jwt_jwe_public_key).export.merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
410
428
  ].compact
411
429
  end
@@ -0,0 +1,104 @@
1
+ # frozen-string-literal: true
2
+
3
+ require "onelogin/ruby-saml"
4
+
5
+ module Rodauth
6
+ Feature.define(:oauth_saml) do
7
+ depends :oauth
8
+
9
+ auth_value_method :oauth_saml_cert_fingerprint, "9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D"
10
+ auth_value_method :oauth_saml_cert_fingerprint_algorithm, nil
11
+ auth_value_method :oauth_saml_name_identifier_format, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
12
+
13
+ auth_value_method :oauth_saml_security_authn_requests_signed, false
14
+ auth_value_method :oauth_saml_security_metadata_signed, false
15
+ auth_value_method :oauth_saml_security_digest_method, XMLSecurity::Document::SHA1
16
+ auth_value_method :oauth_saml_security_signature_method, XMLSecurity::Document::RSA_SHA1
17
+
18
+ SAML_GRANT_TYPE = "http://oauth.net/grant_type/assertion/saml/2.0/bearer"
19
+
20
+ # /token
21
+
22
+ def require_oauth_application
23
+ # requset authentication optional for assertions
24
+ return super unless param("grant_type") == SAML_GRANT_TYPE && !param_or_nil("client_id")
25
+
26
+ # TODO: invalid grant
27
+ authorization_required unless saml_assertion
28
+
29
+ redirect_uri = saml_assertion.destination
30
+
31
+ @oauth_application = db[oauth_applications_table].where(
32
+ oauth_applications_homepage_url_column => saml_assertion.audiences,
33
+ oauth_applications_redirect_uri_column => redirect_uri
34
+ ).first
35
+
36
+ # The Assertion's <Issuer> element MUST contain a unique identifier
37
+ # for the entity that issued the Assertion.
38
+ authorization_required unless saml_assertion.issuers.all? do |issuer|
39
+ issuer.start_with?(@oauth_application[oauth_applications_homepage_url_column])
40
+ end
41
+
42
+ authorization_required unless @oauth_application
43
+ end
44
+
45
+ private
46
+
47
+ def secret_matches?(oauth_application, secret)
48
+ return super unless param_or_nil("assertion")
49
+
50
+ true
51
+ end
52
+
53
+ def saml_assertion
54
+ return @saml_assertion if defined?(@saml_assertion)
55
+
56
+ @saml_assertion = begin
57
+ settings = OneLogin::RubySaml::Settings.new
58
+ settings.idp_cert_fingerprint = oauth_saml_cert_fingerprint
59
+ settings.idp_cert_fingerprint_algorithm = oauth_saml_cert_fingerprint_algorithm
60
+ settings.name_identifier_format = oauth_saml_name_identifier_format
61
+ settings.security[:authn_requests_signed] = oauth_saml_security_authn_requests_signed
62
+ settings.security[:metadata_signed] = oauth_saml_security_metadata_signed
63
+ settings.security[:digest_method] = oauth_saml_security_digest_method
64
+ settings.security[:signature_method] = oauth_saml_security_signature_method
65
+
66
+ response = OneLogin::RubySaml::Response.new(param("assertion"), settings: settings, skip_recipient_check: true)
67
+
68
+ return unless response.is_valid?
69
+
70
+ response
71
+ end
72
+ end
73
+
74
+ def validate_oauth_token_params
75
+ return super unless param("grant_type") == SAML_GRANT_TYPE
76
+
77
+ redirect_response_error("invalid_client") unless param_or_nil("assertion")
78
+
79
+ redirect_response_error("invalid_scope") unless check_valid_scopes?
80
+ end
81
+
82
+ def create_oauth_token
83
+ if param("grant_type") == SAML_GRANT_TYPE
84
+ create_oauth_token_from_saml_assertion
85
+ else
86
+ super
87
+ end
88
+ end
89
+
90
+ def create_oauth_token_from_saml_assertion
91
+ account = db[accounts_table].where(login_column => saml_assertion.nameid).first
92
+
93
+ redirect_response_error("invalid_client") unless oauth_application && account
94
+
95
+ create_params = {
96
+ oauth_tokens_account_id_column => account[account_id_column],
97
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
98
+ oauth_tokens_scopes_column => (param_or_nil("scope") || oauth_application[oauth_applications_scopes_column])
99
+ }
100
+
101
+ generate_oauth_token(create_params, false)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodauth
4
+ module OAuth
5
+ # rubocop:disable Naming/MethodName, Metrics/ParameterLists
6
+ def self.ExtendDatabase(db)
7
+ Module.new do
8
+ dataset = db.dataset
9
+
10
+ if dataset.supports_returning?(:insert)
11
+ def __insert_and_return__(dataset, _pkey, params)
12
+ dataset.returning.insert(params).first
13
+ end
14
+ else
15
+ def __insert_and_return__(dataset, pkey, params)
16
+ id = dataset.insert(params)
17
+ dataset.where(pkey => id).first
18
+ end
19
+ end
20
+
21
+ if dataset.supports_returning?(:update)
22
+ def __update_and_return__(dataset, params)
23
+ dataset.returning.update(params).first
24
+ end
25
+ else
26
+ def __update_and_return__(dataset, params)
27
+ dataset.update(params)
28
+ dataset.first
29
+ end
30
+ end
31
+
32
+ if dataset.respond_to?(:supports_insert_conflict?) && dataset.supports_insert_conflict?
33
+ def __insert_or_update_and_return__(dataset, pkey, unique_columns, params, conds = nil, exclude_on_update = nil)
34
+ to_update = params.keys - unique_columns
35
+ to_update -= exclude_on_update if exclude_on_update
36
+
37
+ dataset = dataset.insert_conflict(
38
+ target: unique_columns,
39
+ update: Hash[ to_update.map { |attribute| [attribute, Sequel[:excluded][attribute]] } ],
40
+ update_where: conds
41
+ )
42
+
43
+ __insert_and_return__(dataset, pkey, params)
44
+ end
45
+ else
46
+ def __insert_or_update_and_return__(dataset, pkey, unique_columns, params, conds = nil, exclude_on_update = nil)
47
+ find_params, update_params = params.partition { |key, _| unique_columns.include?(key) }.map { |h| Hash[h] }
48
+
49
+ dataset_where = dataset.where(find_params)
50
+ record = if conds
51
+ dataset_where_conds = dataset_where.where(conds)
52
+
53
+ # this means that there's still a valid entry there, so return early
54
+ return if dataset_where.count != dataset_where_conds.count
55
+
56
+ dataset_where_conds.first
57
+ else
58
+ dataset_where.first
59
+ end
60
+
61
+ if record
62
+ update_params.reject! { |k, _v| exclude_on_update.include?(k) } if exclude_on_update
63
+ __update_and_return__(dataset_where, update_params)
64
+ else
65
+ __insert_and_return__(dataset, pkey, params)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ # rubocop:enable Naming/MethodName, Metrics/ParameterLists
72
+ end
73
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rodauth-oauth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tiago Cardoso
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-08-01 00:00:00.000000000 Z
11
+ date: 2020-09-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Implementation of the OAuth 2.0 protocol on top of rodauth.
14
14
  email:
@@ -32,8 +32,10 @@ files:
32
32
  - lib/rodauth/features/oauth.rb
33
33
  - lib/rodauth/features/oauth_http_mac.rb
34
34
  - lib/rodauth/features/oauth_jwt.rb
35
+ - lib/rodauth/features/oauth_saml.rb
35
36
  - lib/rodauth/features/oidc.rb
36
37
  - lib/rodauth/oauth.rb
38
+ - lib/rodauth/oauth/database_extensions.rb
37
39
  - lib/rodauth/oauth/railtie.rb
38
40
  - lib/rodauth/oauth/ttl_store.rb
39
41
  - lib/rodauth/oauth/version.rb