omniauth-google-oauth2 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@
3
3
  require 'spec_helper'
4
4
  require 'json'
5
5
  require 'omniauth-google-oauth2'
6
+ require 'openssl'
6
7
  require 'stringio'
7
8
 
8
9
  describe OmniAuth::Strategies::GoogleOauth2 do
@@ -23,6 +24,8 @@ describe OmniAuth::Strategies::GoogleOauth2 do
23
24
 
24
25
  before do
25
26
  OmniAuth.config.test_mode = true
27
+ # The key set is cached on the class, so it must not leak between examples.
28
+ OmniAuth::Strategies::GoogleOauth2.reset_jwks_cache!
26
29
  end
27
30
 
28
31
  after do
@@ -442,6 +445,7 @@ describe OmniAuth::Strategies::GoogleOauth2 do
442
445
  {
443
446
  'abc' => 'xyz',
444
447
  'exp' => Time.now.to_i + 3600,
448
+ 'sub' => '12345',
445
449
  'nbf' => Time.now.to_i - 60,
446
450
  'iat' => Time.now.to_i,
447
451
  'aud' => 'appid',
@@ -449,7 +453,9 @@ describe OmniAuth::Strategies::GoogleOauth2 do
449
453
  }
450
454
  end
451
455
  let(:id_token) { JWT.encode(token_info, 'secret') }
452
- let(:access_token) { OAuth2::AccessToken.from_hash(client, 'id_token' => id_token) }
456
+ let(:access_token) do
457
+ OAuth2::AccessToken.from_hash(client, 'access_token' => 'valid_access_token', 'id_token' => id_token)
458
+ end
453
459
 
454
460
  it 'should include id_token when set on the access_token' do
455
461
  expect(subject.extra).to include(id_token: id_token)
@@ -468,6 +474,14 @@ describe OmniAuth::Strategies::GoogleOauth2 do
468
474
  it 'should include id_info when id_token is set on the access_token by default' do
469
475
  expect(subject.extra).to include(id_info: token_info)
470
476
  end
477
+
478
+ it 'decodes the token only once across repeated extra calls' do
479
+ allow(JWT).to receive(:decode).and_call_original
480
+
481
+ 2.times { subject.extra }
482
+
483
+ expect(JWT).to have_received(:decode).once
484
+ end
471
485
  end
472
486
  end
473
487
 
@@ -479,6 +493,7 @@ describe OmniAuth::Strategies::GoogleOauth2 do
479
493
  {
480
494
  'abc' => 'xyz',
481
495
  'exp' => Time.now.to_i + 3600,
496
+ 'sub' => '12345',
482
497
  'nbf' => Time.now.to_i - 60,
483
498
  'iat' => Time.now.to_i,
484
499
  'aud' => 'appid',
@@ -486,16 +501,71 @@ describe OmniAuth::Strategies::GoogleOauth2 do
486
501
  }
487
502
  end
488
503
  let(:id_token) { JWT.encode(token_info, 'secret') }
489
- let(:access_token) { OAuth2::AccessToken.from_hash(client, 'id_token' => id_token) }
504
+ let(:access_token) do
505
+ OAuth2::AccessToken.from_hash(client, 'access_token' => 'valid_access_token', 'id_token' => id_token)
506
+ end
490
507
 
491
508
  it 'raises JWT::InvalidIssuerError' do
492
509
  expect { subject.extra }.to raise_error(JWT::InvalidIssuerError)
493
510
  end
494
511
  end
495
512
 
513
+ # Reaching extra without going through verified_id_token, so the claim
514
+ # cache is empty and extra does the audience check itself. This is the
515
+ # multi-platform case: a token minted for a sibling client id.
516
+ context 'when the id_token is minted for an authorized client' do
517
+ let(:token_info) do
518
+ {
519
+ 'exp' => Time.now.to_i + 3600,
520
+ 'sub' => '12345',
521
+ 'nbf' => Time.now.to_i - 60,
522
+ 'iat' => Time.now.to_i,
523
+ 'aud' => 'android-client-id',
524
+ 'iss' => 'https://accounts.google.com'
525
+ }
526
+ end
527
+ let(:id_token) { JWT.encode(token_info, 'secret') }
528
+ let(:access_token) do
529
+ OAuth2::AccessToken.from_hash(client, 'access_token' => 'valid_access_token', 'id_token' => id_token)
530
+ end
531
+
532
+ it 'decodes it when that client is configured' do
533
+ # Built here rather than through `subject`, because the enclosing
534
+ # before hook instantiates the strategy before an example body runs.
535
+ strategy = described_class.new(app, 'appid', 'secret', authorized_client_ids: ['android-client-id'])
536
+ allow(strategy).to receive_messages(request: request, access_token: access_token)
537
+
538
+ expect(strategy.extra[:id_info]).to include('aud' => 'android-client-id')
539
+ end
540
+
541
+ it 'rejects it when that client is not configured' do
542
+ expect { subject.extra }.to raise_error(JWT::InvalidAudError)
543
+ end
544
+ end
545
+
546
+ # Also reached with an empty claim cache, so extra applies the requirement
547
+ # itself. A claim check passes silently when the claim is absent, so
548
+ # without this an id_token carrying no exp would never expire.
549
+ context 'when the id_token omits a required claim' do
550
+ let(:token_info) do
551
+ {
552
+ 'exp' => Time.now.to_i + 3600,
553
+ 'aud' => 'appid',
554
+ 'iss' => 'https://accounts.google.com'
555
+ }
556
+ end
557
+ let(:id_token) { JWT.encode(token_info, 'secret') }
558
+ let(:access_token) do
559
+ OAuth2::AccessToken.from_hash(client, 'access_token' => 'valid_access_token', 'id_token' => id_token)
560
+ end
561
+
562
+ it 'raises rather than exposing the remaining claims' do
563
+ expect { subject.extra }.to raise_error(JWT::MissingRequiredClaim, /sub/)
564
+ end
565
+ end
566
+
496
567
  context 'when the access token is empty or nil' do
497
568
  let(:access_token) { OAuth2::AccessToken.new(client, nil, { 'refresh_token' => 'foo' }) }
498
- before { allow(subject.extra).to receive(:access_token).and_return(access_token) }
499
569
 
500
570
  it 'should not include id_token' do
501
571
  expect(subject.extra).not_to have_key(:id_token)
@@ -505,6 +575,14 @@ describe OmniAuth::Strategies::GoogleOauth2 do
505
575
  expect(subject.extra).not_to have_key(:id_info)
506
576
  end
507
577
  end
578
+
579
+ context 'when the access token does not include an id_token' do
580
+ let(:access_token) { OAuth2::AccessToken.from_hash(client, 'access_token' => 'opaque_access_token') }
581
+
582
+ it 'does not treat the opaque access token as an id_token' do
583
+ expect(subject.extra).not_to include(:id_token, :id_info)
584
+ end
585
+ end
508
586
  end
509
587
 
510
588
  describe 'raw_info' do
@@ -512,6 +590,7 @@ describe OmniAuth::Strategies::GoogleOauth2 do
512
590
  {
513
591
  'abc' => 'xyz',
514
592
  'exp' => Time.now.to_i + 3600,
593
+ 'sub' => '12345',
515
594
  'nbf' => Time.now.to_i - 60,
516
595
  'iat' => Time.now.to_i,
517
596
  'aud' => 'appid',
@@ -723,6 +802,21 @@ describe OmniAuth::Strategies::GoogleOauth2 do
723
802
  end
724
803
 
725
804
  describe 'build_access_token' do
805
+ # Stands in for what the token endpoint really hands back; a bare stub would
806
+ # return nil, which the strategy now rejects as "no credential".
807
+ let(:stubbed_token) { double('AccessToken') }
808
+
809
+ # Mimics a Rack 3 input stream: readable, but deliberately not rewindable.
810
+ # A strict double fails the example if anything calls rewind on it.
811
+ def non_rewindable_input(content)
812
+ io = StringIO.new(content)
813
+ double('Rack3Input').tap do |input|
814
+ allow(input).to receive(:read) { |*args| io.read(*args) }
815
+ allow(input).to receive(:gets) { io.gets }
816
+ allow(input).to receive(:each) { |&block| io.each(&block) }
817
+ end
818
+ end
819
+
726
820
  it 'should use a hybrid authorization request_uri if this is an AJAX request with a code parameter' do
727
821
  allow(request).to receive(:xhr?).and_return(true)
728
822
  allow(request).to receive(:params).and_return('code' => 'valid_code')
@@ -731,9 +825,8 @@ describe OmniAuth::Strategies::GoogleOauth2 do
731
825
  auth_code = double(:auth_code)
732
826
  allow(client).to receive(:auth_code).and_return(auth_code)
733
827
  expect(subject).to receive(:client).and_return(client)
734
- expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'postmessage' }, {})
828
+ expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'postmessage' }, {}).and_return(stubbed_token)
735
829
 
736
- expect(subject).not_to receive(:orig_build_access_token)
737
830
  subject.build_access_token
738
831
  end
739
832
 
@@ -745,9 +838,8 @@ describe OmniAuth::Strategies::GoogleOauth2 do
745
838
  auth_code = double(:auth_code)
746
839
  allow(client).to receive(:auth_code).and_return(auth_code)
747
840
  expect(subject).to receive(:client).and_return(client)
748
- expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: '' }, {})
841
+ expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: '' }, {}).and_return(stubbed_token)
749
842
 
750
- expect(subject).not_to receive(:orig_build_access_token)
751
843
  subject.build_access_token
752
844
  end
753
845
 
@@ -759,13 +851,12 @@ describe OmniAuth::Strategies::GoogleOauth2 do
759
851
  auth_code = double(:auth_code)
760
852
  allow(client).to receive(:auth_code).and_return(auth_code)
761
853
  expect(subject).to receive(:client).and_return(client)
762
- expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'redirect_uri' }, {})
854
+ expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'redirect_uri' }, {}).and_return(stubbed_token)
763
855
 
764
- expect(subject).not_to receive(:orig_build_access_token)
765
856
  subject.build_access_token
766
857
  end
767
858
 
768
- it 'should read access_token from hash if this is not an AJAX request with a code parameter' do
859
+ it 'should read only access_token from params if this is not an AJAX request with a code parameter' do
769
860
  client = OAuth2::Client.new('abc', 'def') do |builder|
770
861
  builder.request :url_encoded
771
862
  builder.adapter :test do |stub|
@@ -774,18 +865,29 @@ describe OmniAuth::Strategies::GoogleOauth2 do
774
865
  end
775
866
 
776
867
  allow(request).to receive(:xhr?).and_return(false)
777
- allow(request).to receive(:params).and_return('access_token' => 'valid_access_token')
868
+ allow(request).to receive(:params).and_return(
869
+ 'access_token' => 'valid_access_token',
870
+ 'id_token' => 'forged_id_token',
871
+ 'refresh_token' => 'forged_refresh_token',
872
+ 'expires_at' => 123_456_789
873
+ )
778
874
  expect(subject).to receive(:verify_token).with('valid_access_token').and_return true
779
875
  expect(subject).to receive(:client).and_return(client)
876
+ allow(subject).to receive(:warn)
780
877
 
781
878
  token = subject.build_access_token
782
879
  expect(token).to be_instance_of(OAuth2::AccessToken)
783
880
  expect(token.token).to eq('valid_access_token')
784
881
  expect(token.client).to eq(client)
882
+ expect(token.params).to be_empty
883
+ expect(token.refresh_token).to be_nil
884
+ expect(token.expires_at).to be_nil
785
885
  end
786
886
 
787
887
  it 'reads the code from a json request body' do
788
- body = StringIO.new(%({"code":"json_access_token"}))
888
+ # Literal UTF-8 rather than JSON.dump, which would escape the accent back to ASCII.
889
+ payload = %({"code":"json_access_token","note":"café"})
890
+ body = non_rewindable_input(payload)
789
891
  client = double(:client)
790
892
  auth_code = double(:auth_code)
791
893
 
@@ -795,9 +897,12 @@ describe OmniAuth::Strategies::GoogleOauth2 do
795
897
  allow(client).to receive(:auth_code).and_return(auth_code)
796
898
  expect(subject).to receive(:client).and_return(client)
797
899
 
798
- expect(auth_code).to receive(:get_token).with('json_access_token', { redirect_uri: 'postmessage' }, {})
900
+ expect(auth_code).to receive(:get_token).with('json_access_token', { redirect_uri: 'postmessage' }, {}).and_return(stubbed_token)
799
901
 
800
902
  subject.build_access_token
903
+
904
+ expect(request.env['rack.input']).to be_a(StringIO)
905
+ expect(request.env['rack.input'].read.b).to eq(payload.b)
801
906
  end
802
907
 
803
908
  it 'reads the redirect uri from a json request body' do
@@ -811,13 +916,18 @@ describe OmniAuth::Strategies::GoogleOauth2 do
811
916
  allow(client).to receive(:auth_code).and_return(auth_code)
812
917
  expect(subject).to receive(:client).and_return(client)
813
918
 
814
- expect(auth_code).to receive(:get_token).with('json_access_token', { redirect_uri: 'sample' }, {})
919
+ expect(auth_code).to receive(:get_token).with('json_access_token', { redirect_uri: 'sample' }, {}).and_return(stubbed_token)
815
920
 
816
921
  subject.build_access_token
817
922
  end
818
923
 
819
- it 'reads the access token from a json request body' do
820
- body = StringIO.new(%({"access_token":"valid_access_token"}))
924
+ it 'reads only the access token from a json request body' do
925
+ body = StringIO.new(JSON.dump(
926
+ access_token: 'valid_access_token',
927
+ id_token: 'forged_id_token',
928
+ refresh_token: 'forged_refresh_token',
929
+ expires_at: 123_456_789
930
+ ))
821
931
  client = OAuth2::Client.new('abc', 'def') do |builder|
822
932
  builder.request :url_encoded
823
933
  builder.adapter :test do |stub|
@@ -829,6 +939,7 @@ describe OmniAuth::Strategies::GoogleOauth2 do
829
939
  allow(request).to receive(:content_type).and_return('application/json')
830
940
  allow(request).to receive(:body).and_return(body)
831
941
  expect(subject).to receive(:client).and_return(client)
942
+ allow(subject).to receive(:warn)
832
943
 
833
944
  expect(subject).to receive(:verify_token).with('valid_access_token').and_return true
834
945
 
@@ -836,17 +947,52 @@ describe OmniAuth::Strategies::GoogleOauth2 do
836
947
  expect(token).to be_instance_of(OAuth2::AccessToken)
837
948
  expect(token.token).to eq('valid_access_token')
838
949
  expect(token.client).to eq(client)
950
+ expect(token.params).to be_empty
951
+ expect(token.refresh_token).to be_nil
952
+ expect(token.expires_at).to be_nil
839
953
  end
840
954
 
841
955
  it 'should handle a malformed json request body gracefully' do
842
- body = StringIO.new('not valid json{{{')
956
+ payload = 'not valid json{{{'
957
+ body = non_rewindable_input(payload)
843
958
 
844
959
  allow(request).to receive(:xhr?).and_return(false)
845
960
  allow(request).to receive(:params).and_return({})
846
961
  allow(request).to receive(:content_type).and_return('application/json')
847
962
  allow(request).to receive(:body).and_return(body)
848
963
 
849
- expect { subject.build_access_token }.to output(/JSON parse error/).to_stderr
964
+ # Warns about the body, then fails as a normal auth failure rather than
965
+ # handing a nil token to the caller.
966
+ expect do
967
+ expect { subject.build_access_token }.to raise_error(OmniAuth::Strategies::OAuth2::CallbackError)
968
+ end.to output(/JSON parse error/).to_stderr
969
+
970
+ # The body is restored for downstream middlewares even when parsing fails.
971
+ expect(request.env['rack.input'].read.b).to eq(payload.b)
972
+ end
973
+
974
+ it 'fails cleanly when the request carries no usable credential' do
975
+ allow(request).to receive(:xhr?).and_return(false)
976
+ allow(request).to receive(:params).and_return('id_token' => 'an-id-token-with-no-access-token')
977
+ allow(request).to receive(:content_type).and_return(nil)
978
+
979
+ # The reason matters: verify_hd raises the same class, and applications
980
+ # branch on the message OmniAuth puts in the failure redirect.
981
+ expect { subject.build_access_token }.to raise_error(
982
+ OmniAuth::Strategies::OAuth2::CallbackError, /invalid_credentials.*No valid credentials/
983
+ )
984
+ end
985
+
986
+ [['a JSON array', '[1,2,3]'], ['a JSON number', '42'], ['a JSON string', '"nope"']].each do |label, payload|
987
+ it "fails cleanly when the JSON body is #{label} rather than an object" do
988
+ allow(request).to receive(:xhr?).and_return(false)
989
+ allow(request).to receive(:params).and_return({})
990
+ allow(request).to receive(:content_type).and_return('application/json')
991
+ allow(request).to receive(:body).and_return(non_rewindable_input(payload))
992
+
993
+ # Indexing a non-Hash by string would raise TypeError out of the callback.
994
+ expect { subject.build_access_token }.to raise_error(OmniAuth::Strategies::OAuth2::CallbackError)
995
+ end
850
996
  end
851
997
 
852
998
  it 'should use callback_url without query_string if this is not an AJAX request' do
@@ -860,8 +1006,413 @@ describe OmniAuth::Strategies::GoogleOauth2 do
860
1006
  allow(subject).to receive(:callback_url).and_return('redirect_uri_without_query_string')
861
1007
 
862
1008
  expect(subject).to receive(:client).and_return(client)
863
- expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'redirect_uri_without_query_string' }, {})
1009
+ expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'redirect_uri_without_query_string' }, {}).and_return(stubbed_token)
1010
+ subject.build_access_token
1011
+ end
1012
+ end
1013
+
1014
+ describe 'client-supplied id_token verification' do
1015
+ # Generated once: RSA keygen is slow and the key is immutable across examples.
1016
+ signing_key = OpenSSL::PKey::RSA.generate(2048)
1017
+
1018
+ # Exposed through a let so the helper methods below can reach it; a bare
1019
+ # local is out of scope inside a def.
1020
+ let(:key) { signing_key }
1021
+ let(:jwks_status) { 200 }
1022
+ let(:jwks_body) do
1023
+ JSON.dump('keys' => [JWT::JWK.new(key, { kid: 'test-key', use: 'sig', alg: 'RS256' }).export])
1024
+ end
1025
+ let(:claims) do
1026
+ { 'iss' => 'https://accounts.google.com', 'aud' => 'appid', 'sub' => '12345',
1027
+ 'email' => 'john@example.com', 'email_verified' => true,
1028
+ 'exp' => Time.now.to_i + 3600, 'nbf' => Time.now.to_i - 60 }
1029
+ end
1030
+ let(:genuine_id_token) { signed(claims) }
1031
+ let(:jwks_response) { jwks_body }
1032
+ let(:client) do
1033
+ OAuth2::Client.new('appid', 'secret', site: 'https://www.googleapis.com') do |builder|
1034
+ builder.request :url_encoded
1035
+ builder.adapter :test do |stub|
1036
+ stub.get('/oauth2/v3/certs') do
1037
+ jwks_status == 200 ? [200, { 'content-type' => 'application/json' }, jwks_response] : [jwks_status, {}, 'upstream error']
1038
+ end
1039
+ stub.get('/oauth2/v3/userinfo') { [200, { 'content-type' => 'application/json' }, '{"sub":"12345"}'] }
1040
+ end
1041
+ end
1042
+ end
1043
+
1044
+ def signed(payload)
1045
+ JWT.encode(payload, key, 'RS256', { kid: 'test-key' })
1046
+ end
1047
+
1048
+ def build_token(id_token)
1049
+ allow(request).to receive(:xhr?).and_return(false)
1050
+ allow(request).to receive(:params).and_return(
1051
+ 'access_token' => 'valid_access_token', 'id_token' => id_token,
1052
+ 'refresh_token' => 'forged_refresh_token', 'expires_at' => 123_456_789
1053
+ )
1054
+ allow(subject).to receive(:verify_token).with('valid_access_token').and_return(true)
1055
+ allow(subject).to receive(:client).and_return(client)
1056
+ allow(subject).to receive(:warn)
1057
+ subject.build_access_token
1058
+ end
1059
+
1060
+ it 'keeps an id_token that Google actually signed' do
1061
+ expect(build_token(genuine_id_token)['id_token']).to eq(genuine_id_token)
1062
+ end
1063
+
1064
+ it 'exposes the verified claims as id_info' do
1065
+ subject.access_token = build_token(genuine_id_token)
1066
+ expect(subject.extra[:id_info]['email']).to eq('john@example.com')
1067
+ end
1068
+
1069
+ it 'decodes a caller-supplied id_token only once' do
1070
+ allow(JWT).to receive(:decode).and_call_original
1071
+
1072
+ subject.access_token = build_token(genuine_id_token)
1073
+ subject.extra
1074
+
1075
+ expect(JWT).to have_received(:decode).once
1076
+ end
1077
+
1078
+ it 'does not revalidate a caller-supplied id_token during extra processing' do
1079
+ now = Time.now
1080
+ allow(Time).to receive(:now).and_return(now)
1081
+ expiring = signed(claims.merge('exp' => now.to_i + 1))
1082
+ subject.access_token = build_token(expiring)
1083
+
1084
+ allow(Time).to receive(:now).and_return(now + 3600)
1085
+
1086
+ expect(subject.extra[:id_info]).to include('sub' => '12345')
1087
+ end
1088
+
1089
+ it 'keeps an id_token minted for an authorized client' do
1090
+ subject.options.authorized_client_ids = ['mobile-client']
1091
+ authorized = signed(claims.merge('aud' => 'mobile-client'))
1092
+
1093
+ subject.access_token = build_token(authorized)
1094
+
1095
+ expect(subject.extra).to include(
1096
+ id_token: authorized,
1097
+ id_info: hash_including('aud' => 'mobile-client')
1098
+ )
1099
+ end
1100
+
1101
+ it 'keeps an authorized-client id_token when JWT output is skipped' do
1102
+ subject.options.authorized_client_ids = ['mobile-client']
1103
+ subject.options.skip_jwt = true
1104
+ authorized = signed(claims.merge('aud' => 'mobile-client'))
1105
+
1106
+ subject.access_token = build_token(authorized)
1107
+
1108
+ expect(subject.extra).to include(id_token: authorized)
1109
+ expect(subject.extra).not_to have_key(:id_info)
1110
+ end
1111
+
1112
+ # These carry a kid that IS in the key set, so they reach the algorithm check
1113
+ # rather than being turned away earlier at key lookup. Without the kid they
1114
+ # would pass even if the RS256 pin were removed.
1115
+ it 'discards an unsigned id_token' do
1116
+ forged = JWT.encode(claims.merge('email' => 'victim@example.com'), nil, 'none', { kid: 'test-key' })
1117
+ expect(build_token(forged)['id_token']).to be_nil
1118
+ end
1119
+
1120
+ it 'discards an id_token signed with a symmetric algorithm' do
1121
+ forged = JWT.encode(claims.merge('email' => 'victim@example.com'), 'guessed-secret', 'HS256', { kid: 'test-key' })
1122
+ expect(build_token(forged)['id_token']).to be_nil
1123
+ end
1124
+
1125
+ it 'discards an id_token signed with the public key as an HMAC secret' do
1126
+ forged = JWT.encode(claims.merge('email' => 'victim@example.com'), key.public_key.to_pem, 'HS256', { kid: 'test-key' })
1127
+ expect(build_token(forged)['id_token']).to be_nil
1128
+ end
1129
+
1130
+ # Each claim check passes silently when the claim is simply missing, so
1131
+ # without a required list a token with no exp would never expire.
1132
+ described_class::REQUIRED_ID_TOKEN_CLAIMS.each do |claim|
1133
+ it "discards an id_token with no #{claim} claim" do
1134
+ incomplete = signed(claims.reject { |k, _| k == claim })
1135
+ expect(build_token(incomplete)['id_token']).to be_nil
1136
+ end
1137
+ end
1138
+
1139
+ it 'discards an id_token that is not yet valid' do
1140
+ expect(build_token(signed(claims.merge('nbf' => Time.now.to_i + 3600)))['id_token']).to be_nil
1141
+ end
1142
+
1143
+ it 'discards an id_token signed by an unknown key' do
1144
+ stranger = OpenSSL::PKey::RSA.generate(2048)
1145
+ forged = JWT.encode(claims, stranger, 'RS256', { kid: 'test-key' })
1146
+ expect(build_token(forged)['id_token']).to be_nil
1147
+ end
1148
+
1149
+ it 'discards an id_token minted for another audience' do
1150
+ expect(build_token(signed(claims.merge('aud' => 'someone-elses-app')))['id_token']).to be_nil
1151
+ end
1152
+
1153
+ it 'discards an id_token from an untrusted issuer' do
1154
+ expect(build_token(signed(claims.merge('iss' => 'https://evil.example.com')))['id_token']).to be_nil
1155
+ end
1156
+
1157
+ it 'discards an expired id_token' do
1158
+ expect(build_token(signed(claims.merge('exp' => Time.now.to_i - 3600)))['id_token']).to be_nil
1159
+ end
1160
+
1161
+ # jwt builds "Could not find public key for kid <caller's kid>", so the
1162
+ # message quotes the request and could otherwise forge log records.
1163
+ it 'keeps a hostile kid header to a single bounded log line' do
1164
+ hostile = JWT.encode(claims, key, 'RS256', { kid: "evil\nFORGED LOG LINE\r\nAND ANOTHER#{'x' * 500}" })
1165
+ allow(request).to receive(:xhr?).and_return(false)
1166
+ allow(request).to receive(:params).and_return(
1167
+ 'access_token' => 'valid_access_token', 'id_token' => hostile
1168
+ )
1169
+ allow(subject).to receive(:verify_token).with('valid_access_token').and_return(true)
1170
+ allow(subject).to receive(:client).and_return(client)
1171
+
1172
+ warnings = []
1173
+ allow(subject).to receive(:warn) { |line| warnings << line }
1174
+ subject.build_access_token
1175
+
1176
+ expect(warnings.size).to eq(1)
1177
+ expect(warnings.first).not_to include("\n", "\r")
1178
+ expect(warnings.first.length).to be <= described_class::LOG_MESSAGE_LIMIT + 80
1179
+ end
1180
+
1181
+ it 'refetches the key set when a token names a key it does not hold' do
1182
+ # Exercised through a real id_token rather than by calling the cache
1183
+ # directly, so the wiring from the decoder's invalidate hook is covered:
1184
+ # without it a rotated Google key would never be picked up.
1185
+ fetches = 0
1186
+ counting_client = OAuth2::Client.new('appid', 'secret', site: 'https://www.googleapis.com') do |builder|
1187
+ builder.request :url_encoded
1188
+ builder.adapter :test do |stub|
1189
+ stub.get('/oauth2/v3/certs') do
1190
+ fetches += 1
1191
+ [200, { 'content-type' => 'application/json' }, jwks_body]
1192
+ end
1193
+ stub.get('/oauth2/v3/userinfo') { [200, { 'content-type' => 'application/json' }, '{"sub":"12345"}'] }
1194
+ end
1195
+ end
1196
+
1197
+ allow(request).to receive(:xhr?).and_return(false)
1198
+ allow(request).to receive(:params).and_return(
1199
+ 'access_token' => 'valid_access_token',
1200
+ 'id_token' => JWT.encode(claims, key, 'RS256', { kid: 'rotated-key' })
1201
+ )
1202
+ allow(subject).to receive(:verify_token).with('valid_access_token').and_return(true)
1203
+ allow(subject).to receive_messages(client: counting_client, warn: nil)
1204
+
864
1205
  subject.build_access_token
1206
+
1207
+ expect(fetches).to eq(2)
1208
+ end
1209
+
1210
+ it 'still discards request-supplied refresh_token and expiry' do
1211
+ token = build_token(genuine_id_token)
1212
+ expect(token.refresh_token).to be_nil
1213
+ expect(token.expires_at).to be_nil
1214
+ end
1215
+
1216
+ # Precomputed with Base64.urlsafe_encode64 over 'valid_access_token' and
1217
+ # 'another_access_token', pinning the strategy's pack-based encoding against
1218
+ # an independent implementation. The userinfo stub above reports sub 12345,
1219
+ # which is the subject the claims carry.
1220
+ let(:at_hash_of_access_token) { 'CD6l-YB7vlH5uyYrxoiQtg' }
1221
+ let(:at_hash_of_other_token) { 'ThOh39PmYAiHJ-oJozGU7A' }
1222
+
1223
+ it 'keeps an id_token whose at_hash matches the access token' do
1224
+ bound = signed(claims.merge('at_hash' => at_hash_of_access_token))
1225
+ expect(build_token(bound)['id_token']).to eq(bound)
1226
+ end
1227
+
1228
+ # The example above cannot tell whether at_hash or the subject check accepted
1229
+ # the token, because both agree. Here they disagree: a matching at_hash proves
1230
+ # the pair was issued together, so it decides alone and userinfo is not
1231
+ # consulted. Any error in the digest, its length, or its encoding fails this.
1232
+ it 'accepts a matching at_hash on its own, without consulting userinfo' do
1233
+ bound = signed(claims.merge('sub' => 'a-different-subject', 'at_hash' => at_hash_of_access_token))
1234
+ expect(subject).not_to receive(:userinfo_for)
1235
+
1236
+ expect(build_token(bound)['id_token']).to eq(bound)
1237
+ end
1238
+
1239
+ it 'keeps an id_token for the same user whose at_hash is stale' do
1240
+ # A client that refreshed its access token but forwarded the id_token it
1241
+ # was originally issued. Same person, so the subject check carries it.
1242
+ skewed = signed(claims.merge('at_hash' => at_hash_of_other_token))
1243
+ expect(build_token(skewed)['id_token']).to eq(skewed)
1244
+ end
1245
+
1246
+ it 'discards an id_token for a different user than the access token' do
1247
+ impostor = signed(claims.merge('sub' => 'someone-else', 'at_hash' => at_hash_of_other_token))
1248
+ expect(build_token(impostor)['id_token']).to be_nil
1249
+ end
1250
+
1251
+ it 'discards an id_token for a different user even with no at_hash to check' do
1252
+ impostor = signed(claims.merge('sub' => 'someone-else'))
1253
+ # Decoded, because `impostor` is the encoded JWT string and a plain
1254
+ # include? on it would be a substring check over base64 text.
1255
+ expect(JWT.decode(impostor, nil, false).first).not_to have_key('at_hash')
1256
+ expect(build_token(impostor)['id_token']).to be_nil
1257
+ end
1258
+
1259
+ it 'keeps a genuine id_token that carries no at_hash' do
1260
+ expect(claims).not_to have_key('at_hash')
1261
+ expect(build_token(genuine_id_token)['id_token']).to eq(genuine_id_token)
1262
+ end
1263
+
1264
+ it 'checks the subject even when skip_info is set' do
1265
+ @options = { skip_info: true }
1266
+ impostor = signed(claims.merge('sub' => 'someone-else'))
1267
+ expect(build_token(impostor)['id_token']).to be_nil
1268
+ end
1269
+
1270
+ it 'discards the id_token when userinfo cannot be reached' do
1271
+ allow_any_instance_of(OAuth2::AccessToken).to receive(:get).and_raise(OAuth2::Error.new(double(parsed: {}, body: '', headers: {}, status: 500)))
1272
+ expect { build_token(genuine_id_token) }.not_to raise_error
1273
+ expect(build_token(genuine_id_token)['id_token']).to be_nil
1274
+ end
1275
+
1276
+ it 'verifies id_tokens for subclassed strategies too' do
1277
+ subclass = Class.new(OmniAuth::Strategies::GoogleOauth2)
1278
+ strategy = subclass.new(app, 'appid', 'secret').tap do |s|
1279
+ allow(s).to receive(:request).and_return(request)
1280
+ allow(s).to receive(:verify_token).with('valid_access_token').and_return(true)
1281
+ allow(s).to receive(:client).and_return(client)
1282
+ end
1283
+ allow(request).to receive(:xhr?).and_return(false)
1284
+ allow(request).to receive(:params).and_return(
1285
+ 'access_token' => 'valid_access_token', 'id_token' => genuine_id_token
1286
+ )
1287
+
1288
+ expect(strategy.build_access_token['id_token']).to eq(genuine_id_token)
1289
+ end
1290
+
1291
+ context 'when the key set cannot be fetched' do
1292
+ let(:jwks_status) { 500 }
1293
+
1294
+ it 'discards the id_token rather than raising' do
1295
+ expect { build_token(genuine_id_token) }.not_to raise_error
1296
+ end
1297
+
1298
+ # The load-bearing assertion: an unreachable key set must never mean the
1299
+ # token is taken on trust. Without this, failing open is invisible here.
1300
+ it 'does not fall back to trusting an unverified id_token' do
1301
+ expect(build_token(genuine_id_token)['id_token']).to be_nil
1302
+ end
1303
+
1304
+ it 'still returns a usable access token' do
1305
+ expect(build_token(genuine_id_token).token).to eq('valid_access_token')
1306
+ end
1307
+ end
1308
+
1309
+ # JWT::JWK::Set will build a key set out of surprising input rather than
1310
+ # refusing it, including an HMAC key from a bare string, so the shape is
1311
+ # checked before it gets there.
1312
+ # The reason is asserted, not just the outcome: a malformed key set makes the
1313
+ # token unverifiable whatever we do, so only the diagnostic distinguishes
1314
+ # rejecting the shape from stumbling into a confusing error further down.
1315
+ {
1316
+ 'a JSON array' => ['[1,2,3]', /not an object/],
1317
+ 'a JSON scalar' => ['42', /not an object/],
1318
+ 'an object with no keys entry' => ['{"nope":true}', /no keys array/],
1319
+ 'an object whose keys entry is a string' => ['{"keys":"nope"}', /no keys array/],
1320
+ 'a body that is not JSON at all' => ['<html>502 Bad Gateway</html>', /JSON::ParserError/]
1321
+ }.each do |label, (malformed, reason)|
1322
+ context "when the key set response is #{label}" do
1323
+ let(:jwks_response) { malformed }
1324
+
1325
+ it 'discards the id_token and reports why' do
1326
+ expect { build_token(genuine_id_token) }.not_to raise_error
1327
+ expect(build_token(genuine_id_token)['id_token']).to be_nil
1328
+ expect(subject).to have_received(:warn).with(reason).at_least(:once)
1329
+ end
1330
+ end
1331
+ end
1332
+ end
1333
+
1334
+ describe 'JWKS retrieval' do
1335
+ it 'refetches when a token names a key it does not hold' do
1336
+ # Key rotation has to resolve, so the first unknown kid forces a refresh.
1337
+ attempts = 0
1338
+ fetch = -> { attempts += 1 and :key_set }
1339
+ described_class.cached_jwks(&fetch)
1340
+ described_class.cached_jwks(force: true, &fetch)
1341
+
1342
+ expect(attempts).to eq(2)
1343
+ end
1344
+
1345
+ it 'throttles forced refetches so unknown kids cannot drive them' do
1346
+ # The sender picks the kid, so an unthrottled force is a way to make the
1347
+ # process fetch on demand while holding the lock.
1348
+ attempts = 0
1349
+ fetch = -> { attempts += 1 and :key_set }
1350
+ described_class.cached_jwks(&fetch)
1351
+ 10.times { described_class.cached_jwks(force: true, &fetch) }
1352
+
1353
+ expect(attempts).to eq(2)
1354
+ end
1355
+
1356
+ # Subclassing the strategy is common, and reset_jwks_cache! is the documented
1357
+ # test hook, so both class methods have to work from a subclass receiver.
1358
+ context 'called on a subclass' do
1359
+ let(:subclass) { Class.new(described_class) }
1360
+
1361
+ it 'shares the base class cache rather than keeping its own' do
1362
+ fetched = subclass.cached_jwks { :shared_key_set }
1363
+
1364
+ expect(fetched).to eq(:shared_key_set)
1365
+ expect(described_class.instance_variable_get(:@jwks)).to eq(:shared_key_set)
1366
+ end
1367
+
1368
+ it 'clears the base class cache on reset' do
1369
+ described_class.cached_jwks { :shared_key_set }
1370
+
1371
+ expect { subclass.reset_jwks_cache! }.not_to raise_error
1372
+ expect(described_class.instance_variable_get(:@jwks)).to be_nil
1373
+ end
1374
+ end
1375
+
1376
+ it 'clears every piece of cached state on reset' do
1377
+ described_class.cached_jwks { :key_set }
1378
+ described_class.reset_jwks_cache!
1379
+
1380
+ %i[@jwks @jwks_expires_at @jwks_retry_at @jwks_forced_at].each do |ivar|
1381
+ expect(described_class.instance_variable_get(ivar)).to be_nil
1382
+ end
1383
+ end
1384
+
1385
+ it 'backs off after a failed fetch even with nothing cached' do
1386
+ attempts = 0
1387
+ 3.times do
1388
+ expect do
1389
+ described_class.cached_jwks do
1390
+ attempts += 1
1391
+ raise described_class::JwksUnavailable
1392
+ end
1393
+ end.to raise_error(described_class::JwksUnavailable)
1394
+ end
1395
+
1396
+ expect(attempts).to eq(1)
1397
+ end
1398
+
1399
+ it 'serves the stale key set and backs off when a refresh fails' do
1400
+ described_class.cached_jwks { :cached_key_set }
1401
+ described_class.instance_variable_set(:@jwks_expires_at, Time.now.to_i - 1)
1402
+
1403
+ attempts = 0
1404
+ served = described_class.cached_jwks do
1405
+ attempts += 1
1406
+ raise SocketError, 'key endpoint unreachable'
1407
+ end
1408
+
1409
+ expect(served).to eq(:cached_key_set)
1410
+ expect(attempts).to eq(1)
1411
+ end
1412
+
1413
+ it 'raises rather than returning nothing when the first fetch fails' do
1414
+ expect { described_class.cached_jwks { raise SocketError, 'down at boot' } }
1415
+ .to raise_error(SocketError)
865
1416
  end
866
1417
  end
867
1418
 
@@ -884,6 +1435,12 @@ describe OmniAuth::Strategies::GoogleOauth2 do
884
1435
  stub.post('/oauth2/v3/tokeninfo', 'access_token=invalid_access_token') do
885
1436
  [400, { 'Content-Type' => 'application/json; charset=UTF-8' }, JSON.dump(error_description: 'Invalid Value')]
886
1437
  end
1438
+ stub.post('/oauth2/v3/tokeninfo', 'access_token=second_access_token') do
1439
+ [200, { 'Content-Type' => 'application/json; charset=UTF-8' }, JSON.dump(
1440
+ aud: 'another.apps.googleusercontent.com',
1441
+ scope: 'https://www.googleapis.com/auth/drive'
1442
+ )]
1443
+ end
887
1444
  end
888
1445
  end
889
1446
  end
@@ -911,6 +1468,18 @@ describe OmniAuth::Strategies::GoogleOauth2 do
911
1468
  subject.send(:verify_token, 'invalid_access_token')
912
1469
  end.to raise_error(OAuth2::Error)
913
1470
  end
1471
+
1472
+ # One request can ask about more than one token: verify_token inspects the
1473
+ # one from the request, while the credentials block later asks about the
1474
+ # token the code exchange returned.
1475
+ it 'looks each access token up on its own rather than reusing the first' do
1476
+ expect(subject.send(:token_info, 'valid_access_token')['scope']).to eq('profile email')
1477
+ expect(subject.send(:token_info, 'second_access_token')['scope']).to eq('https://www.googleapis.com/auth/drive')
1478
+ end
1479
+
1480
+ it 'caches each token so a repeated lookup does not re-query' do
1481
+ expect(subject.send(:token_info, 'valid_access_token')).to equal(subject.send(:token_info, 'valid_access_token'))
1482
+ end
914
1483
  end
915
1484
 
916
1485
  describe 'verify_hd' do