jwt_keeper 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +14 -0
- data/.gitignore +21 -0
- data/.rspec +3 -0
- data/.rubocop.yml +22 -0
- data/.travis.yml +20 -0
- data/Gemfile +3 -0
- data/LICENSE +23 -0
- data/README.md +83 -0
- data/Rakefile +7 -0
- data/docker-compose.yml +4 -0
- data/example.env +1 -0
- data/jwt_keeper.gemspec +36 -0
- data/lib/generators/keeper/install/install_generator.rb +15 -0
- data/lib/generators/templates/jwt_keeper.rb +32 -0
- data/lib/jwt_keeper/configuration.rb +30 -0
- data/lib/jwt_keeper/controller.rb +66 -0
- data/lib/jwt_keeper/datastore.rb +39 -0
- data/lib/jwt_keeper/engine.rb +12 -0
- data/lib/jwt_keeper/exceptions.rb +19 -0
- data/lib/jwt_keeper/token.rb +137 -0
- data/lib/jwt_keeper/version.rb +4 -0
- data/lib/jwt_keeper.rb +28 -0
- data/spec/lib/keeper/configuration_spec.rb +5 -0
- data/spec/lib/keeper/controller_spec.rb +188 -0
- data/spec/lib/keeper/datastore_spec.rb +70 -0
- data/spec/lib/keeper/token_spec.rb +180 -0
- data/spec/lib/keeper_spec.rb +38 -0
- data/spec/spec_helper.rb +58 -0
- metadata +263 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: bd966e0e79df17e42e2f825289387dcb9f386bde
|
4
|
+
data.tar.gz: 32aaa2f138fd3102ea431b3b244fc77e6736bf3a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 842033c9c72f8c350a22c84075d78a8609beb43109bbfa10cb52eaea72034baa99609c862f2a6dc4d6866203c9ef15bfcabd091ef809d4b748fa5ee3519ee6b9
|
7
|
+
data.tar.gz: fca8a945722eace48870831e435e6628bd48a666dd7ced51d5066d0bee138b1837b9b838aded1b947464db5d4c73ddd9fa4211b7f39f97f022ae199551d3cde6
|
data/.editorconfig
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# EditorConfig is awesome: http://EditorConfig.org
|
2
|
+
|
3
|
+
# top-most EditorConfig file
|
4
|
+
root = true
|
5
|
+
|
6
|
+
# Unix-style newlines with a newline ending every file
|
7
|
+
[*]
|
8
|
+
end_of_line = lf
|
9
|
+
insert_final_newline = true
|
10
|
+
charset = utf-8
|
11
|
+
|
12
|
+
[*.rb]
|
13
|
+
indent_style = space
|
14
|
+
indent_size = 2
|
data/.gitignore
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
vendor
|
19
|
+
.ruby-version
|
20
|
+
.env
|
21
|
+
/spec/examples.txt
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# inherit_from: .rubocop_todo.yml
|
2
|
+
AllCops:
|
3
|
+
Include:
|
4
|
+
- 'Gemfile'
|
5
|
+
- 'Rakefile'
|
6
|
+
- '**/*.rake'
|
7
|
+
Documentation:
|
8
|
+
Enabled: false
|
9
|
+
Metrics/LineLength:
|
10
|
+
Max: 100
|
11
|
+
Style/PercentLiteralDelimiters:
|
12
|
+
PreferredDelimiters:
|
13
|
+
'%': ()
|
14
|
+
'%q': ()
|
15
|
+
'%Q': ()
|
16
|
+
'%i': []
|
17
|
+
'%I': []
|
18
|
+
'%r': ()
|
19
|
+
'%s': ()
|
20
|
+
'%w': []
|
21
|
+
'%W': []
|
22
|
+
'%x': ()
|
data/.travis.yml
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
language: ruby
|
2
|
+
rvm:
|
3
|
+
- 2.0.0
|
4
|
+
- 2.1.8
|
5
|
+
- 2.2.4
|
6
|
+
- 2.3.0
|
7
|
+
- ruby-head
|
8
|
+
matrix:
|
9
|
+
allow_failures:
|
10
|
+
- rvm: ruby-head
|
11
|
+
addons:
|
12
|
+
code_climate:
|
13
|
+
repo_token: f69bb189f348c1d7992d8ed8690d0a2c9c885c1aac45e2f4d48732034592b37b
|
14
|
+
services:
|
15
|
+
- redis-server
|
16
|
+
env:
|
17
|
+
global:
|
18
|
+
- REDIS_URL=redis://localhost:6379
|
19
|
+
notifications:
|
20
|
+
email: false
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
|
2
|
+
Copyright (c) 2014 David Rivera
|
3
|
+
|
4
|
+
MIT License
|
5
|
+
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
7
|
+
a copy of this software and associated documentation files (the
|
8
|
+
"Software"), to deal in the Software without restriction, including
|
9
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
10
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
11
|
+
permit persons to whom the Software is furnished to do so, subject to
|
12
|
+
the following conditions:
|
13
|
+
|
14
|
+
The above copyright notice and this permission notice shall be
|
15
|
+
included in all copies or substantial portions of the Software.
|
16
|
+
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
18
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
19
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
20
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
21
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
22
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
23
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
# JWT Keeper
|
2
|
+
[![Build Status](https://img.shields.io/travis/sirwolfgang/jwt_keeper/master.svg)](https://travis-ci.org/sirwolfgang/jwt_keeper)
|
3
|
+
[![Dependency Status](https://img.shields.io/gemnasium/sirwolfgang/jwt_keeper.svg)](https://gemnasium.com/sirwolfgang/jwt_keeper)
|
4
|
+
[![Code Climate](https://img.shields.io/codeclimate/github/sirwolfgang/jwt_keeper.svg)](https://codeclimate.com/github/sirwolfgang/jwt_keeper)
|
5
|
+
[![Test Coverage](https://img.shields.io/codeclimate/coverage/github/sirwolfgang/jwt_keeper.svg)](https://codeclimate.com/github/sirwolfgang/jwt_keeper/coverage)
|
6
|
+
[![Inline docs](http://inch-ci.org/github/sirwolfgang/jwt_keeper.svg?style=shields)](http://inch-ci.org/github/sirwolfgang/jwt_keeper)
|
7
|
+
|
8
|
+
An managing interface layer for handling the creation and validation of JWTs.
|
9
|
+
|
10
|
+
## Setup
|
11
|
+
- Add `gem 'jwt_keeper', '~> 2.0'` to Gemfile
|
12
|
+
- Run `rails generate keeper:install`
|
13
|
+
- Configure `config/initializers/keeper.rb`
|
14
|
+
- Done
|
15
|
+
|
16
|
+
## Basic Usage
|
17
|
+
Here are the basic methods you can call to perform various operations
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
token = JWTKeeper::Token.create(private_claim_hash)
|
21
|
+
token = JWTKeeper::Token.find(raw_token_string)
|
22
|
+
|
23
|
+
token.revoke
|
24
|
+
token.rotate
|
25
|
+
|
26
|
+
token.valid?
|
27
|
+
raw_token_string = token.to_jwt
|
28
|
+
```
|
29
|
+
|
30
|
+
## Rails Usage
|
31
|
+
The designed rails token flow is to receive and respond to requests with the token being present in the `Authorization` part of the header. This is to allow us to seamlessly rotate the tokens on the fly without having to rebuff the request as part of the user flow. Automatic rotation happens as part of the `require_authentication` action, meaning that you will always get the latest token data as
|
32
|
+
created by `generate_claims` in your controllers. This new token is added to the response with
|
33
|
+
the `respond_with_authentication` action.
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
class ApplicationController < ActionController::Base
|
37
|
+
before_action :require_authentication
|
38
|
+
after_action :respond_with_authentication
|
39
|
+
|
40
|
+
def not_authenticated
|
41
|
+
# Overload to return status 401
|
42
|
+
end
|
43
|
+
|
44
|
+
def authenticated(token)
|
45
|
+
# Overload to make use of token data
|
46
|
+
end
|
47
|
+
|
48
|
+
def regenerate_claims(old_token)
|
49
|
+
# Overload to update claims on automatic rotation.
|
50
|
+
current_user = User.find(authentication_token.claims[:uid])
|
51
|
+
{ uid: current_user.id, usn: current_user.email }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
class SessionsController < ApplicationController
|
58
|
+
skip_before_action :require_authentication, only: :create
|
59
|
+
skip_after_action :respond_with_authentication, only: :destroy
|
60
|
+
|
61
|
+
# POST /sessions
|
62
|
+
def create
|
63
|
+
authentication_token = JWTKeeper::Token.create({ uid: @user.id, usn: @user.email })
|
64
|
+
end
|
65
|
+
|
66
|
+
# PATCH/PUT /sessions
|
67
|
+
def update
|
68
|
+
authentication_token = request_token.rotate(generate_claims)
|
69
|
+
end
|
70
|
+
|
71
|
+
# DELETE /sessions
|
72
|
+
def destroy
|
73
|
+
request_token.revoke
|
74
|
+
authentication_token = nil
|
75
|
+
end
|
76
|
+
```
|
77
|
+
|
78
|
+
## Invalidation
|
79
|
+
### Hard Invalidation
|
80
|
+
Hard Invalidation is a permanent revocation of the token. The primary cases of this is when a user wishes to logout, or when your security has been otherwise compromised. To revoke all tokens simply update the configuration `secret`. To revoke a single token you can utilize either the class(`Token.revoke(jti)`) or instance(`token.revoke`) method.
|
81
|
+
|
82
|
+
### Soft Invalidation
|
83
|
+
Soft Invalidation is the process of triggering a rotation upon the next time a token is seen in a request. On the global scale this is done when there is a version mismatch in the config. Utilizing the rails controller flow, this method works even if you have two different versions of your app deployed and requests bounce back and forth; Making rolling deployments and rollbacks completely seamless. To rotate a single token, like in the case of a change of user permissions, simply use the class(`Token.rotate`) method to flag the token for regeneration.
|
data/Rakefile
ADDED
data/docker-compose.yml
ADDED
data/example.env
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
REDIS_URL=redis://localhost:6379
|
data/jwt_keeper.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'jwt_keeper/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'jwt_keeper'
|
8
|
+
spec.version = JWTKeeper::VERSION
|
9
|
+
spec.authors = ['David Rivera', 'Zane Wolfgang Pickett']
|
10
|
+
spec.email = ['david.r.rivera193@gmail.com', 'sirwolfgang@users.noreply.github.com']
|
11
|
+
spec.summary = 'JWT for Rails made easy'
|
12
|
+
spec.description = 'It is a keeper'
|
13
|
+
spec.homepage = 'https://github.com/sirwolfgang/jwt_keeper'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_development_dependency 'bundler'
|
22
|
+
spec.add_development_dependency 'rake'
|
23
|
+
spec.add_development_dependency 'yard'
|
24
|
+
spec.add_development_dependency 'rubocop'
|
25
|
+
spec.add_development_dependency 'dotenv'
|
26
|
+
|
27
|
+
spec.add_development_dependency 'rspec', '~> 3.4'
|
28
|
+
spec.add_development_dependency 'fuubar'
|
29
|
+
spec.add_development_dependency 'simplecov'
|
30
|
+
spec.add_development_dependency 'codeclimate-test-reporter'
|
31
|
+
|
32
|
+
spec.add_dependency 'redis', '~> 3.3'
|
33
|
+
spec.add_dependency 'rails', '~> 4.2'
|
34
|
+
spec.add_dependency 'activesupport', '~> 4.2'
|
35
|
+
spec.add_dependency 'jwt', '~> 1.5'
|
36
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'rails/generators/base'
|
2
|
+
|
3
|
+
module JWTKeeper
|
4
|
+
class InstallGenerator < Rails::Generators::Base
|
5
|
+
source_root File.expand_path('../../../templates', __FILE__)
|
6
|
+
|
7
|
+
# Copies the default config
|
8
|
+
#
|
9
|
+
# @example Install
|
10
|
+
# rails generate keeper:install
|
11
|
+
def copy_files
|
12
|
+
copy_file 'jwt_keeper.rb', 'config/initializers/keeper.rb'
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
JWTKeeper.configure do |config|
|
2
|
+
# The time to expire for the tokens
|
3
|
+
# config.expiry = 24.hours
|
4
|
+
|
5
|
+
# The hashing method to for the tokens
|
6
|
+
# Options:
|
7
|
+
# HS256 - HMAC using SHA-256 hash algorithm (default)
|
8
|
+
# HS384 - HMAC using SHA-384 hash algorithm
|
9
|
+
# HS512 - HMAC using SHA-512 hash algorithm
|
10
|
+
# RS256 - RSA using SHA-256 hash algorithm
|
11
|
+
# RS384 - RSA using SHA-384 hash algorithm
|
12
|
+
# RS512 - RSA using SHA-512 hash algorithm
|
13
|
+
# ES256 - ECDSA using P-256 and SHA-256
|
14
|
+
# ES384 - ECDSA using P-384 and SHA-384
|
15
|
+
# ES512 - ECDSA using P-521 and SHA-512
|
16
|
+
# config.algorithm = 'HS512'
|
17
|
+
|
18
|
+
# the secret in which you data is hash with
|
19
|
+
# config.secret = 'secret'
|
20
|
+
|
21
|
+
# the issuer of the tokens
|
22
|
+
# config.issuer = 'api.example.com'
|
23
|
+
|
24
|
+
# the default audience of the tokens
|
25
|
+
# config.audience = 'example.com'
|
26
|
+
|
27
|
+
# the location of redis config file
|
28
|
+
# config.redis_connection = Redis.new(connection_options)
|
29
|
+
|
30
|
+
# A unique idenfitier for the token version.
|
31
|
+
# config.version = 1
|
32
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module JWTKeeper
|
2
|
+
class Configuration < OpenStruct
|
3
|
+
DEFAULTS = {
|
4
|
+
algorithm: 'HS512',
|
5
|
+
secret: nil,
|
6
|
+
expiry: 24.hours,
|
7
|
+
issuer: 'api.example.com',
|
8
|
+
audience: 'example.com',
|
9
|
+
redis_connection: nil,
|
10
|
+
version: nil
|
11
|
+
}.freeze
|
12
|
+
|
13
|
+
# Creates a new Configuration from the passed in parameters
|
14
|
+
# @param params [Hash] configuration options
|
15
|
+
# @return [Configuration]
|
16
|
+
def initialize(params = {})
|
17
|
+
super(DEFAULTS.merge(params))
|
18
|
+
end
|
19
|
+
|
20
|
+
# @!visibility private
|
21
|
+
def base_claims
|
22
|
+
{
|
23
|
+
iss: JWTKeeper.configuration.issuer, # issuer
|
24
|
+
aud: JWTKeeper.configuration.audience, # audience
|
25
|
+
exp: JWTKeeper.configuration.expiry.from_now.to_i, # expiration time
|
26
|
+
ver: JWTKeeper.configuration.version # Version
|
27
|
+
}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module JWTKeeper
|
2
|
+
module Controller
|
3
|
+
def self.included(klass)
|
4
|
+
klass.class_eval do
|
5
|
+
include InstanceMethods
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
module InstanceMethods
|
10
|
+
# Available to be used as a before_action by the application's controllers. This is
|
11
|
+
# the main logical section for decoding, and automatically rotating tokens
|
12
|
+
def require_authentication
|
13
|
+
token = authentication_token
|
14
|
+
return not_authenticated if token.nil?
|
15
|
+
|
16
|
+
if token.version_mismatch? || token.pending?
|
17
|
+
new_claims = regenerate_claims(token)
|
18
|
+
token.rotate(new_claims)
|
19
|
+
self.authentication_token = token
|
20
|
+
end
|
21
|
+
|
22
|
+
authenticated(token)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Invoked by the require_authentication method as part of the automatic rotation
|
26
|
+
# process. The application should override this method to include the necessary
|
27
|
+
# claims.
|
28
|
+
def regenerate_claims(old_token)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Moves the authentication_token from the request to the response
|
32
|
+
def respond_with_authentication
|
33
|
+
response.headers['Authorization'] = request.headers['Authorization']
|
34
|
+
end
|
35
|
+
|
36
|
+
# Decodes and returns the token
|
37
|
+
def authentication_token
|
38
|
+
return nil unless request.headers['Authorization']
|
39
|
+
JWTKeeper::Token.find(request.headers['Authorization'].split.last)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Assigns a token to the request to act as a single source of truth
|
43
|
+
def authentication_token=(token)
|
44
|
+
request.headers['Authorization'] = "Bearer #{token.to_jwt}"
|
45
|
+
end
|
46
|
+
|
47
|
+
# Used when a user tries to access a page while logged out, is asked to login,
|
48
|
+
# and we want to return him back to the page he originally wanted.
|
49
|
+
def redirect_back_or_to(url, flash_hash = {})
|
50
|
+
redirect_to(session[:return_to_url] || url, flash: flash_hash)
|
51
|
+
session[:return_to_url] = nil
|
52
|
+
end
|
53
|
+
|
54
|
+
# The default action for denying non-authenticated connections.
|
55
|
+
# You can override this method in your controllers
|
56
|
+
def not_authenticated
|
57
|
+
redirect_to root_path
|
58
|
+
end
|
59
|
+
|
60
|
+
# The default action for accepting authenticated connections.
|
61
|
+
# You can override this method in your controllers
|
62
|
+
def authenticated(token)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module JWTKeeper
|
2
|
+
module Datastore
|
3
|
+
class << self
|
4
|
+
# @!visibility private
|
5
|
+
def rotate(jti, seconds)
|
6
|
+
set_with_expiry(jti, seconds, :soft)
|
7
|
+
end
|
8
|
+
|
9
|
+
# @!visibility private
|
10
|
+
def revoke(jti, seconds)
|
11
|
+
set_with_expiry(jti, seconds, :hard)
|
12
|
+
end
|
13
|
+
|
14
|
+
# @!visibility private
|
15
|
+
def pending?(jti)
|
16
|
+
value = get(jti)
|
17
|
+
value.present? && value.to_sym == :soft
|
18
|
+
end
|
19
|
+
|
20
|
+
# @!visibility private
|
21
|
+
def revoked?(jti)
|
22
|
+
value = get(jti)
|
23
|
+
value.present? && value.to_sym == :hard
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# @!visibility private
|
29
|
+
def set_with_expiry(jti, seconds, type)
|
30
|
+
JWTKeeper.configuration.redis_connection.setex(jti, seconds, type)
|
31
|
+
end
|
32
|
+
|
33
|
+
# @!visibility private
|
34
|
+
def get(jti)
|
35
|
+
JWTKeeper.configuration.redis_connection.get(jti)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'jwt_keeper'
|
2
|
+
require 'rails'
|
3
|
+
|
4
|
+
module JWTKeeper
|
5
|
+
# The Sorcery engine takes care of extending ActiveRecord (if used) and ActionController,
|
6
|
+
# With the plugin logic.
|
7
|
+
class Engine < ::Rails::Engine
|
8
|
+
initializer 'extend Controller with keeper' do |_app|
|
9
|
+
ActionController::Base.send(:include, JWTKeeper::Controller)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module JWTKeeper
|
2
|
+
# The token is invalid
|
3
|
+
class InvalidTokenError < StandardError; end
|
4
|
+
|
5
|
+
# The token expiry claim is invalid
|
6
|
+
class ExpiredTokenError < InvalidTokenError; end
|
7
|
+
|
8
|
+
# The token was force expired
|
9
|
+
class RevokedTokenError < InvalidTokenError; end
|
10
|
+
|
11
|
+
# The token not before claim is invalid
|
12
|
+
class EarlyTokenError < InvalidTokenError; end
|
13
|
+
|
14
|
+
# The token issuer claim is invalid
|
15
|
+
class BadIssuerError < InvalidTokenError; end
|
16
|
+
|
17
|
+
# The token audience claim is invalid
|
18
|
+
class LousyAudienceError < InvalidTokenError; end
|
19
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
module JWTKeeper
|
2
|
+
class Token
|
3
|
+
attr_accessor :claims
|
4
|
+
|
5
|
+
# Initalizes a new web token
|
6
|
+
# @param private_claims [Hash] the custom claims to encode
|
7
|
+
def initialize(private_claims = {})
|
8
|
+
@claims = {
|
9
|
+
nbf: DateTime.now.to_i, # not before
|
10
|
+
iat: DateTime.now.to_i, # issued at
|
11
|
+
jti: SecureRandom.uuid # JWT ID
|
12
|
+
}
|
13
|
+
@claims.merge!(JWTKeeper.configuration.base_claims)
|
14
|
+
@claims.merge!(private_claims)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Creates a new web token
|
18
|
+
# @param private_claims [Hash] the custom claims to encode
|
19
|
+
# @return [Token] token object
|
20
|
+
def self.create(private_claims)
|
21
|
+
new(private_claims)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Decodes and validates an existing token
|
25
|
+
# @param raw_token [String] the raw token
|
26
|
+
# @return [Token] token object
|
27
|
+
def self.find(raw_token)
|
28
|
+
claims = decode(raw_token)
|
29
|
+
return nil if claims.nil?
|
30
|
+
|
31
|
+
new_token = new(claims)
|
32
|
+
return nil if new_token.revoked?
|
33
|
+
new_token
|
34
|
+
end
|
35
|
+
|
36
|
+
# Sets a token to the pending rotation state. The expire is set to the maxium possible time but
|
37
|
+
# is inherently ignored by the token's exp check and then rewritten with the revokation on
|
38
|
+
# rotate.
|
39
|
+
# @param token_jti [String] the token unique id
|
40
|
+
def self.rotate(token_jti)
|
41
|
+
Datastore.rotate(token_jti, JWTKeeper.configuration.expiry.from_now.to_i)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Revokes a web token
|
45
|
+
# @param token_jti [String] the token unique id
|
46
|
+
def self.revoke(token_jti)
|
47
|
+
Datastore.revoke(token_jti, JWTKeeper.configuration.expiry.from_now.to_i)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Easy interface for using the token's id
|
51
|
+
# @return [String] token's uuid
|
52
|
+
def id
|
53
|
+
claims[:jti]
|
54
|
+
end
|
55
|
+
|
56
|
+
# Revokes and creates a new web token
|
57
|
+
# @param new_claims [Hash] Used to override and update claims during rotation
|
58
|
+
# @return [String] new token
|
59
|
+
def rotate(new_claims = nil)
|
60
|
+
revoke
|
61
|
+
|
62
|
+
new_claims ||= claims.except(:iss, :aud, :exp, :nbf, :iat, :jti)
|
63
|
+
new_token = self.class.new(new_claims)
|
64
|
+
@claims = new_token.claims
|
65
|
+
self
|
66
|
+
end
|
67
|
+
|
68
|
+
# Revokes a web token
|
69
|
+
def revoke
|
70
|
+
return if invalid?
|
71
|
+
Datastore.revoke(id, claims[:exp] - DateTime.now.to_i)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Checks if a web token is pending a rotation
|
75
|
+
# @return [Boolean]
|
76
|
+
def pending?
|
77
|
+
Datastore.pending?(id)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Checks if a web token is pending a global rotation
|
81
|
+
# @return [Boolean]
|
82
|
+
def version_mismatch?
|
83
|
+
claims[:ver] != JWTKeeper.configuration.version
|
84
|
+
end
|
85
|
+
|
86
|
+
# Checks if a web token has been revoked
|
87
|
+
# @return [Boolean]
|
88
|
+
def revoked?
|
89
|
+
Datastore.revoked?(id)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Checks if the token valid?
|
93
|
+
# @return [Boolean]
|
94
|
+
def valid?
|
95
|
+
!invalid?
|
96
|
+
end
|
97
|
+
|
98
|
+
# Checks if the token invalid?
|
99
|
+
# @return [Boolean]
|
100
|
+
def invalid?
|
101
|
+
self.class.decode(encode).nil? || revoked?
|
102
|
+
end
|
103
|
+
|
104
|
+
# Encodes the jwt
|
105
|
+
# @return [String]
|
106
|
+
def to_jwt
|
107
|
+
encode
|
108
|
+
end
|
109
|
+
alias to_s to_jwt
|
110
|
+
|
111
|
+
# @!visibility private
|
112
|
+
def self.decode(raw_token)
|
113
|
+
JWT.decode(raw_token, JWTKeeper.configuration.secret, true,
|
114
|
+
algorithm: JWTKeeper.configuration.algorithm,
|
115
|
+
verify_iss: true,
|
116
|
+
verify_aud: true,
|
117
|
+
verify_iat: true,
|
118
|
+
verify_sub: false,
|
119
|
+
verify_jti: false,
|
120
|
+
leeway: 0,
|
121
|
+
|
122
|
+
iss: JWTKeeper.configuration.issuer,
|
123
|
+
aud: JWTKeeper.configuration.audience
|
124
|
+
).first.symbolize_keys
|
125
|
+
|
126
|
+
rescue JWT::DecodeError
|
127
|
+
return nil
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
# @!visibility private
|
133
|
+
def encode
|
134
|
+
JWT.encode(claims, JWTKeeper.configuration.secret, JWTKeeper.configuration.algorithm)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
data/lib/jwt_keeper.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'jwt'
|
2
|
+
require 'redis'
|
3
|
+
require 'active_support'
|
4
|
+
require 'active_support/core_ext/numeric'
|
5
|
+
|
6
|
+
require 'jwt_keeper/version'
|
7
|
+
require 'jwt_keeper/exceptions'
|
8
|
+
require 'jwt_keeper/configuration'
|
9
|
+
require 'jwt_keeper/datastore'
|
10
|
+
require 'jwt_keeper/token'
|
11
|
+
require 'jwt_keeper/controller'
|
12
|
+
|
13
|
+
module JWTKeeper
|
14
|
+
class << self
|
15
|
+
attr_reader :configuration, :datastore
|
16
|
+
end
|
17
|
+
|
18
|
+
# Creates/sets a new configuration for the gem, yield a configuration object
|
19
|
+
# @param new_configuration [Configuration] new configuration
|
20
|
+
# @return [Configuration] the frozen configuration
|
21
|
+
def self.configure(new_configuration = Configuration.new)
|
22
|
+
yield(new_configuration) if block_given?
|
23
|
+
|
24
|
+
@configuration = new_configuration.freeze
|
25
|
+
end
|
26
|
+
|
27
|
+
require 'jwt_keeper/engine' if defined?(Rails)
|
28
|
+
end
|