Adding 'Sign in with Apple' to your Ruby on Rails 7.1 App: A Step-by-Step Guide
Also, a fix for the nasty CSRF issue.
I added omniauth-apple to one of my side projects to get 'Sign in with Apple' authentication. I hit a couple of issues that took me a while to fix, so I want to share how I solved them, hoping it saves others some time.
Getting Started
I'm working with Rails 7.1 and authentication-zero. My Gemfile
already has omniauth and omniauth-rails_csrf_protection because I passed the --omniauthable
flag to the authentication-zero
generator:
rails generate authentication --omniauthable
Next, I added omniauth-apple
to my Gemfile
:
gem "omniauth-apple"
Configuring 'Sign in with Apple' requires several steps on the Apple side of the house that I won't go into. However, the short version is you will need an Apple Developer account, and you'll need to add some configuration to it. The omniauth-apple
gem outlines how to do that in its README.
Next, I added the Apple configuration to my Custom Credentials:
apple:
bundle_id: com.example
app_id_prefix: <redacted>
key_id: <redacted>
private_key: |
-----BEGIN PRIVATE KEY-----
<redacted>
-----END PRIVATE KEY-----
Then, I added the apple provider to config/initializers/omniauth.rb
:
Rails.application.config.middleware.use OmniAuth::Builder do
provider :apple,
Rails.application.credentials.apple[:bundle_id],
"",
{
scope: "email name",
team_id: Rails.application.credentials.apple[:app_id_prefix],
key_id: Rails.application.credentials.apple[:key_id],
pem: Rails.application.credentials.apple[:private_key]
}
end
Finally, you must add a button to 'Sign in with Apple' on your login page. I'm using tailwindcss via tailwindcss-rails to style the button:
<%= button_to "/auth/apple", "data-turbo" => false, class: "text-white bg-[#050708] hover:bg-[#050708]/90 focus:ring-4 focus:ring-[#050708]/50 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:focus:ring-[#050708]/50 dark:hover:bg-[#050708]/30 mr-2 mb-2" do %>
<svg class="mr-2 -ml-1 w-5 h-5" aria-hidden="true" focusable="false" data-prefix="fab" data-icon="apple" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"></path></svg>
<span class="text-sm font-semibold leading-6">Sign in with Apple</span>
<% end %>
If you want to test this locally, you'll need to use https
. There are multiple options for that, but I use ngrok. Once you have an https
URL pointing at your local Rails server, you'll need to add a couple of things to your config/environments/development.rb
:
config.force_ssl = true
config.hosts << "yourexternalurl.com" # If you're using something like ngrok
While we're at it, you might want to turn on force_ssl
in your config/environments/production.rb
for your deployed app:
config.assume_ssl = true # You may need this depending on your prodution deployment
config.force_ssl = true
Note: Don't forget to update your domains and redirect_uris in your 'Sign in with Apple' configuration within your Apple Developer account (see the README)
The force_ssl
configuration is crucial because it ensures that your Rails session cookie is set with the Secure
attribute. We're going to set SameSite
to None
later to fix an error, which only works with a secure cookie.
That's a fair bit of work! You are probably excited to try this out, but you'll likely encounter an error.
CSRF detected 😱
If you click on your 'Sign in with Apple' button, everything should work fine until you're redirected back. If you're testing locally, you'll probably see this:
This took me quite a while to wrap my head around, and I'll explain what's going on, but if you want the fix and don't care about the explanation, add this to your config/application.rb
(credit to jmonteiro)
config.action_dispatch.cookies_same_site_protection =
lambda do |request|
request.path.starts_with?("/auth/apple") ? :none : :lax
end
Why does this happen?
There are multiple discussions about what's happening here and even a couple of PRs against omniauth-apple
that have been closed by the maintainer. A lot is going on, but I'll try to summarize it in a Rails context and provide links for more information along the way:
Modern versions of Rails set the
SameSite
attribute toLax
on the session cookie (see the PR here for more explanation). This means the cookie is sent along to cross-site requests (like our 'Sign in with Apple' link), but it only works with "safe" HTTP methods likeGET
.Apple somewhat breaks OAuth protocol by redirecting back with a
POST
instead of aGET
.Part of a secure OAuth handshake is generating a
state
, storing it, and then sending it along as a parameter to the identity provider (in this case, Apple). When the user is redirected back, the identity provider sends thatstate
back as a parameter for the server to compare to its storedstate
. If it doesn't match, we have a CSRF problem (see more here).omniauth-oauth2 stores the state in the rack session, which, in Rails, means storing it in a cookie (see the first bullet).
Because of the
POST
redirect back from Apple and theLax
SameSite
cookie setting, thestate
parameter never gets back to our Rails app, andomniauth-oauth2
correctly raisescsrf_detected
.
Now, back to the fix (see above). In our case, we just set SameSite
to None
, but only in the case where we're hitting the omniauth /auth/apple
route.
Is this secure? Well, you'll have to make that determination for yourself, but let me explain why I'm ok with it:
We're selectively setting
SameSite
toNone
in the case where we're making a request to 'Sign in with Apple' (viaomniauth
). That means, on all our other routes, the session cookie is set withSameSite
toLax
.We already have another form of CSRF protection built into OAuth with the state parameter.
Rails cookie sessions are encrypted, meaning if there was a man-in-the-middle attack, the attacker couldn't read the
state
, which means they couldn't send it back in the redirect andomniauth-oauth2
would raisecsrf_detected
.
I hope that helps! If you feel like I got something wrong or you have a question, please comment or shoot me an email at mattlins@hey.com