For a long time, and probably still today, the reference for authentication in Rails is using a gem like Devise.
Thing is, you'll probably end up customizing it a lot: views, emails, onboarding flow, etc.
Since Rails 7.1, we have access to several new features that make it easier to implement authentication with minimal extra code, making it a viable option for many projects.
One of these features is generates_token_for
, which allows you to generate non-persisted tokens for your models, allowing you to implement features such as passwordless auth, password reset, email confirmation, and more.
When I stumbled upon this feature, my first thought was: That's magic.
In this post, We'll see how to use generates_token_for
to generate magic tokens in Rails, then we'll dive into the code to understand how it works.
How to use generates_token_for
Here's a basic example of how to use generates_token_for
in your Rails models:
class User < ApplicationRecord
generates_token_for :account_activation
end
user = User.find(42)
token = user.generate_token_for(:account_activation) # => "sometoken===--somesignature"
User.find_by_token_for(:account_activation, token) # => #<User id: 42, ...>
Once we declare that we want to generate a token for :account_activation
, we can call generate_token_for
to generate a token and find_by_token_for
to find a user from a given token.
This token is not persisted anywhere, it just contains the user id and a signature to verify its authenticity, making it a very convenient way to implement features that require a token.
Expiration
Token expiration is also supported, you can pass a expires_in
option to generates_token_for
to set the expiration time :
class User < ApplicationRecord
generates_token_for :account_activation, expires_in: 1.day
end
If you try to find a user by an expired token, it will return nil
.
Invalidating the token when something changes
generates_token_for
supports making the token dependant on an arbitrary block of code, allowing to implement features like password reset tokens that are invalidated when the password changes:
class User < ApplicationRecord
generates_token_for :password_reset, expires_in: 1.day do
password_salt&.last(10)
end
end
In this example, the token is dependent on the last 10 characters of the password salt.
The generated token will contain the content of the block, this is why it should be deterministic and not contain any sensitive information, and why we use the last 10 characters of the password salt in this example instead of the password hash directly.
When trying to find a user by a token, the block will be called again and compared to the value in the token, if they don't match, the token is considered invalid.
This is very powerful and allows to implement complex token invalidation logic with minimal code, you can make tokens dependent on values, state, timestamps, etc.
How it works
Let's have a look at the code to understand how generates_token_for
works. Here's a portion of the ActiveRecord::TokenFor
module that is included in our active record classes :
# activerecord/lib/active_record/token_for.rb
included do
class_attribute :token_definitions, instance_accessor: false, instance_predicate: false, default: {}
end
# ...
def generates_token_for(purpose, expires_in: nil, &block)
self.token_definitions = token_definitions.merge(purpose => TokenDefinition.new(self, purpose, expires_in, block))
end
We see that a token_definitions hash is defined, and that generates_token_for
is just a method that adds a TokenDefinition
to it.
The TokenDefinition
is a class defined in the same file, through a Struct
:
# activerecord/lib/active_record/token_for.rb
TokenDefinition = Struct.new(:defining_class, :purpose, :expires_in, :block) do # :nodoc:
# Some methods we'll see right after
end
It's basically a class that accepts params such as defining_class
purpose
, expires_in
and block
, with some methods used to do the token generation and verification.
Before diving in, let's have a quick look at the generate_token_for
method used on an instance of a model :
# activerecord/lib/active_record/token_for.rb
def generate_token_for(purpose)
self.class.token_definitions.fetch(purpose).generate_token(self)
end
Pretty straight forward, we're looking for the token definition for the good purpose, and calling generate_token
on it.
# activerecord/lib/active_record/token_for.rb
def full_purpose
@full_purpose ||= [defining_class.name, purpose, expires_in].join("\n")
end
def payload_for(model)
block ? [model.id, model.instance_eval(&block).as_json] : [model.id]
end
def generate_token(model)
message_verifier.generate(payload_for(model), expires_in: expires_in, purpose: full_purpose)
end
We use the rails MessageVerifier
, that can generate and verify signed messages, based on a secret key. It's also used in other features such as CSRF token validation.
We'll use it to generate our token, based on a payload consisting either only of the model id if no block was passed, or the id along the content of the block if one was passed.
The generated string will look like this :
eyJfcmFpbHMiOnsiZGF0YSI6WzQyXSwicHVyIjoiVXNlclxuc2Vzc2lvblxuIn19--7db5fb8690104cec00ec6443353c2362760e7078
The first part is the actual data, in Base64, and the second is the signature.
If we decode the first part, we obtain this :
{"_rails":{"data":[42],"pur":"User\nsession\n"}}
And here is another example for a token using expiration and a block :
{"_rails":{"data":[42,"93LHs7.oVu"],"exp":"2024-04-28T16:44:03.463Z","pur":"User\npassword_reset\n3600"}}
Basically, a token is just a big JSON object containing our purpose, our model id, and optionally the expiration time and arbitrary block value.
Now, the last part to inspect is the find_by_token_for
method used on a model class to retrieve a record from a token :
def find_by_token_for(purpose, token)
raise UnknownPrimaryKey.new(self) unless primary_key
token_definitions.fetch(purpose).resolve_token(token) { |id| find_by(primary_key => id) }
end
and the resolve_token
method on the TokenDefinition
:
def resolve_token(token)
payload = message_verifier.verified(token, purpose: full_purpose)
model = yield(payload[0]) if payload
model if model && payload_for(model) == payload
end
We use the message verifier to decode the token and ensure it was generated by our rails app, this is all done inside the MessageVerifier
, this will return nil if the data is invalid, not verified, or expired.
We find the model by its primary key (through yielding its id to the calling method)
Then, we recompute the payload, and compare it with the one we got from the token, if it matches, the model is returned, otherwise nil
. Not so magic after all.
Conclusion
As always, reading through the code can help us broader our understanding of a feature, and understand any possible gotchas.
Features such as generates_token_for
are great tools to implement strong authentication features in a Rails app, making it possible to drop big dependencies and stay pretty vanilla, with minimal extra code.
Top comments (0)