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: dbef18f1449e29f9a2634d4ac069294555d068b0c645e187935bd88133649d49
4
- data.tar.gz: fceb6cf31ca8a240ea7969255b3d6954547bfc5f69f620ba4cae1d301b6c6981
3
+ metadata.gz: fa6edd707d2fbe2489dfdebfade37312811fd607aaae48bcc62d973db1e66086
4
+ data.tar.gz: 433da88bb0da62c2af3fe3fe988dcbef5262a3ad3f079877fe611d8b0cb670bc
5
5
  SHA512:
6
- metadata.gz: 9f2ee859ac9c6a41f6fc5839cb597e2940d95ae0919e0a86233202f34ecc0f99d94ca4e574b6c9b790ee31e4c42003d358dc0fc5a997a3874f2de0b1b72f57fe
7
- data.tar.gz: fab39f1f052246162a6fece0aa612bc8871fccb54e095d58f7d52d0b62b8307c927444043fcddf7b3b54a07da0ffcbceacb0be419379097261eeae8c59c45b24
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 = { algorithm: algorithm }
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
- jwk = JWT::JWK.new(verification_key, kid: key_id)
136
- { keys: [jwk.export] }
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
- def self.parse_private_key(key_source)
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 = algorithm_config[:key_class]
203
+ key_class ||= algorithm_config[:key_class]
145
204
 
146
205
  key_class.new(pem)
147
206
  end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.2.7"
2
+ VERSION = "0.2.8"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.7
4
+ version: 0.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim