Migrating from Devise to Rails Auth before you can say "Rails World keynote"
Radan here: this is another guest post by Miha. He was so excited about it that he interrupted my weekend with a brand new post to review. I still found it interesting, so I hope you enjoy it as much as I did! Back to Miha now.
Whether you caught wind of it through the GitHub PR, watched David’s Rails World 2024 opening keynote, or read the announcement on the Ruby on Rails blog for Beta 1, the message is clear. If this is your first time hearing about it, prepare to be amazed: Rails now ships with built-in, native authentication.
Well, sort of. It ships with a basic generator that gets you started. In the words of DHH:
This is not intended to be an all-singing, all-dancing answer to every possible authentication concern. It’s merely intended to illuminate the basic path, and reveal that rolling your own authentication system is not some exotic adventure.
I see it as a stripped-down version of authentication-zero, which I used on ECT Business and it’s been flawless. Visualizer1, however, started a while ago, and there I used Devise, like many other (maybe even a majority?) of Rails apps do.
I’ve always had some problems with it when upgrading Rails versions. There were often GitHub issues and blog posts discussing workarounds or monkey patches to make it compatible, as Devise updates and releases tend to lag behind Rails releases. For example: support for Hotwire was added in February 2023 - a full year and 3 months after Rails 7.0 with Turbo Drive was released.
Trouble in paradise
So, imagine my surprise, when after upgrading Visualizer to Rails 8.0 everything Devise-related was working fine. Well everything was seemingly working fine - smoothest Rails upgrade ever.
But, wait…hmmm…Turbo Streams are broken in production?
Interesting, I see:
connection.js:39 WebSocket connection to 'wss://visualizer.coffee/cable' failed
in the console.ActionController::RoutingError (No route matches [GET] "/cable")
in logs.
Huh, it works locally? Oh, wait a second: it doesn’t work locally when I run it in production
environment. Probably something is broken in Rails, it is beta 1 after all. Let’s make a rails new
app, and confirm it’s broken. But it isn’t! Hmmmm…time for a deeper dive.
A couple of bundle open
s later and pokings around I found this block which prepends the mounting of "/cable"
to routes. With some further puts debugging I found that in the brand-new app the block registers and executes while in Visualizer it registers but never executes.
I added some puts debugs inside ActionDispatch#clear! and found that in the new app the ActionCable initializer is registered before first clear
call, but in my app it happened after
. So it has no chance to run the block. Culprit found.
Now I needed to know why this happens, and I put some puts caller
in there. The only diff was that one included devise-4.9.4/lib/devise/rails.rb:17
. Oh, god-damn, it’s Devise again, isn’t it? 😒
I looked at the code and found this comment: # Force routes to be loaded if we are doing any eager load with very simple app.reload_routes! if Devise.reload_routes
. And simply disabling that in config/initializers/devise.rb
fixed the issue.
I don’t have time or Devise knowledge required to dive deeper, but I opened a GitHub issue, so that anyone else with similar problems can find it, and that hopefully Devise fixes it in the next year or two.
Now that could have been it. But you saw the title of the blog post already, so you know there’s more. Of course there’s more, look at the scrollbar position. 😂
Migrating away from Devise
Now, Visualizer is not a huge app, but it’s not a simple/tiny one either. I’m in no way pushing Devise to its limits, but I do use quite a lot of it: sign up flow, sign in flow, password reset flow, omniauthable to Airtable, authenticate
route constraint, and I also provide Doorkeeper OAuth flow.
Step one: setting it up
But, just for fun, how far can I push it with rails generate authentication
? I ran the generator and found that it’s pretty nice, yet some things are weirdly omitted: there’s no sign up flow and routes just use resources
with no constraint which generates routes that controllers/views don’t handle. Outside of that, it’s pretty straight-forward: Session
that belongs to User
, with IP and User Agent persistence. It uses ActiveSupport::CurrentAttributes
which provides thread-isolated per-request attributes, which I was already familiar with, since authentication-zero uses it as well.
So after that, it was time for some rapid fire changes, mostly find & replace (abbreviated to f&r so it looks cool):
- f&r
current_user
withCurrent.user
- f&r
if current_user
withif authenticated?
- f&r
authenticate_user!
withrequire_authentication
- change to new sign in/up paths
- rename
User
’s column fromencrypted_password
topassword_digest
Yeah, you’re right, the latter is not zero-downtime, but Postgres is pretty fast with these things, so it worked just fine for my scale2. Luckily both Devise and has_secure_password
use BCrypt::Password
under the hood, and since I haven’t changed Devise’s defaults, it should just work.
Step two: migrating user sessions
And it did mostly work. So I was starting to wonder if there’s a way to migrate users from Devise sessions/cookies seamlessly. After some googling I found that it stores id
and first 30 characters of password salt in session["warden.user.user.key"]
and cookies.signed["remember_user_token"]
. The latter one also stores Time.now.utc.to_f.to_s
for some reason, but it’s irrelevant for our case. With that knowledge I was able to make this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def find_devise_session
return unless devise_info
clear_devise_info
start_new_session_for(devise_user) if devise_user
end
def devise_info
# try getting info from active session or from remembered cookie
@devise_info ||= session["warden.user.user.key"].presence || cookies.signed["remember_user_token"].presence
end
def devise_user
@devise_user ||= begin
# the session looks like this: [[id], salt]
# the cookie looks like this: [[id], salt, generated_at]
user_id = devise_info.dig(0, 0)
user_salt = devise_info.dig(1)
return if user_id.blank? || user_salt.blank?
user = User.find_by(id: user_id)
# if we find user and its salt matches then we save it to @devise_user
user if user&.password_digest[0, 29] == user_salt
end
end
def clear_devise_info
# we don't want to keep these around otherwise user won't be able to sign out
session.delete("warden.user.user.key")
cookies.delete("remember_user_token")
end
And there was this simple change to the generated methods:
1
2
3
4
5
6
7
8
9
def resume_session
- Current.session = find_session_by_cookie
+ find_session_by_cookie || find_devise_session
end
def find_session_by_cookie
- Session.find_by(id: cookies.signed[:session_id])
+ Current.session = Session.find_by(id: cookies.signed[:session_id])
end
I’ll probably remove this after a couple of weeks, but it’s really nice that I can migrate sessions over and not require users to sign in again.
Step three: migrating views and adding user creation
Next, I copied all my customized Devise views and simply updated the form_with
call and field names. Then I discovered that the new Rails generator does not provide a way to create/sign up a user. Very weird choice, I believe. So I added a simple RegistrationsController
with new
and create
actions, and reused the old views again.
The default generator creates a User
with email_address
, but I prefer just plain old email
attribute. I also brought the svg inline and migrated from .slim
to .erb
while at it3. And then the same for password reset flow. And, of course, emails. With that, the main app was pretty much working, and the whole thing took me about 2 hours.
Step four: Doorkeeper
Then I needed to focus on the API which uses basic auth and Doorkeeper. Basic auth was very easy:
1
2
3
4
5
6
authenticate_with_http_basic do |email, password|
- user = User.find_by(email:)
- user if user&.valid_password?(password)
+ next unless user = User.authenticate_by(email:, password:)
+ start_new_session_for(user)
end
And Doorkeeper resource_owner_authenticator
needed a bit more logic, but the code is very straight-forward:
1
2
3
4
5
6
7
- current_user || warden.authenticate!(scope: :user)
+ Current.session = Session.find_by(id: cookies.signed[:session_id])
+ next Current.user if Current.user # return if we have a signed user
+
+ # set the current path to return to after user signs in
+ session[:return_to_after_authenticating] = request.fullpath
+ redirect_to new_session_url
Step five: route constraints
I use PgHero and Mission Control — Jobs and I don’t want their engines to be exposed to non-admins. Devise makes this very simple with authenticate
method, but without it, we can still make the same functionality very easily with constraints:
1
2
- authenticate :user, ->(user) { user.admin? } do
+ constraints ->(request) { AuthConstraint.admin?(request) } do
What’s this AuthConstraint
you ask? Well, I’m glad you asked:
1
2
3
4
5
6
7
8
9
10
class AuthConstraint
def self.admin?(request)
# we're not in ActionController context so we don't have access to cookies yet
# luckily, it's very easy to get them
cookies = ActionDispatch::Cookies::CookieJar.build(request, request.cookies)
# we check if there is an admin that has a session with session_id
User.joins(:sessions).where(sessions: {id: cookies.signed[:session_id]}, admin: true).exists?
end
end
Step six: Omniauth
Lastly it was time for something I was putting off from the get-go: omniauth. Devise does seemingly a ton of magic there, so I was quite afraid to tackle it. Turns out, the fears were unfounded. I simply needed to write a new initializer for OmniAuth
:
1
2
3
4
5
6
7
8
Rails.application.config.middleware.use OmniAuth::Builder do
provider(
:airtable,
Rails.application.credentials.dig(:airtable, :client_id),
Rails.application.credentials.dig(:airtable, :client_secret),
scope: "data.records:read data.records:write schema.bases:read schema.bases:write webhook:manage"
)
end
add some routes:
1
2
3
get "auth/airtable/callback", to: "omniauth_callbacks#airtable"
get "auth/airtable", as: :connect_airtable
get "auth/failure", to: "sessions#omniauth_failure"
and change user_airtable_omniauth_authorize_path
to connect_airtable_path
. Wow. Really? That’s it? I guess this is the benefit of me abstracting OmniauthCallbacksController
and having a simple OauthWrapper class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class OauthWrapper < SimpleDelegator
def identifiers
{provider:, uid:}
end
def identifiers_with_blob
identifiers.merge(blob: self)
end
def identifiers_with_blob_and_token
identifiers_with_blob.merge(
token:,
refresh_token:,
expires_at: Time.zone.at(expires_at)
)
end
%i[token refresh_token expires_at].each do |credential|
define_method(credential) { dig(:credentials, credential) }
end
end
This last one I’ve been carrying with me from project to project since 2013, and I hardly changed it since writing it over a decade ago. It really makes everything OAuth flow related so much easier. No Hashie, no HashWithIndifferentAccess, just Plain Old Ruby Object SimpleDelegator with a bit of metaprogramming sprinkles.
Step seven: cleanup
Anyway, we’re getting side-tracked. Back at routes.rb
I noticed that the generated session
and passwords
use resource
/ resources
, without any only
or except
options, yet the controller doesn’t define all actions. So I added some:
1
2
resource :session, only: %i[new create destroy]
resources :passwords, param: :token, only: %i[new create edit update]
Step eight: …profit?
With that, the migration PR looked complete. With testing, googling, LLMing, and what not, the whole thing took about 6 hours. And the line count is incredible +737 −753
, given I changed many slim templates to erb which are much longer. But, the line count is deceiving because of this:
1
- gem "devise"
It only counts as one removed line, but in reality, a massive dependency with roughly 7,000 lines got removed. 🤯
But what makes me the happiest is that now the entire authentication system is vastly simplified and completely under my control. And that I’ll never be afraid of Devise messing up my bundle update rails
.
I’m incredibly grateful for Devise existing. I can say with certainty that without it, I wouldn’t be where I am now. But I find myself now in a place where it no longer sparks joy. Thank you, and goodbye. 👋
Footnotes
Hi, yes, it’s me, Miha, you might remember me from the getting rid of Pagy post. Yes, I like to remove gems from my projects. ↩
The entire migration took 0.0247s ↩
LLMs are incredible at this task ↩