warden-jwt_auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.codeclimate.yml +17 -0
- data/.gitignore +10 -0
- data/.overcommit.yml +53 -0
- data/.overcommit_gems.rb +15 -0
- data/.reek +0 -0
- data/.rspec +2 -0
- data/.rubocop.yml +10 -0
- data/.travis.yml +21 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Dockerfile +8 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +187 -0
- data/Rakefile +8 -0
- data/bin/console +16 -0
- data/bin/setup +8 -0
- data/docker-compose.yml +7 -0
- data/lib/warden/jwt_auth.rb +101 -0
- data/lib/warden/jwt_auth/errors.rb +17 -0
- data/lib/warden/jwt_auth/header_parser.rb +48 -0
- data/lib/warden/jwt_auth/hooks.rb +55 -0
- data/lib/warden/jwt_auth/interfaces.rb +57 -0
- data/lib/warden/jwt_auth/middleware.rb +27 -0
- data/lib/warden/jwt_auth/middleware/revocation_manager.rb +46 -0
- data/lib/warden/jwt_auth/middleware/token_dispatcher.rb +35 -0
- data/lib/warden/jwt_auth/payload_user_helper.rb +39 -0
- data/lib/warden/jwt_auth/strategy.rb +37 -0
- data/lib/warden/jwt_auth/token_decoder.rb +22 -0
- data/lib/warden/jwt_auth/token_encoder.rb +34 -0
- data/lib/warden/jwt_auth/token_revoker.rb +20 -0
- data/lib/warden/jwt_auth/user_decoder.rb +45 -0
- data/lib/warden/jwt_auth/user_encoder.rb +29 -0
- data/lib/warden/jwt_auth/version.rb +7 -0
- data/warden-jwt_auth.gemspec +35 -0
- metadata +233 -0
data/bin/setup
ADDED
data/docker-compose.yml
ADDED
@@ -0,0 +1,101 @@
|
|
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
|
+
# The secret used to encode the token
|
23
|
+
setting :secret
|
24
|
+
|
25
|
+
# Expiration time for tokens
|
26
|
+
setting :expiration_time, 3600
|
27
|
+
|
28
|
+
# A hash of warden scopes as keys and user repositories as values.
|
29
|
+
#
|
30
|
+
# @see Interfaces::UserRepository
|
31
|
+
# @see Interfaces::User
|
32
|
+
setting(:mappings, {}) do |value|
|
33
|
+
Hash[
|
34
|
+
value.each_pair do |scope, mapping|
|
35
|
+
[scope.to_sym, mapping]
|
36
|
+
end
|
37
|
+
]
|
38
|
+
end
|
39
|
+
|
40
|
+
# Array of tuples [request_method, request_path_regex] to match request
|
41
|
+
# verbs and paths where a JWT token should be added to the `Authorization`
|
42
|
+
# response header
|
43
|
+
#
|
44
|
+
# @example
|
45
|
+
# [
|
46
|
+
# ['POST', %r{^/sign_in$}]
|
47
|
+
# ]
|
48
|
+
setting(:dispatch_requests, []) do |value|
|
49
|
+
value.map do |tuple|
|
50
|
+
method, path = tuple
|
51
|
+
[method.to_s.upcase, path]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Array of tuples [request_method, request_path_regex] to match request
|
56
|
+
# verbs and paths where incoming JWT token should be be revoked
|
57
|
+
#
|
58
|
+
# @example
|
59
|
+
# [
|
60
|
+
# ['DELETE', %r{^/sign_out$}]
|
61
|
+
# ]
|
62
|
+
setting :revocation_requests, [] do |value|
|
63
|
+
value.map do |tuple|
|
64
|
+
method, path = tuple
|
65
|
+
[method.to_s.upcase, path]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Hash with scopes as keys and values with the strategy to revoke tokens for
|
70
|
+
# that scope
|
71
|
+
#
|
72
|
+
# @example
|
73
|
+
# {
|
74
|
+
# user: UserRevocationStrategy
|
75
|
+
# }
|
76
|
+
#
|
77
|
+
# @see Interfaces::RevocationStrategy
|
78
|
+
setting(:revocation_strategies, {}) do |value|
|
79
|
+
Hash[
|
80
|
+
value.each_pair do |scope, strategy|
|
81
|
+
[scope.to_sym, strategy]
|
82
|
+
end
|
83
|
+
]
|
84
|
+
end
|
85
|
+
|
86
|
+
Import = Dry::AutoInject(config)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
require 'warden/jwt_auth/version'
|
91
|
+
require 'warden/jwt_auth/header_parser'
|
92
|
+
require 'warden/jwt_auth/payload_user_helper'
|
93
|
+
require 'warden/jwt_auth/user_encoder'
|
94
|
+
require 'warden/jwt_auth/user_decoder'
|
95
|
+
require 'warden/jwt_auth/token_encoder'
|
96
|
+
require 'warden/jwt_auth/token_decoder'
|
97
|
+
require 'warden/jwt_auth/token_revoker'
|
98
|
+
require 'warden/jwt_auth/hooks'
|
99
|
+
require 'warden/jwt_auth/strategy'
|
100
|
+
require 'warden/jwt_auth/middleware'
|
101
|
+
require 'warden/jwt_auth/interfaces'
|
@@ -0,0 +1,17 @@
|
|
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 trying to decode a token for an scope that doesn't
|
12
|
+
# match the one encoded in the payload
|
13
|
+
class WrongScope < JWT::DecodeError
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,48 @@
|
|
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 = env['HTTP_AUTHORIZATION']
|
18
|
+
return nil unless auth
|
19
|
+
method, token = auth.split
|
20
|
+
method == METHOD ? token : nil
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns a copy of `env` with token added to the `HTTP_AUTHORIZATION`
|
24
|
+
# header. Be aware than `env` is not modified in place.
|
25
|
+
#
|
26
|
+
# @param env [Hash] rack env hash
|
27
|
+
# @param token [String] JWT token
|
28
|
+
# @return [Hash] modified rack env
|
29
|
+
def self.to_env(env, token)
|
30
|
+
env = env.dup
|
31
|
+
env['HTTP_AUTHORIZATION'] = "#{METHOD} #{token}"
|
32
|
+
env
|
33
|
+
end
|
34
|
+
|
35
|
+
# Returns a copy of headers with token added in the `Authorization` key.
|
36
|
+
# Be aware that headers is not modified in place
|
37
|
+
#
|
38
|
+
# @param headers [Hash] rack hash response headers
|
39
|
+
# @param token [String] JWT token
|
40
|
+
# @return [Hash] response headers with the token added
|
41
|
+
def self.to_headers(headers, token)
|
42
|
+
headers = headers.dup
|
43
|
+
headers['Authorization'] = "#{METHOD} #{token}"
|
44
|
+
headers
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,55 @@
|
|
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']
|
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
|
+
token = UserEncoder.new.call(user, scope)
|
28
|
+
env[PREPARED_TOKEN_ENV_KEY] = token
|
29
|
+
end
|
30
|
+
|
31
|
+
def token_should_be_added?(scope, env)
|
32
|
+
jwt_scope?(scope) && request_matches?(env)
|
33
|
+
end
|
34
|
+
|
35
|
+
def jwt_scope?(scope)
|
36
|
+
jwt_scopes = mappings.keys
|
37
|
+
jwt_scopes.include?(scope)
|
38
|
+
end
|
39
|
+
|
40
|
+
# :reek:FeatureEnvy
|
41
|
+
def request_matches?(env)
|
42
|
+
dispatch_requests.each do |tuple|
|
43
|
+
method, path = tuple
|
44
|
+
return true if env['PATH_INFO'].match(path) &&
|
45
|
+
env['REQUEST_METHOD'] == method
|
46
|
+
end
|
47
|
+
false
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
Warden::Manager.after_set_user do |user, auth, opts|
|
54
|
+
Warden::JWTAuth::Hooks.after_set_user(user, auth, opts)
|
55
|
+
end
|
@@ -0,0 +1,57 @@
|
|
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 [BasicObject] 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
|
22
|
+
#
|
23
|
+
# @return [BasicObject] `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
|
+
end
|
35
|
+
|
36
|
+
# Strategy to manage JWT revocation
|
37
|
+
class RevocationStrategy
|
38
|
+
# Does something to revoke a JWT payload
|
39
|
+
#
|
40
|
+
# @param _payload [Hash]
|
41
|
+
# @param _user [User]
|
42
|
+
def revoke_jwt(_payload, _user)
|
43
|
+
raise NotImplementedError
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns whether a JWT payload is revoked
|
47
|
+
#
|
48
|
+
# @param _payload [Hash]
|
49
|
+
# @param _user [User]
|
50
|
+
# @return [Boolean]
|
51
|
+
def jwt_revoked?(_payload, _user)
|
52
|
+
raise NotImplementedError
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,27 @@
|
|
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
|
+
# :reek:FeatureEnvy
|
18
|
+
def call(env)
|
19
|
+
builder = Rack::Builder.new
|
20
|
+
builder.use(RevocationManager)
|
21
|
+
builder.use(TokenDispatcher)
|
22
|
+
builder.run(app)
|
23
|
+
builder.call(env)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,46 @@
|
|
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
|
12
|
+
|
13
|
+
def initialize(app)
|
14
|
+
@app = app
|
15
|
+
@config = JWTAuth.config
|
16
|
+
end
|
17
|
+
|
18
|
+
def call(env)
|
19
|
+
env[ENV_KEY] = true
|
20
|
+
response = app.call(env)
|
21
|
+
revoke_token(env)
|
22
|
+
response
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def revoke_token(env)
|
28
|
+
token = HeaderParser.from_env(env)
|
29
|
+
return unless token && token_should_be_revoked?(env)
|
30
|
+
TokenRevoker.new.call(token)
|
31
|
+
end
|
32
|
+
|
33
|
+
# :reek:FeatureEnvy
|
34
|
+
def token_should_be_revoked?(env)
|
35
|
+
revocation_requests = config.revocation_requests
|
36
|
+
revocation_requests.each do |tuple|
|
37
|
+
method, path = tuple
|
38
|
+
return true if env['PATH_INFO'].match(path) &&
|
39
|
+
env['REQUEST_METHOD'] == method
|
40
|
+
end
|
41
|
+
false
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,35 @@
|
|
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
|
+
# :reek:UtilityFunction
|
28
|
+
def headers_with_token(env, headers)
|
29
|
+
token = env[Hooks::PREPARED_TOKEN_ENV_KEY]
|
30
|
+
token ? HeaderParser.to_headers(headers, token) : headers
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,39 @@
|
|
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 the payload to encode for a given user in a scope
|
26
|
+
#
|
27
|
+
# @param user [Interfaces::User] an user, whatever it is
|
28
|
+
# @param scope [Symbol] A Warden scope
|
29
|
+
# @return [Hash] payload to encode
|
30
|
+
# :reek:ManualDispatch
|
31
|
+
def self.payload_for_user(user, scope)
|
32
|
+
sub = user.jwt_subject
|
33
|
+
payload = { 'sub' => sub, 'scp' => scope.to_s }
|
34
|
+
return payload unless user.respond_to?(:jwt_payload)
|
35
|
+
user.jwt_payload.merge(payload)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|