rack-cloudflare-jwt 0.0.5 → 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 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