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 +4 -4
- data/README.md +8 -5
- data/lib/rack/cloudflare/jwt.rb +1 -0
- data/lib/rack/cloudflare_jwt.rb +3 -4
- data/lib/rack/cloudflare_jwt/auth.rb +203 -153
- data/lib/rack/cloudflare_jwt/version.rb +2 -2
- metadata +32 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9cfffcc56a02828c0ab0aea34ce64dd64e7fa09b9c564cc2146f7a54f01ff189
|
4
|
+
data.tar.gz: 55b46d11820643dead91670a3c23aaa25d0d80526844ecdcadb38c2ec5110465
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
* `
|
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
|
-
* `
|
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
|
-
|
45
|
-
|
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'
|
data/lib/rack/cloudflare_jwt.rb
CHANGED
@@ -5,180 +5,230 @@ require 'multi_json'
|
|
5
5
|
require 'net/http'
|
6
6
|
require 'rack/jwt'
|
7
7
|
|
8
|
-
module Rack
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
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
|
-
|
64
|
-
|
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
|
-
|
67
|
-
|
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
|
-
|
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
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
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
|
-
|
98
|
-
|
99
|
-
#
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
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
|
-
|
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
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
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
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
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
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
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
|
-
|
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
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
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
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
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
|
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
|
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:
|
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.
|
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
|