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