diff --git a/CHANGELOG.md b/CHANGELOG.md index 08891148..e6209b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## HEAD +* Add Apple Provider [#344](https://github.com/Sorcery/sorcery/pull/344) ## 0.16.5 * Raise ArgumentError when calling change_password! with blank password [#333](https://github.com/Sorcery/sorcery/pull/333) diff --git a/lib/generators/sorcery/templates/initializer.rb b/lib/generators/sorcery/templates/initializer.rb index c4fe62f4..dca7b3a2 100644 --- a/lib/generators/sorcery/templates/initializer.rb +++ b/lib/generators/sorcery/templates/initializer.rb @@ -226,7 +226,6 @@ # config.line.bot_prompt = "normal" # config.line.user_info_mapping = {name: 'displayName'} - # For information about Discord API # https://discordapp.com/developers/docs/topics/oauth2 # config.discord.key = "xxxxxx" @@ -241,6 +240,17 @@ # config.battlenet.secret = "xxxxxx" # config.battlenet.callback_url = "http://localhost:3000/oauth/callback?provider=battlenet" # config.battlenet.scope = "openid" + + # For information about Sign in with Apple visit: + # https://developer.apple.com/sign-in-with-apple/ + # config.apple.key = "com.example.de" #Should be your apple service bundle id (https://developer.apple.com/account/resources/identifiers/add/bundleId -> AppID) + # config.apple.team_id = "xxxxxx" #App ID Prefix + # config.apple.key_id = #Create a new auth key (https://developer.apple.com/account/resources/authkeys/add), and attach it to your primary AppID + # config.apple.pem = #Received when creating a new auth key + # config.apple.callback_url = "http://localhost:3000/oauth/callback?provider=apple" # allow list for domains should be entered when creating the service keys: (https://developer.apple.com/account/resources/identifiers/add/bundleId -> ServiceID) + # config.apple.verify_payload = true/false # Set to true, so the payload retrieved by apple is verified + # config.apple.user_info_mapping = {email: 'email'} + # --- user config --- config.user_config do |user| # -- core -- diff --git a/lib/sorcery/controller/submodules/external.rb b/lib/sorcery/controller/submodules/external.rb index 019a67a4..02c8a769 100644 --- a/lib/sorcery/controller/submodules/external.rb +++ b/lib/sorcery/controller/submodules/external.rb @@ -28,6 +28,7 @@ def self.included(base) require 'sorcery/providers/line' require 'sorcery/providers/discord' require 'sorcery/providers/battlenet' + require 'sorcery/providers/apple' Config.module_eval do class << self diff --git a/lib/sorcery/providers/apple.rb b/lib/sorcery/providers/apple.rb new file mode 100644 index 00000000..467e4b3b --- /dev/null +++ b/lib/sorcery/providers/apple.rb @@ -0,0 +1,153 @@ +require 'jwt' + +module Sorcery + module Providers + # This class adds support for OAuth with apple.com. + # + # config.apple.key = + # config.apple.team_id = + # config.apple.key_id = + # config.apple.pem = + # config.apple.verify_payload = + # ... + # + class Apple < Base + include Protocols::Oauth2 + + attr_accessor :auth_url, :token_url, :keys_url, :key, :team_id, :key_id, :pem, :verify_payload, :site, :user_info + + def initialize + super + + @site = 'https://appleid.apple.com' + @auth_url = '/auth/authorize' + @token_url = '/auth/token' + @keys_url = '/auth/keys' + @scope = 'name email' + end + + def get_user_hash(access_token) + # The actual user information should be obtained from the id_token + decoded_id_token = decode_id_token(access_token) + + verify_claims!(decoded_id_token) + + auth_hash(access_token).tap do |h| + h[:user_info] = decoded_id_token.merge(@user_info) + h[:uid] = decoded_id_token['sub'] + end + end + + def login_url(params, session) + @secret = client_secret + params[:scope] ||= 'name email' + params[:nonce] = new_nonce(session) + params[:response_mode] = 'form_post' + authorize_url(authorize_url: auth_url, connection_opts: { params: params }) + end + + def process_callback(params, _session) + args = {}.tap do |a| + a[:code] = params[:code] if params[:code] + a[:key] = key + a[:client_secret] = client_secret + end + + @user_info = JSON.parse(params[:user] || '{}') + + get_access_token(args, token_url: token_url, token_method: :post) + end + + private + + def new_nonce(session) + session['sorcery.apple.nonce'] = SecureRandom.urlsafe_base64(16) + end + + def stored_nonce + session.delete('sorcery.apple.nonce') + end + + def decode_id_token(access_token) + id_token = access_token.params['id_token'] + + if verify_payload + _, decoded_header = JWT.decode(id_token, nil, false) + kid = decoded_header['kid'] + + keys_response = access_token.get(keys_url) + json_response = JSON.parse(keys_response.body) + + matching_key = find_key_by_kid(json_response['keys'], kid) + + raise 'No matching key found' unless matching_key + + jwk_key = JWT::JWK.import(matching_key) + public_key = jwk_key.keypair + + verified_payload, = JWT.decode(id_token, public_key, true, { algorithm: matching_key['alg'] }) + + verified_payload + else + payload, = JWT.decode(id_token, nil, false) + + payload + end + end + + def find_key_by_kid(keys, kid) + keys.find { |key| key['kid'] == kid } + end + + def client_secret + JWT.encode({ + iss: team_id, + aud: site, + sub: key, + kid: key_id, + iat: Time.now.to_i, + exp: (Time.now + 60).to_i + }, private_key, 'ES256') + end + + def private_key + ::OpenSSL::PKey::EC.new(pem) + end + + def verify_claims!(id_token) + verify_iss!(id_token) + verify_aud!(id_token) + verify_iat!(id_token) + verify_exp!(id_token) + verify_nonce!(id_token) if id_token[:nonce_supported] + end + + def verify_iss!(id_token) + invalid_claim! :iss unless id_token['iss'] == site + end + + def verify_aud!(id_token) + invalid_claim! :aud unless id_token['aud'] == key + end + + def verify_iat!(id_token) + invalid_claim! :iat unless id_token['iat'] <= Time.now.to_i + end + + def verify_exp!(id_token) + invalid_claim! :exp unless id_token['exp'] >= Time.now.to_i + end + + def verify_nonce!(id_token) + invalid_claim! :nonce unless id_token['nonce'] && id_token['nonce'] == stored_nonce + end + + def invalid_claim!(claim) + raise InvalidClaim, "#{claim} invalid" + end + end + + class InvalidClaim < StandardError + end + end +end diff --git a/sorcery.gemspec b/sorcery.gemspec index 44e76074..d91cf9c6 100644 --- a/sorcery.gemspec +++ b/sorcery.gemspec @@ -34,6 +34,7 @@ Gem::Specification.new do |s| s.required_ruby_version = '>= 2.4.9' s.add_dependency 'bcrypt', '~> 3.1' + s.add_dependency 'jwt', '~> 2.7' s.add_dependency 'oauth', '>= 0.6' s.add_dependency 'oauth2', '~> 2.0' diff --git a/spec/controllers/controller_oauth2_spec.rb b/spec/controllers/controller_oauth2_spec.rb index 0732c3b4..4e402bab 100644 --- a/spec/controllers/controller_oauth2_spec.rb +++ b/spec/controllers/controller_oauth2_spec.rb @@ -164,13 +164,16 @@ expect(flash[:notice]).to eq 'Success!' end - %i[github google liveid vk salesforce paypal slack wechat microsoft instagram auth0 discord battlenet].each do |provider| + %i[github google liveid vk salesforce paypal slack wechat microsoft instagram auth0 discord battlenet apple].each do |provider| describe "with #{provider}" do it 'login_at redirects correctly' do get :"login_at_test_#{provider}" + # get nonce from session if provider is apple for provider_url comparison + apple_nonce = provider == :apple ? session['sorcery.apple.nonce'] : nil + expect(response).to be_a_redirect - expect(response).to redirect_to(provider_url(provider)) + expect(response).to redirect_to(provider_url(provider, apple_nonce)) end it "'login_from' logins if user exists" do @@ -228,6 +231,7 @@ line discord battlenet + apple ] ) @@ -278,6 +282,12 @@ sorcery_controller_external_property_set(:battlenet, :key, '4c43d4862c774ca5bbde89873bf0d338') sorcery_controller_external_property_set(:battlenet, :secret, 'TxY7IwKOykACd8kUxPyVGTqBs44UBDdX') sorcery_controller_external_property_set(:battlenet, :callback_url, 'http://blabla.com') + sorcery_controller_external_property_set(:apple, :key, 'de.foo.bar') + sorcery_controller_external_property_set(:apple, :team_id, 'XpbeSdCoaKSmQGSeokz5qcUATClRW5u08QWNfv71N8') + sorcery_controller_external_property_set(:apple, :key_id, 'XpbeSdCoaKSmQGSeokz5qcUATClRW5u08QWNfv71N8') + sorcery_controller_external_property_set(:apple, :pem, "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBpOIbsjNWeKNsFLGCWa4ee0IJXQHuV81dQnImiTSHAEoAoGCCqGSM49\nAwEHoUQDQgAEQXmlbSpK0mbeU6DgnkllnL3/3so10T9EW/luSO2k3IFGnbrcDu2X\nByrwFUt+DO9epIjS4Azb1T4rd7HxVBZ7Lg==\n-----END EC PRIVATE KEY-----\n") + sorcery_controller_external_property_set(:apple, :callback_url, 'http://blabla.com') + sorcery_controller_external_property_set(:apple, :verify_payload, true) end after(:each) do @@ -300,7 +310,7 @@ expect(ActionMailer::Base.deliveries.size).to eq old_size end - %i[github google liveid vk salesforce paypal wechat microsoft instagram auth0 discord battlenet].each do |provider| + %i[github google liveid vk salesforce paypal wechat microsoft instagram auth0 discord battlenet apple].each do |provider| it "does not send activation email to external users (#{provider})" do old_size = ActionMailer::Base.deliveries.size create_new_external_user provider @@ -324,7 +334,7 @@ sorcery_reload!(%i[activity_logging external]) end - %w[facebook github google liveid vk salesforce slack discord battlenet].each do |provider| + %w[facebook github google liveid vk salesforce slack discord battlenet apple].each do |provider| context "when #{provider}" do before(:each) do sorcery_controller_property_set(:register_login_time, true) @@ -363,7 +373,7 @@ let(:user) { double('user', id: 42) } - %w[facebook github google liveid vk salesforce slack discord battlenet].each do |provider| + %w[facebook github google liveid vk salesforce slack discord battlenet apple].each do |provider| context "when #{provider}" do before(:each) do sorcery_model_property_set(:authentications_class, Authentication) @@ -473,12 +483,52 @@ def stub_all_oauth2_requests! }.to_json } allow(access_token).to receive(:get) { response } + apple_response = double(OAuth2::Response) + allow(apple_response).to receive(:body) { apple_jwk_response.to_json } + allow(access_token).to receive(:get).with('/auth/keys') { apple_response } allow(access_token).to receive(:token) { '187041a618229fdaf16613e96e1caabc1e86e46bbfad228de41520e63fe45873684c365a14417289599f3' } - # access_token params for VK auth - allow(access_token).to receive(:params) { { 'user_id' => '100500', 'email' => 'nbenari@gmail.com' } } + # access_token params for VK auth and additionally 'id_token' for apple + allow(access_token).to receive(:params) { { 'user_id' => '100500', 'email' => 'nbenari@gmail.com', 'id_token' => apple_id_token } } allow_any_instance_of(OAuth2::Strategy::AuthCode).to receive(:get_token) { access_token } end + def apple_id_token + payload = { + "iss": "https://appleid.apple.com", + "aud": "de.foo.bar", + "exp": Time.now.to_i + 60, + "iat": Time.now.to_i, + "sub": "123", + "nonce": "foo", + "at_hash": "foo", + "email": "foo@example.com", + "email_verified": "true", + "auth_time": 1_681_987_625, + "nonce_supported": true + } + + header = { + "kid": "foo", + "alg": "ES256" + } + + JWT.encode(payload, apple_mock_private_key, 'ES256', header) + end + + def apple_mock_private_key + key = "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBYG8ZQt41JtTYkvq5U7EzOWU9MM3hUBYBOzwQo/A9uGoAoGCCqGSM49\nAwEHoUQDQgAEt15yIMhHBH+PbvdGgVTxfMyoT5RntvUaIOlYtIg8SXHnG709us1y\n2bz9bVl4ZceRaINV4Vxbj236l1kvjYEtZw==\n-----END EC PRIVATE KEY-----\n" + + ::OpenSSL::PKey::EC.new(key) + end + + def apple_jwk_response + optional_parameters = { kid: 'foo', use: 'sig', alg: 'ES256' } + + jwk = JWT::JWK.new(apple_mock_private_key, optional_parameters) + + { 'keys' => [jwk.as_json['parameters']] } + end + def set_external_property sorcery_controller_property_set( :external_providers, @@ -498,6 +548,7 @@ def set_external_property line discord battlenet + apple ] ) sorcery_controller_external_property_set(:facebook, :key, 'eYVNBjBDi33aa9GkA3w') @@ -546,9 +597,15 @@ def set_external_property sorcery_controller_external_property_set(:battlenet, :key, '4c43d4862c774ca5bbde89873bf0d338') sorcery_controller_external_property_set(:battlenet, :secret, 'TxY7IwKOykACd8kUxPyVGTqBs44UBDdX') sorcery_controller_external_property_set(:battlenet, :callback_url, 'http://blabla.com') + sorcery_controller_external_property_set(:apple, :key, 'de.foo.bar') + sorcery_controller_external_property_set(:apple, :team_id, 'XpbeSdCoaKSmQGSeokz5qcUATClRW5u08QWNfv71N8') + sorcery_controller_external_property_set(:apple, :key_id, 'XpbeSdCoaKSmQGSeokz5qcUATClRW5u08QWNfv71N8') + sorcery_controller_external_property_set(:apple, :pem, "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBpOIbsjNWeKNsFLGCWa4ee0IJXQHuV81dQnImiTSHAEoAoGCCqGSM49\nAwEHoUQDQgAEQXmlbSpK0mbeU6DgnkllnL3/3so10T9EW/luSO2k3IFGnbrcDu2X\nByrwFUt+DO9epIjS4Azb1T4rd7HxVBZ7Lg==\n-----END EC PRIVATE KEY-----\n") + sorcery_controller_external_property_set(:apple, :callback_url, 'http://blabla.com') + sorcery_controller_external_property_set(:apple, :verify_payload, true) end - def provider_url(provider) + def provider_url(provider, nonce = nil) { github: "https://github.com/login/oauth/authorize?client_id=#{::Sorcery::Controller::Config.github.key}&display&redirect_uri=http%3A%2F%2Fblabla.com&response_type=code&scope&state", paypal: "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize?client_id=#{::Sorcery::Controller::Config.paypal.key}&display&redirect_uri=http%3A%2F%2Fblabla.com&response_type=code&scope=openid+email&state", @@ -562,7 +619,8 @@ def provider_url(provider) instagram: "https://api.instagram.com/oauth/authorize?client_id=#{::Sorcery::Controller::Config.instagram.key}&display&redirect_uri=http%3A%2F%2Fblabla.com&response_type=code&scope=#{::Sorcery::Controller::Config.instagram.scope}&state", auth0: "https://sorcery-test.auth0.com/authorize?client_id=#{::Sorcery::Controller::Config.auth0.key}&display&redirect_uri=http%3A%2F%2Fblabla.com&response_type=code&scope=openid+profile+email&state", discord: "https://discordapp.com/api/oauth2/authorize?client_id=#{::Sorcery::Controller::Config.discord.key}&display&redirect_uri=http%3A%2F%2Fblabla.com&response_type=code&scope=identify&state", - battlenet: "https://eu.battle.net/oauth/authorize?client_id=#{::Sorcery::Controller::Config.battlenet.key}&display&redirect_uri=http%3A%2F%2Fblabla.com&response_type=code&scope=openid&state" + battlenet: "https://eu.battle.net/oauth/authorize?client_id=#{::Sorcery::Controller::Config.battlenet.key}&display&redirect_uri=http%3A%2F%2Fblabla.com&response_type=code&scope=openid&state", + apple: "https://appleid.apple.com/auth/authorize?action=login_at_test_apple&client_id=#{::Sorcery::Controller::Config.apple.key}&controller=sorcery&display&nonce=#{nonce}&redirect_uri=http%3A%2F%2Fblabla.com&response_mode=form_post&response_type=code&scope=name+email&state" }[provider] end end diff --git a/spec/rails_app/app/controllers/sorcery_controller.rb b/spec/rails_app/app/controllers/sorcery_controller.rb index 8214e36a..fb971713 100644 --- a/spec/rails_app/app/controllers/sorcery_controller.rb +++ b/spec/rails_app/app/controllers/sorcery_controller.rb @@ -174,6 +174,10 @@ def login_at_test_battlenet login_at(:battlenet) end + def login_at_test_apple + login_at(:apple) + end + def test_login_from_twitter if (@user = login_from(:twitter)) redirect_to 'bla', notice: 'Success!' @@ -312,6 +316,14 @@ def test_login_from_battlenet end end + def test_login_from_apple + if (@user = login_from(:apple)) + redirect_to 'bla', notice: 'Success!' + else + redirect_to 'blu', alert: 'Failed!' + end + end + def test_return_to_with_external_twitter if (@user = login_from(:twitter)) redirect_back_or_to 'bla', notice: 'Success!' @@ -450,6 +462,14 @@ def test_return_to_with_external_battlenet end end + def test_return_to_with_external_apple + if (@user = login_from(:apple)) + redirect_back_or_to 'bla', notice: 'Success!' + else + redirect_to 'blu', alert: 'Failed!' + end + end + def test_create_from_provider provider = params[:provider] login_from(provider) diff --git a/spec/rails_app/config/routes.rb b/spec/rails_app/config/routes.rb index 4bf82c29..a007c9d1 100644 --- a/spec/rails_app/config/routes.rb +++ b/spec/rails_app/config/routes.rb @@ -36,6 +36,7 @@ get :test_login_from_line get :test_login_from_discord get :test_login_from_battlenet + get :test_login_from_apple get :login_at_test get :login_at_test_twitter get :login_at_test_facebook @@ -54,6 +55,7 @@ get :login_at_test_line get :login_at_test_discord get :login_at_test_battlenet + get :login_at_test_apple get :test_return_to_with_external get :test_return_to_with_external_twitter get :test_return_to_with_external_facebook @@ -72,6 +74,7 @@ get :test_return_to_with_external_line get :test_return_to_with_external_discord get :test_return_to_with_external_battlenet + get :test_return_to_with_external_apple get :test_http_basic_auth get :some_action_making_a_non_persisted_change_to_the_user post :test_login_with_remember