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.
- checksums.yaml +4 -4
- data/.github/workflows/rubocop.yml +1 -1
- data/CHANGELOG.md +166 -4
- data/README.md +77 -45
- data/examples/config.ru +42 -23
- data/lib/omniauth/google_oauth2/version.rb +1 -1
- data/lib/omniauth/strategies/google_oauth2.rb +248 -21
- data/omniauth-google-oauth2.gemspec +1 -1
- data/spec/omniauth/strategies/google_oauth2_spec.rb +588 -19
- data/spec/spec_helper.rb +9 -0
- metadata +7 -1
|
@@ -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)
|
|
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)
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|