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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +5 -4
- data/.github/workflows/rubocop.yml +19 -0
- data/.rubocop.yml +10 -3
- data/CHANGELOG.md +180 -1
- data/Gemfile +2 -0
- data/README.md +148 -127
- data/_config.yml +3 -0
- data/examples/Gemfile +1 -0
- data/examples/config.ru +96 -62
- data/examples/omni_auth.rb +7 -15
- data/lib/omniauth/google_oauth2/version.rb +1 -1
- data/lib/omniauth/strategies/google_oauth2.rb +251 -27
- data/omniauth-google-oauth2.gemspec +7 -8
- data/spec/omniauth/strategies/google_oauth2_spec.rb +643 -19
- data/spec/spec_helper.rb +9 -0
- metadata +16 -26
- data/spec/rubocop_spec.rb +0 -9
|
@@ -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)
|
|
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)
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|