usps-jwt_auth 1.2.2 → 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: 17662af2194b354229fdc800d007e1121fb3a34dbb65bcf1a8e1df2ec0011087
4
- data.tar.gz: 7a2017a06711cb9a4714b111a4a2ee85ca3ede7c7e9d0d851e098d1dd83fa302
3
+ metadata.gz: 6ce721a644d600d1c0d4f628bd17a1588631ce652bbf0cc5393a2cba5a21e931
4
+ data.tar.gz: 14d6701e1890f6c43b0c77d0c42b9b7b35a7ee32a0fd2935ed358720fa9f5882
5
5
  SHA512:
6
- metadata.gz: 33b3ce36812329c3d25a890fd1c5d0c8b591671fef159fd602276b5217f238c021e65c60512e1ea75dc5addc07d6b1f56b0b6c2329c027cfe79362f9752752c4
7
- data.tar.gz: e7e2aed19988c5148ad9550e20b94e018ac1fad129704badbce18a35f027d9c5da231b1570d7eb9821d08838921af238fed223cdd7412b72e5f2b9dab69a7f41
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.2)
4
+ usps-jwt_auth (1.2.4)
5
5
  activesupport (~> 8.0)
6
6
  colorize (~> 1.1)
7
7
  fileutils (~> 1.7)
@@ -75,18 +75,30 @@ 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
86
86
 
87
+ # Back to where the login began, with the query it began with. The query rides through the login
88
+ # as a single `query_string` parameter, and is unpacked here rather than handed on still packed.
89
+ # It is re-encoded rather than passed through as received, so the redirect stays well-formed
90
+ # whatever arrives.
87
91
  def redirect_to_path!
88
- path = params[:path] || root_path
89
- redirect_to(params.key?(:query_string) ? "#{path}?query_string=#{CGI.escape(params[:query_string])}" : path)
92
+ path = local_path(params[:path])
93
+ pairs = URI.decode_www_form(params[:query_string].to_s).reject { |key, _value| key.empty? }
94
+
95
+ redirect_to(pairs.empty? ? path : "#{path}?#{URI.encode_www_form(pairs)}")
96
+ end
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
90
102
  end
91
103
 
92
104
  def store_jwt(token)
@@ -107,36 +119,38 @@ module Usps
107
119
  session[:jwt] || cookies[:jwt]
108
120
  end
109
121
 
110
- def jwt
111
- Decode.decode(
112
- fetch_jwt,
113
- audience: [JwtAuth.config.audience],
114
- issuer: Regexp.union(JwtAuth.config.issuers)
115
- )
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))
116
126
  end
117
127
 
118
128
  def redirect_to_login
119
129
  return if @set_new_jwt
120
130
 
121
- url = 'https://www.usps.org/jwt/'
122
- local = "#{url}?local&port=#{ENV.fetch('PORT', '3000')}"
123
- production = "#{url}?application=#{JwtAuth.config.audience}"
124
- url = JwtAuth.config.environment.development? ? local : production
125
- 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)}"
126
133
  url = "#{url}&query_string=#{CGI.escape(request.query_string)}" if request.query_string.present?
127
134
 
128
135
  redirect_to(url, allow_other_host: true)
129
136
  end
130
137
 
131
- def ensure_valid_jwt_has_valid_member!
132
- fetch_jwt
133
- 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?
134
149
  rescue JWT::DecodeError
135
- # The token cannot be verified (e.g. an unresolvable key). Drop it and report
136
- # "not signed in" so the caller redirects to login — never let this escape as an
137
- # unhandled 500, which the error page would re-trigger when it re-authenticates.
138
- clear_jwt
139
- @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
140
154
  rescue ActiveRecord::RecordNotFound
141
155
  reset_session
142
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.2'
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.2
4
+ version: 1.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Fiander