warden-jwt_auth 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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