diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..0313a624 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,27 @@ +.test-template: &test + cache: + paths: + - vendor/ruby + before_script: + - gem install bundler --no-document + - bundle config set --local path 'vendor/ruby' + - bundle install -j $(nproc) --path vendor # Install dependencies into ./vendor/ruby + - ruby -v # Print out ruby version for debugging + script: + - bundle exec rake test + +rspec-2.5: + image: "ruby:2.5" + <<: *test + +rspec-2.6: + image: "ruby:2.6" + <<: *test + +rspec-2.7: + image: "ruby:2.7" + <<: *test + +rspec-3.0: + image: "ruby:3.0" + <<: *test diff --git a/.travis.yml b/.travis.yml index b15dfe38..a3c33d87 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,5 +4,6 @@ rvm: - 2.5 - 2.6 - 2.7 + - 3.0 - jruby-head - ruby-head diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4d5d75..e435df64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +# v0.9.1 (01.03.2022) + +- [Assume public key encryption unless HMAC is specified](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/19) + +# v0.9.0 (01.03.2022) + +- [Add support for ES[256|384|512|256K] algorithms](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/17) + +# v0.8.0 (07.16.2021) + +- [Add `jwt_secret_base64` option to support binary secrets](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/12) + +# v0.7.0 (07.16.2021) + +- [Add `jwt_secret` option to support Keycloak private key](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/10) + +# v0.6.0 (07.08.2021) + +- [Support verification of HS256-signed JWTs](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/8) + +# v0.5.0 (05.07.2021) + +- [Add email_verified field to info dict](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/7) +- [Simplify error handling for decoding individual keys](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/6) +- [Always convert client_signing_alg to be a symbol](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/5) + +# v0.4.0 (04.23.2021) + +- [Fetch key from JWKS URI if available](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/3) +- [Fix handling of JWT without key ID](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/2) +- [Add .gitlab-ci.yml and test with Ruby 3.0](https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect/-/merge_requests/1) + # v0.3.5 (07.06.2020) - bugfix: Info from decoded id_token is not exposed into `request.env['omniauth.auth']` [#61](https://github.com/m0n9oose/omniauth_openid_connect/pull/61) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..47ac4043 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +## Developer Certificate of Origin and License + +By contributing to GitLab B.V., you accept and agree to the following terms and +conditions for your present and future contributions submitted to GitLab B.V. +Except for the license granted herein to GitLab B.V. and recipients of software +distributed by GitLab B.V., you reserve all right, title, and interest in and to +your Contributions. + +All contributions are subject to the Developer Certificate of Origin and license set out at [docs.gitlab.com/ce/legal/developer_certificate_of_origin](https://docs.gitlab.com/ce/legal/developer_certificate_of_origin). + +_This notice should stay as the first item in the CONTRIBUTING.md file._ + +## Code of conduct + +As contributors and maintainers of this project, we pledge to respect all people +who contribute through reporting issues, posting feature requests, updating +documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of level of experience, gender, gender +identity and expression, sexual orientation, disability, personal appearance, +body size, race, ethnicity, age, or religion. + +Examples of unacceptable behavior by participants include the use of sexual +language or imagery, derogatory comments or personal attacks, trolling, public +or private harassment, insults, or other unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct. Project maintainers who do not follow the +Code of Conduct may be removed from the project team. + +This code of conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior can be +reported by emailing contact@gitlab.com. + +This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org), version 1.1.0, +available at [https://contributor-covenant.org/version/1/1/0/](https://contributor-covenant.org/version/1/1/0/). diff --git a/README.md b/README.md index d74ba19e..64d2cc6c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # OmniAuth::OpenIDConnect +This project was forked from +[m0n9oose/omniauth_openid_connect](https://github.com/m0n9oose/omniauth_openid_connect) +since a number of important bug fixes have not been merged in the past year. + Originally was [omniauth-openid-connect](https://github.com/jjbohn/omniauth-openid-connect) I've forked this repository and launch as separate gem because maintaining of original was dropped. @@ -10,7 +14,7 @@ I've forked this repository and launch as separate gem because maintaining of or Add this line to your application's Gemfile: - gem 'omniauth_openid_connect' + gem 'gitlab-omniauth-openid-connect', require: 'omniauth_openid_connect' And then execute: @@ -19,7 +23,7 @@ And then execute: Or install it yourself as: $ gem install omniauth_openid_connect - + ## Supported Ruby Versions OmniAuth::OpenIDConnect is tested under 2.4, 2.5, 2.6, 2.7 @@ -62,6 +66,8 @@ config.omniauth :openid_connect, { | post_logout_redirect_uri | The logout redirect uri to use per the [session management draft](https://openid.net/specs/openid-connect-session-1_0.html) | no | empty | https://myapp.com/logout/callback | | uid_field | The field of the user info response to be used as a unique id | no | 'sub' | "sub", "preferred_username" | | client_options | A hash of client options detailed in its own section | yes | | | +| jwt_secret | For HMAC with SHA2 (e.g. HS256) signing algorithms, specify the secret used to sign the JWT token. Defaults to the OAuth2 client secret if not specified. For secrets in binary, use `jwt_secret_base64`. | no | client_options.secret | "mysecret" | +| jwt_secret_base64 | For HMAC with SHA2 (e.g. HS256) signing algorithms, specify the base64-encoded secret used to sign the JWT token. Defaults to the OAuth2 client secret if not specified. `jwt_secret` takes precedence. | no | client_options.secret | "bXlzZWNyZXQ=\n" ### Client Config Options diff --git a/omniauth_openid_connect.gemspec b/gitlab-omniauth-openid-connect.gemspec similarity index 61% rename from omniauth_openid_connect.gemspec rename to gitlab-omniauth-openid-connect.gemspec index dc0be0e9..9231b1f7 100644 --- a/omniauth_openid_connect.gemspec +++ b/gitlab-omniauth-openid-connect.gemspec @@ -5,13 +5,13 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'omniauth/openid_connect/version' Gem::Specification.new do |spec| - spec.name = 'omniauth_openid_connect' + spec.name = 'gitlab-omniauth-openid-connect' spec.version = OmniAuth::OpenIDConnect::VERSION spec.authors = ['John Bohn', 'Ilya Shcherbinin'] spec.email = ['jjbohn@gmail.com', 'm0n9oose@gmail.com'] spec.summary = 'OpenID Connect Strategy for OmniAuth' spec.description = 'OpenID Connect Strategy for OmniAuth.' - spec.homepage = 'https://github.com/m0n9oose/omniauth_openid_connect' + spec.homepage = 'https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect' spec.license = 'MIT' spec.files = `git ls-files -z`.split("\x0") @@ -19,17 +19,17 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.add_dependency 'addressable', '~> 2.5' + spec.add_dependency 'addressable', '~> 2.7' spec.add_dependency 'omniauth', '~> 1.9' - spec.add_dependency 'openid_connect', '~> 1.1' + spec.add_dependency 'openid_connect', '~> 1.2' spec.add_development_dependency 'coveralls', '~> 0.8' - spec.add_development_dependency 'faker', '~> 1.6' + spec.add_development_dependency 'faker', '~> 2.17' spec.add_development_dependency 'guard', '~> 2.14' - spec.add_development_dependency 'guard-bundler', '~> 2.2' + spec.add_development_dependency 'guard-bundler', '~> 3.0' spec.add_development_dependency 'guard-minitest', '~> 2.4' - spec.add_development_dependency 'minitest', '~> 5.1' - spec.add_development_dependency 'mocha', '~> 1.7' - spec.add_development_dependency 'rake', '~> 12.0' - spec.add_development_dependency 'rubocop', '~> 0.63' - spec.add_development_dependency 'simplecov', '~> 0.12' + spec.add_development_dependency 'minitest', '~> 5.14' + spec.add_development_dependency 'mocha', '~> 1.12' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rubocop', '~> 1.12' + spec.add_development_dependency 'simplecov', '~> 0.16' end diff --git a/lib/omniauth/openid_connect/version.rb b/lib/omniauth/openid_connect/version.rb index 9265aa02..ddd8f5e1 100644 --- a/lib/omniauth/openid_connect/version.rb +++ b/lib/omniauth/openid_connect/version.rb @@ -2,6 +2,6 @@ module OmniAuth module OpenIDConnect - VERSION = '0.3.5' + VERSION = '0.9.1'.freeze end end diff --git a/lib/omniauth/strategies/openid_connect.rb b/lib/omniauth/strategies/openid_connect.rb index c7e86f70..74c43985 100644 --- a/lib/omniauth/strategies/openid_connect.rb +++ b/lib/omniauth/strategies/openid_connect.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'addressable/uri' +require 'base64' require 'timeout' require 'net/http' require 'open-uri' @@ -36,7 +37,9 @@ class OpenIDConnect option :issuer option :discovery, false - option :client_signing_alg + option :client_signing_alg # Deprecated since we detect what is used to sign the JWT + option :jwt_secret + option :jwt_secret_base64 option :client_jwk_signing_key option :client_x509_signing_key option :scope, [:openid] @@ -65,6 +68,7 @@ def uid { name: user_info.name, email: user_info.email, + email_verified: user_info.email_verified, nickname: user_info.preferred_username, first_name: user_info.given_name, last_name: user_info.family_name, @@ -177,13 +181,35 @@ def authorize_uri end def public_key - return config.jwks if options.discovery + @public_key ||= begin + if options.discovery + config.jwks + elsif configured_public_key + configured_public_key + elsif client_options.jwks_uri + fetch_key + end + end + end - key_or_secret + # Some OpenID providers use the OAuth2 client secret as the shared secret, but + # Keycloak uses a separate key that's stored inside the database. + def secret + options.jwt_secret || base64_decoded_jwt_secret || client_options.secret end private + def base64_decoded_jwt_secret + return unless options.jwt_secret_base64 + + Base64.decode64(options.jwt_secret_base64) + end + + def fetch_key + @fetch_key ||= parse_jwk_key(::OpenIDConnect.http_client.get_content(client_options.jwks_uri)) + end + def issuer resource = "#{ client_options.scheme }://#{ client_options.host }" resource = "#{ resource }:#{ client_options.port }" if client_options.port @@ -225,8 +251,62 @@ def access_token @access_token end + # Unlike ::OpenIDConnect::ResponseObject::IdToken.decode, this + # method splits the decoding and verification of JWT into two + # steps. First, we decode the JWT without verifying it to + # determine the algorithm used to sign. Then, we verify it using + # the appropriate public key (e.g. if algorithm is RS256) or + # shared secret (e.g. if algorithm is HS256). This works around a + # limitation in the openid_connect gem: + # https://github.com/nov/openid_connect/issues/61 def decode_id_token(id_token) - ::OpenIDConnect::ResponseObject::IdToken.decode(id_token, public_key) + decoded = JSON::JWT.decode(id_token, :skip_verification) + algorithm = decoded.algorithm.to_sym + + keyset = + case algorithm + when :HS256, :HS384, :HS512 + secret + else + public_key + end + + decoded.verify!(keyset) + ::OpenIDConnect::ResponseObject::IdToken.new(decoded) + rescue JSON::JWK::Set::KidNotFound + # If the JWT has a key ID (kid), then we know that the set of + # keys supplied doesn't contain the one we want, and we're + # done. However, if there is no kid, then we try each key + # individually to see if one works: + # https://github.com/nov/json-jwt/pull/92#issuecomment-824654949 + raise if decoded&.header&.key?('kid') + + decoded = decode_with_each_key!(id_token, keyset) + + raise unless decoded + + decoded + + end + + def decode!(id_token, key) + ::OpenIDConnect::ResponseObject::IdToken.decode(id_token, key) + end + + def decode_with_each_key!(id_token, keyset) + return unless keyset.is_a?(JSON::JWK::Set) + + keyset.each do |key| + begin + decoded = decode!(id_token, key) + rescue JSON::JWS::VerificationFailed, JSON::JWS::UnexpectedAlgorithm, JSON::JWS::UnknownAlgorithm + next + end + + return decoded if decoded + end + + nil end def client_options @@ -262,11 +342,8 @@ def session super end - def key_or_secret - case options.client_signing_alg - when :HS256, :HS384, :HS512 - client_options.secret - when :RS256, :RS384, :RS512 + def configured_public_key + @configured_public_key ||= begin if options.client_jwk_signing_key parse_jwk_key(options.client_jwk_signing_key) elsif options.client_x509_signing_key diff --git a/test/fixtures/id_token.txt b/test/fixtures/id_token.txt deleted file mode 100644 index 0afb3b9f..00000000 --- a/test/fixtures/id_token.txt +++ /dev/null @@ -1 +0,0 @@ -eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzcyI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZfV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5NzAKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6qJp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJNqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7TpdQyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoSK5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg diff --git a/test/lib/omniauth/strategies/openid_connect_test.rb b/test/lib/omniauth/strategies/openid_connect_test.rb index b72f6ce0..483b31cb 100644 --- a/test/lib/omniauth/strategies/openid_connect_test.rb +++ b/test/lib/omniauth/strategies/openid_connect_test.rb @@ -163,13 +163,12 @@ def test_uid def test_callback_phase(session = {}, params = {}) code = SecureRandom.hex(16) state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('code' => code, 'state' => state) request.stubs(:path_info).returns('') strategy.options.issuer = 'example.com' strategy.options.client_signing_alg = :RS256 - strategy.options.client_jwk_signing_key = File.read('test/fixtures/jwks.json') + strategy.options.client_jwk_signing_key = jwks.to_s strategy.options.response_type = 'code' strategy.unstub(:user_info) @@ -178,7 +177,7 @@ def test_callback_phase(session = {}, params = {}) access_token.stubs(:refresh_token) access_token.stubs(:expires_in) access_token.stubs(:scope) - access_token.stubs(:id_token).returns(File.read('test/fixtures/id_token.txt')) + access_token.stubs(:id_token).returns(jwt.to_s) client.expects(:access_token!).at_least_once.returns(access_token) access_token.expects(:userinfo!).returns(user_info) @@ -193,15 +192,13 @@ def test_callback_phase(session = {}, params = {}) end def test_callback_phase_with_id_token - code = SecureRandom.hex(16) state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) - request.stubs(:params).returns('id_token' => code, 'state' => state) + request.stubs(:params).returns('id_token' => jwt.to_s, 'state' => state) request.stubs(:path_info).returns('') strategy.options.issuer = 'example.com' strategy.options.client_signing_alg = :RS256 - strategy.options.client_jwk_signing_key = File.read('test/fixtures/jwks.json') + strategy.options.client_jwk_signing_key = jwks.to_json strategy.options.response_type = 'id_token' strategy.unstub(:user_info) @@ -210,7 +207,7 @@ def test_callback_phase_with_id_token access_token.stubs(:refresh_token) access_token.stubs(:expires_in) access_token.stubs(:scope) - access_token.stubs(:id_token).returns(File.read('test/fixtures/id_token.txt')) + access_token.stubs(:id_token).returns(jwt.to_s) id_token = stub('OpenIDConnect::ResponseObject::IdToken') id_token.stubs(:raw_attributes).returns('sub' => 'sub', 'name' => 'name', 'email' => 'email') @@ -222,13 +219,140 @@ def test_callback_phase_with_id_token strategy.callback_phase end + def test_callback_phase_with_id_token_no_kid + other_rsa_private = OpenSSL::PKey::RSA.generate(2048) + + key = JSON::JWK.new(private_key) + other_key = JSON::JWK.new(other_rsa_private) + state = SecureRandom.hex(16) + request.stubs(:params).returns('id_token' => jwt.to_s, 'state' => state) + request.stubs(:path_info).returns('') + + strategy.options.issuer = issuer + strategy.options.client_signing_alg = :RS256 + strategy.options.client_jwk_signing_key = { 'keys' => [other_key, key] }.to_json + strategy.options.response_type = 'id_token' + + strategy.unstub(:user_info) + strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce }) + strategy.callback_phase + end + + def test_callback_phase_with_id_token_with_kid + other_rsa_private = OpenSSL::PKey::RSA.generate(2048) + + key = JSON::JWK.new(private_key) + other_key = JSON::JWK.new(other_rsa_private) + state = SecureRandom.hex(16) + jwt_with_kid = JSON::JWT.new(payload).sign(key, :RS256) + request.stubs(:params).returns('id_token' => jwt_with_kid.to_s, 'state' => state) + request.stubs(:path_info).returns('') + + strategy.options.issuer = issuer + strategy.options.client_signing_alg = :RS256 + strategy.options.client_jwk_signing_key = { 'keys' => [other_key, key] }.to_json + strategy.options.response_type = 'id_token' + + strategy.unstub(:user_info) + strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce }) + strategy.callback_phase + end + + def test_callback_phase_with_id_token_with_kid_and_no_matching_kid + other_rsa_private = OpenSSL::PKey::RSA.generate(2048) + + key = JSON::JWK.new(private_key) + other_key = JSON::JWK.new(other_rsa_private) + state = SecureRandom.hex(16) + jwt_with_kid = JSON::JWT.new(payload).sign(key, :RS256) + request.stubs(:params).returns('id_token' => jwt_with_kid.to_s, 'state' => state) + request.stubs(:path_info).returns('') + + strategy.options.issuer = issuer + strategy.options.client_signing_alg = :RS256 + # We use private_key here instead of the wrapped key, which contains a kid + strategy.options.client_jwk_signing_key = { 'keys' => [other_key, private_key] }.to_json + strategy.options.response_type = 'id_token' + + strategy.unstub(:user_info) + strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce }) + + assert_raises JSON::JWK::Set::KidNotFound do + strategy.callback_phase + end + end + + def test_callback_phase_with_id_token_with_hs256 + state = SecureRandom.hex(16) + request.stubs(:params).returns('id_token' => jwt_with_hs256.to_s, 'state' => state) + request.stubs(:path_info).returns('') + + strategy.options.issuer = issuer + strategy.options.client_options.secret = hmac_secret + strategy.options.client_signing_alg = :HS256 + strategy.options.response_type = 'id_token' + + strategy.unstub(:user_info) + strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce }) + strategy.callback_phase + end + + def test_callback_phase_with_hs256_jwt_secret + state = SecureRandom.hex(16) + request.stubs(:params).returns('id_token' => jwt_with_hs256.to_s, 'state' => state) + request.stubs(:path_info).returns('') + + strategy.options.issuer = issuer + strategy.options.jwt_secret = hmac_secret + strategy.options.response_type = 'id_token' + + strategy.unstub(:user_info) + strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce }) + strategy.callback_phase + end + + def test_callback_phase_with_hs256_base64_jwt_secret + state = SecureRandom.hex(16) + request.stubs(:params).returns('id_token' => jwt_with_hs256.to_s, 'state' => state) + request.stubs(:path_info).returns('') + + strategy.options.issuer = issuer + strategy.options.jwt_secret_base64 = Base64.encode64(hmac_secret) + strategy.options.response_type = 'id_token' + + strategy.unstub(:user_info) + strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce }) + strategy.callback_phase + end + + def test_callback_phase_with_id_token_no_matching_key + rsa_private = OpenSSL::PKey::RSA.generate(2048) + other_rsa_private = OpenSSL::PKey::RSA.generate(2048) + + key = JSON::JWK.new(rsa_private) + other_key = JSON::JWK.new(other_rsa_private) + token = JSON::JWT.new(payload).sign(rsa_private, :RS256).to_s + state = SecureRandom.hex(16) + request.stubs(:params).returns('id_token' => token, 'state' => state) + request.stubs(:path_info).returns('') + + strategy.options.issuer = issuer + strategy.options.client_signing_alg = :RS256 + strategy.options.client_jwk_signing_key = { 'keys' => [other_key] }.to_json + strategy.options.response_type = 'id_token' + + strategy.unstub(:user_info) + strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce }) + + assert_raises JSON::JWK::Set::KidNotFound do + strategy.callback_phase + end + end + def test_callback_phase_with_discovery - code = SecureRandom.hex(16) state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) - jwks = JSON::JWK::Set.new(JSON.parse(File.read('test/fixtures/jwks.json'))['keys']) - request.stubs(:params).returns('code' => code, 'state' => state) + request.stubs(:params).returns('code' => jwt.to_s, 'state' => state) request.stubs(:path_info).returns('') strategy.options.client_options.host = 'example.com' @@ -243,7 +367,7 @@ def test_callback_phase_with_discovery config.stubs(:token_endpoint).returns('https://example.com/token') config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') config.stubs(:jwks_uri).returns('https://example.com/jwks') - config.stubs(:jwks).returns(jwks) + config.stubs(:jwks).returns(JSON::JWK::Set.new(jwks['keys'])) ::OpenIDConnect::Discovery::Provider::Config.stubs(:discover!).with('https://example.com/').returns(config) @@ -258,7 +382,7 @@ def test_callback_phase_with_discovery access_token.stubs(:refresh_token) access_token.stubs(:expires_in) access_token.stubs(:scope) - access_token.stubs(:id_token).returns(File.read('test/fixtures/id_token.txt')) + access_token.stubs(:id_token).returns(jwt.to_s) client.expects(:access_token!).at_least_once.returns(access_token) access_token.expects(:userinfo!).returns(user_info) @@ -266,9 +390,41 @@ def test_callback_phase_with_discovery strategy.callback_phase end + def test_callback_phase_with_jwks_uri + id_token = jwt.to_s + state = SecureRandom.hex(16) + request.stubs(:params).returns('id_token' => id_token, 'state' => state) + request.stubs(:path_info).returns('') + + strategy.options.issuer = 'example.com' + strategy.options.client_options.jwks_uri = 'https://jwks.example.com' + strategy.options.response_type = 'id_token' + + HTTPClient + .any_instance.stubs(:get_content) + .with(strategy.options.client_options.jwks_uri) + .returns(jwks.to_json) + + strategy.unstub(:user_info) + access_token = stub('OpenIDConnect::AccessToken') + access_token.stubs(:access_token) + access_token.stubs(:refresh_token) + access_token.stubs(:expires_in) + access_token.stubs(:scope) + access_token.stubs(:id_token).returns(id_token) + + id_token = stub('OpenIDConnect::ResponseObject::IdToken') + id_token.stubs(:raw_attributes).returns('sub' => 'sub', 'name' => 'name', 'email' => 'email') + id_token.stubs(:verify!).with(issuer: strategy.options.issuer, client_id: @identifier, nonce: nonce).returns(true) + ::OpenIDConnect::ResponseObject::IdToken.stubs(:decode).returns(id_token) + id_token.expects(:verify!) + + strategy.call!('rack.session' => { 'omniauth.state' => state, 'omniauth.nonce' => nonce }) + strategy.callback_phase + end + def test_callback_phase_with_error state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('error' => 'invalid_request') request.stubs(:path_info).returns('') @@ -280,7 +436,6 @@ def test_callback_phase_with_error def test_callback_phase_with_invalid_state code = SecureRandom.hex(16) state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('code' => code, 'state' => 'foobar') request.stubs(:path_info).returns('') @@ -291,7 +446,6 @@ def test_callback_phase_with_invalid_state def test_callback_phase_without_code state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('state' => state) request.stubs(:path_info).returns('') @@ -303,7 +457,6 @@ def test_callback_phase_without_code def test_callback_phase_without_id_token state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('state' => state) request.stubs(:path_info).returns('') strategy.options.response_type = 'id_token' @@ -316,7 +469,6 @@ def test_callback_phase_without_id_token def test_callback_phase_without_id_token_symbol state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('state' => state) request.stubs(:path_info).returns('') strategy.options.response_type = :id_token @@ -330,7 +482,6 @@ def test_callback_phase_without_id_token_symbol def test_callback_phase_with_timeout code = SecureRandom.hex(16) state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('code' => code, 'state' => state) request.stubs(:path_info).returns('') @@ -350,7 +501,6 @@ def test_callback_phase_with_timeout def test_callback_phase_with_etimeout code = SecureRandom.hex(16) state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('code' => code, 'state' => state) request.stubs(:path_info).returns('') @@ -370,7 +520,6 @@ def test_callback_phase_with_etimeout def test_callback_phase_with_socket_error code = SecureRandom.hex(16) state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('code' => code, 'state' => state) request.stubs(:path_info).returns('') @@ -390,7 +539,6 @@ def test_callback_phase_with_socket_error def test_callback_phase_with_rack_oauth2_client_error code = SecureRandom.hex(16) state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) request.stubs(:params).returns('code' => code, 'state' => state) request.stubs(:path_info).returns('') @@ -411,6 +559,7 @@ def test_info info = strategy.info assert_equal user_info.name, info[:name] assert_equal user_info.email, info[:email] + assert_equal user_info.email_verified, info[:email_verified] assert_equal user_info.preferred_username, info[:nickname] assert_equal user_info.given_name, info[:first_name] assert_equal user_info.family_name, info[:last_name] @@ -427,7 +576,7 @@ def test_extra def test_credentials strategy.options.issuer = 'example.com' strategy.options.client_signing_alg = :RS256 - strategy.options.client_jwk_signing_key = File.read('test/fixtures/jwks.json') + strategy.options.client_jwk_signing_key = jwks.to_json id_token = stub('OpenIDConnect::ResponseObject::IdToken') id_token.stubs(:verify!).returns(true) @@ -438,7 +587,7 @@ def test_credentials access_token.stubs(:refresh_token).returns(SecureRandom.hex(16)) access_token.stubs(:expires_in).returns(Time.now) access_token.stubs(:scope).returns('openidconnect') - access_token.stubs(:id_token).returns(File.read('test/fixtures/id_token.txt')) + access_token.stubs(:id_token).returns(jwt.to_s) client.expects(:access_token!).returns(access_token) access_token.expects(:refresh_token).returns(access_token.refresh_token) @@ -519,18 +668,17 @@ def test_dynamic_state def test_option_client_auth_method state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) opts = strategy.options.client_options opts[:host] = 'foobar.com' strategy.options.issuer = 'foobar.com' strategy.options.client_auth_method = :not_basic strategy.options.client_signing_alg = :RS256 - strategy.options.client_jwk_signing_key = File.read('test/fixtures/jwks.json') + strategy.options.client_jwk_signing_key = jwks.to_json json_response = { access_token: 'test_access_token', - id_token: File.read('test/fixtures/id_token.txt'), + id_token: jwt.to_s, token_type: 'Bearer', }.to_json success = Struct.new(:status, :body).new(200, json_response) @@ -553,16 +701,14 @@ def test_option_client_auth_method def test_public_key_with_jwks strategy.options.client_signing_alg = :RS256 - strategy.options.client_jwk_signing_key = File.read('./test/fixtures/jwks.json') + strategy.options.client_jwk_signing_key = jwks.to_json assert_equal JSON::JWK::Set, strategy.public_key.class end def test_public_key_with_jwk strategy.options.client_signing_alg = :RS256 - jwks_str = File.read('./test/fixtures/jwks.json') - jwks = JSON.parse(jwks_str) - jwk = jwks['keys'].first + jwk = jwks[:keys].first strategy.options.client_jwk_signing_key = jwk.to_json assert_equal JSON::JWK, strategy.public_key.class @@ -574,30 +720,20 @@ def test_public_key_with_x509 assert_equal OpenSSL::PKey::RSA, strategy.public_key.class end - def test_public_key_with_hmac + def test_secret_with_hmac strategy.options.client_options.secret = 'secret' strategy.options.client_signing_alg = :HS256 - assert_equal strategy.options.client_options.secret, strategy.public_key + assert_equal strategy.options.client_options.secret, strategy.secret end def test_id_token_auth_hash state = SecureRandom.hex(16) - nonce = SecureRandom.hex(16) strategy.options.response_type = 'id_token' strategy.options.issuer = 'example.com' id_token = stub('OpenIDConnect::ResponseObject::IdToken') id_token.stubs(:verify!).returns(true) - id_token.stubs(:raw_attributes, :to_h).returns( - { - "iss": "http://server.example.com", - "sub": "248289761001", - "aud": "s6BhdRkqt3", - "nonce": "n-0S6_WzA2Mj", - "exp": 1311281970, - "iat": 1311280970, - } - ) + id_token.stubs(:raw_attributes, :to_h).returns(payload) request.stubs(:params).returns('state' => state, 'nounce' => nonce, 'id_token' => id_token) request.stubs(:path_info).returns('') diff --git a/test/strategy_test_case.rb b/test/strategy_test_case.rb index 6e7b15ef..f073f043 100644 --- a/test/strategy_test_case.rb +++ b/test/strategy_test_case.rb @@ -3,22 +3,60 @@ class DummyApp def call(env); end end - attr_accessor :identifier, :secret + attr_accessor :identifier, :secret, :issuer, :nonce def setup @identifier = '1234' @secret = '1234asdgat3' + @issuer = "https://server.example.com" + @nonce = SecureRandom.hex(16) end def client strategy.client end + def payload + { + "iss": issuer, + "aud": identifier, + "sub": "248289761001", + "nonce": nonce, + "exp": Time.now.to_i + 1000, + "iat": Time.now.to_i + 1000 + } + end + + def private_key + @private_key ||= OpenSSL::PKey::RSA.generate(512) + end + + def jwt + @jwt ||= JSON::JWT.new(payload).sign(private_key, :RS256) + end + + def hmac_secret + @hmac_secret ||= SecureRandom.hex(16) + end + + def jwt_with_hs256 + @jwt_with_hs256 ||= JSON::JWT.new(payload).sign(hmac_secret, :HS256) + end + + def jwks + @jwks ||= begin + key = JSON::JWK.new(private_key) + keyset = JSON::JWK::Set.new(key) + { keys: keyset } + end + end + def user_info @user_info ||= OpenIDConnect::ResponseObject::UserInfo.new( sub: SecureRandom.hex(16), name: Faker::Name.name, email: Faker::Internet.email, + email_verified: Faker::Boolean.boolean, nickname: Faker::Name.first_name, preferred_username: Faker::Internet.user_name, given_name: Faker::Name.first_name,