rack-cloudflare-jwt 0.0.6 → 0.2.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: 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