Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Apple as oauth provider #344

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
12 changes: 11 additions & 1 deletion lib/generators/sorcery/templates/initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 = <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 = <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 --
Expand Down
1 change: 1 addition & 0 deletions lib/sorcery/controller/submodules/external.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions lib/sorcery/providers/apple.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
require 'jwt'

module Sorcery
module Providers
# This class adds support for OAuth with apple.com.
#
# config.apple.key = <key>
# config.apple.team_id = <team_id>
# config.apple.key_id = <key_id>
# config.apple.pem = <pem>
# config.apple.verify_payload = <true,false>
# ...
#
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
1 change: 1 addition & 0 deletions sorcery.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
76 changes: 67 additions & 9 deletions spec/controllers/controller_oauth2_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -228,6 +231,7 @@
line
discord
battlenet
apple
]
)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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' => '[email protected]' } }
# access_token params for VK auth and additionally 'id_token' for apple
allow(access_token).to receive(:params) { { 'user_id' => '100500', 'email' => '[email protected]', '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": "[email protected]",
"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,
Expand All @@ -498,6 +548,7 @@ def set_external_property
line
discord
battlenet
apple
]
)
sorcery_controller_external_property_set(:facebook, :key, 'eYVNBjBDi33aa9GkA3w')
Expand Down Expand Up @@ -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",
Expand All @@ -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
Loading