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 +4 -4
- data/Gemfile.lock +5 -3
- data/README.md +24 -0
- data/lib/usps/jwt_auth/concern.rb +1 -1
- data/lib/usps/jwt_auth/config.rb +7 -1
- data/lib/usps/jwt_auth/decode.rb +54 -1
- data/lib/usps/jwt_auth/encode.rb +15 -1
- data/lib/usps/jwt_auth/fingerprint.rb +41 -0
- data/lib/usps/jwt_auth/version.rb +1 -1
- data/lib/usps/jwt_auth.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a248993ec7c0ad7e97d4bfcb7e89302f3366f7c3fd95c122f71d116f9c541bcf
|
|
4
|
+
data.tar.gz: ef4e072d124dd0f398fbb80202523c3a99b5a25220c48dfeaf27c8a732800259
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 (
|
|
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.
|
|
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::
|
|
57
|
+
rescue JWT::DecodeError
|
|
58
58
|
clear_jwt
|
|
59
59
|
nil
|
|
60
60
|
rescue ActiveRecord::RecordNotFound => e
|
data/lib/usps/jwt_auth/config.rb
CHANGED
|
@@ -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
|
data/lib/usps/jwt_auth/decode.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/usps/jwt_auth/encode.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
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
|
|
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
|