How to implement a simple API for ‘Sign in with Apple’
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 withnonce
. 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')
endprivatedef initialize(id_token, nonce)
@id_token = id_token
@nonce = nonce
@validator = AppleID::IdToken
enddef client_id
'mobile-application-identifier'
enddef 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 name
inside 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.
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 includeActiveModel
or 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 User
aggregate 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 thenUserRegisteredFromApple
domain 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.