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 +4 -4
- data/README.md +5 -5
- data/lib/rack/cloudflare/jwt.rb +1 -0
- data/lib/rack/cloudflare_jwt.rb +3 -4
- data/lib/rack/cloudflare_jwt/auth.rb +205 -190
- data/lib/rack/cloudflare_jwt/version.rb +2 -2
- metadata +18 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 240d66a2a123b06cf3625765ce2d7b4772c2a46f059fd62decc90acd4d9e68bc
|
4
|
+
data.tar.gz: 9bf13c926defed079e9266752c64ab3b38d004e3665252b3dd7a663db77ae66c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
+
|
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
|
-
|
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'
|
data/lib/rack/cloudflare_jwt.rb
CHANGED
@@ -5,219 +5,234 @@ 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
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
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
|
-
|
50
|
-
|
51
|
-
|
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
|
-
|
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
|
-
|
65
|
-
def check_policy_aud!
|
66
|
-
return unless !policy_aud.is_a?(String) || policy_aud.strip.empty?
|
71
|
+
private
|
67
72
|
|
68
|
-
|
69
|
-
|
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
|
-
|
72
|
-
|
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
|
-
|
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
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
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
|
-
|
105
|
-
|
106
|
-
#
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
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
|
-
|
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
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
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
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
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
|
-
|
168
|
-
|
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
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
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
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
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
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
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
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
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
|
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.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:
|
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
|
-
|
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
|