From 40be88b608275066372bf44a5038b3c5b45c695a Mon Sep 17 00:00:00 2001 From: Shomy <61943525+ShomyKohai@users.noreply.github.com> Date: Tue, 2 Apr 2024 20:54:17 +0200 Subject: [PATCH] Add Apple Sign-In --- .env | 2 + app/graphql/types/account.rb | 4 ++ .../types/enum/external_identity_provider.rb | 1 + app/models/user.rb | 1 + app/policies/user_policy.rb | 4 +- config/initializers/doorkeeper.rb | 3 + .../20240401005041_add_apple_id_to_user.rb | 6 ++ db/schema.rb | 4 +- lib/authorization/assertion/apple.rb | 70 +++++++++++++++++++ .../lib/authorization/assertion/apple_spec.rb | 45 ++++++++++++ 10 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20240401005041_add_apple_id_to_user.rb create mode 100644 lib/authorization/assertion/apple.rb create mode 100644 spec/lib/authorization/assertion/apple_spec.rb diff --git a/.env b/.env index 07f39e50ca..176e81b650 100644 --- a/.env +++ b/.env @@ -58,3 +58,5 @@ AOZORA_MONGO_URL=mongodb://localhost/aozora ANIME_PLANET_PROXY_HOST=anime-planet.com APPLE_VERIFICATION_URL=https://sandbox.itunes.apple.com/verifyReceipt + +APPLE_CLIENT_ID=some_client_id \ No newline at end of file diff --git a/app/graphql/types/account.rb b/app/graphql/types/account.rb index 5854a4bdd3..54caa62a00 100644 --- a/app/graphql/types/account.rb +++ b/app/graphql/types/account.rb @@ -38,6 +38,10 @@ def profile null: true, description: 'Twitter account linked to the account' + field :apple_id, String, + null: true, + description: 'AppleID linked to the account' + field :sfw_filter, Boolean, null: true, description: 'Whether Not Safe For Work content is accessible' diff --git a/app/graphql/types/enum/external_identity_provider.rb b/app/graphql/types/enum/external_identity_provider.rb index 126dd4b22b..5f51d28966 100644 --- a/app/graphql/types/enum/external_identity_provider.rb +++ b/app/graphql/types/enum/external_identity_provider.rb @@ -1,3 +1,4 @@ class Types::Enum::ExternalIdentityProvider < Types::Enum::Base value 'FACEBOOK', 'Facebook identity', value: :facebook + value 'APPLE', 'Apple Id identity', value: :apple end diff --git a/app/models/user.rb b/app/models/user.rb index ec10fd4642..764a5b5dc9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -215,6 +215,7 @@ class User < ApplicationRecord validates :gender, length: { maximum: 20 } validates :password, length: { maximum: 72 }, if: :registered? validates :facebook_id, uniqueness: true, allow_nil: true + validates :apple_id, uniqueness: true, allow_nil: true scope :active, -> { where(deleted_at: nil) } scope :by_slug, ->(*slugs) { where(slug: slugs&.flatten) } diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 5ce50adad0..53c1732b10 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -28,8 +28,8 @@ def visible_attributes(all) all else all - %i[email password confirmed previous_email language time_zone country share_to_global - title_language_preference sfw_filter rating_system theme facebook_id has_password - ao_pro] + title_language_preference sfw_filter rating_system theme facebook_id apple_id + has_password ao_pro] end end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 56f08759fe..1ec35de91a 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'authorization/assertion/facebook' +require 'authorization/assertion/apple' require 'authorization/password' Doorkeeper.configure do @@ -24,6 +25,8 @@ case params[:provider] when 'facebook' Authorization::Assertion::Facebook.new(params[:assertion]).user! + when 'apple' + Authorization::Assertion::Apple.new(params[:id_token], params[:user]).user! end end # Restrict access to the web interface for adding oauth applications diff --git a/db/migrate/20240401005041_add_apple_id_to_user.rb b/db/migrate/20240401005041_add_apple_id_to_user.rb new file mode 100644 index 0000000000..4d0ec80105 --- /dev/null +++ b/db/migrate/20240401005041_add_apple_id_to_user.rb @@ -0,0 +1,6 @@ +class AddAppleIdToUser < ActiveRecord::Migration[6.1] + def change + add_column :users, :apple_id, :string, limit: 255 + add_index :users, :apple_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 784e40cefd..7a93a7d608 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_10_28_222209) do +ActiveRecord::Schema.define(version: 2024_04_01_005041) do # These are extensions that must be enabled in order to support this database enable_extension "citext" @@ -1563,9 +1563,11 @@ t.jsonb "avatar_data" t.jsonb "cover_image_data" t.integer "sfw_filter_preference", default: 0, null: false + t.string "apple_id", limit: 255 t.index "lower((email)::text)", name: "users_lower_idx" t.index "lower((name)::text), id", name: "index_users_on_lower_name_and_id", comment: "Unknown who is querying this, but it is painfully slow without this index" t.index ["ao_id"], name: "index_users_on_ao_id", unique: true + t.index ["apple_id"], name: "index_users_on_apple_id", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["facebook_id"], name: "index_users_on_facebook_id", unique: true t.index ["slug"], name: "index_users_on_slug", unique: true diff --git a/lib/authorization/assertion/apple.rb b/lib/authorization/assertion/apple.rb new file mode 100644 index 0000000000..3baa748e5f --- /dev/null +++ b/lib/authorization/assertion/apple.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Authorization + module Assertion + class Apple + APPLE_KEYS = 'https://appleid.apple.com/auth/keys' + + def initialize(jwt, user_id) + @jwt = jwt + @user_id = user_id + end + + def user! + return nil unless validate! + @user ||= User.where(apple_id:).first + end + + private + + def data + @data ||= decode_jwt + end + + def apple_id + data[:sub] + end + + def email + data[:email] + end + + def validate! + validate_aud && validate_exp && validate_sub && validate_iat && validate_iss + end + + def validate_sub + apple_id == @user_id + end + + def validate_iat + data[:iat].to_i <= Time.now.to_i + end + + def validate_exp + data[:exp].to_i > Time.now.to_i + end + + def validate_aud + data[:aud] == ENV['APPLE_CLIENT_ID'] + end + + def validate_iss + data[:iss] == 'https://appleid.apple.com' + end + + def decode_jwt + return {} unless @jwt + jwt_header = JSON.parse(Base64.decode64(@jwt.split('.').first)) + + response = Net::HTTP.get(URI.parse(APPLE_KEYS)) + apple_jwks = JSON.parse(response) + matching_key = apple_jwks['keys'].select { |key| key['kid'] == jwt_header['kid'] } + + jwk = JWT::JWK.create_from(matching_key.first) + @decoded_jwt = JWT.decode(@jwt, jwk.public_key, true, algorithm: matching_key.first['alg']) + @decoded_jwt.first.deep_symbolize_keys + end + end + end +end diff --git a/spec/lib/authorization/assertion/apple_spec.rb b/spec/lib/authorization/assertion/apple_spec.rb new file mode 100644 index 0000000000..c46bffb0f9 --- /dev/null +++ b/spec/lib/authorization/assertion/apple_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'authorization/assertion/apple' + +RSpec.describe Authorization::Assertion::Apple do + let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:apple_auth) { Authorization::Assertion::Apple.new(signed_jwt, user_id) } + let(:jwk) { JWT::JWK.new(private_key) } + let(:jwt) do + { + iss: 'https://appleid.apple.com', + aud: ENV['APPLE_CLIENT_ID'], + iat: Time.now.to_i, + exp: Time.now.to_i + 10.minutes, + sub: 'xxx.yyy.zzz', + email: 'kitsu-dev@privaterelay.appleid.com', + is_private_email: 'true' + } + end + let(:exported_private_key) { JWT::JWK::RSA.new(private_key).export.merge({ alg: 'RS256' }) } + let(:apple_jwks) { [exported_private_key] } + let(:signed_jwt) { JWT.encode(jwt, private_key, 'RS256', kid: jwk.kid) } + let(:user_id) { 'xxx.yyy.zzz' } + + before do + stub_request(:get, 'https://appleid.apple.com/auth/keys').to_return( + body: { + keys: apple_jwks + }.to_json, + status: 200, + headers: { 'Content-Type': 'application/json' } + ) + end + + describe '#user!' do # rubocop:disable RSpec/MultipleMemoizedHelpers + subject(:test_subject) { apple_auth.user! } + + let!(:user) { create(:user, apple_id: 'xxx.yyy.zzz') } + + it 'returns the user' do + expect(test_subject).to eq(user) + end + end +end