warden-jwt_auth
is a warden extension which uses JWT tokens for user authentication. It follows secure by default principle.
This gem is just a replacement for cookies when these can't be used. As
cookies, a token expired with warden-jwt_auth
will mandatorily have an
expiration time. If you need that your users never sign out, you will be better
off with a solution 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
If what you need is a JWT authentication library for devise, better look at devise-jwt, which is just a thin layer on top of this gem.
gem 'warden-jwt_auth'
And then execute:
$ bundle
Or install it yourself as:
$ gem install warden-jwt_auth
You can look at this gem's wiki to see some example applications. Please, add yours if you think it can help somebody.
At its core, this library consists of:
- A Warden strategy that authenticates a user if a valid JWT token is present in the request headers.
- A rack middleware which adds a JWT token to the response headers in configured requests.
- A rack middleware which revokes JWT tokens in configured requests.
As you see, JWT revocation is supported. I wrote why I think JWT tokens revocation is useful and needed.
First of all, you have to configure the secret key that will be used to sign generated tokens.
Warden::JWTAuth.configure do |config|
config.secret = ENV['WARDEN_JWT_SECRET_KEY']
end
Important: You are encouraged to use a dedicated secret key, different than others in use in your application. If several components share the same secret key, chances that a vulnerability in one of them has a wider impact increase. 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 default. Configure the matching secret and algorithm name to use a different one (e.g. RS256) (see ruby-jwt to see which are supported)
Warden::JWTAuth.configure do |config|
config.secret = OpenSSL::PKey::RSA.new(ENV['WARDEN_JWT_SECRET_KEY'])
config.algorithm = ENV['WARDEN_JWT_ALGORITHM']
end
If the algorithm is asymmetric (e.g. RS256) and necessitates a different decoding secret than the encoding secret, configure the decoding_secret
setting as well.
Warden::JWTAuth.configure do |config|
config.secret = OpenSSL::PKey::RSA.new(ENV['WARDEN_JWT_PRIVATE_KEY'])
config.decoding_secret = OpenSSL::PKey::RSA.new(ENV['WARDEN_JWT_PUBLIC_KEY'])
config.algorithm = 'RS256' # or other asymmetric algorithm
end
You have to map the warden scopes that will be authenticatable through JWT, with the user repositories from where these scope user records can be fetched. If a string is supplied, the user repository will first be looked up as a constant.
For instance:
config.mappings = { user: UserRepository }
For this example, UserRepository
must implement a method find_for_jwt_authentication
that takes as argument the sub
claim in the JWT payload. This method should return a user record from :user
scope:
module UserRepository
# @returns User
def self.find_for_jwt_authentication(sub)
Repo.find_user_by_id(sub)
end
end
User records must implement a jwt_subject
method returning what should be encoded in the sub
claim on dispatch time. Be aware that what is returned must be coercible to string in order to conform with RFC7519 standard for sub
claim.
User = Struct.new(:id, :name)
def jwt_subject
id
end
end
User records may also implement a jwt_payload
method, which gives it a chance to add something to the JWT payload:
def jwt_payload
{ 'foo' => 'bar' }
end
Just when a token is going to be dispatched to a client, a hook method on_jwt_dispatch
is invoked, only when it exist, on the user record. This method takes the token
and the payload
as arguments.
def on_jwt_dispatch(token, payload)
# Do something
end
You need to add Warden::JWTAuth::Middleware
to your rack middlewares stack. Actually, it is just a wrapper which adds two middlewares that do the actual job: dispatching tokens and revoking tokens.
You need to tell which requests will dispatch tokens for the user that has been previously authenticated (usually through some other warden strategy, such as one requiring username and email parameters).
To configure it, you must provide 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:
config.dispatch_requests = [
['POST', %r{^/sign_in$}]
]
Important: You are encouraged to delimit your regular expression with ^
and $
to avoid unintentional matches.
Tokens will be returned in the Authorization
response header, with format Bearer #{token}
.
Once you have a valid token, you can authenticate following requests providing the token in the Authorization
request header, with format Bearer #{token}
.
You need to tell which requests will revoke incoming JWT tokens.
To configure it, you must provide 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:
config.revocation_requests = [
['DELETE', %r{^/sign_out$}]
]
Important: You are encouraged to delimit your regular expression with ^
and $
to avoid unintentional matches.
Besides, you need to configure which revocation strategy will be used for each scope. If a string is supplied, the revocation strategy will first be looked up as a constant.
config.revocation_strategies = { user: RevocationStrategy }
The implementation of the revocation strategy is also on your side. They just need to implement two methods: jwt_revoked?
and revoke_jwt
, both of them accepting as parameters the JWT payload and the user record, in this order.
You can read about which JWT recovation strategies can be implement with their pros and cons.
module RevocationStrategy
def self.jwt_revoked?(payload, user)
# Does something to check whether the JWT token is revoked for given user
end
def self.revoke_jwt(payload, user)
# Does something to revoke the JWT token for given user
end
end
Authentication will be refused if a client requesting to be authenticated through a token is not the same to which it was originally issued. To do so, the content of the header JWT_AUD
(configurable via config.aud_header
) is stored as aud
claim. If you don't want to differentiate between clients, you don't need to provide that header.
Important: Be aware that this workflow is not bullet proof. In some scenarios a user can handcraft the request headers, therefore being able to impersonate any client. In such cases you could need something more robust, like an OAuth workflow with client id and client secret.
Secret rotation is supported by setting rotation_secret
. Set the new secret as the secret
and copy the previous secret to rotation_secret
Warden::JWTAuth.configure do |config|
config.secret = ENV['WARDEN_JWT_SECRET_KEY']
config.rotation_secret = ENV['WARDEN_JWT_SECRET_KEY_ROTATION']
end
You can remove the rotation_secret
when you are condifent that large enough user base has the fetched the token encrypted with the new secret.
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 at https://github.com/waiting-for-dev/warden-jwt_auth. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
warden-jwt_auth
follows the principles of semantic versioning.
The gem is available as open source under the terms of the MIT License.