Uh oh!
There was an error while loading.Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork130
JWT token authentication with devise and rails
License
waiting-for-dev/devise-jwt
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
devise-jwt is aDevise extension which usesJWT tokens for user authentication. It followssecure by default principle.
This gem is just a replacement for cookies when these can't be used. As withcookies, adevise-jwt token will mandatorily have an expirationtime. If you need that your users never sign out, you will be better off with asolution using refresh tokens, like some implementation of OAuth2.
You can read about which security concerns this library takes into account and about JWT generic secure usage in the following series of posts:
- Stand Up for JWT Revocation
- JWT Revocation Strategies
- JWT Secure Usage
- A secure JWT authentication implementation for Rack and Rails
devise-jwt is just a thin layer on top ofwarden-jwt_auth that configures it to be used out of the box with Devise and Rails.
Since version v0.7.0Blacklist revocation strategy has been renamed toDenylist whileWhitelist has been renamed toAllowlist.
ForDenylist, you only need to update theinclude line you're using in your revocation strategy model:
# include Devise::JWT::RevocationStrategies::Blacklist # beforeincludeDevise::JWT::RevocationStrategies::Denylist
ForAllowlist, you need to update theinclude line you're using in your user model:
# include Devise::JWT::RevocationStrategies::Whitelist # beforeincludeDevise::JWT::RevocationStrategies::Allowlist
You also have to rename yourWhitelistedJwt model toAllowlistedJwt, renamemodel/whitelisted_jwt.rb tomodel/allowlisted_jwt.rb and change the underlying database table toallowlisted_jwts (or configure the model to keep using the old name).
Add this line to your application's Gemfile:
gem'devise-jwt'
And then execute:
$ bundleOr install it yourself as:
$ gem install devise-jwtFirst, you need to configure Devise to work in an API application. You can follow the instructions in this project wiki pageConfiguring Devise for APIs (you are more than welcome to improve them).
You have to configure the secret key that will be used to sign generated tokens. You can do it in the Devise initializer:
Devise.setupdo |config|# ...config.jwtdo |jwt|jwt.secret=ENV['DEVISE_JWT_SECRET_KEY']endend
If you are using Encrypted Credentials (Rails 5.2+), you can store the secret key inconfig/credentials.yml.enc.
Open your credentials editor usingbin/rails credentials:edit and adddevise_jwt_secret_key.
Note you may need to set
$EDITORdepending on your specific environment.
# Other secrets...# Used as the base secret for Devise JWTdevise_jwt_secret_key:abc...xyz
Add the following to the Devise initializer.
Devise.setupdo |config|# ...config.jwtdo |jwt|jwt.secret=Rails.application.credentials.devise_jwt_secret_key!endend
Important: You are encouraged to use a secret different than your application
secret_key_base. It is quite possible that some other component of your system is already using it. If several components share the same secret key, chances that a vulnerability in one of them has a wider impact increase. In rails, generating new secrets is as easy asrails secret. Also, never share your secrets pushing it to a remote repository, you are better off using an environment variable like in the example.
Currently, HS256 algorithm is the one in use. You may configure a matching secret and algorithm name to use a different one (seeruby-jwt to see which are supported):
Devise.setupdo |config|# ...config.jwtdo |jwt|jwt.secret=OpenSSL::PKey::RSA.new(Rails.application.credentials.devise_jwt_secret_key!)jwt.algorithm=Rails.application.credentials.devise_jwt_algorithm!endend
If the algorithm is asymmetric (e.g. RS256) which necessitates a different decoding secret, configure thedecoding_secret setting as well:
Devise.setupdo |config|# ...config.jwtdo |jwt|jwt.secret=OpenSSL::PKey::RSA.new(Rails.application.credentials.devise_jwt_private_key!)jwt.decoding_secret=OpenSSL::PKey::RSA.new(Rails.application.credentials.devise_jwt_public_key!)jwt.algorithm='RS256'# or some other asymmetric algorithmendend
You have to tell which user models you want to be able to authenticate with JWT tokens. For them, the authentication process will be like this:
- A user authenticates through Devise create session request (for example, using the standard
:database_authenticatablemodule). - If the authentication succeeds, a JWT token is dispatched to the client in the
Authorizationresponse header, with formatBearer #{token}(tokens are also dispatched on a successful sign up). - The client can use this token to authenticate following requests for the same user, providing it in the
Authorizationrequest header, also with formatBearer #{token} - When the client visits Devise destroy session request, the token is revoked.
Seerequest_formats configuration option if you are using paths with a format segment (like.json) in order to use it properly.
As you see, unlike other JWT authentication libraries, it is expected that tokens will be revoked by the server. I wrote aboutwhy I think JWT revocation is needed and useful.
An example configuration:
classUser <ApplicationRecorddevise:database_authenticatable,:jwt_authenticatable,jwt_revocation_strategy:Denylistend
If you need to add something to the JWT payload, you can do it by defining ajwt_payload method in the user model. It must return aHash. For instance:
defjwt_payload{'foo'=>'bar'}end
You can add a hook methodon_jwt_dispatch on the user model. It is executed when a token dispatched for that user instance, and it takestoken andpayload as parameters.
defon_jwt_dispatch(token,payload)do_something(token,payload)end
Note: if you are making cross-domain requests, make sure that you addAuthorization header to the list of allowed request headers and exposed response headers. You can use something likerack-cors for that, for example:
config.middleware.insert_before0,Rack::Corsdoallowdoorigins'http://your.frontend.domain.com'resource'/api/*',headers:%w(Authorization),methods::any,expose:%w(Authorization),max_age:600endend
If you are working with a Rails application that has session storage enabledand a default Devise setup, chances are the same origin requests will beauthenticated from the session regardless of a token being present in theheaders or not.
This is so because of the following default Devise workflow:
- When a user signs in with
:database_authenticatablestrategy, the user isstored in the session unless one of the following conditions is met:- Session is disabled.
- Devise
config.skip_session_storageincludes:params_auth. - Rails Request forgeryprotectionhandles an unverified request (but this is usually deactivated for APIrequests).
- Warden (the engine below Devise), authenticates any request that the user hasin the session without requiring a strategy (
:jwt_authenticatablein our case).
So, if you want to avoid this caveat you have five options:
Disable the session. If you are developing an API, you probably don't needit. In order to disable it, change
config/initializers/session_store.rbto:Rails.application.config.session_store:disabled
Notice that if you created the application with the
--apiflag you alreadyhave the session disabled.If you still need the session for any other purpose, disable
:database_authenticatableuser storage. Inconfig/initializers/devise.rb:config.skip_session_storage=[:http_auth,:params_auth]
If you are using Devise for another model (e.g.
AdminUser) and doesn't wantto disable session storage for Devise entirely, you can disable it on aper-model basis:classUser <ApplicationRecorddevise:database_authenticatable#, your other enabled modules...self.skip_session_storage=[:http_auth,:params_auth]end
If you need the session for some of the controllers, you are able to disable it atthe controller level for those controllers which don't need it:
classAdminsController <ApplicationControllerbefore_action:drop_session_cookieprivatedefdrop_session_cookierequest.session_options[:skip]=trueend
As the last option you can tell Devise to not store the user in the Warden sessionif you override default Devise
SessionsControllerwith your own one, and passstore: falseattribute to thesign_in,sign_in_and_redirect,bypass_sign_inmethods:sign_inuser,store:false
devise-jwt comes with three revocation strategies out of the box. Some of them are implementations of what is discussed in the blog postJWT Revocation Strategies, where I also talk about their pros and cons.
Here, the model class acts as the revocation strategy. It needs a new string column namedjti to be added to the user.jti stands for JWT ID, and it is a standard claim meant to uniquely identify a token.
It works like the following:
- When a token is dispatched for a user, the
jticlaim is taken from thejticolumn in the model (which has been initialized when the record has been created). - At every authenticated action, the incoming token
jticlaim is matched against thejticolumn for that user. The authentication only succeeds if they are the same. - When the user requests to sign out its
jticolumn changes, so that provided token won't be valid anymore.
In order to use it, you need to add thejti column to the user model. So, you have to set something like the following in a migration:
defchangeadd_column:users,:jti,:string,null:falseadd_index:users,:jti,unique:true# If you already have user records, you will need to initialize its `jti` column before setting it to not nullable. Your migration will look this way:# add_column :users, :jti, :string# User.all.each { |user| user.update_column(:jti, SecureRandom.uuid) }# change_column_null :users, :jti, false# add_index :users, :jti, unique: trueend
Important: You are encouraged to set a unique index in thejti column. This way we can be sure at the database level that there aren't two valid tokens with samejti at the same time.
Then, you have to add the strategy to the model class and configure it accordingly:
classUser <ApplicationRecordincludeDevise::JWT::RevocationStrategies::JTIMatcherdevise:database_authenticatable,:jwt_authenticatable,jwt_revocation_strategy:selfend
Be aware that this strategy makes uses ofjwt_payload method in the user model, so if you need to use it don't forget to callsuper:
defjwt_payloadsuper.merge('foo'=>'bar')end
In this strategy, a database table is used as a list of revoked JWT tokens. Thejti claim, which uniquely identifies a token, is persisted. Theexp claim is also stored to allow the clean-up of stale tokens.
In order to use it, you need to create the denylist table in a migration:
defchangecreate_table:jwt_denylistdo |t|t.string:jti,null:falset.datetime:exp,null:falseendadd_index:jwt_denylist,:jtiend
For performance reasons, it is better if thejti column is an index.
Note: if you used the denylist strategy before version 0.4.0 you may not have the fieldexp. If not, run the following migration:
classAddExpirationTimeToJWTDenylist <ActiveRecord::Migrationdefchangeadd_column:jwt_denylist,:exp,:datetime,null:falseendend
Then, you need to create the corresponding model and include the strategy:
classJwtDenylist <ApplicationRecordincludeDevise::JWT::RevocationStrategies::Denylistself.table_name='jwt_denylist'end
Last, configure the user model to use it:
classUser <ApplicationRecorddevise:database_authenticatable,:jwt_authenticatable,jwt_revocation_strategy:JwtDenylistend
Here, the model itself also acts as a revocation strategy, but it needs to havea one-to-many association with another table which stores the tokens (in facttheirjti claim, which uniquely identifies them) that are valid for each user record.
The workflow is as the following:
- Once a token is dispatched for a user, its
jticlaim is stored in theassociated table. - At every authentication, the incoming token
jtiis matched against all thejtiassociated to that user. The authentication only succeeds if one ofthem matches. - On a sign out, the token
jtiis deleted from the associated table.
In fact, besides thejti claim, theaud claim is also stored and matched atevery authentication. This, together with theaud_headerconfiguration parameter, can be used to differentiate between clients ordevices for the same user.
Theexp claim is also stored to allow the clean-up of staled tokens.
In order to use it, you have to create the associated table and model.The association table must be calledallowlisted_jwts:
defchangecreate_table:allowlisted_jwtsdo |t|t.string:jti,null:falset.string:aud# If you want to leverage the `aud` claim, add to it a `NOT NULL` constraint:# t.string :aud, null: falset.datetime:exp,null:falset.references:your_user_table,foreign_key:{on_delete::cascade},null:falseendadd_index:allowlisted_jwts,:jti,unique:trueend
Important: You are encouraged to set a unique index in thejti column. This way we can be sure at the database level that there aren't two valid tokens with the samejti at the same time. Definingforeign_key: { on_delete: :cascade }, null: false ont.references :your_user_table helps to keep referential integrity of your database.
And then, the model:
classAllowlistedJwt <ApplicationRecordend
Finally, include the strategy in the model and configure it:
classUser <ApplicationRecordincludeDevise::JWT::RevocationStrategies::Allowlistdevise:database_authenticatable,:jwt_authenticatable,jwt_revocation_strategy:selfend
Be aware that this strategy makes uses ofon_jwt_dispatch method in the user model, so if you need to use it don't forget to callsuper:
defon_jwt_dispatch(token,payload)superdo_something(token,payload)end
Anull object pattern strategy, which does not revoke tokens, is provided out of the box just in case you are absolutely sure you don't need token revocation. It is recommendednot to use it.
classUser <ApplicationRecorddevise:database_authenticatable,:jwt_authenticatable,jwt_revocation_strategy:Devise::JWT::RevocationStrategies::Nullend
You can also implement your own strategies. They just need to implement two methods:jwt_revoked? andrevoke_jwt, both of them accept the JWT payload and the user record as parameters, in this order.
For instance:
moduleMyCustomStrategydefself.jwt_revoked?(payload,user)# Does something to check whether the JWT token is revoked for given userenddefself.revoke_jwt(payload,user)# Does something to revoke the JWT token for given userendendclassUser <ApplicationRecorddevise:database_authenticatable,:jwt_authenticatable,jwt_revocation_strategy:MyCustomStrategyend
Models configured with:jwt_authenticatable usually won't be retrieved fromthe session. For this reason,sign_in Devise testing helper methods won'twork as expected.
What you need to do to authenticate test environment requests is thesame that you will do in production: to provide a valid token in theAuthorization header (in the form ofBearer #{token}) at every request.
There are two ways you can get a valid token:
- Inspecting the
Authorizationresponse header after a valid sign in request. - Manually creating it.
The first option tests the real workflow of your application, but it can slowthings if you perform it at every test.
For the second option, a test helper is provided in order to add theAuthorization name/value pair to given request headers. You can use it as inthe following example:
# First, require the helper modulerequire'devise/jwt/test_helpers'# ...it'tests something'douser=fetch_my_user()headers={'Accept'=>'application/json','Content-Type'=>'application/json'}# This will add a valid token for `user` in the `Authorization` headerauth_headers=Devise::JWT::TestHelpers.auth_headers(headers,user)get'/my/end_point',headers:auth_headersexpect_something()end
Usually you will wrap this in your own test helper.
This library can be configured callingjwt on Devise config object:
Devise.setupdo |config|config.jwtdo |jwt|# ...endend
Secret key is used to sign generated JWT tokens. You must set it.
Allow rotating secrets. Set a new value tosecret and copy the old secret torotation_secret.
Number of seconds while a JWT is valid after its generation. After that, it won't be valid anymore, even if it hasn't been revoked.
Defaults to 3600 seconds (1 hour).
Besides the create session one, there are additional requests where JWT tokens should be dispatched.
It must be a bidimensional array, each item being an array of two elements: the request method and a regular expression that must match the request path.
For example:
jwt.dispatch_requests=[['POST',%r{^/dispatch_path_1$}],['GET',%r{^/dispatch_path_2$}],]
Important: You are encouraged to delimit your regular expression with^ and$ to avoid unintentional matches.
Besides the destroy session one, there are additional requests where JWT tokens should be revoked.
It must be a bidimensional array, each item being an array of two elements: the request method and a regular expression that must match the request path.
For example:
jwt.revocation_requests=[['DELETE',%r{^/revocation_path_1$}],['GET',%r{^/revocation_path_2$}],]
Important: You are encouraged to delimit your regular expression with^ and$ to avoid unintentional matches.
Request formats that must be processed (in order to dispatch or revoke tokens).
It must be a hash of Devise scopes as keys and an array of request formats asvalues. When a scope is not present or if it has a nil item, requests withoutformat will be taken into account.
For example, with following configuration,user scope would dispatch andrevoke tokens injson requests (as in/users/sign_in.json), whileadmin_user would do it inxml and with no format (as in/admin_user/sign_in.xml and/admin_user/sign_in).
jwt.request_formats={user:[:json],admin_user:[nil,:xml]}
By default, only requests without format are processed.
Request/response header which will transmit the JWT token.
Defaults to 'Authorization'
Expected issuer claim. If present, it will be checked against the incomingtoken issuer claim and authorization will be skipped if they don't match.
Defaults to nil.
Request header which content will be stored to theaud claim in the payload.
It is used to validate whether an incoming token was originally issued to thesame client, checking ifaud and theaud_header header value match. If youdon't want to differentiate between clients, you don't need to provide thatheader.
Important: Be aware that this workflow is not bullet proof. In somescenarios a user can handcraft the request headers, therefore being able toimpersonate any client. In such cases you could need something more robust,like an OAuth workflow with client id and client secret.
Defaults toJWT_AUD.
Request header containing the token in the format ofBearer #{token}.
Defaults toAuthorization.
If present, it will be checked against the incoming token issuer claim andauthorization will be skipped if they don't match.
Defaults tonil.
jwt.issuer='http://myapp.com'
There are docker and docker-compose files configured to create a development environment for this gem. So, if you use Docker you only need to run:
docker-compose up -d
An then, for example:
docker-compose exec app rspec
Bug reports and pull requests are welcome on GitHub athttps://github.com/waiting-for-dev/devise-jwt. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to theContributor Covenant code of conduct.
devise-jwt follows the principles ofsemantic versioning.
The gem is available as open source under the terms of theMIT License.
About
JWT token authentication with devise and rails
Topics
Resources
License
Code of conduct
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Sponsor this project
Uh oh!
There was an error while loading.Please reload this page.
Packages0
Uh oh!
There was an error while loading.Please reload this page.