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