usps-jwt_auth 1.0.6 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b570538c78235e550ff7903dfb293cbf8d7116ee050af858a963db6ddc84c2e
4
- data.tar.gz: e41a91e29de1b8449780e39d0bf93b8ca61f3f08eb43ff08f470ac244a2675ea
3
+ metadata.gz: a248993ec7c0ad7e97d4bfcb7e89302f3366f7c3fd95c122f71d116f9c541bcf
4
+ data.tar.gz: ef4e072d124dd0f398fbb80202523c3a99b5a25220c48dfeaf27c8a732800259
5
5
  SHA512:
6
- metadata.gz: 8eaf955b197330910784b35669dec3ef5d6cd073f910f66623188f2e6fa672c588e84ec45bea8f2e045b1809ff39241f356e398b501217d9a743a7f69b0b192f
7
- data.tar.gz: 73f575a6f21f5efa3fdbdf485f208d163d7d25b2de288ce641048acaf27b9f4809a36478278c785bb148843f470d503fbf5fb8e9395b71e089b149e0686248f2
6
+ metadata.gz: f38759de429b850a32caa02b412fbbc644dca520f74422bef274fe30dc0f709b272057b81aaf411afce3a7c979bc550e1bb1531dbd228f0185fb6e12bff6ea73
7
+ data.tar.gz: 57c98a347480941fbf0f7c88a27252228eb0198d91ffb11f7259b5abf5dfb79f50b4c6db7113dbbe5fe5748ffa7a90a4fdc172268123741d6b27faec0023124c
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- usps-jwt_auth (1.0.6)
4
+ usps-jwt_auth (1.2.0)
5
5
  activesupport (~> 8.0)
6
6
  colorize (~> 1.1)
7
7
  fileutils (~> 1.7)
@@ -49,7 +49,9 @@ GEM
49
49
  language_server-protocol (3.17.0.5)
50
50
  lint_roller (1.1.0)
51
51
  logger (1.7.0)
52
- minitest (5.26.0)
52
+ minitest (6.0.6)
53
+ drb (~> 2.0)
54
+ prism (~> 1.5)
53
55
  parallel (1.27.0)
54
56
  parser (3.3.9.0)
55
57
  ast (~> 2.4.1)
@@ -118,7 +120,7 @@ GEM
118
120
  concurrent-ruby (~> 1.0)
119
121
  unicode-display_width (3.2.0)
120
122
  unicode-emoji (~> 4.1)
121
- unicode-emoji (4.1.0)
123
+ unicode-emoji (4.2.0)
122
124
  uri (1.0.4)
123
125
 
124
126
  PLATFORMS
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
@@ -54,7 +54,7 @@ module Usps
54
54
 
55
55
  # Admin has entered impersonation mode -- override current_user
56
56
  @current_user = JwtAuth.config.find_member.call(session['impersonate']['impersonated'])
57
- rescue JWT::ExpiredSignature
57
+ rescue JWT::DecodeError
58
58
  clear_jwt
59
59
  nil
60
60
  rescue ActiveRecord::RecordNotFound => e
@@ -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,5 +1,9 @@
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
@@ -32,8 +36,57 @@ module Usps
32
36
 
33
37
  private
34
38
 
39
+ # Resolve the public key the token names by its fingerprint. Fast path is the local
40
+ # cache; on a miss we fetch it once from the shared store and cache it permanently
41
+ # (a fingerprint maps to one key forever). The store is treated as untrusted — see
42
+ # cache_verified_key — so a tampered key can never be trusted.
35
43
  def public_key(token)
36
- OpenSSL::PKey::RSA.new(File.read("#{JwtAuth.config.public_keys_path}/#{fingerprint(token)}.pub"))
44
+ fingerprint = fingerprint(token)
45
+ OpenSSL::PKey::RSA.new(File.read(cache_path(fingerprint)))
46
+ rescue Errno::ENOENT
47
+ fetch_public_key(fingerprint)
48
+ end
49
+
50
+ def cache_path(fingerprint)
51
+ "#{JwtAuth.config.public_keys_path}/#{fingerprint}.pub"
52
+ end
53
+
54
+ # Fetch the named key from the shared store, verify it, and cache it locally.
55
+ def fetch_public_key(fingerprint)
56
+ url = JwtAuth.config.public_keys_url
57
+ # No store configured, so a missing local key cannot be resolved (e.g. a stale token
58
+ # after rotation): the token cannot be verified and is treated as invalid.
59
+ raise JWT::VerificationError, 'No public key for token fingerprint' unless url
60
+
61
+ pem = http_get("#{url}/#{fingerprint}.pub")
62
+ cache_verified_key(fingerprint, pem)
63
+ rescue JWT::VerificationError
64
+ raise
65
+ rescue StandardError => e
66
+ raise JWT::VerificationError, "Could not fetch public key for token fingerprint: #{e.message}"
67
+ end
68
+
69
+ # Trust nothing the store returns until the key reproduces the fingerprint the token
70
+ # names. Without this check a compromised store/CDN could serve an attacker key under a
71
+ # known fingerprint and forge valid tokens. Only verified keys are written to the cache.
72
+ def cache_verified_key(fingerprint, pem)
73
+ key = OpenSSL::PKey::RSA.new(pem)
74
+ unless Fingerprint.for(key.public_key) == fingerprint
75
+ raise JWT::VerificationError, 'Fetched public key does not match token fingerprint'
76
+ end
77
+
78
+ path = cache_path(fingerprint)
79
+ FileUtils.mkdir_p(File.dirname(path))
80
+ File.write(path, pem)
81
+ key
82
+ end
83
+
84
+ def http_get(url)
85
+ uri = URI.parse(url)
86
+ response = Net::HTTP.get_response(uri)
87
+ raise "HTTP #{response.code} for #{url}" unless response.is_a?(Net::HTTPSuccess)
88
+
89
+ response.body
37
90
  end
38
91
 
39
92
  def fingerprint(token)
@@ -30,8 +30,10 @@ module Usps
30
30
  @public_key ||= private_key.public_key
31
31
  end
32
32
 
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.
33
35
  def fingerprint
34
- OpenSSL::Digest::SHA256.new(public_key.to_der).to_s
36
+ Fingerprint.for(public_key)
35
37
  end
36
38
 
37
39
  private
@@ -64,10 +66,22 @@ module Usps
64
66
  ::FileUtils.chmod(0o600, path)
65
67
  end
66
68
 
69
+ # Ensure the public key is published, but only write it when missing — the file
70
+ # is stable for a given key, so rewriting it on every encode is needless churn.
67
71
  def store_public_key
68
72
  path = "#{JwtAuth.config.public_keys_path}/#{fingerprint}.pub"
73
+ return if File.exist?(path)
74
+
69
75
  File.open(path, 'w+') { |f| f.puts(public_key) }
70
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)
71
85
  end
72
86
 
73
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.0.6'
5
+ VERSION = '1.2.0'
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.0.6
4
+ version: 1.2.0
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