Adding 'Sign in with Apple' to your Ruby on Rails 7.1 App: A Step-by-Step Guide

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.

·

5 min read

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:

  • Rails uses a cookie to store sessions by default.

  • Modern versions of Rails set the SameSite attribute to Lax 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 like GET.

  • Apple somewhat breaks OAuth protocol by redirecting back with a POST instead of a GET.

  • 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 that state back as a parameter for the server to compare to its stored state. 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 the Lax SameSite cookie setting, the state parameter never gets back to our Rails app, and omniauth-oauth2 correctly raises csrf_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 to None in the case where we're making a request to 'Sign in with Apple' (via omniauth). That means, on all our other routes, the session cookie is set with SameSite to Lax.

  • 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 and omniauth-oauth2 would raise csrf_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