├── .codeclimate.yml ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── lint.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── docker-compose.yml ├── issue_template.md ├── lib └── warden │ ├── jwt_auth.rb │ └── jwt_auth │ ├── env_helper.rb │ ├── errors.rb │ ├── header_parser.rb │ ├── hooks.rb │ ├── interfaces.rb │ ├── middleware.rb │ ├── middleware │ ├── revocation_manager.rb │ └── token_dispatcher.rb │ ├── payload_user_helper.rb │ ├── strategy.rb │ ├── token_decoder.rb │ ├── token_encoder.rb │ ├── token_revoker.rb │ ├── user_decoder.rb │ ├── user_encoder.rb │ └── version.rb ├── spec ├── spec_helper.rb ├── support │ ├── fixtures.rb │ └── shared_contexts │ │ ├── configuration.rb │ │ ├── feature.rb │ │ ├── fixtures.rb │ │ └── middleware.rb └── warden │ ├── jwt_auth │ ├── env_helper_spec.rb │ ├── features │ │ ├── authorization_spec.rb │ │ ├── token_dispatch_spec.rb │ │ └── token_revocation_spec.rb │ ├── header_parser_spec.rb │ ├── hooks_spec.rb │ ├── middleware │ │ ├── revocation_manager_spec.rb │ │ └── token_dispatcher_spec.rb │ ├── middleware_spec.rb │ ├── payload_user_helper_spec.rb │ ├── strategy_spec.rb │ ├── token_decoder_spec.rb │ ├── token_encoder_spec.rb │ ├── token_revoker_spec.rb │ ├── user_decoder_spec.rb │ └── user_encoder_spec.rb │ └── jwt_auth_spec.rb └── warden-jwt_auth.gemspec /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | duplication: 3 | enabled: true 4 | config: 5 | languages: 6 | - ruby 7 | fixme: 8 | enabled: true 9 | rubocop: 10 | enabled: true 11 | ratings: 12 | paths: 13 | - "**.rb" 14 | exclude_paths: 15 | - spec/ 16 | - Gemfile 17 | - warden-jwt_auth.gemspec 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: waiting-for-dev 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby-version: ['3.1', '3.2', '3.3', '3.4', ruby-head] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Ruby ${{ matrix.ruby-version }} 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby-version }} 18 | bundler-cache: true # 'bundle install' and cache 19 | - name: Run specs 20 | run: | 21 | bundle exec rspec 22 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Ruby ${{ matrix.ruby-version }} 11 | uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: 2.7 14 | bundler-cache: true # 'bundle install' and cache 15 | - name: Run specs 16 | run: | 17 | bundle exec rubocop 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .overcommit_gems.rb.lock 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | AllCops: 3 | TargetRubyVersion: 2.7 4 | Exclude: 5 | - Gemfile 6 | - warden-jwt_auth.gemspec 7 | - spec/support/shared_contexts/*rb 8 | - vendor/**/* 9 | RSpec/NestedGroups: 10 | Max: 3 11 | RSpec/MessageExpectation: 12 | EnforcedStyle: 'expect' 13 | Metrics/BlockLength: 14 | Exclude: 15 | - "spec/**/*.rb" 16 | Style/SafeNavigation: 17 | Enabled: false 18 | Layout/EmptyLinesAroundAttributeAccessor: 19 | Enabled: true 20 | Layout/SpaceAroundMethodCallOperator: 21 | Enabled: true 22 | Lint/DeprecatedOpenSSLConstant: 23 | Enabled: true 24 | Lint/MixedRegexpCaptureTypes: 25 | Enabled: true 26 | Lint/RaiseException: 27 | Enabled: true 28 | Lint/StructNewOverride: 29 | Enabled: true 30 | Style/AccessorGrouping: 31 | Enabled: true 32 | Style/BisectedAttrAccessor: 33 | Enabled: true 34 | Style/ExponentialNotation: 35 | Enabled: true 36 | Style/HashEachMethods: 37 | Enabled: true 38 | Style/HashTransformKeys: 39 | Enabled: true 40 | Style/HashTransformValues: 41 | Enabled: true 42 | Style/RedundantAssignment: 43 | Enabled: true 44 | Style/RedundantFetchBlock: 45 | Enabled: true 46 | Style/RedundantRegexpCharacterClass: 47 | Enabled: true 48 | Style/RedundantRegexpEscape: 49 | Enabled: true 50 | Style/SlicingWithRange: 51 | Enabled: true 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [0.11.0] - 2024-12-20 8 | - Prevent strategy from running when the current path matches a dispatch request ([60](https://github.com/waiting-for-dev/warden-jwt_auth/pull/60)) 9 | 10 | ## [0.10.1] - 2024-12-15 11 | - Fix version mismatch 12 | 13 | ## [0.8.0] - 2024-06-28 14 | - Add support for issue claim ([56](https://github.com/waiting-for-dev/warden-jwt_auth/pull/56)) 15 | 16 | ## [0.8.0] - 2023-01-31 17 | - Add support for secret rotation ([49](https://github.com/waiting-for-dev/warden-jwt_auth/pull/49)) 18 | - Support dry-* v1 ([52](https://github.com/waiting-for-dev/warden-jwt_auth/pull/52)) 19 | 20 | ## [0.7.0] - 2022-09-12 21 | - Support asymmetric algorithms ([40](https://github.com/waiting-for-dev/warden-jwt_auth/issues/40)) 22 | 23 | ## [0.6.0] - 2021-09-21 24 | - Support ruby 3.0 and deprecate 2.5 25 | - Fixed dry-configurable compatibility. ([28](https://github.com/waiting-for-dev/warden-jwt_auth/issues/28)) 26 | 27 | ## [0.5.0] 28 | ### Fixed 29 | - Fixed dry-configurable compatibility. ([28](https://github.com/waiting-for-dev/warden-jwt_auth/issues/28)) 30 | 31 | ## [0.4.2] - 2020-03-19 32 | ### Fixed 33 | - Lock dry-configurable dependency to fix upstream regression. ([21](https://github.com/waiting-for-dev/warden-jwt_auth/issues/21)) 34 | - Fix ruby 2.7 warnings (@trevorrjohn [23](https://github.com/waiting-for-dev/warden-jwt_auth/pull/23) ) 35 | 36 | ## [0.4.1] - 2020-02-23 37 | ### Fixed 38 | - Upgrade dry-configurable dependency to fix upstream bug preventing 39 | warden-jwt_auth to be loaded ([21](https://github.com/waiting-for-dev/warden-jwt_auth/issues/21)). 40 | 41 | ## [0.4.0] - 2019-08-01 42 | ### Added 43 | - Allow configuration of the signing algorithm ([19](https://github.com/waiting-for-dev/warden-jwt_auth/pull/19)]. 44 | 45 | ## [0.3.6] - 2019-03-29 46 | ### Fixed 47 | - Update depencies. 48 | 49 | ## [0.3.5] - 2018-01-30 50 | ### Fixed 51 | - Do not disallow fetching JWT scopes from session 52 | 53 | ## [0.3.4] - 2018-01-09 54 | ### Fixed 55 | - Do not log out from session for standard AJAX requests 56 | 57 | ## [0.3.3] - 2017-12-31 58 | ### Fixed 59 | - Check it is not a html request when disallowing fetching from session 60 | 61 | ## [0.3.2] - 2017-12-23 62 | ### Fixed 63 | - Do not couple `aud_header` env value to the setting 64 | 65 | ## [0.3.1] - 2017-12-11 66 | ### Added 67 | - Ensure JWT scopes are not fetched from session. Workaround for 68 | https://github.com/hassox/warden/pull/118 69 | 70 | ## [0.3.0] - 2017-12-06 71 | ### Added 72 | - Add and call hook method `on_jwt_dispatch` on user instance 73 | - Encode and validate an `aud` claim from the request headers 74 | 75 | ## [0.2.1] - 2017-12-04 76 | ### Added 77 | - Allow configuring classes as strings 78 | 79 | ### Fixed 80 | - Take `PATH_INFO` as an empty string when it is not present 81 | 82 | ## [0.2.0] - 2017-11-23 83 | ### Added 84 | - `fail!` with message 85 | 86 | ### Fixed 87 | - Unauthorize when fetched user is nil 88 | 89 | ## [0.1.4] - 2017-11-21 90 | ### Fixed 91 | - Update `jwt` dependency 92 | 93 | ## [0.1.3] - 2017-04-15 94 | ### Fixed 95 | - Coerce `sub` to string to conform with JWT specification 96 | 97 | ## [0.1.2] - 2017-04-13 98 | ### Fixed 99 | - Ignore expired tokens on revocation instead of fail 100 | 101 | ## [0.1.1] - 2017-02-28 102 | ### Fixed 103 | - Explicit require of `securerandom` standard library 104 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at marc@lamarciana.com. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.0.0 2 | ENV APP_USER warden_jwt_auth_user 3 | RUN useradd -ms /bin/bash $APP_USER 4 | USER $APP_USER 5 | WORKDIR /home/$APP_USER/app 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in warden-jwt_auth.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Marc Busqué 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Warden::JWTAuth 2 | 3 | [![Gem Version](https://badge.fury.io/rb/warden-jwt_auth.svg)](https://badge.fury.io/rb/warden-jwt_auth) 4 | [![Build Status](https://travis-ci.org/waiting-for-dev/warden-jwt_auth.svg?branch=master)](https://travis-ci.org/waiting-for-dev/warden-jwt_auth) 5 | [![Code Climate](https://codeclimate.com/github/waiting-for-dev/warden-jwt_auth/badges/gpa.svg)](https://codeclimate.com/github/waiting-for-dev/warden-jwt_auth) 6 | [![Test Coverage](https://codeclimate.com/github/waiting-for-dev/warden-jwt_auth/badges/coverage.svg)](https://codeclimate.com/github/waiting-for-dev/warden-jwt_auth/coverage) 7 | 8 | `warden-jwt_auth` is a [warden](https://github.com/hassox/warden) extension which uses [JWT](https://jwt.io/) tokens for user authentication. It follows [secure by default](https://en.wikipedia.org/wiki/Secure_by_default) principle. 9 | 10 | This gem is just a replacement for cookies when these can't be used. As 11 | cookies, a token expired with `warden-jwt_auth` will mandatorily have an 12 | expiration time. If you need that your users never sign out, you will be better 13 | off with a solution using refresh tokens, like some implementation of OAuth2. 14 | 15 | You can read about which security concerns this library takes into account and about JWT generic secure usage in the following series of posts: 16 | 17 | - [Stand Up for JWT Revocation](http://waiting-for-dev.github.io/blog/2017/01/23/stand_up_for_jwt_revocation) 18 | - [JWT Revocation Strategies](http://waiting-for-dev.github.io/blog/2017/01/24/jwt_revocation_strategies) 19 | - [JWT Secure Usage](http://waiting-for-dev.github.io/blog/2017/01/25/jwt_secure_usage) 20 | - [A secure JWT authentication implementation for Rack and Rails](http://waiting-for-dev.github.io/blog/2017/01/26/a_secure_jwt_authentication_implementation_for_rack_and_rails) 21 | 22 | If what you need is a JWT authentication library for [devise](https://github.com/plataformatec/devise), better look at [devise-jwt](https://github.com/waiting-for-dev/devise-jwt), which is just a thin layer on top of this gem. 23 | 24 | ## Installation 25 | 26 | ```ruby 27 | gem 'warden-jwt_auth' 28 | ``` 29 | 30 | And then execute: 31 | 32 | $ bundle 33 | 34 | Or install it yourself as: 35 | 36 | $ gem install warden-jwt_auth 37 | 38 | ## Usage 39 | 40 | You can look at this gem's wiki to see some [example applications](https://github.com/waiting-for-dev/warden-jwt_auth/wiki). Please, add yours if you think it can help somebody. 41 | 42 | At its core, this library consists of: 43 | 44 | - A Warden strategy that authenticates a user if a valid JWT token is present in the request headers. 45 | - A rack middleware which adds a JWT token to the response headers in configured requests. 46 | - A rack middleware which revokes JWT tokens in configured requests. 47 | 48 | As you see, JWT revocation is supported. I wrote [why I think JWT tokens revocation is useful and needed](http://waiting-for-dev.github.io/blog/2017/01/23/stand_up_for_jwt_revocation/). 49 | 50 | ### Secret key configuration 51 | 52 | First of all, you have to configure the secret key that will be used to sign generated tokens. 53 | 54 | ```ruby 55 | Warden::JWTAuth.configure do |config| 56 | config.secret = ENV['WARDEN_JWT_SECRET_KEY'] 57 | end 58 | ``` 59 | 60 | **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. 61 | 62 | Currently, HS256 algorithm is the default. 63 | Configure the matching secret and algorithm name to use a different one (e.g. RS256) (see [ruby-jwt](https://github.com/jwt/ruby-jwt#algorithms-and-usage) to see which are supported) 64 | ```ruby 65 | Warden::JWTAuth.configure do |config| 66 | config.secret = OpenSSL::PKey::RSA.new(ENV['WARDEN_JWT_SECRET_KEY']) 67 | config.algorithm = ENV['WARDEN_JWT_ALGORITHM'] 68 | end 69 | ``` 70 | 71 | 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. 72 | 73 | ```ruby 74 | Warden::JWTAuth.configure do |config| 75 | config.secret = OpenSSL::PKey::RSA.new(ENV['WARDEN_JWT_PRIVATE_KEY']) 76 | config.decoding_secret = OpenSSL::PKey::RSA.new(ENV['WARDEN_JWT_PUBLIC_KEY']) 77 | config.algorithm = 'RS256' # or other asymmetric algorithm 78 | end 79 | ``` 80 | 81 | ### Warden scopes configuration 82 | 83 | 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. 84 | 85 | For instance: 86 | 87 | ```ruby 88 | config.mappings = { user: UserRepository } 89 | ``` 90 | 91 | 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: 92 | 93 | ```ruby 94 | module UserRepository 95 | # @returns User 96 | def self.find_for_jwt_authentication(sub) 97 | Repo.find_user_by_id(sub) 98 | end 99 | end 100 | ``` 101 | 102 | 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](https://tools.ietf.org/html/rfc7519#section-4.1.2). 103 | 104 | ```ruby 105 | User = Struct.new(:id, :name) 106 | def jwt_subject 107 | id 108 | end 109 | end 110 | ``` 111 | 112 | User records may also implement a `jwt_payload` method, which gives it a chance to add something to the JWT payload: 113 | 114 | ```ruby 115 | def jwt_payload 116 | { 'foo' => 'bar' } 117 | end 118 | ``` 119 | 120 | 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. 121 | 122 | ```ruby 123 | def on_jwt_dispatch(token, payload) 124 | # Do something 125 | end 126 | ``` 127 | 128 | ### Middlewares addition 129 | 130 | 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. 131 | 132 | ### Token dispatch configuration 133 | 134 | 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). 135 | 136 | 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. 137 | 138 | For example: 139 | 140 | ```ruby 141 | config.dispatch_requests = [ 142 | ['POST', %r{^/sign_in$}] 143 | ] 144 | ``` 145 | 146 | **Important**: You are encouraged to delimit your regular expression with `^` and `$` to avoid unintentional matches. 147 | 148 | Tokens will be returned in the `Authorization` response header (configurable via `config.token_header`), with format `Bearer #{token}`. 149 | 150 | ### Requests authentication 151 | 152 | Once you have a valid token, you can authenticate following requests providing the token in the `Authorization` request header, with format `Bearer #{token}`. 153 | 154 | ### Revocation configuration 155 | 156 | You need to tell which requests will revoke incoming JWT tokens. 157 | 158 | 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. 159 | 160 | For example: 161 | 162 | ```ruby 163 | config.revocation_requests = [ 164 | ['DELETE', %r{^/sign_out$}] 165 | ] 166 | ``` 167 | 168 | **Important**: You are encouraged to delimit your regular expression with `^` and `$` to avoid unintentional matches. 169 | 170 | 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. 171 | 172 | ```ruby 173 | config.revocation_strategies = { user: RevocationStrategy } 174 | ``` 175 | 176 | 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. 177 | 178 | You can read about which [JWT recovation strategies](http://waiting-for-dev.github.io/blog/2017/01/24/jwt_revocation_strategies) can be implement with their pros and cons. 179 | 180 | ```ruby 181 | module RevocationStrategy 182 | def self.jwt_revoked?(payload, user) 183 | # Does something to check whether the JWT token is revoked for given user 184 | end 185 | 186 | def self.revoke_jwt(payload, user) 187 | # Does something to revoke the JWT token for given user 188 | end 189 | end 190 | ``` 191 | 192 | ### Requesting client validation 193 | 194 | 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. 195 | 196 | **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. 197 | 198 | ### Secret rotation 199 | 200 | Secret rotation is supported by setting `rotation_secret`. Set the new secret as the `secret` and copy the previous secret to `rotation_secret` 201 | 202 | ```ruby 203 | Warden::JWTAuth.configure do |config| 204 | config.secret = ENV['WARDEN_JWT_SECRET_KEY'] 205 | config.rotation_secret = ENV['WARDEN_JWT_SECRET_KEY_ROTATION'] 206 | end 207 | ``` 208 | 209 | 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. 210 | 211 | ### Multiple issuers 212 | 213 | When your application handles JWT tokens from multiple sources (e.g. webhooks authenticated via provider JTW tokens) you can configure this gem to use the [issuer claim](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1) to only handle tokens it has issued. 214 | 215 | ```ruby 216 | Warden::JWTAuth.configure do |config| 217 | config.secret = ENV['WARDEN_JWT_SECRET_KEY'] 218 | config.issuer = 'http://my-application.com' 219 | end 220 | ``` 221 | 222 | ## Development 223 | 224 | 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: 225 | 226 | `docker-compose up -d` 227 | 228 | An then, for example: 229 | 230 | `docker-compose exec app rspec` 231 | 232 | ## Contributing 233 | 234 | 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](http://contributor-covenant.org) code of conduct. 235 | 236 | ## Release Policy 237 | 238 | `warden-jwt_auth` follows the principles of [semantic versioning](http://semver.org/). 239 | 240 | ## License 241 | 242 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 243 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # !/usr/bin/env ruby 4 | 5 | require 'bundler/setup' 6 | require 'warden/jwt_auth' 7 | 8 | # You can add fixtures and/or initialization code here to make experimenting 9 | # with your gem easier. You can also use a different console, if you like. 10 | 11 | # (If you use this, don't forget to add pry to your Gemfile!) 12 | # require "pry" 13 | # Pry.start 14 | 15 | require 'irb' 16 | IRB.start 17 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | app: 4 | build: . 5 | image: warden_jwt_auth 6 | command: bash -c "bundle && tail -f Gemfile" 7 | volumes: 8 | - .:/home/warden_jwt_auth_user/app 9 | tty: true 10 | stdin_open: true 11 | tmpfs: 12 | - /tmp 13 | -------------------------------------------------------------------------------- /issue_template.md: -------------------------------------------------------------------------------- 1 | Please, for a bug report fill in the following template. Before that, make sure to read the whole [README](https://github.com/waiting-for-dev/warden-jwt_auth/blob/master/README.md). 2 | 3 | Feature requests and questions about `warden-jwt_auth` are also accepted. 4 | 5 | ## Expected behavior 6 | 7 | ## Actual behavior 8 | 9 | ## Steps to Reproduce the Problem 10 | 11 | 1. 12 | 2. 13 | 3. 14 | 15 | ## Debugging information 16 | 17 | Provide following information. Please, format pasted output as code. Feel free to remove the secret key value. 18 | 19 | - Version of `warden-jwt_auth` in use 20 | - Output of `Warden::JWTAuth.config` 21 | - If your issue is related with not getting a JWT from the server: 22 | - Involved request path, method and request headers 23 | - Response headers for that request 24 | - If your issue is related with not being able to revoke a JWT: 25 | - Involved request path, method and request headers 26 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dry/configurable' 4 | require 'dry/auto_inject' 5 | require 'jwt' 6 | require 'warden' 7 | 8 | module Warden 9 | # JWT authentication plugin for warden. 10 | # 11 | # It consists of a strategy which tries to authenticate an user decoding a 12 | # token present in the `Authentication` header (as `Bearer %token%`). 13 | # From it, it takes the `sub` claim and provides it to a configured repository 14 | # of users for the current scope. 15 | # 16 | # It also consists of two rack middlewares which perform two actions for 17 | # configured request paths: dispatching a token for a signed in user and 18 | # revoking an incoming token. 19 | module JWTAuth 20 | extend Dry::Configurable 21 | 22 | module_function 23 | 24 | def symbolize_keys(hash) 25 | hash.transform_keys(&:to_sym) 26 | end 27 | 28 | def upcase_first_items(array) 29 | array.map do |tuple| 30 | method, path = tuple 31 | [method.to_s.upcase, path] 32 | end 33 | end 34 | 35 | def constantize_values(hash) 36 | hash.transform_values do |value| 37 | value.is_a?(String) ? Object.const_get(value) : value 38 | end 39 | end 40 | 41 | # The secret used to encode the token 42 | setting :secret 43 | 44 | # The old secret used for rotation 45 | setting :rotation_secret 46 | 47 | # The secret used to decode the token, defaults to `secret` if not provided 48 | setting :decoding_secret, constructor: ->(value) { value || config.secret } 49 | 50 | # The algorithm used to encode the token 51 | setting :algorithm, default: 'HS256' 52 | 53 | # Expiration time for tokens 54 | setting :expiration_time, default: 3600 55 | 56 | # Request header that will be used for receiving and returning the token. 57 | setting :token_header, default: 'Authorization' 58 | 59 | # The issuer claims associated with the tokens 60 | # 61 | # Will be used to only apply the warden strategy when the issuer matches. 62 | # This allows for multiple token issuers being used. 63 | # @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 64 | setting :issuer, default: nil 65 | 66 | # Request header which value will be encoded as `aud` claim in JWT. If 67 | # the header is not present `aud` will be `nil`. 68 | setting :aud_header, default: 'JWT_AUD' 69 | 70 | # A hash of warden scopes as keys and user repositories as values. The 71 | # values can be either the constants themselves or the constant names. 72 | # 73 | # @see Interfaces::UserRepository 74 | # @see Interfaces::User 75 | setting(:mappings, 76 | default: {}, 77 | constructor: ->(value) { constantize_values(symbolize_keys(value)) }) 78 | 79 | # Array of tuples [request_method, request_path_regex] to match request 80 | # verbs and paths where a JWT token should be added to the `Authorization` 81 | # response header 82 | # 83 | # @example 84 | # [ 85 | # ['POST', %r{^/sign_in$}] 86 | # ] 87 | setting(:dispatch_requests, 88 | default: [], 89 | constructor: ->(value) { upcase_first_items(value) }) 90 | 91 | # Array of tuples [request_method, request_path_regex] to match request 92 | # verbs and paths where incoming JWT token should be be revoked 93 | # 94 | # @example 95 | # [ 96 | # ['DELETE', %r{^/sign_out$}] 97 | # ] 98 | setting(:revocation_requests, 99 | default: [], 100 | constructor: ->(value) { upcase_first_items(value) }) 101 | 102 | # Hash with scopes as keys and strategies to revoke tokens for that scope 103 | # as values. The values can be either the constants themselves or the 104 | # constant names. 105 | # 106 | # @example 107 | # { 108 | # user: UserRevocationStrategy 109 | # } 110 | # 111 | # @see Interfaces::RevocationStrategy 112 | setting(:revocation_strategies, 113 | default: {}, 114 | constructor: ->(value) { constantize_values(symbolize_keys(value)) }) 115 | 116 | Import = Dry::AutoInject(config) 117 | end 118 | end 119 | 120 | require 'warden/jwt_auth/version' 121 | require 'warden/jwt_auth/header_parser' 122 | require 'warden/jwt_auth/payload_user_helper' 123 | require 'warden/jwt_auth/env_helper' 124 | require 'warden/jwt_auth/user_encoder' 125 | require 'warden/jwt_auth/user_decoder' 126 | require 'warden/jwt_auth/token_encoder' 127 | require 'warden/jwt_auth/token_decoder' 128 | require 'warden/jwt_auth/token_revoker' 129 | require 'warden/jwt_auth/hooks' 130 | require 'warden/jwt_auth/strategy' 131 | require 'warden/jwt_auth/middleware' 132 | require 'warden/jwt_auth/interfaces' 133 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/env_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | # Helper functions to centralize working with rack env. 6 | # 7 | # It follows 8 | # [rack](http://www.rubydoc.info/github/rack/rack/file/SPEC#The_Environment) 9 | # and [PEP 333](https://www.python.org/dev/peps/pep-0333/#environ-variables) 10 | # conventions. 11 | module EnvHelper 12 | # Returns PATH_INFO environment variable 13 | # 14 | # @param env [Hash] Rack env 15 | # @return [String] 16 | def self.path_info(env) 17 | env['PATH_INFO'] || '' 18 | end 19 | 20 | # Returns REQUEST_METHOD environment variable 21 | # 22 | # @param env [Hash] Rack env 23 | # @return [String] 24 | def self.request_method(env) 25 | env['REQUEST_METHOD'] 26 | end 27 | 28 | # Returns header configured through `token_header` option 29 | # 30 | # @param env [Hash] Rack env 31 | # @return [String] 32 | def self.authorization_header(env) 33 | header_env_name = env_name(JWTAuth.config.token_header) 34 | env[header_env_name] 35 | end 36 | 37 | # Returns a copy of `env` with value added to the environment variable 38 | # configured through `token_header` option 39 | # 40 | # Be aware than `env` is not modified in place and still an updated copy 41 | # is returned. 42 | # 43 | # @param env [Hash] Rack env 44 | # @param value [String] 45 | # @return [Hash] modified rack env 46 | def self.set_authorization_header(env, value) 47 | env = env.dup 48 | header_env_name = env_name(JWTAuth.config.token_header) 49 | env[header_env_name] = value 50 | env 51 | end 52 | 53 | # Returns header configured through `aud_header` option 54 | # 55 | # @param env [Hash] Rack env 56 | # @return [String] 57 | def self.aud_header(env) 58 | header_env_name = env_name(JWTAuth.config.aud_header) 59 | env[header_env_name] 60 | end 61 | 62 | # Returns the ENV name for a given header 63 | # 64 | # @param header [String] Header name 65 | # @return [String] 66 | def self.env_name(header) 67 | ('HTTP_' + header.upcase).tr('-', '_') 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | module Errors 6 | # Error raised when trying to decode a token that has been revoked for an 7 | # user 8 | class RevokedToken < JWT::DecodeError 9 | end 10 | 11 | # Error raised when the user decoded from a token is nil 12 | class NilUser < JWT::DecodeError 13 | end 14 | 15 | # Error raised when trying to decode a token for an scope that doesn't 16 | # match the one encoded in the payload 17 | class WrongScope < JWT::DecodeError 18 | end 19 | 20 | # Error raised when trying to decode a token which `aud` claim does not 21 | # match with the expected one 22 | class WrongAud < JWT::DecodeError 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/header_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | # Helpers to parse token from a request and to a response 6 | module HeaderParser 7 | # Method for `Authorization` header. Token is present in request/response 8 | # headers as `Bearer %token%` 9 | METHOD = 'Bearer' 10 | 11 | # Parses the token from a rack request 12 | # 13 | # @param env [Hash] rack env hash 14 | # @return [String] JWT token 15 | # @return [nil] if token is not present 16 | def self.from_env(env) 17 | auth = EnvHelper.authorization_header(env) 18 | return nil unless auth 19 | 20 | method, token = auth.split 21 | method == METHOD ? token : nil 22 | end 23 | 24 | # Returns a copy of `env` with token added to the header configured through 25 | # `token_header` option. Be aware than `env` is not modified in place. 26 | # 27 | # @param env [Hash] rack env hash 28 | # @param token [String] JWT token 29 | # @return [Hash] modified rack env 30 | def self.to_env(env, token) 31 | EnvHelper.set_authorization_header(env, "#{METHOD} #{token}") 32 | end 33 | 34 | # Returns a copy of headers with token added in the `Authorization` key. 35 | # Be aware that headers is not modified in place 36 | # 37 | # @param headers [Hash] rack hash response headers 38 | # @param token [String] JWT token 39 | # @return [Hash] response headers with the token added 40 | def self.to_headers(headers, token) 41 | headers = headers.dup 42 | headers[JWTAuth.config.token_header] = "#{METHOD} #{token}" 43 | headers 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/hooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | # Warden hooks 6 | class Hooks 7 | include JWTAuth::Import['mappings', 'dispatch_requests', 'aud_header'] 8 | 9 | # `env` key where JWT is added 10 | PREPARED_TOKEN_ENV_KEY = 'warden-jwt_auth.token' 11 | 12 | # Adds a token for the signed in user to the request `env` if current path 13 | # and verb match with configuration. This will be picked up later on by a 14 | # rack middleware which will add it to the response headers. 15 | # 16 | # @see https://github.com/hassox/warden/wiki/Callbacks 17 | def self.after_set_user(user, auth, opts) 18 | new.send(:prepare_token, user, auth, opts) 19 | end 20 | 21 | private 22 | 23 | def prepare_token(user, auth, opts) 24 | env = auth.env 25 | scope = opts[:scope] 26 | return unless token_should_be_added?(scope, env) 27 | 28 | add_token_to_env(user, scope, env) 29 | end 30 | 31 | def token_should_be_added?(scope, env) 32 | path_info = EnvHelper.path_info(env) 33 | method = EnvHelper.request_method(env) 34 | jwt_scope?(scope) && request_matches?(path_info, method) 35 | end 36 | 37 | def add_token_to_env(user, scope, env) 38 | aud = EnvHelper.aud_header(env) 39 | token, payload = UserEncoder.new.call(user, scope, aud) 40 | user.on_jwt_dispatch(token, payload) if user.respond_to?(:on_jwt_dispatch) 41 | env[PREPARED_TOKEN_ENV_KEY] = token 42 | end 43 | 44 | def jwt_scope?(scope) 45 | jwt_scopes = mappings.keys 46 | jwt_scopes.include?(scope) 47 | end 48 | 49 | def request_matches?(path_info, method) 50 | dispatch_requests.each do |(dispatch_method, dispatch_path)| 51 | return true if path_info.match(dispatch_path) && 52 | method == dispatch_method 53 | end 54 | false 55 | end 56 | end 57 | end 58 | end 59 | 60 | Warden::Manager.after_set_user do |user, auth, opts| 61 | Warden::JWTAuth::Hooks.after_set_user(user, auth, opts) 62 | end 63 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/interfaces.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | # Interfaces expected to be implemented in applications working with this 6 | # gem 7 | module Interfaces 8 | # Repository that returns [User] 9 | class UserRepository 10 | # Finds and returns an [User] 11 | # 12 | # @param _sub [String] JWT sub claim 13 | # @return [User] 14 | def find_for_jwt_authentication(_sub) 15 | raise NotImplementedError 16 | end 17 | end 18 | 19 | # An user 20 | class User 21 | # What will be encoded as `sub` claim. It must respond to `#to_s`. 22 | # 23 | # @return [#to_s] `sub` claim 24 | def jwt_subject 25 | raise NotImplementedError 26 | end 27 | 28 | # Allows adding extra claims to be encoded within the payload 29 | # 30 | # @return [Hash] claims to be merged with defaults 31 | def jwt_payload 32 | {} 33 | end 34 | 35 | # Does something just after a JWT for the user has been dispatched. 36 | # 37 | # @param _token [String] 38 | # @param _payload [Hash] 39 | def on_jwt_dispatch(_token, _payload) 40 | raise NotImplementedError 41 | end 42 | end 43 | 44 | # Strategy to manage JWT revocation 45 | class RevocationStrategy 46 | # Does something to revoke a JWT payload 47 | # 48 | # @param _payload [Hash] 49 | # @param _user [User] 50 | def revoke_jwt(_payload, _user) 51 | raise NotImplementedError 52 | end 53 | 54 | # Returns whether a JWT payload is revoked 55 | # 56 | # @param _payload [Hash] 57 | # @param _user [User] 58 | # @return [Boolean] 59 | def jwt_revoked?(_payload, _user) 60 | raise NotImplementedError 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'warden/jwt_auth/middleware/token_dispatcher' 4 | require 'warden/jwt_auth/middleware/revocation_manager' 5 | 6 | module Warden 7 | module JWTAuth 8 | # Simple rack middleware which is just a wrapper for other middlewares which 9 | # actually perform some work. 10 | class Middleware 11 | attr_reader :app 12 | 13 | def initialize(app) 14 | @app = app 15 | end 16 | 17 | def call(env) 18 | builder = Rack::Builder.new 19 | builder.use(RevocationManager) 20 | builder.use(TokenDispatcher) 21 | builder.run(app) 22 | builder.call(env) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/middleware/revocation_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | class Middleware 6 | # Revokes a token if it path and method match with configured 7 | class RevocationManager < Middleware 8 | # Debugging key added to `env` 9 | ENV_KEY = 'warden-jwt_auth.revocation_manager' 10 | 11 | attr_reader :app, :config, :helper 12 | 13 | def initialize(app) 14 | @app = app 15 | @config = JWTAuth.config 16 | @helper = EnvHelper 17 | end 18 | 19 | def call(env) 20 | env[ENV_KEY] = true 21 | response = app.call(env) 22 | revoke_token(env) 23 | response 24 | end 25 | 26 | private 27 | 28 | def revoke_token(env) 29 | token = HeaderParser.from_env(env) 30 | path_info = EnvHelper.path_info(env) 31 | method = EnvHelper.request_method(env) 32 | return unless token && token_should_be_revoked?(path_info, method) 33 | 34 | TokenRevoker.new.call(token) 35 | end 36 | 37 | def token_should_be_revoked?(path_info, method) 38 | revocation_requests = config.revocation_requests 39 | revocation_requests.each do |tuple| 40 | revocation_method, revocation_path = tuple 41 | return true if path_info.match(revocation_path) && 42 | method == revocation_method 43 | end 44 | false 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/middleware/token_dispatcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | class Middleware 6 | # Dispatches a token (adds it to `Authorization` response header) if it 7 | # has been added to the request `env` by [Hooks] 8 | class TokenDispatcher < Middleware 9 | # Debugging key added to `env` 10 | ENV_KEY = 'warden-jwt_auth.token_dispatcher' 11 | 12 | attr_reader :app 13 | 14 | def initialize(app) 15 | @app = app 16 | end 17 | 18 | def call(env) 19 | env[ENV_KEY] = true 20 | status, headers, response = app.call(env) 21 | headers = headers_with_token(env, headers) 22 | [status, headers, response] 23 | end 24 | 25 | private 26 | 27 | def headers_with_token(env, headers) 28 | token = env[Hooks::PREPARED_TOKEN_ENV_KEY] 29 | token ? HeaderParser.to_headers(headers, token) : headers 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/payload_user_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | # Helper functions to deal with user info present in a decode payload 6 | module PayloadUserHelper 7 | # Returns user encoded in given payload 8 | # 9 | # @param payload [Hash] JWT payload 10 | # @return [Interfaces::User] an user, whatever it is 11 | def self.find_user(payload) 12 | config = JWTAuth.config 13 | scope = payload['scp'].to_sym 14 | user_repo = config.mappings[scope] 15 | user_repo.find_for_jwt_authentication(payload['sub']) 16 | end 17 | 18 | # Returns whether given scope matches with the one encoded in the payload 19 | # @param payload [Hash] JWT payload 20 | # @return [Boolean] 21 | def self.scope_matches?(payload, scope) 22 | payload['scp'] == scope.to_s 23 | end 24 | 25 | # Returns whether given aud matches with the one encoded in the payload 26 | # @param payload [Hash] JWT payload 27 | # @return [Boolean] 28 | def self.aud_matches?(payload, aud) 29 | payload['aud'] == aud 30 | end 31 | 32 | # Returns whether given issuer matches with the one encoded in the payload 33 | # @param payload [Hash] JWT payload 34 | # @param issuer [String] The issuer to match 35 | # @return [Boolean] 36 | def self.issuer_matches?(payload, issuer) 37 | payload['iss'] == issuer.to_s 38 | end 39 | 40 | # Returns the payload to encode for a given user in a scope 41 | # 42 | # @param user [Interfaces::User] an user, whatever it is 43 | # @param scope [Symbol] A Warden scope 44 | # @return [Hash] payload to encode 45 | def self.payload_for_user(user, scope) 46 | sub = user.jwt_subject 47 | payload = { 'sub' => String(sub), 'scp' => scope.to_s } 48 | return payload unless user.respond_to?(:jwt_payload) 49 | 50 | user.jwt_payload.merge(payload) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/strategy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'warden' 4 | 5 | module Warden 6 | module JWTAuth 7 | # Warden strategy to authenticate an user through a JWT token in the 8 | # `Authorization` request header 9 | class Strategy < Warden::Strategies::Base 10 | include JWTAuth::Import['dispatch_requests'] 11 | 12 | def valid? 13 | token_exists? && issuer_claim_valid? && !path_is_dispatch_request_path? 14 | end 15 | 16 | def store? 17 | false 18 | end 19 | 20 | def authenticate! 21 | aud = EnvHelper.aud_header(env) 22 | user = UserDecoder.new.call(token, scope, aud) 23 | success!(user) 24 | rescue JWT::DecodeError => e 25 | fail!(e.message) 26 | end 27 | 28 | private 29 | 30 | def path_is_dispatch_request_path? 31 | current_path = EnvHelper.path_info(env) 32 | request_method = EnvHelper.request_method(env) 33 | dispatch_requests.any? do |(dispatch_method, dispatch_path)| 34 | request_method == dispatch_method && current_path.match(dispatch_path) 35 | end 36 | end 37 | 38 | def issuer_claim_valid? 39 | configured_issuer = Warden::JWTAuth.config.issuer 40 | return true if configured_issuer.nil? 41 | 42 | payload = TokenDecoder.new.call(token) 43 | PayloadUserHelper.issuer_matches?(payload, configured_issuer) 44 | rescue JWT::DecodeError 45 | true 46 | end 47 | 48 | def token_exists? 49 | !token.nil? 50 | end 51 | 52 | def token 53 | @token ||= HeaderParser.from_env(env) 54 | end 55 | end 56 | end 57 | end 58 | 59 | Warden::Strategies.add(:jwt, Warden::JWTAuth::Strategy) 60 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/token_decoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwt/error' 4 | 5 | module Warden 6 | module JWTAuth 7 | # Decodes a JWT into a hash payload into a JWT token 8 | class TokenDecoder 9 | include JWTAuth::Import['decoding_secret', 'rotation_secret', 'algorithm'] 10 | 11 | # Decodes the payload from a JWT as a hash 12 | # 13 | # @see JWT.decode for all the exceptions than can be raised when given 14 | # token is invalid 15 | # 16 | # @param token [String] a JWT 17 | # @return [Hash] payload decoded from the JWT 18 | def call(token) 19 | decode(token, decoding_secret) 20 | rescue JWT::VerificationError 21 | decode(token, rotation_secret) 22 | end 23 | 24 | private 25 | 26 | def decode(token, secret) 27 | JWT.decode(token, 28 | secret, 29 | true, 30 | algorithm: algorithm, 31 | verify_jti: true)[0] 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/token_encoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | 5 | module Warden 6 | module JWTAuth 7 | # Encodes a payload into a JWT token, adding some configurable 8 | # claims 9 | class TokenEncoder 10 | include JWTAuth::Import['secret', 'algorithm', 'expiration_time', 'issuer'] 11 | 12 | # Encodes a payload into a JWT 13 | # 14 | # @param payload [Hash] what has to be encoded 15 | # @return [String] JWT 16 | def call(payload) 17 | payload_to_encode = merge_with_default_claims(payload) 18 | JWT.encode(payload_to_encode, secret, algorithm) 19 | end 20 | 21 | private 22 | 23 | def merge_with_default_claims(payload) 24 | now = Time.now.to_i 25 | payload['iat'] ||= now 26 | payload['exp'] ||= now + expiration_time 27 | payload['iss'] ||= issuer if issuer 28 | payload['jti'] ||= SecureRandom.uuid 29 | payload 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/token_revoker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | # Revokes a JWT using configured revocation strategy 6 | class TokenRevoker 7 | include JWTAuth::Import['revocation_strategies'] 8 | 9 | # Revokes the JWT token 10 | # 11 | # @param token [String] a JWT 12 | def call(token) 13 | payload = TokenDecoder.new.call(token) 14 | scope = payload['scp'].to_sym 15 | user = PayloadUserHelper.find_user(payload) 16 | revocation_strategies[scope].revoke_jwt(payload, user) 17 | # rubocop:disable Lint/SuppressedException 18 | rescue JWT::ExpiredSignature 19 | end 20 | # rubocop:enable Lint/SuppressedException 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/user_decoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'warden/jwt_auth/errors' 4 | 5 | module Warden 6 | module JWTAuth 7 | # Layer above token decoding which directly decodes a user from a JWT 8 | class UserDecoder 9 | include JWTAuth::Import['revocation_strategies'] 10 | 11 | attr_reader :helper 12 | 13 | def initialize(**args) 14 | super 15 | @helper = PayloadUserHelper 16 | end 17 | 18 | # Returns the user that is encoded in a JWT. The scope is used to choose 19 | # the user repository to which send `#find_for_jwt_authentication(sub)` 20 | # with decoded `sub` claim. 21 | # 22 | # @param token [String] a JWT 23 | # @param scope [Symbol] Warden scope 24 | # @param aud [String] Expected aud claim 25 | # @return [Interfaces::User] an user, whatever it is 26 | # @raise [Errors::RevokedToken] when token has been revoked for the 27 | # encoded user 28 | # @raise [Errors::NilUser] when decoded user is nil 29 | # @raise [Errors::WrongScope] when encoded scope does not match with scope 30 | # @raise [Errors::WrongAud] when encoded aud does not match with aud 31 | # argument 32 | def call(token, scope, aud) 33 | payload = TokenDecoder.new.call(token) 34 | check_valid_claims(payload, scope, aud) 35 | user = helper.find_user(payload) 36 | check_valid_user(payload, user, scope) 37 | user 38 | end 39 | 40 | private 41 | 42 | def check_valid_claims(payload, scope, aud) 43 | raise Errors::WrongScope, 'wrong scope' unless helper.scope_matches?(payload, scope) 44 | raise Errors::WrongAud, 'wrong aud' unless helper.aud_matches?(payload, aud) 45 | end 46 | 47 | def check_valid_user(payload, user, scope) 48 | raise Errors::NilUser, 'nil user' unless user 49 | 50 | strategy = revocation_strategies[scope] 51 | raise Errors::RevokedToken, 'revoked token' if strategy.jwt_revoked?(payload, user) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/user_encoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'warden/jwt_auth/errors' 4 | 5 | module Warden 6 | module JWTAuth 7 | # Layer above token encoding which directly encodes a user to a JWT 8 | class UserEncoder 9 | attr_reader :helper 10 | 11 | def initialize 12 | @helper = PayloadUserHelper 13 | end 14 | 15 | # Encodes a user for given scope into a JWT. 16 | # 17 | # Payload generated includes: 18 | # 19 | # - a `sub` claim which is build calling `jwt_subject` in `user` 20 | # - an `aud` claim taken as it is in the `aud` parameter 21 | # - a custom `scp` claim taken as the value of the `scope` parameter 22 | # as a string. 23 | # 24 | # The result of calling `jwt_payload` in user is also merged 25 | # into the payload. 26 | # 27 | # @param user [Interfaces::User] an user, whatever it is 28 | # @param scope [Symbol] Warden scope 29 | # @param aud [String] JWT aud claim 30 | # @return [String, String] encoded JWT and decoded payload 31 | def call(user, scope, aud) 32 | payload = helper.payload_for_user(user, scope).merge('aud' => aud) 33 | token = TokenEncoder.new.call(payload) 34 | [token, payload] 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/warden/jwt_auth/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Warden 4 | module JWTAuth 5 | VERSION = '0.11.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 | require 'warden/jwt_auth' 5 | require 'pry-byebug' 6 | require 'simplecov' 7 | 8 | SimpleCov.start 9 | 10 | SPEC_ROOT = Pathname(__FILE__).dirname 11 | 12 | Dir[SPEC_ROOT.join('support/**/*.rb')].sort.each do |file| 13 | require file 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/fixtures.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'singleton' 4 | 5 | module Fixtures 6 | # An user record 7 | class User 8 | include Singleton 9 | 10 | def jwt_subject 11 | '1' 12 | end 13 | 14 | def jwt_payload 15 | { 'foo' => 'bar' } 16 | end 17 | 18 | def on_jwt_dispatch(_token, _payload) 19 | # Does something 20 | end 21 | end 22 | 23 | # User repository 24 | class UserRepo 25 | def self.find_for_jwt_authentication(_sub) 26 | User.instance 27 | end 28 | end 29 | 30 | # User repository that mimics returning a nil user (probably a user that has 31 | # been deleted) 32 | class NilUserRepo 33 | def self.find_for_jwt_authentication(_sub) 34 | nil 35 | end 36 | end 37 | 38 | # A dummy revocation strategy which keeps the state in its instances 39 | class RevocationStrategy 40 | attr_reader :revoked 41 | 42 | def initialize 43 | @revoked = [] 44 | end 45 | 46 | def revoke_jwt(payload, _user) 47 | revoked << payload['jti'] 48 | end 49 | 50 | def jwt_revoked?(payload, _user) 51 | revoked.member?(payload['jti']) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_context 'configuration' do 4 | before do 5 | Warden::JWTAuth.configure do |config| 6 | config.secret = '123' 7 | config.rotation_secret = '456' 8 | config.decoding_secret = '123' 9 | config.algorithm = 'HS256' 10 | config.dispatch_requests = [['POST', %r{^/sign_in$}]] 11 | config.revocation_requests = [['DELETE', %r{^/sign_out$}]] 12 | config.revocation_strategies = { user: Fixtures::RevocationStrategy.new } 13 | config.mappings = { user: Fixtures::UserRepo } 14 | config.token_header = 'Authorization' 15 | config.aud_header = 'TEST_AUD' 16 | config.issuer = 'http://example.com' 17 | end 18 | end 19 | 20 | let(:config) { Warden::JWTAuth.config } 21 | let(:secret) { config.secret } 22 | let(:rotation_secret) { config.rotation_secret } 23 | let(:decoding_secret) { config.decoding_secret } 24 | let(:algorithm) { config.algorithm } 25 | let(:dispatch_requests) { config.dispatch_requests } 26 | let(:revocation_requests) { config.revocation_requests } 27 | let(:revocation_strategies) { config.revocation_strategies } 28 | let(:mappings) { config.mappings } 29 | let(:expiration_time) { config.expiration_time } 30 | let(:token_header) { config.token_header} 31 | let(:env_token_header) { ('HTTP_' + config.token_header.upcase).tr('-', '_') } 32 | let(:aud_header) { config.aud_header } 33 | let(:env_aud_header) { ('HTTP_' + config.aud_header.upcase).tr('-', '_') } 34 | end 35 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/feature.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_context 'feature' do 4 | require 'rack' 5 | 6 | let(:failure_app) { ->(_env) { [401, {}, ['unauthorized']] } } 7 | let(:auth_app) do 8 | lambda do |env| 9 | proxy = env['warden'] 10 | proxy.authenticate!(scope: :user) 11 | success_response 12 | end 13 | end 14 | 15 | let(:success_response) { [200, {}, ['success']] } 16 | 17 | let(:pristine_env) { {} } 18 | 19 | def build_app(app) 20 | builder = Rack::Builder.new 21 | builder.use Warden::JWTAuth::Middleware 22 | add_warden(builder) 23 | builder.run(app) 24 | builder 25 | end 26 | 27 | def add_warden(builder) 28 | builder.use Warden::Manager do |manager| 29 | manager.default_strategies(:jwt) 30 | manager.failure_app = failure_app 31 | end 32 | end 33 | 34 | def call_app(app, env, request_tuple) 35 | method, path = request_tuple 36 | env = env.dup 37 | env['PATH_INFO'] = path 38 | env['REQUEST_METHOD'] = method 39 | app.call(env) 40 | end 41 | 42 | def generate_token(user, scope, env) 43 | aud = Warden::JWTAuth::EnvHelper.aud_header(env) 44 | token, _payload = Warden::JWTAuth::UserEncoder.new.call(user, scope, aud) 45 | token 46 | end 47 | 48 | def env_with_token(env, token) 49 | Warden::JWTAuth::HeaderParser.to_env(env, token) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/fixtures.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_context 'fixtures' do 4 | let(:user_repo) { Fixtures::UserRepo } 5 | let(:nil_user_repo) { Fixtures::NilUserRepo } 6 | let(:user) { Fixtures::User.instance } 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_context 'middleware' do 4 | include Rack::Test::Methods 5 | include Warden::Test::Helpers 6 | 7 | let(:dummy_app) { ->(_env) { [200, {}, []] } } 8 | 9 | def warden_app(app) 10 | Warden::Manager.new(app) 11 | end 12 | 13 | after { Warden.test_reset! } 14 | end 15 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/env_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::EnvHelper do 6 | include_context 'configuration' 7 | 8 | describe '::path_info(env)' do 9 | it 'returns PATH_INFO' do 10 | env = { 'PATH_INFO' => '/foo' } 11 | 12 | expect(described_class.path_info(env)).to eq('/foo') 13 | end 14 | 15 | it 'returns empty strig when PATH_INFO is nil' do 16 | env = {} 17 | 18 | expect(described_class.path_info(env)).to eq('') 19 | end 20 | end 21 | 22 | describe '::request_method(env)' do 23 | it 'returns REQUEST_METHOD' do 24 | env = { 'REQUEST_METHOD' => 'POST' } 25 | 26 | expect(described_class.request_method(env)).to eq('POST') 27 | end 28 | end 29 | 30 | describe '::authorization_header(env)' do 31 | it 'returns configured authorization_header' do 32 | env = { env_token_header => 'Bearer 123' } 33 | 34 | expect(described_class.authorization_header(env)).to eq('Bearer 123') 35 | end 36 | end 37 | 38 | describe '::set_authorization_header(env, value)' do 39 | it 'sets value as configured token_header' do 40 | env = {} 41 | 42 | updated_env = described_class.set_authorization_header(env, 'Bearer 123') 43 | 44 | expect(updated_env[env_token_header]).to eq('Bearer 123') 45 | end 46 | end 47 | 48 | describe '::aud_header(env)' do 49 | it 'returns configured aud_header' do 50 | env = { env_aud_header => 'FOO_AUD' } 51 | 52 | expect(described_class.aud_header(env)).to eq('FOO_AUD') 53 | end 54 | end 55 | 56 | describe '::env_name(header)' do 57 | it 'returns env name for header' do 58 | header = 'Test-Authorization' 59 | 60 | expect(described_class.env_name(header)).to eq('HTTP_TEST_AUTHORIZATION') 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/features/authorization_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'Authorization', type: :feature do 6 | include_context 'configuration' 7 | include_context 'fixtures' 8 | include_context 'feature' 9 | 10 | let(:app) { build_app(auth_app) } 11 | 12 | context 'when a valid token is provided' do 13 | it 'authenticates the user' do 14 | token = generate_token(user, :user, pristine_env) 15 | env = env_with_token(pristine_env, token) 16 | 17 | status = call_app(app, env, ['GET', '/'])[0] 18 | 19 | expect(status).to eq(200) 20 | end 21 | end 22 | 23 | context 'when provided token is not valid' do 24 | it 'does not authenticate the user' do 25 | token = '123' 26 | env = env_with_token(pristine_env, token) 27 | 28 | status = call_app(app, env, ['GET', '/'])[0] 29 | 30 | expect(status).to eq(401) 31 | end 32 | end 33 | 34 | context 'when provided token has been revoked' do 35 | it 'does not authenticate the user' do 36 | token = generate_token(user, :user, pristine_env) 37 | env = env_with_token(pristine_env, token) 38 | 39 | call_app(app, env, ['DELETE', '/sign_out']) 40 | status = call_app(app, env, ['GET', '/'])[0] 41 | 42 | expect(status).to eq(401) 43 | end 44 | end 45 | 46 | context 'when provided token is from another scope' do 47 | it 'does not authenticate the user' do 48 | token = generate_token(user, :unknown, pristine_env) 49 | env = env_with_token(pristine_env, token) 50 | 51 | status = call_app(app, env, ['GET', '/'])[0] 52 | 53 | expect(status).to eq(401) 54 | end 55 | end 56 | 57 | context 'when the user fetched from repo is nil' do 58 | before { Warden::JWTAuth.config.mappings = { user: nil_user_repo } } 59 | 60 | after { Warden::JWTAuth.config.mappings = { user: user_repo } } 61 | 62 | it 'does not authenticate' do 63 | token = generate_token(user, :user, pristine_env) 64 | env = env_with_token(pristine_env, token) 65 | 66 | status = call_app(app, env, ['GET', '/'])[0] 67 | 68 | expect(status).to eq(401) 69 | end 70 | end 71 | 72 | context 'when aud provided by the client does not match' do 73 | it 'does not authenticate the user' do 74 | token = generate_token(user, :user, pristine_env) 75 | env = env_with_token(pristine_env, token).merge(env_aud_header => 'FOO_AUD') 76 | 77 | status = call_app(app, env, ['GET', '/'])[0] 78 | 79 | expect(status).to eq(401) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/features/token_dispatch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'Token dispatch', type: :feature do 6 | include_context 'configuration' 7 | include_context 'fixtures' 8 | include_context 'feature' 9 | 10 | let(:signed_in_app) do 11 | dispatch_app = lambda do |env| 12 | proxy = env['warden'] 13 | proxy.set_user(user, scope: :user) 14 | success_response 15 | end 16 | build_app(dispatch_app) 17 | end 18 | 19 | let(:not_signed_in_app) do 20 | dispatch_app = lambda do |_env| 21 | success_response 22 | end 23 | build_app(dispatch_app) 24 | end 25 | 26 | context 'when path and method match with configured' do 27 | it 'adds the token to Authorization response header' do 28 | headers = call_app(signed_in_app, pristine_env, ['POST', '/sign_in'])[1] 29 | 30 | expect(headers).to have_key('Authorization') 31 | end 32 | end 33 | 34 | context 'when path does not match with configured' do 35 | it 'does not add the token to the response' do 36 | headers = call_app(signed_in_app, pristine_env, ['POST', '/'])[1] 37 | 38 | expect(headers).not_to have_key('Authorization') 39 | end 40 | end 41 | 42 | context 'when method does not match with configured' do 43 | it 'does not add the token to the response' do 44 | headers = call_app(signed_in_app, pristine_env, ['GET', '/sign_in'])[1] 45 | 46 | expect(headers).not_to have_key('Authorization') 47 | end 48 | end 49 | 50 | context 'when user is not set' do 51 | it 'does not raise an error' do 52 | status = call_app(not_signed_in_app, pristine_env, ['GET', '/'])[0] 53 | 54 | expect(status).to eq(200) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/features/token_revocation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'Token revocation', type: :feature do 6 | include_context 'configuration' 7 | include_context 'fixtures' 8 | include_context 'feature' 9 | 10 | let(:app) { build_app(auth_app) } 11 | 12 | context 'when path and method match with configured' do 13 | it 'revokes the token' do 14 | token = generate_token(user, :user, pristine_env) 15 | env = env_with_token(pristine_env, token) 16 | 17 | call_app(app, env, ['DELETE', '/sign_out']) 18 | status = call_app(app, env, ['GET', '/'])[0] 19 | 20 | expect(status).to eq(401) 21 | end 22 | end 23 | 24 | context 'when path does not match with configured' do 25 | it 'does not revoke the token' do 26 | token = generate_token(user, :user, pristine_env) 27 | env = env_with_token(pristine_env, token) 28 | 29 | call_app(app, env, ['GET', '/']) 30 | status = call_app(app, env, ['GET', '/'])[0] 31 | 32 | expect(status).to eq(200) 33 | end 34 | end 35 | 36 | context 'when method does not match with configured' do 37 | it 'does not revoke the token' do 38 | token = generate_token(user, :user, pristine_env) 39 | env = env_with_token(pristine_env, token) 40 | 41 | call_app(app, env, ['POST', '/sign_out']) 42 | status = call_app(app, env, ['GET', '/'])[0] 43 | 44 | expect(status).to eq(200) 45 | end 46 | end 47 | 48 | context 'when Authorization header is not set' do 49 | it 'does not raise an error' do 50 | status = call_app(app, pristine_env, ['GET', '/'])[0] 51 | 52 | expect(status).to eq(401) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/header_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::HeaderParser do 6 | describe '#from_env(env)' do 7 | context 'when authorization method is Bearer' do 8 | it 'returns token' do 9 | env = { 'HTTP_AUTHORIZATION' => 'Bearer 123' } 10 | 11 | expect(described_class.from_env(env)).to eq('123') 12 | end 13 | end 14 | 15 | context 'when authorization method is something else' do 16 | it 'returns nil' do 17 | env = { 'HTTP_AUTHORIZATION' => 'Basic 123' } 18 | 19 | expect(described_class.from_env(env)).to be_nil 20 | end 21 | end 22 | 23 | context 'when there is no authorization method' do 24 | it 'returns nil' do 25 | env = { 'HTTP_AUTHORIZATION' => '123' } 26 | 27 | expect(described_class.from_env(env)).to be_nil 28 | end 29 | end 30 | 31 | context 'when there is no Authorization header' do 32 | it 'returns nil' do 33 | env = {} 34 | 35 | expect(described_class.from_env(env)).to be_nil 36 | end 37 | end 38 | end 39 | 40 | describe '#to_env(env, token)' do 41 | it 'returns a copy of env with token added in Authorization header' do 42 | env = {} 43 | 44 | env = described_class.to_env(env, '123') 45 | 46 | expect(env).to eq('HTTP_AUTHORIZATION' => 'Bearer 123') 47 | end 48 | end 49 | 50 | describe '#to_headers(headers, token)' do 51 | it 'returns a copy of headers with token added to Authorization key' do 52 | headers = {} 53 | 54 | headers = described_class.to_headers(headers, '123') 55 | 56 | expect(headers).to eq('Authorization' => 'Bearer 123') 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/hooks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack/test' 5 | 6 | describe Warden::JWTAuth::Hooks do 7 | include_context 'configuration' 8 | include_context 'middleware' 9 | include_context 'fixtures' 10 | 11 | context 'with user set' do 12 | let(:app) { warden_app(dummy_app) } 13 | 14 | def token(request) 15 | request.env['warden-jwt_auth.token'] 16 | end 17 | 18 | def payload(request) 19 | token = token(request) 20 | Warden::JWTAuth::TokenDecoder.new.call(token) 21 | end 22 | 23 | context 'when method and path match and scope is known ' do 24 | before do 25 | header aud_header.gsub('HTTP_', ''), 'warden_tests' 26 | login_as user, scope: :user 27 | end 28 | 29 | it 'codes a token and adds it to env' do 30 | post '/sign_in' 31 | 32 | expect(token(last_request)).not_to be_nil 33 | end 34 | 35 | it 'adds user info to the token' do 36 | post '/sign_in' 37 | 38 | expect(payload(last_request)['sub']).to eq(user.jwt_subject) 39 | end 40 | 41 | it 'adds configured client id header into the aud claim' do 42 | post '/sign_in' 43 | 44 | expect(payload(last_request)['aud']).to eq('warden_tests') 45 | end 46 | 47 | it 'calls on_jwt_dispatch method in the user' do 48 | allow(user).to receive(:on_jwt_dispatch) 49 | 50 | post '/sign_in' 51 | 52 | expect(user).to have_received(:on_jwt_dispatch) 53 | end 54 | end 55 | 56 | context 'when scope is unknown' do 57 | it 'does nothing' do 58 | login_as user, scope: :unknown 59 | 60 | post '/sign_in' 61 | 62 | expect(token(last_request)).to be_nil 63 | end 64 | end 65 | 66 | context 'when path does not match' do 67 | it 'does nothing' do 68 | login_as user, scope: :user 69 | 70 | post '/' 71 | 72 | expect(token(last_request)).to be_nil 73 | end 74 | end 75 | 76 | context 'when method does not match' do 77 | it 'does nothing' do 78 | login_as user, scope: :user 79 | 80 | get '/sign_in' 81 | 82 | expect(token(last_request)).to be_nil 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/middleware/revocation_manager_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack/test' 5 | 6 | # rubocop:disable RSpec/MultipleMemoizedHelpers 7 | describe Warden::JWTAuth::Middleware::RevocationManager do 8 | include_context 'configuration' 9 | include_context 'fixtures' 10 | include_context 'middleware' 11 | 12 | let(:token_payload) { Warden::JWTAuth::UserEncoder.new.call(user, :user, 'aud') } 13 | let(:token) { token_payload[0] } 14 | let(:payload) { token_payload[1] } 15 | 16 | let(:this_app) { described_class.new(dummy_app) } 17 | let(:app) { warden_app(this_app) } 18 | 19 | def sign_in_with_jwt 20 | header('Authorization', "Bearer #{token}") 21 | login_as(user, scope: :user) 22 | end 23 | 24 | describe '::ENV_KEY' do 25 | it 'is warden-jwt_auth.revocation_manager' do 26 | expect( 27 | described_class::ENV_KEY 28 | ).to eq('warden-jwt_auth.revocation_manager') 29 | end 30 | end 31 | 32 | describe '#call(env)' do 33 | let(:revocation_strategy) { revocation_strategies[:user] } 34 | 35 | it 'adds ENV_KEY key to env' do 36 | get '/' 37 | 38 | expect(last_request.env[described_class::ENV_KEY]).to eq(true) 39 | end 40 | 41 | context 'when path and method match' do 42 | it 'revokes the token' do 43 | sign_in_with_jwt 44 | delete '/sign_out' 45 | 46 | expect(revocation_strategy.jwt_revoked?(payload, user)).to eq(true) 47 | end 48 | end 49 | 50 | context 'when path does not match' do 51 | it 'does not call the revocation strategy' do 52 | sign_in_with_jwt 53 | delete '/another_request' 54 | 55 | expect(revocation_strategy.jwt_revoked?(payload, user)).to eq(false) 56 | end 57 | end 58 | 59 | context 'when verb does not match' do 60 | it 'does not call the revocation strategy' do 61 | sign_in_with_jwt 62 | post '/sign_out' 63 | 64 | expect(revocation_strategy.jwt_revoked?(payload, user)).to eq(false) 65 | end 66 | end 67 | 68 | context 'when token is not present in request headers' do 69 | it 'does not call the revocation strategy' do 70 | login_as user, scope: :user 71 | delete '/sign_out' 72 | sign_in_with_jwt 73 | 74 | expect(revocation_strategy.jwt_revoked?(payload, user)).to eq(false) 75 | end 76 | end 77 | end 78 | end 79 | # rubocop:enable RSpec/MultipleMemoizedHelpers 80 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/middleware/token_dispatcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack/test' 5 | 6 | describe Warden::JWTAuth::Middleware::TokenDispatcher do 7 | include_context 'configuration' 8 | include_context 'fixtures' 9 | include_context 'middleware' 10 | 11 | let(:this_app) { described_class.new(dummy_app) } 12 | let(:app) { warden_app(this_app) } 13 | 14 | describe '::ENV_KEY' do 15 | it 'is warden-jwt_auth.token_dispatcher' do 16 | expect( 17 | described_class::ENV_KEY 18 | ).to eq('warden-jwt_auth.token_dispatcher') 19 | end 20 | end 21 | 22 | describe '#call(env)' do 23 | it 'adds ENV_KEY key to env' do 24 | get '/' 25 | 26 | expect(last_request.env[described_class::ENV_KEY]).to eq(true) 27 | end 28 | 29 | context 'when token has been added to env' do 30 | it 'adds it to the Authorization header' do 31 | login_as user, scope: :user 32 | 33 | post '/sign_in' 34 | 35 | expect(last_response.headers['Authorization']).not_to be_nil 36 | end 37 | end 38 | 39 | context 'when token has not been added to env' do 40 | it 'adds nothing to the Authorization header' do 41 | post '/sign_in' 42 | 43 | expect(last_response.headers['Authorization']).to be_nil 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack/test' 5 | 6 | describe Warden::JWTAuth::Middleware do 7 | include_context 'configuration' 8 | include_context 'middleware' 9 | 10 | describe '#call(env)' do 11 | let(:app) { described_class.new(dummy_app) } 12 | 13 | before { get '/' } 14 | 15 | it 'adds TokenDispatcher middleware' do 16 | env_key = Warden::JWTAuth::Middleware::TokenDispatcher::ENV_KEY 17 | 18 | expect(last_request.env[env_key]).to eq(true) 19 | end 20 | 21 | it 'adds RevocationManager middleware' do 22 | env_key = Warden::JWTAuth::Middleware::RevocationManager::ENV_KEY 23 | 24 | expect(last_request.env[env_key]).to eq(true) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/payload_user_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::PayloadUserHelper do 6 | include_context 'configuration' 7 | include_context 'fixtures' 8 | 9 | describe '::find_user(payload)' do 10 | it 'returns user encoded in a payload for given scope' do 11 | payload = { 'sub' => 1, 'scp' => 'user' } 12 | 13 | expect(described_class.find_user(payload)).to eq(user) 14 | end 15 | end 16 | 17 | describe '::scope_matches?(payload, scope)' do 18 | context 'when given scope matches the one encoded in payload' do 19 | it 'returns true' do 20 | payload = { 'scp' => 'user' } 21 | 22 | expect(described_class.scope_matches?(payload, :user)).to eq(true) 23 | end 24 | end 25 | 26 | context 'when given scope does not match the one encoded in payload' do 27 | it 'returns false' do 28 | payload = { 'scp' => 'unknown' } 29 | 30 | expect(described_class.scope_matches?(payload, :user)).to eq(false) 31 | end 32 | end 33 | end 34 | 35 | describe '::aud_matches?(payload, aud)' do 36 | context 'when given aud matches the one encoded in payload' do 37 | it 'returns true' do 38 | payload = { 'aud' => 'foo' } 39 | 40 | expect(described_class.aud_matches?(payload, 'foo')).to eq(true) 41 | end 42 | end 43 | 44 | context 'when given aud does not match the one encoded in payload' do 45 | it 'returns false' do 46 | payload = { 'aud' => 'unknown' } 47 | 48 | expect(described_class.aud_matches?(payload, 'foo')).to eq(false) 49 | end 50 | end 51 | end 52 | 53 | describe '::issuer_matches?(payload, aud)' do 54 | context 'when given iss matches the one encoded in payload' do 55 | it 'returns true' do 56 | payload = { 'iss' => 'http://example.com' } 57 | 58 | expect(described_class.issuer_matches?(payload, 'http://example.com')).to eq(true) 59 | end 60 | end 61 | 62 | context 'when given iss does not match the one encoded in payload' do 63 | it 'returns false' do 64 | payload = { 'iss' => 'unknown' } 65 | 66 | expect(described_class.issuer_matches?(payload, 'http://example.com')).to eq(false) 67 | end 68 | end 69 | end 70 | 71 | describe '::payload_for_user(user, scope)' do 72 | let(:payload) { described_class.payload_for_user(user, :user) } 73 | 74 | it 'adds a `sub` claim with the result on `#jwt_subject` on user' do 75 | expect(payload['sub']).to eq(user.jwt_subject) 76 | end 77 | 78 | it 'coercers sub claim to string to conform with RFC7519' do 79 | allow(user).to receive(:jwt_subject).and_return(2) 80 | 81 | expect(payload['sub']).to eq('2') 82 | end 83 | 84 | it 'adds a `scp` claim with given scope' do 85 | expect(payload['scp']).to eq('user') 86 | end 87 | 88 | it 'merges claims defined in user `#jwt_payload`' do 89 | expect(payload['foo']).to eq('bar') 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/strategy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::Strategy do 6 | include_context 'configuration' 7 | include_context 'fixtures' 8 | 9 | it 'adds JWTAuth::Strategy to Warden with jwt name' do 10 | expect(Warden::Strategies._strategies).to include( 11 | jwt: described_class 12 | ) 13 | end 14 | 15 | describe '#valid?' do 16 | context 'when Authorization header is valid' do 17 | it "returns true when it doesn't match a dispatch request and issuer claim is not present in the token" do 18 | env = { 'HTTP_AUTHORIZATION' => 'Bearer 123' } 19 | strategy = described_class.new(env, :user) 20 | 21 | expect(strategy).to be_valid 22 | end 23 | 24 | it 'returns false when the current path & method match a dispatch request' do 25 | env = { 'HTTP_AUTHORIZATION' => 'Bearer 123', 'PATH_INFO' => '/sign_in', 'REQUEST_METHOD' => 'POST' } 26 | strategy = described_class.new(env, :user) 27 | 28 | expect(strategy).not_to be_valid 29 | end 30 | 31 | it 'returns true when only the current path but not the method matches a dispatch request' do 32 | env = { 'HTTP_AUTHORIZATION' => 'Bearer 123', 'PATH_INFO' => '/sign_in', 'REQUEST_METHOD' => 'GET' } 33 | strategy = described_class.new(env, :user) 34 | 35 | expect(strategy).to be_valid 36 | end 37 | 38 | it 'returns true when issuer claim is configured and it matches the configured issuer' do 39 | token = Warden::JWTAuth::TokenEncoder.new.call({ 'iss' => Warden::JWTAuth.config.issuer }) 40 | env = { 'HTTP_AUTHORIZATION' => "Bearer #{token}" } 41 | 42 | strategy = described_class.new(env, :user) 43 | 44 | expect(strategy).to be_valid 45 | end 46 | 47 | it "returns false when issuer claim is configured and it doesn't match the configured issuer" do 48 | token = Warden::JWTAuth::TokenEncoder.new.call({ 'iss' => Warden::JWTAuth.config.issuer + 'aaa' }) 49 | env = { 'HTTP_AUTHORIZATION' => "Bearer #{token}" } 50 | 51 | strategy = described_class.new(env, :user) 52 | 53 | expect(strategy).not_to be_valid 54 | end 55 | end 56 | 57 | context 'when Authorization header is not valid' do 58 | it 'returns false' do 59 | env = {} 60 | strategy = described_class.new(env, :user) 61 | 62 | expect(strategy).not_to be_valid 63 | end 64 | end 65 | end 66 | 67 | describe '#persist?' do 68 | it 'returns false' do 69 | expect(described_class.new({}).store?).to eq(false) 70 | end 71 | end 72 | 73 | describe '#authenticate!' do 74 | context 'when token is invalid' do 75 | let(:env) { { 'HTTP_AUTHORIZATION' => 'Bearer 123' } } 76 | let(:strategy) { described_class.new(env, :user) } 77 | 78 | before { strategy.authenticate! } 79 | 80 | it 'fails authentication' do 81 | expect(strategy).not_to be_successful 82 | end 83 | 84 | it 'halts authentication' do 85 | expect(strategy).to be_halted 86 | end 87 | end 88 | 89 | context 'when token is valid' do 90 | let(:token) { Warden::JWTAuth::UserEncoder.new.call(user, :user, 'aud')[0] } 91 | let(:env) { { 'HTTP_AUTHORIZATION' => "Bearer #{token}", env_aud_header => 'aud' } } 92 | let(:strategy) { described_class.new(env, :user) } 93 | 94 | before { strategy.authenticate! } 95 | 96 | it 'successes authentication' do 97 | expect(strategy).to be_successful 98 | end 99 | 100 | it 'logs in user returned by current mapping' do 101 | expect(strategy.user).to eq(user) 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/token_decoder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::TokenDecoder do 6 | include_context 'configuration' 7 | 8 | describe '#call(token)' do 9 | let(:payload) { { 'sub' => '1', 'jti' => '123' } } 10 | let(:token) { ::JWT.encode(payload, secret, 'HS256') } 11 | let(:rotated_token) { ::JWT.encode(payload, rotation_secret, 'HS256') } 12 | let(:invalid_token) { ::JWT.encode(payload, 'invalid', 'HS256') } 13 | 14 | it 'returns the payload encoded in the token' do 15 | expect(described_class.new.call(token)).to eq(payload) 16 | end 17 | 18 | it 'raises an error if decode fails' do 19 | expect { described_class.new.call(invalid_token) }.to raise_error(JWT::VerificationError) 20 | end 21 | 22 | it 'raises an error if no secret is set' do 23 | expect { described_class.new.call(nil) }.to raise_error(JWT::DecodeError) 24 | end 25 | 26 | it 'returns the payload encoded in the token when it\'s rotated' do 27 | expect(described_class.new.call(rotated_token)).to eq(payload) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/token_encoder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::TokenEncoder do 6 | include_context 'configuration' 7 | 8 | describe '#call(payload)' do 9 | let(:payload) { { 'foo' => 'bar' } } 10 | let(:token) { described_class.new.call(payload) } 11 | let(:decoded_payload) do 12 | JWT.decode(token, secret, true, algorithm: algorithm)[0] 13 | end 14 | 15 | it 'encodes given payload using HS256 algorithm and secret as key' do 16 | expect { decoded_payload }.not_to raise_error 17 | end 18 | 19 | it 'merges in provided payload' do 20 | expect(decoded_payload['foo']).to eq('bar') 21 | end 22 | 23 | it 'adds an `iat` claim with the issue time' do 24 | iat = decoded_payload['iat'] 25 | 26 | expect(Time.at(iat).to_date).to eq(Date.today) 27 | end 28 | 29 | it 'adds an `exp` claim with configured expiration time' do 30 | exp = decoded_payload['exp'] 31 | 32 | expect(Time.at(exp)).to be_within(10).of(Time.now + expiration_time) 33 | end 34 | 35 | it 'adds a `jti` claim with a random unique id' do 36 | expect(decoded_payload['jti']).not_to be_nil 37 | end 38 | 39 | it 'when merging provided payload overrides automatic payload' do 40 | payload['jti'] = 'unique' 41 | 42 | expect(decoded_payload['jti']).to eq('unique') 43 | end 44 | 45 | context 'with RS256 algorithm' do 46 | let(:decoded_payload) do 47 | JWT.decode(token, decoding_secret, true, algorithm: algorithm)[0] 48 | end 49 | 50 | before do 51 | rsa_private = OpenSSL::PKey::RSA.generate 2048 52 | 53 | Warden::JWTAuth.configure do |config| 54 | config.secret = rsa_private 55 | config.decoding_secret = rsa_private.public_key 56 | config.algorithm = 'RS256' 57 | end 58 | end 59 | 60 | it 'encodes given payload using HS256 algorithm and secret as key' do 61 | expect { decoded_payload }.not_to raise_error 62 | end 63 | 64 | it 'merges in provided payload' do 65 | expect(decoded_payload['foo']).to eq('bar') 66 | end 67 | end 68 | 69 | context 'with issuer claim' do 70 | let(:issuer) { 'http://example.com' } 71 | 72 | before do 73 | Warden::JWTAuth.configure do |config| 74 | config.issuer = issuer 75 | end 76 | end 77 | 78 | it 'adds a `iss` claim with the configured issuer' do 79 | expect(decoded_payload['iss']).to eq(issuer) 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/token_revoker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::TokenRevoker do 6 | include_context 'configuration' 7 | include_context 'fixtures' 8 | 9 | describe '#call(token)' do 10 | let(:revocation_strategy) { revocation_strategies[:user] } 11 | 12 | let(:token_payload) { Warden::JWTAuth::UserEncoder.new.call(user, :user, 'aud') } 13 | let(:token) { token_payload[0] } 14 | let(:payload) { token_payload[1] } 15 | 16 | it 'revokes given token' do 17 | described_class.new.call(token) 18 | 19 | expect(revocation_strategy.jwt_revoked?(payload, user)).to eq(true) 20 | end 21 | 22 | it 'revokes calling revocation_strategy with decoded payload and user' do 23 | allow(revocation_strategy).to(receive(:revoke_jwt).with(payload, user)) 24 | 25 | described_class.new.call(token) 26 | 27 | expect(revocation_strategy).to(have_received(:revoke_jwt).with(payload, user)) 28 | end 29 | 30 | context 'when token is expired' do 31 | before { Warden::JWTAuth.config.expiration_time = -1 } 32 | 33 | after { Warden::JWTAuth.config.expiration_time = 3600 } 34 | 35 | it 'silently ignores it' do 36 | expect { described_class.new.call(token) }.not_to raise_error 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/user_decoder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::UserDecoder do 6 | include_context 'configuration' 7 | include_context 'fixtures' 8 | 9 | let(:token_payload) { Warden::JWTAuth::UserEncoder.new.call(user, :user, 'aud') } 10 | let(:token) { token_payload[0] } 11 | let(:payload) { token_payload[1] } 12 | 13 | describe '#call(token, scope, aud)' do 14 | it 'returns encoded user' do 15 | expect( 16 | described_class.new.call(token, :user, 'aud') 17 | ).to eq(user) 18 | end 19 | 20 | it 'raises RevokedToken if the token has been revoked' do 21 | revocation_strategies[:user].revoke_jwt(payload, user) 22 | 23 | expect do 24 | described_class.new.call(token, :user, 'aud') 25 | end.to raise_error(Warden::JWTAuth::Errors::RevokedToken) 26 | end 27 | 28 | it 'raises WrongScope if encoded token does not match with intended one' do 29 | expect do 30 | described_class.new.call(token, :unknown, 'aud') 31 | end.to raise_error(Warden::JWTAuth::Errors::WrongScope) 32 | end 33 | 34 | it 'raises NilUser if decoded user is equal to nil' do 35 | Warden::JWTAuth.config.mappings = { user: nil_user_repo } 36 | 37 | expect do 38 | described_class.new.call(token, :user, 'aud') 39 | end.to raise_error(Warden::JWTAuth::Errors::NilUser) 40 | 41 | Warden::JWTAuth.config.mappings = { user: user_repo } 42 | end 43 | 44 | it 'raises WrongAud if aud claim does not match with intended one' do 45 | expect do 46 | described_class.new.call(token, :user, 'another_aud') 47 | end.to raise_error(Warden::JWTAuth::Errors::WrongAud) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth/user_encoder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth::UserEncoder do 6 | include_context 'configuration' 7 | include_context 'fixtures' 8 | 9 | let(:token_payload) { described_class.new.call(user, :user, 'aud') } 10 | let(:token) { token_payload[0] } 11 | let(:payload) { token_payload[1] } 12 | 13 | describe '#call(user, scope)' do 14 | it 'merges in user `jwt_subject` result as sub claim' do 15 | expect(payload['sub']).to eq(user.jwt_subject) 16 | end 17 | 18 | it 'merges in given scope as `scp` claim' do 19 | expect(payload['scp']).to eq('user') 20 | end 21 | 22 | it 'merges in given aud as `aud` claim' do 23 | expect(payload['aud']).to eq('aud') 24 | end 25 | 26 | it 'merges in user `jwt_payload` result' do 27 | expect(payload['foo']).to eq(user.jwt_payload['foo']) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/warden/jwt_auth_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Warden::JWTAuth do 6 | it 'has a version number' do 7 | expect(Warden::JWTAuth::VERSION).not_to be nil 8 | end 9 | 10 | describe '#config.mappings' do 11 | context 'when the mapping is a constant name' do 12 | before do 13 | described_class.configure do |config| 14 | config.mappings = { user: 'Fixtures::UserRepo' } 15 | end 16 | end 17 | 18 | it 'resolves to the constant' do 19 | expect(described_class.config.mappings).to eq( 20 | user: Fixtures::UserRepo 21 | ) 22 | end 23 | end 24 | end 25 | 26 | describe '#config.revocation_strategies' do 27 | context 'when the mapping is a constant name' do 28 | before do 29 | described_class.configure do |config| 30 | config.revocation_strategies = { user: 'Fixtures::RevocationStrategy' } 31 | end 32 | end 33 | 34 | it 'resolves to the constant' do 35 | expect(described_class.config.revocation_strategies).to eq( 36 | user: Fixtures::RevocationStrategy 37 | ) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /warden-jwt_auth.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'warden/jwt_auth/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'warden-jwt_auth' 9 | spec.version = Warden::JWTAuth::VERSION 10 | spec.authors = ['Marc Busqué'] 11 | spec.email = ['marc@lamarciana.com'] 12 | 13 | spec.summary = 'JWT authentication for Warden.' 14 | spec.description = 'JWT authentication for Warden, ORM agnostic and accepting the implementation of token revocation strategies.' 15 | spec.homepage = 'https://github.com/waiting-for-dev/warden-jwt_auth' 16 | spec.license = 'MIT' 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | 23 | spec.metadata['rubygems_mfa_required'] = 'true' 24 | 25 | spec.add_dependency 'dry-auto_inject', '>= 0.8', '< 2' 26 | spec.add_dependency 'dry-configurable', '>= 0.13', '< 2' 27 | spec.add_dependency 'jwt', '~> 2.1' 28 | spec.add_dependency 'warden', '~> 1.2' 29 | 30 | spec.add_development_dependency 'bundler' 31 | spec.add_development_dependency 'pry-byebug', '~> 3.7' 32 | spec.add_development_dependency 'rack-test', '~> 1.1' 33 | spec.add_development_dependency 'rake', '~> 12.3' 34 | spec.add_development_dependency 'rspec', '~> 3.8' 35 | # Cops 36 | spec.add_development_dependency 'rubocop', '~> 0.87' 37 | spec.add_development_dependency 'rubocop-rspec', '~> 1.42' 38 | # Test reporting 39 | spec.add_development_dependency 'codeclimate-test-reporter', '~> 1.0' 40 | spec.add_development_dependency 'simplecov', '0.17' 41 | end 42 | --------------------------------------------------------------------------------