rack-cloudflare-jwt 0.0.5 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96e4477d5accd4824246258e9705fea334849ca6727c873349b49f0fb3eac9e1
4
- data.tar.gz: 714c31cc874fc247d83eff6f46ae04b2ce62ad4772c0551bc7782113eb986eba
3
+ metadata.gz: 240d66a2a123b06cf3625765ce2d7b4772c2a46f059fd62decc90acd4d9e68bc
4
+ data.tar.gz: 9bf13c926defed079e9266752c64ab3b38d004e3665252b3dd7a663db77ae66c
5
5
  SHA512:
6
- metadata.gz: 67a1ca74de47a7f0082666903f0bc14dd8229216d6201e9e3983db20d677076693f1d304359f5e861c6635ae1f7e58ef316fa60a8691e535094bd3e84b4011b0
7
- data.tar.gz: 13de638b271c4f015506bfdf6a10d0c2dd441dab5378d6fe6e762203a1c38f73b92c1fd2133d2ca947242acbaf0571481e1194112b9c5c49e3ae8f96b966b201
6
+ metadata.gz: 735971f62a1c16c83d6591baa3d60c052107ef61076850a0598c2456c386aec32c62b79927e15560f2d9f47ef17f991ff084ee6bb27284ab17195e6fcd805148
7
+ data.tar.gz: c3f15c032fa1715e728e6e0337ddfae61165a8bda598cc69f077b5f87f71e93d2e24b249ae79ac197361a533ac730cf8b1c0823f2f1b00e794b15b3d1180c41d
data/README.md CHANGED
@@ -32,17 +32,17 @@ $ gem install rack-cloudflare-jwt
32
32
 
33
33
  ## Usage
34
34
 
35
- `Rack::CloudflareJwt::Auth` accepts several configuration options. All options are passed in a single Ruby Hash:
35
+ `Rack::CloudflareJwt::Auth` accepts configuration options. All options are passed in a single Ruby `Hash<String, String>`. E.g. `{ '/admin' => 'aud-1', '/manager' => 'aud-2' }`.
36
36
 
37
- * `policy_aud` : required : `String` : A Application Audience (AUD) Tag.
37
+ * `Hash` key : `String` : A path string representing paths that should be checked for the presence of a valid JWT token. Includes sub-paths as of specified path as well (e.g. `/docs` includes `/docs/some/thing.html` also). Each path should start with a `/`. If a path doesn't matches the current request path this entire middleware is skipped and no authentication or verification of tokens takes place.
38
+
39
+ * `Hash` value : `String` : A Application Audience (AUD) Tag.
38
40
 
39
- * `include_paths` : optional : Array : An Array of path strings representing paths that should be checked for the presence of a valid JWT token. Includes sub-paths as of specified paths as well (e.g. `%w(/docs)` includes `/docs/some/thing.html` also). Each path should start with a `/`. If a path not matches the current request path this entire middleware is skipped and no authentication or verification of tokens takes place.
40
41
 
41
42
  ### Rails
42
43
 
43
44
  ```ruby
44
- require 'rack/cloudflare_jwt'
45
- Rails.application.config.middleware.use Rack::CloudflareJwt::Auth, policy_aud: 'xxx.yyy.zzz', include_paths: %w[/foo]
45
+ Rails.application.config.middleware.use Rack::CloudflareJwt::Auth, '/my-path' => 'xxx.yyy.zzz'
46
46
  ```
47
47
 
48
48
  ## Contributing
@@ -0,0 +1 @@
1
+ require 'rack/cloudflare_jwt'
@@ -2,9 +2,8 @@
2
2
 
3
3
  require 'rack/cloudflare_jwt/version'
4
4
 
5
- module Rack
5
+ module Rack::CloudflareJwt
6
6
  # CloudFlare JSON Web Token
7
- module CloudflareJwt
8
- autoload :Auth, 'rack/cloudflare_jwt/auth'
9
- end
7
+
8
+ autoload :Auth, 'rack/cloudflare_jwt/auth'
10
9
  end
@@ -5,178 +5,234 @@ require 'multi_json'
5
5
  require 'net/http'
6
6
  require 'rack/jwt'
7
7
 
8
- module Rack
9
- module CloudflareJwt
10
- # Authentication middleware
11
- #
12
- # @see https://developers.cloudflare.com/access/setting-up-access/validate-jwt-tokens/
13
- class Auth
14
- # Certs path
15
- CERTS_PATH = '/cdn-cgi/access/certs'
16
- # Default algorithm
17
- DEFAULT_ALGORITHM = 'RS256'
18
- # CloudFlare JWT header.
19
- HEADER_NAME = 'HTTP_CF_ACCESS_JWT_ASSERTION'
20
-
21
- # Token regex.
22
- #
23
- # @see https://github.com/jwt/ruby-jwt/tree/v2.2.1#algorithms-and-usage
24
- TOKEN_REGEX = /
25
- ^(
26
- [a-zA-Z0-9\-\_]+\. # 1 or more chars followed by a single period
27
- [a-zA-Z0-9\-\_]+\. # 1 or more chars followed by a single period
28
- [a-zA-Z0-9\-\_]+ # 1 or more chars, no trailing chars
29
- )$
30
- /x.freeze
31
-
32
- attr_reader :policy_aud, :include_paths
33
-
34
- # Initializes middleware
35
- def initialize(app, opts = {})
36
- @app = app
37
- @policy_aud = opts.fetch(:policy_aud, nil)
38
- @include_paths = opts.fetch(:include_paths, [])
39
-
40
- check_policy_aud!
41
- check_include_paths_type!
42
- end
8
+ module Rack::CloudflareJwt
9
+ # Authentication middleware
10
+ #
11
+ # @see https://developers.cloudflare.com/access/setting-up-access/validate-jwt-tokens/
12
+ class Auth
13
+ # Custom decode token error.
14
+ class DecodeTokenError < StandardError; end
15
+
16
+ # Certs path
17
+ CERTS_PATH = '/cdn-cgi/access/certs'
18
+ # Default algorithm
19
+ DEFAULT_ALGORITHM = 'RS256'
20
+ # CloudFlare JWT header.
21
+ HEADER_NAME = 'HTTP_CF_ACCESS_JWT_ASSERTION'
22
+ # HTTP_HOST header.
23
+ HEADER_HTTP_HOST = 'HTTP_HOST'
24
+ # Key for get current path.
25
+ PATH_INFO = 'PATH_INFO'
26
+
27
+ # Token regex.
28
+ #
29
+ # @see https://github.com/jwt/ruby-jwt/tree/v2.2.1#algorithms-and-usage
30
+ TOKEN_REGEX = /
31
+ ^(
32
+ [a-zA-Z0-9\-_]+\. # 1 or more chars followed by a single period
33
+ [a-zA-Z0-9\-_]+\. # 1 or more chars followed by a single period
34
+ [a-zA-Z0-9\-_]+ # 1 or more chars, no trailing chars
35
+ )$
36
+ /x.freeze
37
+
38
+ attr_reader :policies
39
+
40
+ # Initializes middleware
41
+ #
42
+ # @example Initialize middleware in Rails
43
+ # config.middleware.use(
44
+ # Rack::CloudflareJwt::Auth,
45
+ # '/admin' => <cloudflare-aud-1>,
46
+ # '/manager' => <cloudflare-aud-2>,
47
+ # )
48
+ #
49
+ # @param policies [Hash<String, String>] the policies with paths and AUDs.
50
+ def initialize(app, policies = {})
51
+ @app = app
52
+ @policies = policies
43
53
 
44
- # Public: Call a middleware.
45
- def call(env)
46
- if !path_matches_include_paths?(env)
47
- @app.call(env)
48
- elsif missing_auth_header?(env)
49
- return_error('Missing Authorization header')
50
- elsif invalid_auth_header?(env)
51
- return_error('Invalid Authorization header format')
52
- else
53
- verify_token(env)
54
- end
55
- end
54
+ check_policy_auds!
55
+ check_paths_type!
56
+ end
56
57
 
57
- private
58
+ # Public: Call a middleware.
59
+ def call(env)
60
+ if !path_matches?(env)
61
+ @app.call(env)
62
+ elsif missing_auth_header?(env)
63
+ return_error('Missing Authorization header')
64
+ elsif invalid_auth_header?(env)
65
+ return_error('Invalid Authorization header format')
66
+ else
67
+ verify_token(env)
68
+ end
69
+ end
58
70
 
59
- # Private: Check policy aud.
60
- def check_policy_aud!
61
- return unless !policy_aud.is_a?(String) || policy_aud.strip.empty?
71
+ private
62
72
 
63
- raise ArgumentError, 'policy_aud argument cannot be nil/empty'
64
- end
73
+ # Private: Check policy auds.
74
+ def check_policy_auds!
75
+ raise ArgumentError, 'policies cannot be nil/empty' if policies.values.empty?
65
76
 
66
- # Private: Check include_paths type.
67
- def check_include_paths_type!
68
- raise ArgumentError, 'include_paths argument must be an Array' unless include_paths.is_a?(Array)
77
+ policies.each_value do |policy_aud|
78
+ next unless !policy_aud.is_a?(String) || policy_aud.strip.empty?
69
79
 
70
- include_paths.each do |path|
71
- raise ArgumentError, 'each include_paths Array element must be a String' unless path.is_a?(String)
72
- raise ArgumentError, 'each include_paths Array element must not be empty' if path.empty?
73
- raise ArgumentError, 'each include_paths Array element must start with a /' unless path.start_with?('/')
74
- end
80
+ raise ArgumentError, 'policy AUD argument cannot be nil/empty'
75
81
  end
82
+ end
76
83
 
77
- # Private: Verify a token.
78
- def verify_token(env)
79
- # extract the token from header.
80
- token = env[HEADER_NAME]
81
- decoded_token = public_keys(env).find do |key|
82
- dt = decode_token(token, key.public_key)
83
- break dt if dt
84
- end
85
-
86
- if decoded_token
87
- env['jwt.payload'] = decoded_token.first
88
- env['jwt.header'] = decoded_token.last
89
- @app.call(env)
90
- else
91
- return_error('Invalid token')
92
- end
84
+ # Private: Check paths type.
85
+ def check_paths_type!
86
+ policies.each_key do |path|
87
+ raise ArgumentError, 'each key element must be a String' unless path.is_a?(String)
88
+ raise ArgumentError, 'each key element must not be empty' if path.empty?
89
+ raise ArgumentError, 'each key element must start with a /' unless path.start_with?('/')
93
90
  end
91
+ end
94
92
 
95
- # Private: Decode a token.
96
- #
97
- # Example:
98
- #
99
- # [
100
- # {"data"=>"test"}, # payload
101
- # {"alg"=>"RS256"} # header
102
- # ]
103
- #
104
- # @return [Array<Hash>] the token.
105
- # @see https://github.com/jwt/ruby-jwt/tree/v2.2.1#algorithms-and-usage
106
- def decode_token(token, secret)
107
- Rack::JWT::Token.decode(token, secret, true, aud: policy_aud, verify_aud: true, algorithm: DEFAULT_ALGORITHM)
108
- rescue ::JWT::VerificationError
109
- Rails.logger.info 'Invalid JWT token : Signature Verification Error'
110
- rescue ::JWT::ExpiredSignature
111
- Rails.logger.info 'Invalid JWT token : Expired Signature (exp)'
112
- rescue ::JWT::IncorrectAlgorithm
113
- Rails.logger.info 'Invalid JWT token : Incorrect Key Algorithm'
114
- rescue ::JWT::ImmatureSignature
115
- Rails.logger.info 'Invalid JWT token : Immature Signature (nbf)'
116
- rescue ::JWT::InvalidIssuerError
117
- Rails.logger.info 'Invalid JWT token : Invalid Issuer (iss)'
118
- rescue ::JWT::InvalidIatError
119
- Rails.logger.info 'Invalid JWT token : Invalid Issued At (iat)'
120
- rescue ::JWT::InvalidAudError
121
- Rails.logger.info 'Invalid JWT token : Invalid Audience (aud)'
122
- rescue ::JWT::InvalidSubError
123
- Rails.logger.info 'Invalid JWT token : Invalid Subject (sub)'
124
- rescue ::JWT::InvalidJtiError
125
- Rails.logger.info 'Invalid JWT token : Invalid JWT ID (jti)'
126
- rescue ::JWT::DecodeError
127
- Rails.logger.info 'Invalid JWT token : Decode Error'
93
+ # Private: Verify a token.
94
+ def verify_token(env)
95
+ # extract the token from header.
96
+ token = env[HEADER_NAME]
97
+ policy_aud = policies.find { |path, _aud| env[PATH_INFO].start_with?(path) }&.last
98
+ decoded_token = public_keys(env).find do |key|
99
+ break decode_token(token, key.public_key, policy_aud)
100
+ rescue DecodeTokenError => e
101
+ logger.info e.message
102
+ nil
128
103
  end
129
104
 
130
- # Private: Check if current path is in the include_paths.
131
- #
132
- # @return [Boolean] true if it is, false otherwise.
133
- def path_matches_include_paths?(env)
134
- include_paths.empty? || include_paths.any? { |ex| env['PATH_INFO'].start_with?(ex) }
135
- end
105
+ if decoded_token
106
+ logger.debug 'CloudFlare JWT token is valid'
136
107
 
137
- # Private: Check if auth header is invalid.
138
- #
139
- # @return [Boolean] true if it is, false otherwise.
140
- def invalid_auth_header?(env)
141
- env[HEADER_NAME] !~ TOKEN_REGEX
108
+ env['jwt.payload'] = decoded_token.first
109
+ env['jwt.header'] = decoded_token.last
110
+ @app.call(env)
111
+ else
112
+ return_error('Invalid token')
142
113
  end
114
+ end
143
115
 
144
- # Private: Check if no auth header.
145
- #
146
- # @return [Boolean] true if it is, false otherwise.
147
- def missing_auth_header?(env)
148
- env[HEADER_NAME].nil? || env[HEADER_NAME].strip.empty?
149
- end
116
+ # Private: Decode a token.
117
+ #
118
+ # @param token [String] the token.
119
+ # @param secret [String] the public key.
120
+ # @param policy_aud [String] the CloudFlare AUD.
121
+ #
122
+ # @example
123
+ #
124
+ # [
125
+ # {"data"=>"test"}, # payload
126
+ # {"alg"=>"RS256"} # header
127
+ # ]
128
+ #
129
+ # @return [Array<Hash>] the token or `nil` at error.
130
+ # @raise [DecodeTokenError] if the token is invalid.
131
+ #
132
+ # @see https://github.com/jwt/ruby-jwt/tree/v2.2.1#algorithms-and-usage
133
+ def decode_token(token, secret, policy_aud)
134
+ Rack::JWT::Token.decode(token, secret, true, aud: policy_aud, verify_aud: true, algorithm: DEFAULT_ALGORITHM)
135
+ rescue ::JWT::VerificationError
136
+ raise DecodeTokenError, 'Invalid JWT token : Signature Verification Error'
137
+ rescue ::JWT::ExpiredSignature
138
+ raise DecodeTokenError, 'Invalid JWT token : Expired Signature (exp)'
139
+ rescue ::JWT::IncorrectAlgorithm
140
+ raise DecodeTokenError, 'Invalid JWT token : Incorrect Key Algorithm'
141
+ rescue ::JWT::ImmatureSignature
142
+ raise DecodeTokenError, 'Invalid JWT token : Immature Signature (nbf)'
143
+ rescue ::JWT::InvalidIssuerError
144
+ raise DecodeTokenError, 'Invalid JWT token : Invalid Issuer (iss)'
145
+ rescue ::JWT::InvalidIatError
146
+ raise DecodeTokenError, 'Invalid JWT token : Invalid Issued At (iat)'
147
+ rescue ::JWT::InvalidAudError
148
+ raise DecodeTokenError, 'Invalid JWT token : Invalid Audience (aud)'
149
+ rescue ::JWT::InvalidSubError
150
+ raise DecodeTokenError, 'Invalid JWT token : Invalid Subject (sub)'
151
+ rescue ::JWT::InvalidJtiError
152
+ raise DecodeTokenError, 'Invalid JWT token : Invalid JWT ID (jti)'
153
+ rescue ::JWT::DecodeError
154
+ raise DecodeTokenError, 'Invalid JWT token : Decode Error'
155
+ end
156
+
157
+ # Private: Check if current path is in the policies.
158
+ #
159
+ # @return [Boolean] true if it is, false otherwise.
160
+ def path_matches?(env)
161
+ policies.empty? || policies.keys.any? { |ex| env[PATH_INFO].start_with?(ex) }
162
+ end
150
163
 
151
- # Private: Return an error.
152
- def return_error(message)
153
- body = { error: message }.to_json
154
- headers = { 'Content-Type' => 'application/json' }
164
+ # Private: Check if auth header is invalid.
165
+ #
166
+ # @return [Boolean] true if it is, false otherwise.
167
+ def invalid_auth_header?(env)
168
+ env[HEADER_NAME] !~ TOKEN_REGEX
169
+ end
155
170
 
156
- [403, headers, [body]]
171
+ # Private: Check if no auth header.
172
+ #
173
+ # @return [Boolean] true if it is, false otherwise.
174
+ def missing_auth_header?(env)
175
+ env[HEADER_NAME].nil? || env[HEADER_NAME].strip.empty?
176
+ end
177
+
178
+ # Private: Return an error.
179
+ def return_error(message)
180
+ body = { error: message }.to_json
181
+ headers = { 'Content-Type' => 'application/json' }
182
+
183
+ [403, headers, [body]]
184
+ end
185
+
186
+ # Private: Get public keys.
187
+ #
188
+ # @return [Array<OpenSSL::PKey::RSA>] the public keys.
189
+ def public_keys(env)
190
+ host = env[HEADER_HTTP_HOST]
191
+ fetch_public_keys_cached(host).map do |jwk_data|
192
+ ::JWT::JWK.import(jwk_data).keypair
157
193
  end
194
+ end
195
+
196
+ # Private: Fetch public keys.
197
+ #
198
+ # @param host [String] The host.
199
+ #
200
+ # @return [Array<Hash>] the public keys.
201
+ def fetch_public_keys(host)
202
+ json = Net::HTTP.get(host, CERTS_PATH)
203
+ json.empty? ? [] : MultiJson.load(json, symbolize_keys: true).fetch(:keys)
204
+ rescue StandardError
205
+ []
206
+ end
158
207
 
159
- # Private: Get public keys.
160
- #
161
- # @return [Array<OpenSSL::PKey::RSA>] the public keys.
162
- def public_keys(env)
163
- host = env['HTTP_HOST']
164
- keys = Rails.cache.fetch([self.class.name, '#secrets', host]) { fetch_public_keys(host) }
165
- keys.map do |jwk_data|
166
- ::JWT::JWK.import(jwk_data).keypair
167
- end
208
+ # Private: Get cached public keys.
209
+ #
210
+ # Store a keys in the cache only 10 minutes.
211
+ #
212
+ # @param host [String] The host.
213
+ #
214
+ # @return [Array<Hash>] the public keys.
215
+ def fetch_public_keys_cached(host)
216
+ key = [self.class.name, '#secrets', host].join('_')
217
+
218
+ if defined? Rails
219
+ Rails.cache.fetch(key, expires_in: 600) { fetch_public_keys(host) }
220
+ elsif defined? Padrino
221
+ keys = Padrino.cache[key]
222
+ keys || Padrino.cache.store(key, fetch_public_keys(host), expires: 600)
223
+ else
224
+ fetch_public_keys(host)
168
225
  end
226
+ end
169
227
 
170
- # Private: Fetch public keys.
171
- #
172
- # @param host [String] The host.
173
- #
174
- # @return [Array<Hash>] the public keys.
175
- def fetch_public_keys(host)
176
- json = Net::HTTP.get(host, CERTS_PATH)
177
- json.present? ? MultiJson.load(json, symbolize_keys: true).fetch(:keys) : []
178
- rescue StandardError
179
- []
228
+ # Private: Get a logger.
229
+ #
230
+ # @return [ActiveSupport::Logger] the logger.
231
+ def logger
232
+ if defined? Rails
233
+ Rails.logger
234
+ elsif defined? Padrino
235
+ Padrino.logger
180
236
  end
181
237
  end
182
238
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rack
3
+ module Rack # rubocop:disable Style/ClassAndModuleChildren
4
4
  module CloudflareJwt
5
- VERSION = '0.0.5'
5
+ VERSION = '0.1.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-cloudflare-jwt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aleksei Vokhmin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-01-28 00:00:00.000000000 Z
11
+ date: 2021-03-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: 1.0.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 2.0.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 2.0.0
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: simplecov
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +108,20 @@ dependencies:
94
108
  - - ">="
95
109
  - !ruby/object:Gem::Version
96
110
  version: 0.16.0
111
+ - !ruby/object:Gem::Dependency
112
+ name: webmock
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 3.8.0
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 3.8.0
97
125
  - !ruby/object:Gem::Dependency
98
126
  name: jwt
99
127
  requirement: !ruby/object:Gem::Requirement
@@ -160,6 +188,7 @@ extra_rdoc_files: []
160
188
  files:
161
189
  - LICENSE
162
190
  - README.md
191
+ - lib/rack/cloudflare/jwt.rb
163
192
  - lib/rack/cloudflare_jwt.rb
164
193
  - lib/rack/cloudflare_jwt/auth.rb
165
194
  - lib/rack/cloudflare_jwt/version.rb