How to implement a simple API for ‘Sign in with Apple’

Mariusz Kapcia
5 min readJun 1, 2020

--

Most of the projects I’m working on right now are related to mobile applications and Ruby on Rails backend. Today we will talk about what needs to be done on your server to support mobile ‘Sign in with Apple’ feature. Our goal is to create a simple API that will allow registration and login process to our system using Apple account and mobile application.

The first thing we need to do is to read the documentation to know if there will be any communication between our system and Apple servers and how to implement it. After a quick read, we will know two things:

  • We need to fetch Apple’s public key for verifying token signature.
  • We need to verify Identity Token which will be sent to us from the mobile application together with nonce. The verification process is described here and we can do it by ourselves or use AppleId gem.

Let’s start by implementing a simple AppleService which will be responsible for all the things described above. We will use the AppleId gem because there is no reason to implement that logic from scratch.

AppleError = Class.new(StandardError)class AppleService
def fetch_profile
decoded_token = @validator.decode(@id_token)
verified_token = decoded_token.verify!(client: client_id, verify_signature: true, nonce: @nonce)
parse_profile(verified_token)
rescue AppleID::IdToken::VerificationFailed
raise AppleError.new('Invalid token')
end
privatedef initialize(id_token, nonce)
@id_token = id_token
@nonce = nonce
@validator = AppleID::IdToken
end
def client_id
'mobile-application-identifier'
end
def parse_profile(verified_token)
{
id: verified_token.sub,
email: verified_token.email,
private_email: verified_token.is_private_email,
name: ''
}
end

Service has one public method fetch_profile where identity token is verified and decoded. The whole process is handled by AppleId gem. Here are a few things worth mentioning:

  • There is no full nameinside the identity token which was a big surprise for me. Unfortunately, we will need to get this value from the mobile application.
  • The nonce is not required but it will increase overall security so it is worth adding.
  • Apple allows to flag user email address as private and in such cases, the email attribute will contain Apple generated alias which later can be used to send messages to the user. There is a separate attribute to indicate if the email address is private or not.
  • ClientId is an identifier of mobile application and you need to ask a mobile developer for it.

Now we can take care of the necessary things around it. We will use concepts from Domain-Driven Design to make things a little more interesting.

Bounded context

This context will be responsible for managing user accounts. We have many possible names: Access, Users, Identity&Access. Bounded contexts are represented as modules in Ruby on Rails application.

module Users
end

Command

We need one command responsible for registration. We will skip validations to keep things clean but feel free to includeActiveModelor dry-struct.

module Users
class RegisterNewUserFromApple
attr_accessor :id
attr_accessor :apple_id
attr_accessor :fullname
attr_accessor :email

def initialize(id:, apple_id:, fullname:, email:)
@id = id
@apple_id = apple_id
@fullname = fullname
@email = email
end
end
end

Domain event

We also need one domain event to represent the fact of user registration. It’s good practice to validate event schema. Here we use ClassyHash gem but dry-struct will work too. In some cases, it’s also worth introducing failure events but we don’t need it here.

module Users
class UserRegisteredFromApple < RailsEventStore::Event
SCHEMA = {
uuid: String,
fullname: String,
email: String,
apple_id: String,
}.freeze
def self.strict(data:)
ClassyHash.validate(data, SCHEMA)
new(data: data)
end
end
end

Aggregate

Depends on the approach we can treat registration as part of Useraggregate or we can create a separate aggregate for the registration process only and create User aggregate later. We will handle all event-related things with RES gem.

There is a simple validation if the user is already registered and if everything is fine thenUserRegisteredFromAppledomain event is published.

module Users
class User
include AggregateRoot
HasBeenAlreadyRegistered = Class.new(StandardError) def initialize(id)
@id = id
@state = :unregistered
end
def register_from_apple(fullname, email, apple_id)
raise HasBeenAlreadyRegistered if @state.in?([:registered])

apply(Users::UserRegisteredFromApple.strict(data: {
id: @id,
fullname: fullname,
email: email,
apple_id: apple_id,
}))
end
private def apply_user_registered_from_apple(_event)
@state = :registered
end
end
end

Command handler

Now we need to load our aggregate and handle the registration command. This part is done by a command handler. Here you can also validate command and run different policies required to check if a command can be performed. We can make sure that users will be unique thanks to using an email address as part of the stream name.

Communication between commands and command handlers can be implemented with a command bus.

module Users
class OnRegisterNewUserFromApple
def call(cmd)
repository = AggregateRoot::Repository.new
repository.with_aggregate(User.new(cmd.id), stream_name(cmd.email) do |user|
user.register_from_apple(cmd.fullname, cmd.email, cmd.apple_id)
end
end
private def stream_name(email)
"Users::User$#{email}"
end
end
end

Read model

Our domain model is event-sourced so we need a read model to represent a cached list of registered users. It’s not very efficient to load all aggregates in the system and check who is registered. We also shouldn’t keep view related data inside aggregate. To make this work, the event store should be configured first.

module Users
class RegisteredUsersReadModel
def call(event)
case event
when Users::UserRegisteredFromApple
User.create!(
id: event.data[:id],
email: event.data[:email],
fullname: event.data[:fullname],
apple_id: event.data[:apple_id],
)
end
end
def find(id)
User.find_by(id: id)
end

def find_by_apple_id(apple_id)
User.find_by(apple_id: apple_id)
end
end
end

Application service

We introduce an application service to keep controller action as clean as possible. Service is responsible for checking if there is a need to create a new user account or only generate a session for an existing user. Implementation of GenerateSession service is not part of this blogpost.

class CreateAppleSession
def call(token, nonce, fullname)
apple_profile = Users::AppleService
.new(token, nonce).fetch_profile
user = Users::RegisteredUsersReadModel
.new
.find_by_apple_id(apple_profile[:id]
user_id = user.try(:id) || SecureRandom.uuid

if user.nil?
apple_profile.merge!(name: fullname)
command_bus.call(register_user_from_apple(user_id, apple_profile))
end
user_session = Users::GenerateSession.new.call(user_id)
{ user_id: user_id, user_session: user_session }
end
private def register_user_from_apple(user_id, apple_profile)
Users::RegisterNewUserFromApple.new(
id: user_id,
fullname: apple_profile[:fullname],
email: apple_profile[:email]
apple_id: apple_profile[:apple_id]
)
end
end

The only thing left is the controller and routes. It’s pretty straight forward and nobody should have problems with it.

What’s more to do?

Of course, we need to implement a mobile application part, but that topic is for another time. If we want to send emails to generated Apple aliases then we need to use Private Email Relay Service and we need to properly configure our domain in Apple Developer Center.

More DDD content

If you want to see how to implement other DDD patterns in Ruby on Rails you can check out my sample applications and bounded contexts here.

--

--