omniauth-google-oauth2 1.2.1 → 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
@@ -341,6 +344,23 @@ describe OmniAuth::Strategies::GoogleOauth2 do
341
344
  end
342
345
  end
343
346
 
347
+ describe '#uid' do
348
+ let(:client) do
349
+ OAuth2::Client.new('abc', 'def') do |builder|
350
+ builder.request :url_encoded
351
+ builder.adapter :test do |stub|
352
+ stub.get('/oauth2/v3/userinfo') { [200, { 'content-type' => 'application/json' }, '{"sub": "12345"}'] }
353
+ end
354
+ end
355
+ end
356
+ let(:access_token) { OAuth2::AccessToken.from_hash(client, { 'access_token' => 'a' }) }
357
+ before { allow(subject).to receive(:access_token).and_return(access_token) }
358
+
359
+ it 'should return the sub from raw_info as uid' do
360
+ expect(subject.uid).to eq('12345')
361
+ end
362
+ end
363
+
344
364
  describe '#info' do
345
365
  let(:client) do
346
366
  OAuth2::Client.new('abc', 'def') do |builder|
@@ -425,6 +445,7 @@ describe OmniAuth::Strategies::GoogleOauth2 do
425
445
  {
426
446
  'abc' => 'xyz',
427
447
  'exp' => Time.now.to_i + 3600,
448
+ 'sub' => '12345',
428
449
  'nbf' => Time.now.to_i - 60,
429
450
  'iat' => Time.now.to_i,
430
451
  'aud' => 'appid',
@@ -432,7 +453,9 @@ describe OmniAuth::Strategies::GoogleOauth2 do
432
453
  }
433
454
  end
434
455
  let(:id_token) { JWT.encode(token_info, 'secret') }
435
- 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
436
459
 
437
460
  it 'should include id_token when set on the access_token' do
438
461
  expect(subject.extra).to include(id_token: id_token)
@@ -451,6 +474,14 @@ describe OmniAuth::Strategies::GoogleOauth2 do
451
474
  it 'should include id_info when id_token is set on the access_token by default' do
452
475
  expect(subject.extra).to include(id_info: token_info)
453
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
454
485
  end
455
486
  end
456
487
 
@@ -462,6 +493,7 @@ describe OmniAuth::Strategies::GoogleOauth2 do
462
493
  {
463
494
  'abc' => 'xyz',
464
495
  'exp' => Time.now.to_i + 3600,
496
+ 'sub' => '12345',
465
497
  'nbf' => Time.now.to_i - 60,
466
498
  'iat' => Time.now.to_i,
467
499
  'aud' => 'appid',
@@ -469,16 +501,71 @@ describe OmniAuth::Strategies::GoogleOauth2 do
469
501
  }
470
502
  end
471
503
  let(:id_token) { JWT.encode(token_info, 'secret') }
472
- 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
473
507
 
474
508
  it 'raises JWT::InvalidIssuerError' do
475
509
  expect { subject.extra }.to raise_error(JWT::InvalidIssuerError)
476
510
  end
477
511
  end
478
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
+
479
567
  context 'when the access token is empty or nil' do
480
568
  let(:access_token) { OAuth2::AccessToken.new(client, nil, { 'refresh_token' => 'foo' }) }
481
- before { allow(subject.extra).to receive(:access_token).and_return(access_token) }
482
569
 
483
570
  it 'should not include id_token' do
484
571
  expect(subject.extra).not_to have_key(:id_token)
@@ -488,6 +575,14 @@ describe OmniAuth::Strategies::GoogleOauth2 do
488
575
  expect(subject.extra).not_to have_key(:id_info)
489
576
  end
490
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
491
586
  end
492
587
 
493
588
  describe 'raw_info' do
@@ -495,6 +590,7 @@ describe OmniAuth::Strategies::GoogleOauth2 do
495
590
  {
496
591
  'abc' => 'xyz',
497
592
  'exp' => Time.now.to_i + 3600,
593
+ 'sub' => '12345',
498
594
  'nbf' => Time.now.to_i - 60,
499
595
  'iat' => Time.now.to_i,
500
596
  'aud' => 'appid',
@@ -687,7 +783,40 @@ describe OmniAuth::Strategies::GoogleOauth2 do
687
783
  end
688
784
  end
689
785
 
786
+ describe 'strip_unnecessary_query_parameters' do
787
+ it 'should return nil when query_parameters is nil' do
788
+ expect(subject.send(:strip_unnecessary_query_parameters, nil)).to be_nil
789
+ end
790
+
791
+ it 'should return nil when sz is the only parameter' do
792
+ expect(subject.send(:strip_unnecessary_query_parameters, 'sz=50')).to be_nil
793
+ end
794
+
795
+ it 'should strip sz and return remaining parameters' do
796
+ expect(subject.send(:strip_unnecessary_query_parameters, 'sz=50&hello=true&life=42')).to eq('hello=true&life=42')
797
+ end
798
+
799
+ it 'should return all parameters when sz is not present' do
800
+ expect(subject.send(:strip_unnecessary_query_parameters, 'hello=true&life=42')).to eq('hello=true&life=42')
801
+ end
802
+ end
803
+
690
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
+
691
820
  it 'should use a hybrid authorization request_uri if this is an AJAX request with a code parameter' do
692
821
  allow(request).to receive(:xhr?).and_return(true)
693
822
  allow(request).to receive(:params).and_return('code' => 'valid_code')
@@ -696,9 +825,8 @@ describe OmniAuth::Strategies::GoogleOauth2 do
696
825
  auth_code = double(:auth_code)
697
826
  allow(client).to receive(:auth_code).and_return(auth_code)
698
827
  expect(subject).to receive(:client).and_return(client)
699
- 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)
700
829
 
701
- expect(subject).not_to receive(:orig_build_access_token)
702
830
  subject.build_access_token
703
831
  end
704
832
 
@@ -710,9 +838,8 @@ describe OmniAuth::Strategies::GoogleOauth2 do
710
838
  auth_code = double(:auth_code)
711
839
  allow(client).to receive(:auth_code).and_return(auth_code)
712
840
  expect(subject).to receive(:client).and_return(client)
713
- 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)
714
842
 
715
- expect(subject).not_to receive(:orig_build_access_token)
716
843
  subject.build_access_token
717
844
  end
718
845
 
@@ -724,13 +851,12 @@ describe OmniAuth::Strategies::GoogleOauth2 do
724
851
  auth_code = double(:auth_code)
725
852
  allow(client).to receive(:auth_code).and_return(auth_code)
726
853
  expect(subject).to receive(:client).and_return(client)
727
- 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)
728
855
 
729
- expect(subject).not_to receive(:orig_build_access_token)
730
856
  subject.build_access_token
731
857
  end
732
858
 
733
- 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
734
860
  client = OAuth2::Client.new('abc', 'def') do |builder|
735
861
  builder.request :url_encoded
736
862
  builder.adapter :test do |stub|
@@ -739,18 +865,29 @@ describe OmniAuth::Strategies::GoogleOauth2 do
739
865
  end
740
866
 
741
867
  allow(request).to receive(:xhr?).and_return(false)
742
- 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
+ )
743
874
  expect(subject).to receive(:verify_token).with('valid_access_token').and_return true
744
875
  expect(subject).to receive(:client).and_return(client)
876
+ allow(subject).to receive(:warn)
745
877
 
746
878
  token = subject.build_access_token
747
- expect(token).to be_instance_of(::OAuth2::AccessToken)
879
+ expect(token).to be_instance_of(OAuth2::AccessToken)
748
880
  expect(token.token).to eq('valid_access_token')
749
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
750
885
  end
751
886
 
752
887
  it 'reads the code from a json request body' do
753
- 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)
754
891
  client = double(:client)
755
892
  auth_code = double(:auth_code)
756
893
 
@@ -760,9 +897,12 @@ describe OmniAuth::Strategies::GoogleOauth2 do
760
897
  allow(client).to receive(:auth_code).and_return(auth_code)
761
898
  expect(subject).to receive(:client).and_return(client)
762
899
 
763
- 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)
764
901
 
765
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)
766
906
  end
767
907
 
768
908
  it 'reads the redirect uri from a json request body' do
@@ -776,13 +916,18 @@ describe OmniAuth::Strategies::GoogleOauth2 do
776
916
  allow(client).to receive(:auth_code).and_return(auth_code)
777
917
  expect(subject).to receive(:client).and_return(client)
778
918
 
779
- 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)
780
920
 
781
921
  subject.build_access_token
782
922
  end
783
923
 
784
- it 'reads the access token from a json request body' do
785
- 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
+ ))
786
931
  client = OAuth2::Client.new('abc', 'def') do |builder|
787
932
  builder.request :url_encoded
788
933
  builder.adapter :test do |stub|
@@ -794,13 +939,60 @@ describe OmniAuth::Strategies::GoogleOauth2 do
794
939
  allow(request).to receive(:content_type).and_return('application/json')
795
940
  allow(request).to receive(:body).and_return(body)
796
941
  expect(subject).to receive(:client).and_return(client)
942
+ allow(subject).to receive(:warn)
797
943
 
798
944
  expect(subject).to receive(:verify_token).with('valid_access_token').and_return true
799
945
 
800
946
  token = subject.build_access_token
801
- expect(token).to be_instance_of(::OAuth2::AccessToken)
947
+ expect(token).to be_instance_of(OAuth2::AccessToken)
802
948
  expect(token.token).to eq('valid_access_token')
803
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
953
+ end
954
+
955
+ it 'should handle a malformed json request body gracefully' do
956
+ payload = 'not valid json{{{'
957
+ body = non_rewindable_input(payload)
958
+
959
+ allow(request).to receive(:xhr?).and_return(false)
960
+ allow(request).to receive(:params).and_return({})
961
+ allow(request).to receive(:content_type).and_return('application/json')
962
+ allow(request).to receive(:body).and_return(body)
963
+
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
804
996
  end
805
997
 
806
998
  it 'should use callback_url without query_string if this is not an AJAX request' do
@@ -814,8 +1006,413 @@ describe OmniAuth::Strategies::GoogleOauth2 do
814
1006
  allow(subject).to receive(:callback_url).and_return('redirect_uri_without_query_string')
815
1007
 
816
1008
  expect(subject).to receive(:client).and_return(client)
817
- 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 }
818
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
+
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)
819
1416
  end
820
1417
  end
821
1418
 
@@ -838,6 +1435,12 @@ describe OmniAuth::Strategies::GoogleOauth2 do
838
1435
  stub.post('/oauth2/v3/tokeninfo', 'access_token=invalid_access_token') do
839
1436
  [400, { 'Content-Type' => 'application/json; charset=UTF-8' }, JSON.dump(error_description: 'Invalid Value')]
840
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
841
1444
  end
842
1445
  end
843
1446
  end
@@ -856,11 +1459,27 @@ describe OmniAuth::Strategies::GoogleOauth2 do
856
1459
  expect(subject.send(:verify_token, 'valid_access_token')).to eq(false)
857
1460
  end
858
1461
 
1462
+ it 'should return false if access_token is nil' do
1463
+ expect(subject.send(:verify_token, nil)).to eq(false)
1464
+ end
1465
+
859
1466
  it 'should raise error if access_token is invalid' do
860
1467
  expect do
861
1468
  subject.send(:verify_token, 'invalid_access_token')
862
1469
  end.to raise_error(OAuth2::Error)
863
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
864
1483
  end
865
1484
 
866
1485
  describe 'verify_hd' do
@@ -945,5 +1564,10 @@ describe OmniAuth::Strategies::GoogleOauth2 do
945
1564
  subject.send(:verify_hd, access_token)
946
1565
  end.to raise_error(OmniAuth::Strategies::GoogleOauth2::CallbackError)
947
1566
  end
1567
+
1568
+ it 'should verify hd if options hd is set to wildcard *' do
1569
+ subject.options.hd = '*'
1570
+ expect(subject.send(:verify_hd, access_token)).to eq(true)
1571
+ end
948
1572
  end
949
1573
  end