usps-jwt_auth 1.1.0 → 1.2.1

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: be13d7639636f1abce28a82c032ea04883808bec8bb3925060f3f2071e1bf3ef
4
- data.tar.gz: 8486bf2584dbcc901f00e17ef059ec3125db036f76f7c81080d68125177f50b0
3
+ metadata.gz: 8c900a7d07163d9ac0be04b7c4ae2ef44a60e02fc081ccd6c776bd056445e2e8
4
+ data.tar.gz: db7b6e04885c6b2ab24e4a6a05af456466e5f33efa39b85e77741523fa95d186
5
5
  SHA512:
6
- metadata.gz: 2a8d5e69bd26ed1a65da6f4c7beb907aa570e7ae1942c4f256fee3c42e8f39bdcbc938018b626825ac77e5d50a64d6b994534361acfac8fda025c2681f39d4a3
7
- data.tar.gz: 5fc08818ac1c3cd4b13b1005f294d6bfd0d3837fc03fd0609c74235cf9de706d5311fc01aaa44cb3cda34edcad22636f56807f6a0ac7d5e7e7b5c79a43048f5f
6
+ metadata.gz: cf1fb79f6441f3571aacd5e2bd2c888a3125fc52c3c5c9a59f7a78bc7c4d0eacd314973976b12d4abdc3703e4cc2ba4d8083fab2fa2fa3fa48c42ae2fc87e02c
7
+ data.tar.gz: 96c07d7c4689b3818f8cf4d497bed1b17b77fc46aa584583a663beb5d00609e4c65306fb8c5503a95bb85ba87b1ea1f01e9a6f7d1676cb48e54d6248f1b20bcd
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- usps-jwt_auth (1.1.0)
4
+ usps-jwt_auth (1.2.1)
5
5
  activesupport (~> 8.0)
6
6
  colorize (~> 1.1)
7
7
  fileutils (~> 1.7)
data/README.md CHANGED
@@ -39,11 +39,35 @@ Usps::JwtAuth.configure do |config|
39
39
  config.issuer_base = 'usps:1' # ENV['JWT_ISSUER_BASE'] # 'usps:1'
40
40
  config.issuers = ['admin:1'] # ENV['JWT_ISSUERS'] # []
41
41
 
42
+ # Base URL of the shared public-key store (see "Public key resolution").
43
+ config.public_keys_url = 'https://keys.aws.usps.org' # ENV['JWT_PUBLIC_KEYS_URL'] # nil
44
+
45
+ # Optional. Called as `publisher.call(fingerprint, pem)` when an issuer writes a
46
+ # new public key, so it can be pushed to the shared store. Defaults to nil (no-op).
47
+ config.publisher = ->(fingerprint, pem) { S3.put("#{fingerprint}.pub", pem) }
48
+
42
49
  config.is_admin = ->(user) { Pundit.policy(user, :admin).admin? }
43
50
  config.find_member = ->(certificate) { Members::Member.find(certificate) }
44
51
  end
45
52
  ```
46
53
 
54
+ ## Public key resolution
55
+
56
+ A token names its signing key by an SSH SHA256 fingerprint (the `key` claim),
57
+ which is content-addressed — a fingerprint maps to exactly one key, forever.
58
+
59
+ When decoding, the gem first looks for `<fingerprint>.pub` under `public_keys_path`
60
+ (the local cache). On a miss, if `public_keys_url` is set, it fetches
61
+ `<public_keys_url>/<fingerprint>.pub` once, **verifies the fetched key reproduces
62
+ the fingerprint** (so the store is an untrusted cache that cannot forge keys),
63
+ caches it under `public_keys_path`, and uses it. With no `public_keys_url` set,
64
+ only locally cached keys are used.
65
+
66
+ Issuers publish keys to that same store: set `publisher` and the gem pushes each
67
+ newly written public key on encode. Because keys are content-addressed, rotation
68
+ needs no consumer changes — a new key is published and consumers fetch it on first
69
+ sight.
70
+
47
71
  ## Usage
48
72
 
49
73
  ```ruby
@@ -71,7 +71,9 @@ module Usps
71
71
  return if params[:jwt].blank? || @set_new_jwt
72
72
 
73
73
  store_jwt(params[:jwt])
74
- ensure_valid_jwt_has_valid_member!
74
+ # An unverifiable token (e.g. a key we cannot resolve) clears the jwt and returns
75
+ # nil; fall through so the visitor is sent to login rather than into the app.
76
+ return unless ensure_valid_jwt_has_valid_member!
75
77
 
76
78
  redirect_to_path!
77
79
  @set_new_jwt = true
@@ -124,6 +126,12 @@ module Usps
124
126
  def ensure_valid_jwt_has_valid_member!
125
127
  fetch_jwt
126
128
  jwt_user
129
+ rescue JWT::DecodeError
130
+ # The token cannot be verified (e.g. an unresolvable key). Drop it and report
131
+ # "not signed in" so the caller redirects to login — never let this escape as an
132
+ # unhandled 500, which the error page would re-trigger when it re-authenticates.
133
+ clear_jwt
134
+ @current_user = nil
127
135
  rescue ActiveRecord::RecordNotFound
128
136
  reset_session
129
137
  clear_jwt
@@ -7,7 +7,8 @@ module Usps
7
7
  class Config
8
8
  REQUIRED_OPTIONS = %i[audience is_admin find_member].freeze
9
9
 
10
- attr_accessor :key_size, :algorithm, :issuer_base, :issuers, :audience, :is_admin, :find_member
10
+ attr_accessor :key_size, :algorithm, :issuer_base, :issuers, :audience, :is_admin, :find_member,
11
+ :public_keys_url, :publisher
11
12
  attr_reader :environment
12
13
 
13
14
  def initialize
@@ -19,6 +20,11 @@ module Usps
19
20
  @issuer_base = ENV.fetch('JWT_ISSUER_BASE', 'usps:1')
20
21
  @issuers = ENV.fetch('JWT_ISSUERS', [])
21
22
  @audience = ENV.fetch('JWT_AUDIENCE', nil)
23
+ # Base URL of the shared public-key store (CloudFront). Consumers fetch
24
+ # "<public_keys_url>/<fingerprint>.pub" on a local cache miss. nil disables fetching.
25
+ # `publisher` (defaults to nil) is an optional callable invoked as
26
+ # publisher.call(fingerprint, pem) when an issuer writes a new public key.
27
+ @public_keys_url = ENV.fetch('JWT_PUBLIC_KEYS_URL', nil)
22
28
 
23
29
  yield self if block_given? # Also support setting options on initialize
24
30
  end
@@ -1,10 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'fileutils'
6
+
3
7
  module Usps
4
8
  module JwtAuth
5
9
  # Decode and validate data from a JWT
6
10
  #
7
11
  class Decode
12
+ # Keep a slow or unreachable store from tying up the caller (e.g. a web worker).
13
+ OPEN_TIMEOUT = 2
14
+ READ_TIMEOUT = 2
15
+
8
16
  def self.decode(token, audience: [], issuer: nil)
9
17
  new.decode(token, audience: audience, issuer: issuer)
10
18
  end
@@ -32,12 +40,69 @@ module Usps
32
40
 
33
41
  private
34
42
 
43
+ # Resolve the public key the token names by its fingerprint. Fast path is the local
44
+ # 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.
35
47
  def public_key(token)
36
- OpenSSL::PKey::RSA.new(File.read("#{JwtAuth.config.public_keys_path}/#{fingerprint(token)}.pub"))
48
+ fingerprint = fingerprint(token)
49
+ OpenSSL::PKey::RSA.new(File.read(cache_path(fingerprint)))
37
50
  rescue Errno::ENOENT
38
- # No public key matches the token's fingerprint (e.g. a stale token after key rotation),
39
- # so the token cannot be verified and is treated as invalid.
40
- raise JWT::VerificationError, 'No public key for token fingerprint'
51
+ fetch_public_key(fingerprint)
52
+ end
53
+
54
+ def cache_path(fingerprint)
55
+ "#{JwtAuth.config.public_keys_path}/#{fingerprint}.pub"
56
+ end
57
+
58
+ # Fetch the named key from the shared store, verify it, and cache it locally.
59
+ def fetch_public_key(fingerprint)
60
+ url = JwtAuth.config.public_keys_url
61
+ # No store configured, so a missing local key cannot be resolved (e.g. a stale token
62
+ # after rotation): the token cannot be verified and is treated as invalid.
63
+ raise JWT::VerificationError, 'No public key for token fingerprint' unless url
64
+
65
+ pem = http_get("#{url}/#{fingerprint}.pub")
66
+ cache_verified_key(fingerprint, pem)
67
+ rescue JWT::VerificationError
68
+ raise
69
+ rescue StandardError => e
70
+ raise JWT::VerificationError, "Could not fetch public key for token fingerprint: #{e.message}"
71
+ end
72
+
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.
76
+ def cache_verified_key(fingerprint, pem)
77
+ key = OpenSSL::PKey::RSA.new(pem)
78
+ unless Fingerprint.for(key.public_key) == fingerprint
79
+ raise JWT::VerificationError, 'Fetched public key does not match token fingerprint'
80
+ end
81
+
82
+ cache_key(fingerprint, pem)
83
+ key
84
+ end
85
+
86
+ # Persist the verified key so the next decode skips the fetch — but best-effort: an
87
+ # unwritable cache dir (e.g. a read-only or wrong-owner deploy) must NOT fail a token
88
+ # we have already fetched and verified. On a write error we just refetch next time.
89
+ def cache_key(fingerprint, pem)
90
+ path = cache_path(fingerprint)
91
+ FileUtils.mkdir_p(File.dirname(path))
92
+ File.write(path, pem)
93
+ rescue SystemCallError
94
+ nil
95
+ end
96
+
97
+ def http_get(url)
98
+ uri = URI.parse(url)
99
+ response = Net::HTTP.start(
100
+ uri.host, uri.port,
101
+ use_ssl: uri.scheme == 'https', open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT
102
+ ) { |http| http.get(uri.request_uri) }
103
+ raise "HTTP #{response.code} for #{url}" unless response.is_a?(Net::HTTPSuccess)
104
+
105
+ response.body
41
106
  end
42
107
 
43
108
  def fingerprint(token)
@@ -30,34 +30,14 @@ module Usps
30
30
  @public_key ||= private_key.public_key
31
31
  end
32
32
 
33
- # OpenSSH-style SHA256 key fingerprint: base64url(SHA256(ssh-rsa wire blob)),
34
- # i.e. the value `ssh-keygen -lf` prints as `SHA256:...`. This matches the key
35
- # filenames and `key` claim that the consuming apps already recognize.
33
+ # The OpenSSH-style SHA256 fingerprint of this key see Fingerprint. Stamped onto
34
+ # the token's `key` claim so consumers can resolve and verify the matching public key.
36
35
  def fingerprint
37
- digest = OpenSSL::Digest::SHA256.digest(ssh_public_blob)
38
- [digest].pack('m0').tr('+/', '-_').delete('=')
36
+ Fingerprint.for(public_key)
39
37
  end
40
38
 
41
39
  private
42
40
 
43
- # Public key in the OpenSSH wire format: string('ssh-rsa') + mpint(e) + mpint(n).
44
- def ssh_public_blob
45
- ssh_string('ssh-rsa') + ssh_mpint(public_key.e) + ssh_mpint(public_key.n)
46
- end
47
-
48
- # SSH "string": 4-byte big-endian length prefix followed by the bytes.
49
- def ssh_string(bytes)
50
- [bytes.bytesize].pack('N') + bytes.b
51
- end
52
-
53
- # SSH "mpint": a length-prefixed big-endian integer; a leading zero byte is
54
- # prepended when the high bit is set so the value stays non-negative.
55
- def ssh_mpint(num)
56
- bytes = num.to_s(2)
57
- bytes = "\x00".b + bytes if bytes.bytes.first && bytes.bytes.first >= 0x80
58
- ssh_string(bytes)
59
- end
60
-
61
41
  def expires_at
62
42
  ::Time.now.to_i + @expiration_time
63
43
  end
@@ -94,6 +74,14 @@ module Usps
94
74
 
95
75
  File.open(path, 'w+') { |f| f.puts(public_key) }
96
76
  ::FileUtils.chmod(0o644, path)
77
+ publish_public_key
78
+ end
79
+
80
+ # Push the freshly written public key to the shared store, if a publisher is wired
81
+ # in, so consumers can resolve this fingerprint without a manual sync. Re-publishing
82
+ # an existing fingerprint is a harmless no-op for the store.
83
+ def publish_public_key
84
+ JwtAuth.config.publisher&.call(fingerprint, public_key.to_pem)
97
85
  end
98
86
 
99
87
  def generate_private_key
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Usps
6
+ module JwtAuth
7
+ # OpenSSH-style SHA256 key fingerprint: base64url(SHA256(ssh-rsa wire blob)),
8
+ # i.e. the value `ssh-keygen -lf` prints as `SHA256:...`. This is the identifier
9
+ # used for key filenames and the JWT `key` claim, and it is content-addressed: a
10
+ # given fingerprint maps to exactly one public key, forever. Encode stamps it onto
11
+ # tokens; Decode uses it to verify a fetched key really is the key the token names.
12
+ module Fingerprint
13
+ class << self
14
+ def for(public_key)
15
+ digest = OpenSSL::Digest::SHA256.digest(ssh_public_blob(public_key))
16
+ [digest].pack('m0').tr('+/', '-_').delete('=')
17
+ end
18
+
19
+ private
20
+
21
+ # Public key in the OpenSSH wire format: string('ssh-rsa') + mpint(e) + mpint(n).
22
+ def ssh_public_blob(public_key)
23
+ ssh_string('ssh-rsa') + ssh_mpint(public_key.e) + ssh_mpint(public_key.n)
24
+ end
25
+
26
+ # SSH "string": 4-byte big-endian length prefix followed by the bytes.
27
+ def ssh_string(bytes)
28
+ [bytes.bytesize].pack('N') + bytes.b
29
+ end
30
+
31
+ # SSH "mpint": a length-prefixed big-endian integer; a leading zero byte is
32
+ # prepended when the high bit is set so the value stays non-negative.
33
+ def ssh_mpint(num)
34
+ bytes = num.to_s(2)
35
+ bytes = "\x00".b + bytes if bytes.bytes.first && bytes.bytes.first >= 0x80
36
+ ssh_string(bytes)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Usps
4
4
  module JwtAuth
5
- VERSION = '1.1.0'
5
+ VERSION = '1.2.1'
6
6
  end
7
7
  end
data/lib/usps/jwt_auth.rb CHANGED
@@ -33,6 +33,7 @@ end
33
33
  # Internal requires
34
34
  require_relative 'jwt_auth/version'
35
35
  require_relative 'jwt_auth/config'
36
+ require_relative 'jwt_auth/fingerprint'
36
37
  require_relative 'jwt_auth/encode'
37
38
  require_relative 'jwt_auth/decode'
38
39
  require_relative 'jwt_auth/incorrect_login'
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.1.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Fiander
@@ -86,6 +86,7 @@ files:
86
86
  - lib/usps/jwt_auth/config.rb
87
87
  - lib/usps/jwt_auth/decode.rb
88
88
  - lib/usps/jwt_auth/encode.rb
89
+ - lib/usps/jwt_auth/fingerprint.rb
89
90
  - lib/usps/jwt_auth/incorrect_login.rb
90
91
  - lib/usps/jwt_auth/railtie.rb
91
92
  - lib/usps/jwt_auth/version.rb