rack-cloudflare-jwt 0.0.9 → 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: 2b2687d696920c000108afefaef5a1ca3362a1abee3a02d70394dbf58d3853c4
4
- data.tar.gz: b70a5704eb4617eda9e0c7da2e16931dd259b7106394f74f8f62dc2d01771ad6
3
+ metadata.gz: 240d66a2a123b06cf3625765ce2d7b4772c2a46f059fd62decc90acd4d9e68bc
4
+ data.tar.gz: 9bf13c926defed079e9266752c64ab3b38d004e3665252b3dd7a663db77ae66c
5
5
  SHA512:
6
- metadata.gz: b0d36f13a6ad4d5dbff4b389026f45c14a1952540058d616d859d60e4016dcb35a2c3899b42a93cd15204e233b267f9687c3664df4199f71e980b48fc85b1ce9
7
- data.tar.gz: f0e6d960ae41120b05d71888d92bc134202186b539126753c0d51d60b34a6a6d964383b809fea8cf63353b4cf499b2556350201c3f14d1ee7fcfc0369853b477
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,219 +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
- # Custom decode token error.
15
- class DecodeTokenError < StandardError; end
16
-
17
- # Certs path
18
- CERTS_PATH = '/cdn-cgi/access/certs'
19
- # Default algorithm
20
- DEFAULT_ALGORITHM = 'RS256'
21
- # CloudFlare JWT header.
22
- HEADER_NAME = 'HTTP_CF_ACCESS_JWT_ASSERTION'
23
- # HTTP_HOST header.
24
- HEADER_HTTP_HOST = 'HTTP_HOST'
25
-
26
- # Token regex.
27
- #
28
- # @see https://github.com/jwt/ruby-jwt/tree/v2.2.1#algorithms-and-usage
29
- TOKEN_REGEX = /
30
- ^(
31
- [a-zA-Z0-9\-\_]+\. # 1 or more chars followed by a single period
32
- [a-zA-Z0-9\-\_]+\. # 1 or more chars followed by a single period
33
- [a-zA-Z0-9\-\_]+ # 1 or more chars, no trailing chars
34
- )$
35
- /x.freeze
36
-
37
- attr_reader :policy_aud, :include_paths
38
-
39
- # Initializes middleware
40
- def initialize(app, opts = {})
41
- @app = app
42
- @policy_aud = opts.fetch(:policy_aud, nil)
43
- @include_paths = opts.fetch(:include_paths, [])
44
-
45
- check_policy_aud!
46
- check_include_paths_type!
47
- 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
48
53
 
49
- # Public: Call a middleware.
50
- def call(env)
51
- if !path_matches_include_paths?(env)
52
- @app.call(env)
53
- elsif missing_auth_header?(env)
54
- return_error('Missing Authorization header')
55
- elsif invalid_auth_header?(env)
56
- return_error('Invalid Authorization header format')
57
- else
58
- verify_token(env)
59
- end
60
- end
54
+ check_policy_auds!
55
+ check_paths_type!
56
+ end
61
57
 
62
- 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
63
70
 
64
- # Private: Check policy aud.
65
- def check_policy_aud!
66
- return unless !policy_aud.is_a?(String) || policy_aud.strip.empty?
71
+ private
67
72
 
68
- raise ArgumentError, 'policy_aud argument cannot be nil/empty'
69
- end
73
+ # Private: Check policy auds.
74
+ def check_policy_auds!
75
+ raise ArgumentError, 'policies cannot be nil/empty' if policies.values.empty?
70
76
 
71
- # Private: Check include_paths type.
72
- def check_include_paths_type!
73
- 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?
74
79
 
75
- include_paths.each do |path|
76
- raise ArgumentError, 'each include_paths Array element must be a String' unless path.is_a?(String)
77
- raise ArgumentError, 'each include_paths Array element must not be empty' if path.empty?
78
- raise ArgumentError, 'each include_paths Array element must start with a /' unless path.start_with?('/')
79
- end
80
+ raise ArgumentError, 'policy AUD argument cannot be nil/empty'
80
81
  end
82
+ end
81
83
 
82
- # Private: Verify a token.
83
- def verify_token(env)
84
- # extract the token from header.
85
- token = env[HEADER_NAME]
86
- decoded_token = public_keys(env).find do |key|
87
- break decode_token(token, key.public_key)
88
- rescue DecodeTokenError => e
89
- logger.info e.message
90
- nil
91
- end
92
-
93
- if decoded_token
94
- logger.debug 'CloudFlare JWT token is valid'
95
-
96
- env['jwt.payload'] = decoded_token.first
97
- env['jwt.header'] = decoded_token.last
98
- @app.call(env)
99
- else
100
- return_error('Invalid token')
101
- 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?('/')
102
90
  end
91
+ end
103
92
 
104
- # Private: Decode a token.
105
- #
106
- # Example:
107
- #
108
- # [
109
- # {"data"=>"test"}, # payload
110
- # {"alg"=>"RS256"} # header
111
- # ]
112
- #
113
- # @return [Array<Hash>] the token or `nil` at error.
114
- # @raise [DecodeTokenError] if the token is invalid.
115
- #
116
- # @see https://github.com/jwt/ruby-jwt/tree/v2.2.1#algorithms-and-usage
117
- def decode_token(token, secret)
118
- Rack::JWT::Token.decode(token, secret, true, aud: policy_aud, verify_aud: true, algorithm: DEFAULT_ALGORITHM)
119
- rescue ::JWT::VerificationError
120
- raise DecodeTokenError, 'Invalid JWT token : Signature Verification Error'
121
- rescue ::JWT::ExpiredSignature
122
- raise DecodeTokenError, 'Invalid JWT token : Expired Signature (exp)'
123
- rescue ::JWT::IncorrectAlgorithm
124
- raise DecodeTokenError, 'Invalid JWT token : Incorrect Key Algorithm'
125
- rescue ::JWT::ImmatureSignature
126
- raise DecodeTokenError, 'Invalid JWT token : Immature Signature (nbf)'
127
- rescue ::JWT::InvalidIssuerError
128
- raise DecodeTokenError, 'Invalid JWT token : Invalid Issuer (iss)'
129
- rescue ::JWT::InvalidIatError
130
- raise DecodeTokenError, 'Invalid JWT token : Invalid Issued At (iat)'
131
- rescue ::JWT::InvalidAudError
132
- raise DecodeTokenError, 'Invalid JWT token : Invalid Audience (aud)'
133
- rescue ::JWT::InvalidSubError
134
- raise DecodeTokenError, 'Invalid JWT token : Invalid Subject (sub)'
135
- rescue ::JWT::InvalidJtiError
136
- raise DecodeTokenError, 'Invalid JWT token : Invalid JWT ID (jti)'
137
- rescue ::JWT::DecodeError
138
- raise DecodeTokenError, '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
139
103
  end
140
104
 
141
- # Private: Check if current path is in the include_paths.
142
- #
143
- # @return [Boolean] true if it is, false otherwise.
144
- def path_matches_include_paths?(env)
145
- include_paths.empty? || include_paths.any? { |ex| env['PATH_INFO'].start_with?(ex) }
146
- end
105
+ if decoded_token
106
+ logger.debug 'CloudFlare JWT token is valid'
147
107
 
148
- # Private: Check if auth header is invalid.
149
- #
150
- # @return [Boolean] true if it is, false otherwise.
151
- def invalid_auth_header?(env)
152
- 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')
153
113
  end
114
+ end
154
115
 
155
- # Private: Check if no auth header.
156
- #
157
- # @return [Boolean] true if it is, false otherwise.
158
- def missing_auth_header?(env)
159
- env[HEADER_NAME].nil? || env[HEADER_NAME].strip.empty?
160
- 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
161
163
 
162
- # Private: Return an error.
163
- def return_error(message)
164
- body = { error: message }.to_json
165
- 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
166
170
 
167
- [403, headers, [body]]
168
- end
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
169
177
 
170
- # Private: Get public keys.
171
- #
172
- # @return [Array<OpenSSL::PKey::RSA>] the public keys.
173
- def public_keys(env)
174
- host = env[HEADER_HTTP_HOST]
175
- fetch_public_keys_cached(host).map do |jwk_data|
176
- ::JWT::JWK.import(jwk_data).keypair
177
- end
178
- end
178
+ # Private: Return an error.
179
+ def return_error(message)
180
+ body = { error: message }.to_json
181
+ headers = { 'Content-Type' => 'application/json' }
179
182
 
180
- # Private: Fetch public keys.
181
- #
182
- # @param host [String] The host.
183
- #
184
- # @return [Array<Hash>] the public keys.
185
- def fetch_public_keys(host)
186
- json = Net::HTTP.get(host, CERTS_PATH)
187
- json.empty? ? [] : MultiJson.load(json, symbolize_keys: true).fetch(:keys)
188
- rescue StandardError
189
- []
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
190
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
191
207
 
192
- # Private: Get cached public keys.
193
- #
194
- # Store a keys in the cache only 10 minutes.
195
- #
196
- # @param host [String] The host.
197
- #
198
- # @return [Array<Hash>] the public keys.
199
- def fetch_public_keys_cached(host)
200
- key = [self.class.name, '#secrets', host].join('_')
201
-
202
- if defined? Rails
203
- Rails.cache.fetch(key, expires_in: 600) { fetch_public_keys(host) }
204
- elsif defined? Padrino
205
- keys = Padrino.cache[key]
206
- keys || Padrino.cache.store(key, fetch_public_keys(host), expires: 600)
207
- else
208
- fetch_public_keys(host)
209
- 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)
210
225
  end
226
+ end
211
227
 
212
- # Private: Get a logger.
213
- #
214
- # @return [ActiveSupport::Logger] the logger.
215
- def logger
216
- if defined? Rails
217
- Rails.logger
218
- elsif defined? Padrino
219
- Padrino.logger
220
- end
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
221
236
  end
222
237
  end
223
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.9'
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.9
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-03-17 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
@@ -174,6 +188,7 @@ extra_rdoc_files: []
174
188
  files:
175
189
  - LICENSE
176
190
  - README.md
191
+ - lib/rack/cloudflare/jwt.rb
177
192
  - lib/rack/cloudflare_jwt.rb
178
193
  - lib/rack/cloudflare_jwt/auth.rb
179
194
  - lib/rack/cloudflare_jwt/version.rb
@@ -196,8 +211,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
196
211
  - !ruby/object:Gem::Version
197
212
  version: '0'
198
213
  requirements: []
199
- rubyforge_project:
200
- rubygems_version: 2.7.8
214
+ rubygems_version: 3.0.3
201
215
  signing_key:
202
216
  specification_version: 4
203
217
  summary: Rack middleware that provides authentication based on CloudFlare JSON Web