rodauth-oauth 0.1.0 → 0.2.0

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