usps-jwt_auth 1.2.3 → 1.2.4

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: 55690055320ed2dfe5ace756f990bedd8769a5003750baa7ab13828330a78052
4
- data.tar.gz: 9bbadbdcb087a8645b2b057a01020589aacbf0d6a5d498fcc907f30f3e667150
3
+ metadata.gz: 6ce721a644d600d1c0d4f628bd17a1588631ce652bbf0cc5393a2cba5a21e931
4
+ data.tar.gz: 14d6701e1890f6c43b0c77d0c42b9b7b35a7ee32a0fd2935ed358720fa9f5882
5
5
  SHA512:
6
- metadata.gz: 57e395c8ee636d9ac8b7d1709927fab0506f4b20acf146925e22829da835bcf943c6151e5732c9b9c6271f348355e8daf7f0c346b31c8bfb9114c93591f03620
7
- data.tar.gz: f7bdfdb5a52b589639f81481e9bb2bf6906f94b037b0865bb22e91a94db10bd91c0c049922f9243f87c2faa49599e08b97a3686a2e2a74921ef2c98166a2fb23
6
+ metadata.gz: e85979fd43fdca1b7a625cbb8ac3c93e71e1d82eaa002805ea4fe396cc2fd05adcbdc996c058ba99089566842643b9399ff9ea00f81f92c7991f2f2726fd989d
7
+ data.tar.gz: a25da72d317735201c4363ec8e0ba02485a5725049c886df3ee0e1a0319f6a60e79ec3f2cbf64eb16ad1ec0597097cadf1af826aa052d1275faf96d18fbd4411
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- usps-jwt_auth (1.2.3)
4
+ usps-jwt_auth (1.2.4)
5
5
  activesupport (~> 8.0)
6
6
  colorize (~> 1.1)
7
7
  fileutils (~> 1.7)
@@ -75,11 +75,11 @@ module Usps
75
75
  def set_new_jwt
76
76
  return if params[:jwt].blank? || @set_new_jwt
77
77
 
78
- store_jwt(params[:jwt])
79
- # An unverifiable token (e.g. a key we cannot resolve) clears the jwt and returns
80
- # nil; fall through so the visitor is sent to login rather than into the app.
81
- return unless ensure_valid_jwt_has_valid_member!
78
+ # Nothing is stored until the token is verified, so a bad one can't disturb the current
79
+ # session. Fall through, and a visitor with no session is sent to login.
80
+ return unless ensure_valid_jwt_has_valid_member!(params[:jwt])
82
81
 
82
+ store_jwt(params[:jwt])
83
83
  redirect_to_path!
84
84
  @set_new_jwt = true
85
85
  end
@@ -89,12 +89,18 @@ module Usps
89
89
  # It is re-encoded rather than passed through as received, so the redirect stays well-formed
90
90
  # whatever arrives.
91
91
  def redirect_to_path!
92
- path = params[:path] || root_path
92
+ path = local_path(params[:path])
93
93
  pairs = URI.decode_www_form(params[:query_string].to_s).reject { |key, _value| key.empty? }
94
94
 
95
95
  redirect_to(pairs.empty? ? path : "#{path}?#{URI.encode_www_form(pairs)}")
96
96
  end
97
97
 
98
+ # Anything but a single leading slash could point the redirect at another host, which Rails
99
+ # answers with a 500.
100
+ def local_path(path)
101
+ path.is_a?(String) && path.match?(%r{\A/(?![/\\])[^\x00-\x1f\x7f]*\z}) ? path : root_path
102
+ end
103
+
98
104
  def store_jwt(token)
99
105
  session[:jwt] = token
100
106
  cookies[:jwt] = { value: token, domain: cookie_domain, httponly: true }
@@ -113,36 +119,38 @@ module Usps
113
119
  session[:jwt] || cookies[:jwt]
114
120
  end
115
121
 
116
- def jwt
117
- Decode.decode(
118
- fetch_jwt,
119
- audience: [JwtAuth.config.audience],
120
- issuer: Regexp.union(JwtAuth.config.issuers)
121
- )
122
+ def jwt = decode_jwt(fetch_jwt)
123
+
124
+ def decode_jwt(token)
125
+ Decode.decode(token, audience: [JwtAuth.config.audience], issuer: Regexp.union(JwtAuth.config.issuers))
122
126
  end
123
127
 
124
128
  def redirect_to_login
125
129
  return if @set_new_jwt
126
130
 
127
- url = 'https://www.usps.org/jwt/'
128
- local = "#{url}?local&port=#{ENV.fetch('PORT', '3000')}"
129
- production = "#{url}?application=#{JwtAuth.config.audience}"
130
- url = JwtAuth.config.environment.development? ? local : production
131
- url = "#{url}&path=#{request.path}"
131
+ # Escaped so the path can't add parameters of its own to the login request.
132
+ url = "#{login_url}&path=#{CGI.escape(request.path)}"
132
133
  url = "#{url}&query_string=#{CGI.escape(request.query_string)}" if request.query_string.present?
133
134
 
134
135
  redirect_to(url, allow_other_host: true)
135
136
  end
136
137
 
137
- def ensure_valid_jwt_has_valid_member!
138
- fetch_jwt
139
- jwt_user
138
+ def login_url
139
+ url = 'https://www.usps.org/jwt/'
140
+ return "#{url}?local&port=#{ENV.fetch('PORT', '3000')}" if JwtAuth.config.environment.development?
141
+
142
+ "#{url}?application=#{JwtAuth.config.audience}"
143
+ end
144
+
145
+ def ensure_valid_jwt_has_valid_member!(token)
146
+ certificate = decode_jwt(token)['certificate']
147
+ # A blank certificate claim is malformed and must never resolve to a member.
148
+ JwtAuth.config.find_member.call(certificate) if certificate.present?
140
149
  rescue JWT::DecodeError
141
- # The token cannot be verified (e.g. an unresolvable key). Drop it and report
142
- # "not signed in" so the caller redirects to login — never let this escape as an
143
- # unhandled 500, which the error page would re-trigger when it re-authenticates.
144
- clear_jwt
145
- @current_user = nil
150
+ # The token cannot be verified (e.g. an unresolvable key). Report it as invalid so the
151
+ # caller falls through — never let this escape as an unhandled 500, which the error page
152
+ # would re-trigger when it re-authenticates.
153
+ nil
146
154
  rescue ActiveRecord::RecordNotFound
147
155
  reset_session
148
156
  clear_jwt
@@ -12,6 +12,7 @@ module Usps
12
12
  # Keep a slow or unreachable store from tying up the caller (e.g. a web worker).
13
13
  OPEN_TIMEOUT = 2
14
14
  READ_TIMEOUT = 2
15
+ KEY_NAME = /\A[A-Za-z0-9_-]{1,128}\z/
15
16
 
16
17
  def self.decode(token, audience: [], issuer: nil)
17
18
  new.decode(token, audience: audience, issuer: issuer)
@@ -29,21 +30,21 @@ module Usps
29
30
  end
30
31
 
31
32
  def decode(token, audience: [], issuer: nil)
32
- result = JWT.decode(
33
- token,
34
- public_key(token),
35
- true,
36
- self.class.token_options(audience:, issuer:)
37
- )
38
- result[0]['data']
33
+ payload, = JWT.decode(token, public_key(token), true, self.class.token_options(audience:, issuer:))
34
+ return payload['data'] if payload['data'].is_a?(Hash)
35
+
36
+ raise JWT::DecodeError, 'Token has no data'
37
+ rescue ArgumentError, TypeError, NoMethodError, OpenSSL::PKey::PKeyError => e
38
+ # Whatever a token holds, callers only need to handle JWT::DecodeError.
39
+ raise JWT::DecodeError, "Unreadable token: #{e.class}"
39
40
  end
40
41
 
41
42
  private
42
43
 
43
44
  # Resolve the public key the token names by its fingerprint. Fast path is the local
44
45
  # cache; on a miss we fetch it once from the shared store and cache it permanently
45
- # (a fingerprint maps to one key forever). The store is treated as untrusted — see
46
- # cache_verified_key — so a tampered key can never be trusted.
46
+ # (a fingerprint maps to one key forever). A token can name any key in the store, so
47
+ # write access to the store is as sensitive as a signing key.
47
48
  def public_key(token)
48
49
  fingerprint = fingerprint(token)
49
50
  OpenSSL::PKey::RSA.new(File.read(cache_path(fingerprint)))
@@ -70,9 +71,9 @@ module Usps
70
71
  raise JWT::VerificationError, "Could not fetch public key for token fingerprint: #{e.message}"
71
72
  end
72
73
 
73
- # Trust nothing the store returns until the key reproduces the fingerprint the token
74
- # names. Without this check a compromised store/CDN could serve an attacker key under a
75
- # known fingerprint and forge valid tokens. Only verified keys are written to the cache.
74
+ # Use nothing the store returns until the key reproduces the fingerprint the token names.
75
+ # This stops the store or CDN swapping in a different key for a known fingerprint. Only
76
+ # verified keys are written to the cache.
76
77
  def cache_verified_key(fingerprint, pem)
77
78
  key = OpenSSL::PKey::RSA.new(pem)
78
79
  unless Fingerprint.for(key.public_key) == fingerprint
@@ -105,8 +106,13 @@ module Usps
105
106
  response.body
106
107
  end
107
108
 
109
+ # Read before the signature is checked, so this claim can only ever name a key file. Its
110
+ # alphabet leaves no way to form a path.
108
111
  def fingerprint(token)
109
- JWT.decode(token, nil, false)[0]['key']
112
+ name = JWT.decode(token, nil, false)[0]['key']
113
+ return name if name.is_a?(String) && name.match?(KEY_NAME)
114
+
115
+ raise JWT::VerificationError, 'Token names an invalid key'
110
116
  end
111
117
  end
112
118
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Usps
4
4
  module JwtAuth
5
- VERSION = '1.2.3'
5
+ VERSION = '1.2.4'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: usps-jwt_auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Fiander