rodauth-tools 0.3.0 → 0.4.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.
data/README.md CHANGED
@@ -192,7 +192,38 @@ end
192
192
 
193
193
  **Documentation:** [docs/features/jwt-secret-guard.md](docs/features/jwt-secret-guard.md)
194
194
 
195
- ### 5. Sequel Migration Generator
195
+ ### 5. Account ID Obfuscation Feature
196
+
197
+ Obfuscate the numeric `account_id` that leaks into email-verification links and the
198
+ remember-me cookie, with no database schema change.
199
+
200
+ ```ruby
201
+ class RodauthApp < Roda
202
+ plugin :rodauth do
203
+ enable :login, :verify_account, :remember
204
+ enable :account_id_obfuscation
205
+
206
+ # Production: raises error if ACCOUNT_ID_SECRET (>= 32 bytes) missing
207
+ # Development: uses fallback secret with warning
208
+ end
209
+ end
210
+ ```
211
+
212
+ Links change from `/verify-account?key=2_SspVz...` to `/verify-account?key=AE946V4SD7Z7RV_SspVz...`.
213
+
214
+ **Key Features:**
215
+
216
+ - Keyed, reversible obfuscation (4-round Feistel format-preserving encryption)
217
+ - Covers all email-link features + the remember cookie via two scoped overrides
218
+ - Leaves `split_token`/`convert_token_id` untouched, so `jwt_refresh` is unaffected
219
+ - Backward compatible: in-flight numeric links and legacy cookies keep working
220
+ - Version-tagged tokens enable config-driven secret rotation
221
+ - Dedicated `ACCOUNT_ID_SECRET` (>= 32 bytes), independent of HMAC/JWT secrets
222
+ - Standalone `Rodauth::Tools::AccountIdCipher` primitive (stdlib `openssl` only)
223
+
224
+ **Documentation:** [docs/features/account-id-obfuscation.md](docs/features/account-id-obfuscation.md)
225
+
226
+ ### 6. Sequel Migration Generator
196
227
 
197
228
  Generate database migrations for Rodauth features.
198
229
 
@@ -306,6 +337,7 @@ Rodauth Features are modules that mix into `Rodauth::Auth` instances at runtime.
306
337
  - **[External Identity Feature](docs/features/external-identity.md)** - Track external service identifiers
307
338
  - **[HMAC Secret Guard Feature](docs/features/hmac-secret-guard.md)** - Validate HMAC secrets at startup
308
339
  - **[JWT Secret Guard Feature](docs/features/jwt-secret-guard.md)** - Validate JWT secrets at startup
340
+ - **[Account ID Obfuscation Feature](docs/features/account-id-obfuscation.md)** - Hide numeric account ids in email links and cookies
309
341
  - **[Sequel Migrations](docs/sequel-migrations.md)** - Integrating table_guard with Sequel migrations
310
342
  - **[Rodauth Feature API](docs/rodauth-features-api.md)** - Complete DSL reference for feature development
311
343
  - **[Rodauth Integration](docs/rodauth-integration.md)** - Framework integration patterns
@@ -0,0 +1,317 @@
1
+ # lib/rodauth/features/account_id_obfuscation.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../tools/account_id_cipher' unless defined?(Rodauth::Tools::AccountIdCipher)
6
+ require_relative '../secret_guard' unless defined?(Rodauth::SecretGuard)
7
+
8
+ module Rodauth
9
+ # Obfuscates the numeric account id that Rodauth otherwise leaks in plaintext
10
+ # inside email-link tokens (e.g. +/verify-account?key=2_...+) and, optionally,
11
+ # the remember-me cookie. The id is replaced by a fixed-width, URL-safe,
12
+ # non-sequential token: a one-character non-digit VERSION tag followed by the
13
+ # 13-char output of {Rodauth::Tools::AccountIdCipher} (keyed format-preserving
14
+ # encryption). No database schema change is required; the integer primary key
15
+ # is used everywhere internally and only the value crossing the network is
16
+ # obfuscated.
17
+ #
18
+ # It wraps the two email-token chokepoints in +email_base+ (+token_param_value+
19
+ # to encode, +account_from_key+ to decode), which together cover ALL email-link
20
+ # features: verify_account, reset_password, email_auth, verify_login_change and
21
+ # lockout/unlock. It deliberately does NOT touch the lower-level +split_token+/
22
+ # +convert_token_id+, so other token consumers (e.g. jwt_refresh) are unaffected.
23
+ #
24
+ # This is deterministic pseudonymisation, NOT access control or encryption of
25
+ # confidential data: the same id always maps to the same token, and the token's
26
+ # authority is unchanged (the email HMAC still gates the request after the id is
27
+ # swapped back to the real Integer). Applies to integer/bigint primary keys.
28
+ #
29
+ # The VERSION tag makes obfuscated tokens deterministically distinguishable from
30
+ # legacy decimal ids (Rodauth ids are always digits, never a letter), so legacy
31
+ # links/cookies pass through untouched. It also selects the secret, enabling
32
+ # config-driven key rotation via +account_id_obfuscation_previous_secrets+.
33
+ #
34
+ # This feature shares its ENV-loading and production-detection plumbing with
35
+ # +hmac_secret_guard+/+jwt_secret_guard+ via +Rodauth::SecretGuard+ (see
36
+ # +production_env_check+ and +validate_secrets_on_configure?+ below, which are
37
+ # the same config methods those two guards expose). It keeps its own
38
+ # always-on 32-byte minimum, though: {Rodauth::Tools::AccountIdCipher} hard-requires
39
+ # that floor regardless of environment, unlike the guards' opt-in, production-only
40
+ # +minimum_secret_length+.
41
+ #
42
+ # @example
43
+ # plugin :rodauth do
44
+ # enable :login, :verify_account, :remember, :account_id_obfuscation
45
+ # # ACCOUNT_ID_SECRET must be set (>= 32 bytes) in production
46
+ # end
47
+ #
48
+ # @example Development / custom secret source
49
+ # plugin :rodauth do
50
+ # enable :account_id_obfuscation
51
+ # account_id_obfuscation_secret_env_key 'MY_ID_SECRET'
52
+ # account_id_obfuscation_remember_cookie? false
53
+ # end
54
+ Feature.define(:account_id_obfuscation, :AccountIdObfuscation) do
55
+ # email_base owns the two chokepoints we wrap; depending on it guarantees it
56
+ # is loaded and sits lower in the ancestor chain (so our +super+ resolves).
57
+ depends :email_base
58
+
59
+ auth_value_method :account_id_obfuscation_secret, nil
60
+ auth_value_method :account_id_obfuscation_secret_env_key, 'ACCOUNT_ID_SECRET'
61
+ auth_value_method :account_id_obfuscation_key_version, 'A' # single non-digit char
62
+ auth_value_method :account_id_obfuscation_previous_secrets, {}.freeze # {ver_char => old_secret}, decode-only
63
+ auth_value_method :account_id_obfuscation_remember_cookie?, true
64
+ # Shared with hmac_secret_guard/jwt_secret_guard (same config method, same
65
+ # fail-safe default): an unset RACK_ENV is treated as production. Avoid
66
+ # `proc { ENV['RACK_ENV'] == 'production' }` — when the variable is unset
67
+ # that returns false and silently falls back to the insecure development
68
+ # secret in what is really a production deploy.
69
+ auth_value_method :production_env_check, proc { ENV.fetch('RACK_ENV', 'production') == 'production' }
70
+ # Shared with hmac_secret_guard/jwt_secret_guard: gates whether post_configure
71
+ # calls the boot-time validator at all.
72
+ auth_value_method :validate_secrets_on_configure?, true
73
+ # Fixed (not random-per-process, unlike the guards' SecureRandom.hex fallback):
74
+ # obfuscated tokens must stay stable across restarts within a single
75
+ # development process' lifetime for links/cookies minted before a restart to
76
+ # keep decoding, and this feature has no server-side session to smooth that
77
+ # over. Still a placeholder — change it, or better, set ACCOUNT_ID_SECRET.
78
+ auth_value_method :development_account_id_obfuscation_secret_fallback,
79
+ 'dev-only-insecure-account-id-obfuscation-secret-please-change-me'
80
+
81
+ translatable_method :account_id_obfuscation_secret_missing_error,
82
+ 'ACCOUNT_ID_SECRET environment variable must be set in production'
83
+ translatable_method :account_id_obfuscation_secret_too_short_error,
84
+ 'account id obfuscation secret must be at least 32 bytes'
85
+ translatable_method :account_id_obfuscation_key_version_error,
86
+ 'account_id_obfuscation_key_version must be a single non-digit character'
87
+ translatable_method :account_id_obfuscation_secret_dev_warning,
88
+ '[rodauth] WARNING: Using default account id obfuscation secret for development only'
89
+
90
+ # post_configure runs on a throwaway instance, so cache the version=>cipher
91
+ # map lazily per runtime instance rather than in an instance ivar.
92
+ auth_cached_method :account_id_ciphers
93
+
94
+ auth_methods :obfuscate_account_id, :deobfuscate_account_id, :production?,
95
+ :validate_secrets!, :validate_account_id_obfuscation_secret!
96
+
97
+ def post_configure
98
+ super
99
+
100
+ load_account_id_secret_from_env
101
+ validate_key_version!
102
+ validate_account_id_obfuscation_secret! if validate_secrets_on_configure?
103
+
104
+ install_remember_cookie_obfuscation if account_id_obfuscation_remember_cookie? && features.include?(:remember)
105
+ end
106
+
107
+ # ---- email-token chokepoints (plain overrides; email_base is a dependency,
108
+ # so +super+ always resolves, and internal_request delegates through) ----
109
+
110
+ # Encode the id in the outgoing email link. Wraps email_base's token
111
+ # ("<id><sep><hmac>") and rewrites only the id segment, staying decoupled from
112
+ # email_base's exact composition.
113
+ def token_param_value(key)
114
+ original = super
115
+ return original if account_id.nil?
116
+
117
+ _id, separator, remainder = original.partition(token_separator)
118
+ remainder.empty? ? original : "#{obfuscate_account_id(account_id)}#{separator}#{remainder}"
119
+ end
120
+
121
+ # Decode the id segment of an incoming token before email_base parses it.
122
+ # A legacy plaintext token (or any non-token) deobfuscates to nil and passes
123
+ # through unchanged, so in-flight links keep working.
124
+ def account_from_key(token, status_id = nil, &)
125
+ if token.is_a?(String)
126
+ segment, separator, remainder = token.partition(token_separator)
127
+ if !remainder.empty? && (real_id = deobfuscate_account_id(segment))
128
+ token = "#{real_id}#{separator}#{remainder}"
129
+ end
130
+ end
131
+
132
+ super
133
+ end
134
+
135
+ # ---- public API ----
136
+
137
+ # @return [String] version-tagged obfuscated form of an account id
138
+ def obfuscate_account_id(id)
139
+ version = account_id_obfuscation_key_version
140
+ "#{version}#{account_id_ciphers.fetch(version).encode(id)}"
141
+ end
142
+
143
+ # @return [Integer, nil] the real id, or nil if +segment+ is not one of our
144
+ # tokens (legacy decimal id, unknown version, wrong width, or bad chars)
145
+ def deobfuscate_account_id(segment)
146
+ return nil unless segment.is_a?(String) &&
147
+ segment.length == 1 + Rodauth::Tools::AccountIdCipher::WIDTH
148
+
149
+ cipher = account_id_ciphers[segment[0]] or return nil
150
+
151
+ cipher.decode(segment[1..])
152
+ end
153
+
154
+ # Check if we're running in production environment.
155
+ #
156
+ # Delegates to the same +Rodauth::SecretGuard.production?+ that
157
+ # +hmac_secret_guard+/+jwt_secret_guard+ use, so behavior is identical
158
+ # whichever guard's copy of this method happens to win when several are
159
+ # enabled together (see +validate_secrets!+ below for why that collision
160
+ # matters more for validation than for this read-only check).
161
+ #
162
+ # @return [Boolean] true if running in production mode based on production_env_check
163
+ def production?
164
+ Rodauth::SecretGuard.production?(self)
165
+ end
166
+
167
+ # Validate that the account-id obfuscation secret(s) are properly
168
+ # configured. Raises ConfigurationError in production if the current
169
+ # secret is missing or blank. Always (in every environment) raises if the
170
+ # current secret, or any +account_id_obfuscation_previous_secrets+ entry,
171
+ # is present but shorter than +AccountIdCipher::MIN_SECRET_BYTES+ — unlike
172
+ # +minimum_secret_length+ on the sibling guards, this floor is not
173
+ # opt-in/production-only, because {Rodauth::Tools::AccountIdCipher} raises
174
+ # ArgumentError below it regardless of environment. In development, a
175
+ # missing current secret warns and installs a fallback.
176
+ #
177
+ # This is the collision-free entry point: prefer it over +validate_secrets!+
178
+ # when this feature is co-enabled with +hmac_secret_guard+/+jwt_secret_guard+
179
+ # (see +post_configure+, which calls this method by name so boot-time
180
+ # validation never depends on which +validate_secrets!+ wins the MRO).
181
+ #
182
+ # @raise [Rodauth::ConfigurationError] if the current or a previous secret is unusable
183
+ # @return [void]
184
+ def validate_account_id_obfuscation_secret!
185
+ secret = account_id_obfuscation_secret
186
+
187
+ if Rodauth::SecretGuard.blank?(secret)
188
+ raise Rodauth::ConfigurationError, account_id_obfuscation_secret_missing_error if Rodauth::SecretGuard.production?(self)
189
+
190
+ warn_dev_account_id_secret
191
+ fallback = development_account_id_obfuscation_secret_fallback
192
+ self.class.send(:define_method, :account_id_obfuscation_secret) { fallback }
193
+ secret = fallback
194
+ end
195
+
196
+ too_short = ->(value) { value.to_s.bytesize < Rodauth::Tools::AccountIdCipher::MIN_SECRET_BYTES }
197
+ if too_short.call(secret) || account_id_obfuscation_previous_secrets.values.any?(&too_short)
198
+ raise Rodauth::ConfigurationError, account_id_obfuscation_secret_too_short_error
199
+ end
200
+
201
+ # Force the version=>cipher map to build now so any other
202
+ # AccountIdCipher.new failure (e.g. a future stricter check) also fails
203
+ # closed at boot instead of lazily on first encode/decode.
204
+ begin
205
+ account_id_ciphers
206
+ rescue ArgumentError => e
207
+ raise Rodauth::ConfigurationError, e.message
208
+ end
209
+ end
210
+
211
+ # Backwards-compatible alias for +validate_account_id_obfuscation_secret!+.
212
+ #
213
+ # Note: +hmac_secret_guard+ and +jwt_secret_guard+ each define a
214
+ # +validate_secrets!+ of their own, so when this feature is co-enabled with
215
+ # either guard this name resolves to only one of them — collision-prone;
216
+ # not for boot. Boot-time validation does not rely on it (see
217
+ # +post_configure+); use +validate_account_id_obfuscation_secret!+ for an
218
+ # unambiguous manual call.
219
+ #
220
+ # @raise [Rodauth::ConfigurationError] if the current or a previous secret is unusable
221
+ # @return [void]
222
+ def validate_secrets!
223
+ validate_account_id_obfuscation_secret!
224
+ end
225
+
226
+ private
227
+
228
+ # Backing method for the +account_id_ciphers+ auth_cached_method: a
229
+ # {version_char => AccountIdCipher} map. The current key_version is always
230
+ # present; previous secrets are added for decode-only rotation support.
231
+ def _account_id_ciphers
232
+ map = {}
233
+ account_id_obfuscation_previous_secrets.each do |version, old_secret|
234
+ map[version.to_s] = Rodauth::Tools::AccountIdCipher.new(old_secret)
235
+ end
236
+ map[account_id_obfuscation_key_version] =
237
+ Rodauth::Tools::AccountIdCipher.new(account_id_obfuscation_secret)
238
+ map
239
+ end
240
+
241
+ # Consume ACCOUNT_ID_SECRET from ENV (read + delete for security), unless an
242
+ # explicit secret was configured. Delegates to the same helper the sibling
243
+ # guards use, so a whitespace-only env value is treated as absent exactly
244
+ # like theirs.
245
+ def load_account_id_secret_from_env
246
+ Rodauth::SecretGuard.load_from_env!(self, :account_id_obfuscation)
247
+ end
248
+
249
+ # The backward-compat guarantee rests on every version tag (current AND any
250
+ # +account_id_obfuscation_previous_secrets+ key) being a single, unique,
251
+ # non-digit char distinct from the separators; fail fast otherwise. A
252
+ # digit would be ambiguous with a legacy decimal id, and a duplicate would
253
+ # mean two secrets claim the same version, making rotation nondeterministic.
254
+ def validate_key_version!
255
+ versions = [account_id_obfuscation_key_version] + account_id_obfuscation_previous_secrets.keys
256
+ versions.each { |version| validate_version_char!(version) }
257
+
258
+ return if versions.tally.values.all? { |count| count == 1 }
259
+
260
+ raise Rodauth::ConfigurationError, account_id_obfuscation_key_version_error
261
+ end
262
+
263
+ def validate_version_char!(version)
264
+ valid = version.is_a?(String) && version.length == 1 &&
265
+ version !~ /\d/ && version != token_separator && version != '_'
266
+
267
+ raise Rodauth::ConfigurationError, account_id_obfuscation_key_version_error unless valid
268
+ end
269
+
270
+ # Wrap the remember cookie so the id segment is obfuscated on the way out and
271
+ # restored on the way back in. remember.rb hardcodes '_' (not token_separator)
272
+ # as the id/key delimiter and splits with limit 2, so an HMAC key containing
273
+ # '_' stays intact and our version-tagged id (never contains '_', never starts
274
+ # with a digit) is safe. Legacy numeric cookies deobfuscate to nil -> passthrough.
275
+ #
276
+ # Installed directly on the Auth class (not as an ordinary module method) so
277
+ # the wrapper wins regardless of the order in which +remember+ and this
278
+ # feature are enabled: a method defined on the class itself always takes
279
+ # precedence over the included feature modules, whereas an ordinary module
280
+ # method would be shadowed by +remember+'s own +_set_/_get_remember_cookie+
281
+ # whenever this feature is enabled BEFORE +remember+ (silently leaving the
282
+ # cookie un-obfuscated).
283
+ #
284
+ # +internal_request+ re-runs +post_configure+ on an internally-created
285
+ # subclass of the already-configured Auth class. We must NOT re-install there:
286
+ # a second override on the subclass would resolve its +super+ to the parent
287
+ # class's override (double-obfuscating the id and crashing on
288
+ # +Integer("A...")+) instead of to remember.rb. Skipping the subclass leaves
289
+ # it inheriting the single parent-class wrapper for +_set_remember_cookie+,
290
+ # while +internal_request+'s own +_get_remember_cookie+ (reading the request
291
+ # param) still wins for internal requests since it sits higher in that
292
+ # subclass's ancestry.
293
+ def install_remember_cookie_obfuscation
294
+ return if defined?(Rodauth::InternalRequestMethods) && is_a?(Rodauth::InternalRequestMethods)
295
+
296
+ self.class.send(:define_method, :_set_remember_cookie) do |account_id, remember_key_value, deadline|
297
+ super(obfuscate_account_id(account_id), remember_key_value, deadline)
298
+ end
299
+
300
+ self.class.send(:define_method, :_get_remember_cookie) do
301
+ raw = super()
302
+ next raw unless raw.is_a?(String)
303
+
304
+ segment, _sep, remainder = raw.partition('_')
305
+ !remainder.empty? && (real_id = deobfuscate_account_id(segment)) ? "#{real_id}_#{remainder}" : raw
306
+ end
307
+ end
308
+
309
+ def warn_dev_account_id_secret
310
+ if respond_to?(:logger) && logger
311
+ logger.warn(account_id_obfuscation_secret_dev_warning)
312
+ else
313
+ warn(account_id_obfuscation_secret_dev_warning)
314
+ end
315
+ end
316
+ end
317
+ end
@@ -2,6 +2,9 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require 'securerandom'
6
+ require_relative '../secret_guard'
7
+
5
8
  module Rodauth
6
9
  # Automatically sets hmac_secret based on HMAC_SECRET and validates it is properly
7
10
  # configured before the application starts. This helps prevent deployment
@@ -9,23 +12,34 @@ module Rodauth
9
12
  # particularly in production environments.
10
13
  #
11
14
  # By default, this feature checks during +post_configure+ that +hmac_secret+
12
- # is set to a non-nil, non-empty value. In production mode, it raises a
15
+ # is set to a non-blank value. In production mode, it raises a
13
16
  # ConfigurationError if the secret is missing. In development mode, it logs
14
17
  # a warning and uses a fallback development secret.
15
18
  #
19
+ # This feature and +jwt_secret_guard+ can be enabled together. Their shared
20
+ # logic lives in +Rodauth::SecretGuard+ and is keyed by secret kind, so each
21
+ # secret is validated independently at boot.
22
+ #
16
23
  # @example Basic Configuration
17
24
  # plugin :rodauth do
18
25
  # enable :hmac_secret_guard
19
26
  # end
20
27
  #
21
- # @example Customizing Production Detection
28
+ # @example Customizing Production Detection (fail-safe)
22
29
  # plugin :rodauth do
23
30
  # enable :hmac_secret_guard
24
- # production_env_check proc { ENV['RACK_ENV'] == 'production' }
25
- # # Or use a boolean:
31
+ # # Treat an unset RACK_ENV as production so a misconfigured deploy fails
32
+ # # closed rather than silently using the development fallback secret:
33
+ # production_env_check proc { ENV.fetch('RACK_ENV', 'production') == 'production' }
34
+ # # Or force it:
26
35
  # # production_env_check true
27
36
  # end
28
37
  #
38
+ # # The default already fails safe: an unset RACK_ENV is treated as
39
+ # # production. Avoid `proc { ENV['RACK_ENV'] == 'production' }` — when the
40
+ # # variable is unset that returns false and silently falls back to the
41
+ # # insecure development secret in what is really a production deploy.
42
+ #
29
43
  # @example Customizing Error Messages
30
44
  # plugin :rodauth do
31
45
  # enable :hmac_secret_guard
@@ -39,6 +53,16 @@ module Rodauth
39
53
  # development_hmac_secret_fallback 'my-custom-dev-secret'
40
54
  # end
41
55
  #
56
+ # # The default fallback is a random per-process value (SecureRandom.hex),
57
+ # # not a constant baked into source. Set an explicit value only if you need
58
+ # # HMAC output to be stable across restarts in development.
59
+ #
60
+ # @example Enforcing a Minimum Secret Length (production only)
61
+ # plugin :rodauth do
62
+ # enable :hmac_secret_guard
63
+ # minimum_secret_length 32 # reject short secrets in production; 0 disables (default)
64
+ # end
65
+ #
42
66
  # @example Disabling Validation
43
67
  # plugin :rodauth do
44
68
  # enable :hmac_secret_guard
@@ -49,8 +73,10 @@ module Rodauth
49
73
  auth_value_method :hmac_secret_env_key, 'HMAC_SECRET'
50
74
  auth_value_method :production_env_check, proc { ENV.fetch('RACK_ENV', 'production') == 'production' }
51
75
  auth_value_method :validate_secrets_on_configure?, true
52
- auth_value_method :development_hmac_secret_fallback,
53
- 'dev-only-insecure-example-hmac-secret-needs-to-be-changed-in-prod'
76
+ auth_value_method :minimum_secret_length, 0
77
+ # Random per-process fallback: never committed to source, and unstable across
78
+ # restarts so it can't be mistaken for a real, persistent secret.
79
+ auth_value_method :development_hmac_secret_fallback, SecureRandom.hex(32)
54
80
 
55
81
  translatable_method :hmac_secret_missing_error, 'HMAC_SECRET environment variable must be set in production'
56
82
  translatable_method :hmac_secret_dev_warning, '[rodauth] WARNING: Using default HMAC secret for development only'
@@ -58,62 +84,44 @@ module Rodauth
58
84
  def post_configure
59
85
  super
60
86
 
61
- # Auto-set hmac_secret if not already set
62
- if hmac_secret.nil? || (hmac_secret.respond_to?(:empty?) && hmac_secret.empty?)
63
- env_value = ENV.delete(hmac_secret_env_key)
64
- self.class.send(:define_method, :hmac_secret) { env_value } if env_value && !env_value.empty?
65
- end
66
-
67
- validate_secrets! if validate_secrets_on_configure?
87
+ Rodauth::SecretGuard.load_from_env!(self, :hmac)
88
+ validate_hmac_secret! if validate_secrets_on_configure?
68
89
  end
69
90
 
70
- auth_methods :validate_secrets!, :production?
91
+ auth_methods :validate_secrets!, :validate_hmac_secret!, :production?
71
92
 
72
93
  # Check if we're running in production environment.
73
94
  #
74
95
  # @return [Boolean] true if running in production mode based on production_env_check
75
96
  def production?
76
- case v = production_env_check
77
- when Proc
78
- instance_exec(&v)
79
- else
80
- !!v
81
- end
97
+ Rodauth::SecretGuard.production?(self)
82
98
  end
83
99
 
84
- # Validate that HMAC secret is properly configured.
85
- # Raises ConfigurationError in production if secret is missing.
86
- # In development, logs a warning and sets a fallback secret.
100
+ # Validate that the HMAC secret is properly configured. Raises
101
+ # ConfigurationError in production if the secret is missing, blank, or (when
102
+ # +minimum_secret_length+ is set) too short. In development it warns and
103
+ # installs a fallback secret.
87
104
  #
88
- # @raise [Rodauth::ConfigurationError] if hmac_secret is missing in production
105
+ # This is the collision-free entry point: prefer it over +validate_secrets!+
106
+ # when both secret guards are enabled.
107
+ #
108
+ # @raise [Rodauth::ConfigurationError] if hmac_secret is unusable in production
89
109
  # @return [void]
90
- def validate_secrets!
91
- # Get the current hmac_secret value (may be nil)
92
- current_secret = hmac_secret
93
-
94
- # Check if secret is missing or empty
95
- return unless current_secret.nil? || (current_secret.respond_to?(:empty?) && current_secret.empty?)
96
- raise Rodauth::ConfigurationError, hmac_secret_missing_error if production?
97
-
98
- # In production, raise an error
99
-
100
- # In development, warn and set a fallback
101
- warn_dev_secret
102
- self.class.send(:define_method, :hmac_secret) { development_hmac_secret_fallback }
110
+ def validate_hmac_secret!
111
+ Rodauth::SecretGuard.validate!(self, :hmac)
103
112
  end
104
113
 
105
- private
106
-
107
- # Warn about using development secret.
108
- # Logs to logger if available, otherwise to stderr.
114
+ # Backwards-compatible alias for +validate_hmac_secret!+.
109
115
  #
116
+ # Note: +jwt_secret_guard+ defines a +validate_secrets!+ of its own, so when
117
+ # both guards are enabled this name resolves to only one of them. Boot-time
118
+ # validation does not rely on it (see +post_configure+); use
119
+ # +validate_hmac_secret!+ for an unambiguous manual call.
120
+ #
121
+ # @raise [Rodauth::ConfigurationError] if hmac_secret is unusable in production
110
122
  # @return [void]
111
- def warn_dev_secret
112
- if respond_to?(:logger) && logger
113
- logger.warn(hmac_secret_dev_warning)
114
- else
115
- warn(hmac_secret_dev_warning)
116
- end
123
+ def validate_secrets!
124
+ validate_hmac_secret!
117
125
  end
118
126
  end
119
127
  end