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.
data/bin/setup ADDED
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,7 @@
1
+ version: '2'
2
+ services:
3
+ app:
4
+ build: .
5
+ command: tail -f Gemfile
6
+ volumes:
7
+ - .:/app
@@ -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