standard_id 0.2.7 → 0.2.8
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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa6edd707d2fbe2489dfdebfade37312811fd607aaae48bcc62d973db1e66086
|
|
4
|
+
data.tar.gz: 433da88bb0da62c2af3fe3fe988dcbef5262a3ad3f079877fe611d8b0cb670bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e7ddc6f377761c1d3f87a32bd2eaeb692b01b3c30140d90403258885606e6220f0047ea29c2e92ddf0568a1e6a5cf580f9a1f2d60c33aba1e6fd2c003575aeb
|
|
7
|
+
data.tar.gz: 4c89ea4e20d364ebbd57c00eebd19bd06da11231ccc6eaf8b8d596709c3fbe09e0f14e016f1f51c615112f062bf4d0790c2c7c3c281b7511b74a4b16fd720eb7
|
|
@@ -80,6 +80,23 @@ StandardId.configure do |c|
|
|
|
80
80
|
# Option 3: File path
|
|
81
81
|
# c.oauth.signing_algorithm = :es256
|
|
82
82
|
# c.oauth.signing_key = Rails.root.join("config/signing_key.pem")
|
|
83
|
+
#
|
|
84
|
+
# Key Rotation:
|
|
85
|
+
# To rotate signing keys with zero downtime:
|
|
86
|
+
# 1. Generate a new key
|
|
87
|
+
# 2. Move the current signing_key into previous_signing_keys
|
|
88
|
+
# 3. Set signing_key to the new key
|
|
89
|
+
# 4. After the grace period (longest token lifetime), remove the old key
|
|
90
|
+
#
|
|
91
|
+
# Same algorithm rotation (plain PEM strings):
|
|
92
|
+
# c.oauth.previous_signing_keys = [
|
|
93
|
+
# Rails.application.credentials.dig(:standard_id, :previous_signing_key)
|
|
94
|
+
# ]
|
|
95
|
+
#
|
|
96
|
+
# Cross-algorithm rotation (e.g., RS256 -> ES256):
|
|
97
|
+
# c.oauth.previous_signing_keys = [
|
|
98
|
+
# { key: Rails.application.credentials.dig(:standard_id, :old_rsa_key), algorithm: :rs256 }
|
|
99
|
+
# ]
|
|
83
100
|
|
|
84
101
|
# Events
|
|
85
102
|
# Enable or disable logging emitted via the internal event system
|
|
@@ -56,6 +56,11 @@ StandardConfig.schema.draw do
|
|
|
56
56
|
# If nil, uses HS256 with Rails.application.secret_key_base
|
|
57
57
|
field :signing_key, type: :any, default: nil
|
|
58
58
|
|
|
59
|
+
# Previous signing keys for key rotation (array of PEM strings or Pathnames)
|
|
60
|
+
# During rotation, move the old signing_key here so tokens signed with it
|
|
61
|
+
# can still be verified. Remove after the grace period.
|
|
62
|
+
field :previous_signing_keys, type: :array, default: -> { [] }
|
|
63
|
+
|
|
59
64
|
# Signing algorithm (see JwtService::SUPPORTED_ALGORITHMS for full list)
|
|
60
65
|
# Symmetric (HMAC): :hs256, :hs384, :hs512
|
|
61
66
|
# Asymmetric (RSA): :rs256, :rs384, :rs512
|
|
@@ -77,9 +77,26 @@ module StandardId
|
|
|
77
77
|
@key_id ||= Digest::SHA256.hexdigest(signing_key.public_to_pem)[0..7]
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
def self.previous_keys
|
|
81
|
+
return [] unless asymmetric?
|
|
82
|
+
|
|
83
|
+
@previous_keys_cache ||= Array(StandardId.config.oauth.previous_signing_keys).filter_map do |entry|
|
|
84
|
+
parse_previous_key_entry(entry)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.all_verification_keys
|
|
91
|
+
return [] unless asymmetric?
|
|
92
|
+
|
|
93
|
+
[{ kid: key_id, key: verification_key }] + previous_keys
|
|
94
|
+
end
|
|
95
|
+
|
|
80
96
|
def self.reset_cached_key!
|
|
81
97
|
@key_id = nil
|
|
82
98
|
@signing_key_cache = nil
|
|
99
|
+
@previous_keys_cache = nil
|
|
83
100
|
@jwks = nil
|
|
84
101
|
end
|
|
85
102
|
|
|
@@ -95,13 +112,33 @@ module StandardId
|
|
|
95
112
|
end
|
|
96
113
|
|
|
97
114
|
def self.decode(token)
|
|
98
|
-
options = {
|
|
115
|
+
options = { algorithms: [algorithm] }
|
|
99
116
|
|
|
100
117
|
if StandardId.config.issuer.present?
|
|
101
118
|
options[:iss] = StandardId.config.issuer
|
|
102
119
|
options[:verify_iss] = true
|
|
103
120
|
end
|
|
104
121
|
|
|
122
|
+
if asymmetric? && previous_keys.any?
|
|
123
|
+
# Include algorithms from previous keys for cross-algorithm rotation
|
|
124
|
+
prev_algorithms = previous_keys.filter_map { |k| k[:algorithm] }
|
|
125
|
+
options[:algorithms] = ([algorithm] + prev_algorithms).uniq
|
|
126
|
+
|
|
127
|
+
# Build a JWKS set with all active keys for kid-based matching
|
|
128
|
+
jwk_set = JWT::JWK::Set.new
|
|
129
|
+
all_verification_keys.each do |entry|
|
|
130
|
+
jwk_set << JWT::JWK.new(entry[:key], kid: entry[:kid])
|
|
131
|
+
end
|
|
132
|
+
options[:jwks] = jwk_set
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
decoded = JWT.decode(token, nil, true, options)
|
|
136
|
+
return decoded.first.with_indifferent_access
|
|
137
|
+
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIatError, JWT::InvalidIssuerError
|
|
138
|
+
return nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
105
142
|
decoded = JWT.decode(token, verification_key, true, options)
|
|
106
143
|
decoded.first.with_indifferent_access
|
|
107
144
|
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIatError, JWT::InvalidIssuerError
|
|
@@ -132,16 +169,38 @@ module StandardId
|
|
|
132
169
|
return nil unless asymmetric?
|
|
133
170
|
|
|
134
171
|
@jwks ||= begin
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
exported_keys = all_verification_keys.map do |entry|
|
|
173
|
+
JWT::JWK.new(entry[:key], kid: entry[:kid]).export
|
|
174
|
+
end
|
|
175
|
+
{ keys: exported_keys }
|
|
137
176
|
end
|
|
138
177
|
end
|
|
139
178
|
|
|
140
179
|
private
|
|
141
180
|
|
|
142
|
-
|
|
181
|
+
# Parses a previous_signing_keys entry into { kid:, key:, algorithm: }
|
|
182
|
+
# Accepts either:
|
|
183
|
+
# - A PEM string or Pathname (uses current algorithm's key class)
|
|
184
|
+
# - A Hash with :key (PEM/Pathname) and :algorithm (e.g. :rs256, :es256)
|
|
185
|
+
def self.parse_previous_key_entry(entry)
|
|
186
|
+
if entry.is_a?(Hash)
|
|
187
|
+
entry = entry.symbolize_keys
|
|
188
|
+
alg = entry[:algorithm].to_s.upcase
|
|
189
|
+
alg_config = SUPPORTED_ALGORITHMS[alg] || raise(ArgumentError, "Unsupported algorithm: #{alg}")
|
|
190
|
+
key = parse_private_key(entry[:key], key_class: alg_config[:key_class])
|
|
191
|
+
else
|
|
192
|
+
alg = algorithm
|
|
193
|
+
key = parse_private_key(entry)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
vkey = key.is_a?(OpenSSL::PKey::EC) ? key : key.public_key
|
|
197
|
+
kid = Digest::SHA256.hexdigest(key.public_to_pem)[0..7]
|
|
198
|
+
{ kid: kid, key: vkey, algorithm: alg }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.parse_private_key(key_source, key_class: nil)
|
|
143
202
|
pem = key_source.is_a?(Pathname) ? File.read(key_source) : key_source
|
|
144
|
-
key_class
|
|
203
|
+
key_class ||= algorithm_config[:key_class]
|
|
145
204
|
|
|
146
205
|
key_class.new(pem)
|
|
147
206
|
end
|
data/lib/standard_id/version.rb
CHANGED