rodauth-tools 0.3.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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +93 -0
  3. data/.gitlint +9 -0
  4. data/.markdownlint-cli2.jsonc +26 -0
  5. data/.pre-commit-config.yaml +46 -0
  6. data/.rubocop.yml +18 -0
  7. data/.rubocop_todo.yml +243 -0
  8. data/CHANGELOG.md +81 -0
  9. data/CLAUDE.md +262 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +111 -0
  12. data/Gemfile +35 -0
  13. data/Gemfile.lock +356 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +339 -0
  16. data/Rakefile +8 -0
  17. data/lib/rodauth/features/external_identity.rb +946 -0
  18. data/lib/rodauth/features/hmac_secret_guard.rb +119 -0
  19. data/lib/rodauth/features/jwt_secret_guard.rb +120 -0
  20. data/lib/rodauth/features/table_guard.rb +937 -0
  21. data/lib/rodauth/sequel_generator.rb +531 -0
  22. data/lib/rodauth/table_inspector.rb +124 -0
  23. data/lib/rodauth/template_inspector.rb +134 -0
  24. data/lib/rodauth/tools/console_helpers.rb +158 -0
  25. data/lib/rodauth/tools/migration/sequel/account_expiration.erb +9 -0
  26. data/lib/rodauth/tools/migration/sequel/active_sessions.erb +10 -0
  27. data/lib/rodauth/tools/migration/sequel/audit_logging.erb +12 -0
  28. data/lib/rodauth/tools/migration/sequel/base.erb +41 -0
  29. data/lib/rodauth/tools/migration/sequel/disallow_password_reuse.erb +8 -0
  30. data/lib/rodauth/tools/migration/sequel/email_auth.erb +17 -0
  31. data/lib/rodauth/tools/migration/sequel/jwt_refresh.erb +18 -0
  32. data/lib/rodauth/tools/migration/sequel/lockout.erb +21 -0
  33. data/lib/rodauth/tools/migration/sequel/otp.erb +9 -0
  34. data/lib/rodauth/tools/migration/sequel/otp_unlock.erb +8 -0
  35. data/lib/rodauth/tools/migration/sequel/password_expiration.erb +7 -0
  36. data/lib/rodauth/tools/migration/sequel/recovery_codes.erb +8 -0
  37. data/lib/rodauth/tools/migration/sequel/remember.erb +16 -0
  38. data/lib/rodauth/tools/migration/sequel/reset_password.erb +17 -0
  39. data/lib/rodauth/tools/migration/sequel/single_session.erb +7 -0
  40. data/lib/rodauth/tools/migration/sequel/sms_codes.erb +10 -0
  41. data/lib/rodauth/tools/migration/sequel/verify_account.erb +9 -0
  42. data/lib/rodauth/tools/migration/sequel/verify_login_change.erb +17 -0
  43. data/lib/rodauth/tools/migration/sequel/webauthn.erb +15 -0
  44. data/lib/rodauth/tools/migration.rb +188 -0
  45. data/lib/rodauth/tools/version.rb +9 -0
  46. data/lib/rodauth/tools.rb +29 -0
  47. data/package-lock.json +500 -0
  48. data/package.json +11 -0
  49. data/rodauth-tools.gemspec +40 -0
  50. metadata +136 -0
@@ -0,0 +1,946 @@
1
+ # lib/rodauth/features/external_identity.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ #
6
+ # Enable with:
7
+ # enable :external_identity
8
+ #
9
+ # Configuration (Layer 1 - Basic):
10
+ # external_identity_column :stripe_customer_id
11
+ # external_identity_column :redis_uuid, method_name: :redis_session_key
12
+ # external_identity_on_conflict :warn # :error, :warn, :skip
13
+ # external_identity_check_columns true # true (default), false, or :autocreate
14
+ #
15
+ # Configuration (Layer 2 - Extended Features):
16
+ # external_identity_column :stripe_customer_id,
17
+ # before_create_account: -> { Stripe::Customer.create(email: account[:email]).id },
18
+ # formatter: -> (v) { v.to_s.strip.downcase },
19
+ # validator: -> (v) { v.start_with?('cus_') },
20
+ # verifier: -> (id) { Stripe::Customer.retrieve(id) && !customer.deleted? },
21
+ # handshake: -> (id, token) { session[:oauth_state] == token }
22
+ #
23
+ # Usage:
24
+ # rodauth.stripe_customer_id # Auto-generated helper method
25
+ # rodauth.redis_uuid # Auto-generated helper method
26
+ #
27
+ # Introspection:
28
+ # rodauth.external_identity_column_list # [:stripe_customer_id, :redis_uuid]
29
+ # rodauth.external_identity_column_config(:stripe_customer_id) # {...}
30
+ # rodauth.external_identity_status # Complete debug info
31
+ #
32
+ # Example:
33
+ #
34
+ # plugin :rodauth do
35
+ # enable :login, :logout, :external_identity
36
+ #
37
+ # # Declare external identity columns
38
+ # external_identity_column :stripe_customer_id
39
+ # external_identity_column :redis_uuid
40
+ #
41
+ # # Custom method name
42
+ # external_identity_column :redis_key, method_name: :redis_session_key
43
+ # end
44
+ #
45
+ # # In your app
46
+ # rodauth.stripe_customer_id # => "cus_abc123"
47
+ # rodauth.redis_uuid # => "550e8400-e29b-41d4-a716-446655440000"
48
+
49
+ module Rodauth
50
+ Feature.define(:external_identity, :ExternalIdentity) do
51
+ # Configuration methods
52
+ auth_value_method :external_identity_on_conflict, :error
53
+ auth_value_method :external_identity_check_columns, true
54
+
55
+ # Public API methods for introspection
56
+ auth_methods(
57
+ :external_identity_column_list,
58
+ :external_identity_column_config,
59
+ :external_identity_helper_methods,
60
+ :external_identity_column?,
61
+ :external_identity_status
62
+ )
63
+
64
+ # Layer 2: Validation methods
65
+ auth_methods(
66
+ :validate_external_identity,
67
+ :validate_all_external_identities
68
+ )
69
+
70
+ # Layer 2: Verification methods
71
+ auth_methods(
72
+ :verify_external_identity,
73
+ :verify_all_external_identities
74
+ )
75
+
76
+ # Layer 2: Handshake methods
77
+ auth_methods(
78
+ :verify_handshake
79
+ )
80
+
81
+ # Use auth_cached_method for column configuration
82
+ # This ensures it works in both post_configure and runtime
83
+ auth_cached_method :external_identity_columns_config
84
+
85
+ # Add external_identity_column method to Configuration class
86
+ # This makes it available during the configuration block
87
+ configuration_module_eval do
88
+ # Declare an external identity column with optional lifecycle callbacks and database options
89
+ #
90
+ # Creates a helper method to access the column value and automatically includes
91
+ # it in account_select (unless disabled). Supports validation, formatting,
92
+ # verification, and lifecycle hooks.
93
+ #
94
+ # @param column [Symbol] Database column name (required)
95
+ # @param options [Hash] Configuration options
96
+ #
97
+ # @option options [Symbol] :method_name
98
+ # Custom method name for accessing the column (default: same as column name)
99
+ #
100
+ # @option options [Boolean] :include_in_select (true)
101
+ # Whether to automatically include in account_select
102
+ #
103
+ # @option options [Proc] :before_create_account
104
+ # Callback to generate value before account creation. Runs during
105
+ # before_create_account hook. Value will be formatted and validated
106
+ # if those callbacks are provided. Block is evaluated in Rodauth instance context.
107
+ #
108
+ # @option options [Proc] :after_create_account
109
+ # Callback to execute after account creation. Receives the column value
110
+ # as argument. Block is evaluated in Rodauth instance context.
111
+ # Useful for provisioning resources or sending notifications.
112
+ #
113
+ # @option options [Proc] :formatter
114
+ # Transform values (e.g., strip whitespace, normalize case).
115
+ # Block receives value and should return formatted value.
116
+ #
117
+ # @option options [Proc] :validator
118
+ # Validate values (must return truthy for valid). Block receives value
119
+ # and should return true/false. Raises ArgumentError if validation fails.
120
+ #
121
+ # @option options [Proc] :verifier
122
+ # Health check callback to verify external identity is still valid.
123
+ # Non-critical - returns false on failure rather than raising.
124
+ #
125
+ # @option options [Proc] :handshake
126
+ # Security-critical verification callback (e.g., OAuth state verification).
127
+ # Must return truthy. Raises on failure. Use for callbacks where failure
128
+ # indicates a security issue.
129
+ #
130
+ # @option options [Symbol, Class] :type (:String)
131
+ # Sequel column type for migration generation. Valid values:
132
+ # :String, :Integer, :Bignum, :Boolean, :Date, :DateTime, :Time, :Text
133
+ #
134
+ # @option options [Boolean] :null (true)
135
+ # Whether column allows NULL values
136
+ #
137
+ # @option options [Object] :default (nil)
138
+ # Default value for column. Can be a literal value or Proc for dynamic defaults.
139
+ #
140
+ # @option options [Boolean] :unique (false)
141
+ # Whether column has unique constraint
142
+ #
143
+ # @option options [Integer] :size
144
+ # Maximum size for String/varchar columns (e.g., 255)
145
+ #
146
+ # @option options [Boolean, Hash] :index (false)
147
+ # Whether to create index. Use true for simple index,
148
+ # or Hash for index options like { unique: true, name: :custom_name }
149
+ #
150
+ # @option options [Hash] :sequel
151
+ # Nested hash for Sequel-specific options (alternative to flat options).
152
+ # Keys: :type, :null, :default, :unique, :size, :index
153
+ #
154
+ # @example Basic usage
155
+ # external_identity_column :stripe_customer_id
156
+ #
157
+ # @example With lifecycle callbacks
158
+ # external_identity_column :stripe_customer_id,
159
+ # before_create_account: -> { Stripe::Customer.create(email: account[:email]).id },
160
+ # after_create_account: -> (id) { StripeMailer.welcome(id).deliver }
161
+ #
162
+ # @example With validation and formatting
163
+ # external_identity_column :api_key,
164
+ # formatter: -> (v) { v.to_s.strip.downcase },
165
+ # validator: -> (v) { v =~ /^[a-z0-9]{32}$/ }
166
+ #
167
+ # @example With database constraints
168
+ # external_identity_column :token,
169
+ # type: String,
170
+ # null: false,
171
+ # unique: true,
172
+ # size: 64,
173
+ # index: true
174
+ #
175
+ # @example With nested Sequel options
176
+ # external_identity_column :session_id,
177
+ # sequel: {
178
+ # type: String,
179
+ # null: false,
180
+ # default: -> { SecureRandom.hex(32) },
181
+ # unique: true,
182
+ # index: { unique: true, name: :idx_session_id }
183
+ # }
184
+ #
185
+ # @example Complete example with all options
186
+ # external_identity_column :external_user_id,
187
+ # method_name: :external_id,
188
+ # include_in_select: true,
189
+ # type: String,
190
+ # null: false,
191
+ # unique: true,
192
+ # size: 100,
193
+ # index: true,
194
+ # before_create_account: -> { ExternalService.create_user(account[:email]) },
195
+ # after_create_account: -> (id) { Logger.info("Created external user: #{id}") },
196
+ # formatter: -> (v) { v.to_s.strip },
197
+ # validator: -> (v) { v.present? && v.length <= 100 }
198
+ #
199
+ # @raise [ArgumentError] If column is not a Symbol
200
+ # @raise [ArgumentError] If column is not a valid Ruby identifier
201
+ # @raise [ArgumentError] If column is already declared
202
+ # @raise [ArgumentError] If method_name is not a valid Ruby identifier
203
+ #
204
+ # @return [nil]
205
+ def external_identity_column(column, **options)
206
+ # Validate column is a symbol
207
+ unless column.is_a?(Symbol)
208
+ raise ArgumentError, "external_identity_column must be a Symbol, got #{column.class}"
209
+ end
210
+
211
+ # Validate column is a valid Ruby identifier
212
+ unless column.to_s =~ /^[a-z_][a-z0-9_]*$/i
213
+ raise ArgumentError, "external_identity_column must be a valid Ruby identifier: #{column}"
214
+ end
215
+
216
+ # Default method name is the column name itself
217
+ method_name = options[:method_name] || column
218
+
219
+ # Validate method name is valid Ruby identifier
220
+ unless method_name.to_s =~ /^[a-z_][a-z0-9_]*[?!=]?$/i
221
+ raise ArgumentError, "Method name must be a valid Ruby identifier: #{method_name}"
222
+ end
223
+
224
+ # Store configuration on the Auth class
225
+ # Use class instance variable to store per-class configuration
226
+ unless @auth.instance_variable_get(:@_external_identity_columns)
227
+ @auth.instance_variable_set(:@_external_identity_columns,
228
+ {})
229
+ end
230
+ columns = @auth.instance_variable_get(:@_external_identity_columns)
231
+
232
+ # Check for duplicate declarations using column name as key
233
+ raise ArgumentError, "external_identity_column :#{column} already declared" if columns.key?(column)
234
+
235
+ # Store configuration (using column as both key and value)
236
+ columns[column] = {
237
+ column: column,
238
+ method_name: method_name,
239
+ include_in_select: options.fetch(:include_in_select, true),
240
+ validate: options[:validate] || false,
241
+ # Layer 2: Lifecycle callbacks
242
+ before_create_account: options[:before_create_account],
243
+ after_create_account: options[:after_create_account],
244
+ formatter: options[:formatter],
245
+ validator: options[:validator],
246
+ verifier: options[:verifier],
247
+ handshake: options[:handshake],
248
+ options: options
249
+ }
250
+
251
+ # Define the helper method on the Auth class
252
+ @auth.send(:define_method, method_name) do
253
+ value = account ? account[column] : nil
254
+
255
+ # Apply formatter if present (Layer 2)
256
+ config = external_identity_columns_config[column]
257
+ if config && config[:formatter] && value
258
+ instance_exec(value, &config[:formatter])
259
+ else
260
+ value
261
+ end
262
+ end
263
+
264
+ nil
265
+ end
266
+ end
267
+
268
+ # NOTE: external_identity_column is defined in configuration_module_eval above
269
+ # It's available during configuration but not as an instance method
270
+
271
+ # Get list of all declared external identity column names
272
+ #
273
+ # @return [Array<Symbol>] List of column names
274
+ #
275
+ # @example
276
+ # external_identity_column_list # => [:stripe_customer_id, :redis_uuid]
277
+ def external_identity_column_list
278
+ external_identity_columns_config.keys
279
+ end
280
+
281
+ # Get configuration for a specific external identity column
282
+ #
283
+ # @param column [Symbol] Column name
284
+ # @return [Hash, nil] Configuration hash or nil if not found
285
+ #
286
+ # @example
287
+ # external_identity_column_config(:stripe_customer_id)
288
+ # # => {column: :stripe_customer_id, method_name: :stripe_customer_id, ...}
289
+ def external_identity_column_config(column)
290
+ external_identity_columns_config[column]
291
+ end
292
+
293
+ # Get list of all generated helper method names
294
+ #
295
+ # @return [Array<Symbol>] List of method names
296
+ #
297
+ # @example
298
+ # external_identity_helper_methods # => [:stripe_customer_id, :redis_uuid]
299
+ def external_identity_helper_methods
300
+ external_identity_columns_config.values.map { |config| config[:method_name] }
301
+ end
302
+
303
+ # Check if a column has been declared as an external identity
304
+ #
305
+ # @param column [Symbol] Column name
306
+ # @return [Boolean] True if declared
307
+ #
308
+ # @example
309
+ # external_identity_column?(:stripe_customer_id) # => true
310
+ # external_identity_column?(:redis_uuid) # => true
311
+ # external_identity_column?(:unknown) # => false
312
+ def external_identity_column?(column)
313
+ external_identity_columns_config.key?(column)
314
+ end
315
+
316
+ # Get complete status information for all declared external identities
317
+ #
318
+ # Useful for debugging and introspection. Shows configuration,
319
+ # current values, and validation status.
320
+ #
321
+ # @return [Array<Hash>] Array of status hashes
322
+ #
323
+ # @example
324
+ # external_identity_status
325
+ # # => [
326
+ # # {
327
+ # # column: :stripe_customer_id,
328
+ # # method: :stripe_customer_id,
329
+ # # value: "cus_abc123",
330
+ # # present: true,
331
+ # # in_select: true,
332
+ # # in_account: true,
333
+ # # column_exists: true
334
+ # # },
335
+ # # ...
336
+ # # ]
337
+ def external_identity_status
338
+ current_select = account_select
339
+
340
+ external_identity_columns_config.map do |column, config|
341
+ method_name = config[:method_name]
342
+
343
+ # Safely get the value
344
+ value = begin
345
+ account ? account[column] : nil
346
+ rescue StandardError
347
+ nil
348
+ end
349
+
350
+ # Check if column exists in database
351
+ column_exists = begin
352
+ db.schema(accounts_table).any? { |col| col[0] == column }
353
+ rescue StandardError
354
+ nil # Unknown if can't check
355
+ end
356
+
357
+ # Check if column is in select (nil means select all)
358
+ in_select = current_select.nil? || current_select.include?(column)
359
+
360
+ {
361
+ column: column,
362
+ method: method_name,
363
+ value: value,
364
+ present: !value.nil?,
365
+ in_select: in_select,
366
+ in_account: account&.key?(column) || false,
367
+ column_exists: column_exists
368
+ }
369
+ end
370
+ end
371
+
372
+ # Validate a specific external identity column value
373
+ #
374
+ # Applies formatter (if configured) then validator (if configured).
375
+ # Returns true if valid or no validator configured.
376
+ # Raises ArgumentError if validation fails.
377
+ #
378
+ # @param column [Symbol] Column name
379
+ # @param value [Object] Value to validate
380
+ # @return [Boolean] True if valid
381
+ # @raise [ArgumentError] If validation fails
382
+ #
383
+ # @example
384
+ # validate_external_identity(:stripe_customer_id, "cus_123") # => true
385
+ # validate_external_identity(:stripe_customer_id, "invalid") # => ArgumentError
386
+ def validate_external_identity(column, value)
387
+ config = external_identity_columns_config[column]
388
+ return true unless config
389
+ return true if value.nil? # nil values are not validated
390
+
391
+ # Apply formatter if present
392
+ formatted_value = if config[:formatter]
393
+ instance_exec(value, &config[:formatter])
394
+ else
395
+ value
396
+ end
397
+
398
+ # Apply validator if present
399
+ if config[:validator]
400
+ is_valid = instance_exec(formatted_value, &config[:validator])
401
+ raise ArgumentError, "Invalid format for #{column}: #{value.inspect}" unless is_valid
402
+ end
403
+
404
+ true
405
+ end
406
+
407
+ # Validate all configured external identity columns
408
+ #
409
+ # Checks all columns that have validators configured.
410
+ # Returns hash of column => validation result.
411
+ #
412
+ # @return [Hash<Symbol, Boolean>] Results per column
413
+ #
414
+ # @example
415
+ # validate_all_external_identities
416
+ # # => {stripe_customer_id: true, redis_uuid: true}
417
+ def validate_all_external_identities
418
+ results = {}
419
+ external_identity_columns_config.each do |column, config|
420
+ next unless config[:validator]
421
+
422
+ value = account ? account[column] : nil
423
+ next if value.nil? # Skip nil values
424
+
425
+ validate_external_identity(column, value)
426
+ results[column] = true
427
+ end
428
+ results
429
+ end
430
+
431
+ # Verify external identity by checking with external service
432
+ #
433
+ # Health check that verifies the external identity still exists
434
+ # and is valid. Non-critical - returns false on failure rather
435
+ # than raising (except for unhandled exceptions).
436
+ #
437
+ # @param column [Symbol] Column name
438
+ # @return [Boolean] True if verified, false if not or no verifier configured
439
+ #
440
+ # @example
441
+ # verify_external_identity(:stripe_customer_id) # => true
442
+ # verify_external_identity(:deleted_id) # => false
443
+ def verify_external_identity(column)
444
+ config = external_identity_columns_config[column]
445
+ return true unless config
446
+ return true unless config[:verifier]
447
+
448
+ value = account ? account[column] : nil
449
+ return true if value.nil? # Skip nil values
450
+
451
+ # Apply formatter if present
452
+ formatted_value = if config[:formatter]
453
+ instance_exec(value, &config[:formatter])
454
+ else
455
+ value
456
+ end
457
+
458
+ # Execute verifier callback
459
+ # Non-critical: catch errors and return false
460
+ begin
461
+ result = instance_exec(formatted_value, &config[:verifier])
462
+ !!result # Ensure boolean
463
+ rescue StandardError => e
464
+ # Log error but don't raise - verification is non-critical
465
+ warn "[external_identity] Verification failed for #{column}: #{e.class} - #{e.message}"
466
+ false
467
+ end
468
+ end
469
+
470
+ # Verify all external identities with verifier callbacks
471
+ #
472
+ # Performs health checks on all configured external identities.
473
+ # Skips columns without verifiers or with nil values.
474
+ # Non-critical - continues checking all columns even if some fail.
475
+ #
476
+ # @return [Hash<Symbol, Boolean>] Results per column with verifier
477
+ #
478
+ # @example
479
+ # verify_all_external_identities
480
+ # # => {stripe_customer_id: true, github_user_id: false}
481
+ def verify_all_external_identities
482
+ results = {}
483
+ external_identity_columns_config.each do |column, config|
484
+ next unless config[:verifier]
485
+
486
+ value = account ? account[column] : nil
487
+ next if value.nil? # Skip nil values
488
+
489
+ results[column] = verify_external_identity(column)
490
+ end
491
+ results
492
+ end
493
+
494
+ # Verify handshake between external identity and verification token
495
+ #
496
+ # Security-critical verification that checks the external identity
497
+ # value against a verification token (e.g., OAuth state, CSRF token).
498
+ # MUST raise on failure by default - this is security-critical.
499
+ #
500
+ # @param column [Symbol] Column name
501
+ # @param value [Object] External identity value to verify
502
+ # @param token [Object] Verification token (e.g., OAuth state)
503
+ # @return [Boolean] True if handshake verified
504
+ # @raise [RuntimeError] If handshake fails (security-critical)
505
+ #
506
+ # @example OAuth CSRF protection
507
+ # verify_handshake(:github_user_id, user_info['id'], params['state'])
508
+ #
509
+ # @example Team invite verification
510
+ # verify_handshake(:team_id, invite.team_id, invite.token)
511
+ def verify_handshake(column, value, token)
512
+ config = external_identity_columns_config[column]
513
+
514
+ # Return true if no handshake configured (pass-through)
515
+ return true unless config
516
+ return true unless config[:handshake]
517
+
518
+ # Apply formatter if present
519
+ formatted_value = if config[:formatter]
520
+ instance_exec(value, &config[:formatter])
521
+ else
522
+ value
523
+ end
524
+
525
+ # Execute handshake callback
526
+ # Security-critical: MUST raise on failure
527
+ result = instance_exec(formatted_value, token, &config[:handshake])
528
+
529
+ raise "Handshake verification failed for #{column}" unless result
530
+
531
+ true
532
+ end
533
+
534
+ # Generate external identities during account creation
535
+ #
536
+ # Runs in before_create_account hook. For each column with
537
+ # before_create_account callback configured:
538
+ # - Skip if value already set (manual override)
539
+ # - Execute callback to generate value
540
+ # - Apply formatter if configured
541
+ # - Apply validator if configured
542
+ # - Set account column
543
+ #
544
+ # Errors during generation will prevent account creation.
545
+ def before_create_account
546
+ super if defined?(super)
547
+ generate_external_identities
548
+ end
549
+
550
+ # Process external identity callbacks after account creation
551
+ #
552
+ # Runs in after_create_account hook. For each column with
553
+ # after_create_account callback configured:
554
+ # - Retrieve current column value
555
+ # - Execute callback with column value as argument
556
+ #
557
+ # Useful for:
558
+ # - Provisioning external resources
559
+ # - Sending notifications with external IDs
560
+ # - Triggering webhooks
561
+ # - Logging/auditing
562
+ def after_create_account
563
+ super if defined?(super)
564
+ process_after_create_callbacks
565
+ end
566
+
567
+ # Validation hook - runs after configuration is complete
568
+ #
569
+ # Validates configuration and provides helpful warnings/errors
570
+ def post_configure
571
+ super if defined?(super)
572
+
573
+ # Wrap account_select to add external identity columns
574
+ setup_account_select_wrapper
575
+
576
+ # Check columns based on configuration
577
+ case external_identity_check_columns
578
+ when true
579
+ # Check existence and raise if missing
580
+ check_columns_exist!
581
+ when :autocreate
582
+ # Check existence and inform table_guard if missing
583
+ check_and_autocreate_columns!
584
+ when false
585
+ # Skip checking entirely
586
+ else
587
+ raise ArgumentError,
588
+ "external_identity_check_columns must be true, false, or :autocreate, got: #{external_identity_check_columns.inspect}"
589
+ end
590
+
591
+ # Always check that columns are included in account_select if they should be
592
+ validate_account_select_inclusion!
593
+ end
594
+
595
+ private
596
+
597
+ # Wrap account_select method to add external identity columns
598
+ #
599
+ # This runs in post_configure after all configuration is complete.
600
+ # It wraps the existing account_select method (whether default nil or
601
+ # user-configured) to add our columns.
602
+ def setup_account_select_wrapper
603
+ # Get current account_select value at configuration time
604
+ # (calling the method gets the configured value)
605
+ current_select = account_select
606
+
607
+ # Get columns to add
608
+ columns_to_add = external_identity_columns_config.select do |_name, config|
609
+ config[:include_in_select]
610
+ end.keys
611
+
612
+ # Return early if no columns to add
613
+ return if columns_to_add.empty?
614
+
615
+ # Redefine account_select on the Auth subclass to wrap it
616
+ #
617
+ # NOTE: We use self.class.send(:define_method) rather than define_singleton_method
618
+ # because in Rodauth's architecture, each configuration gets its own Rodauth::Auth
619
+ # SUBCLASS (not just instance). Defining on self.class ensures the method is available
620
+ # on all runtime instances of this particular Auth subclass, which is what we need.
621
+ # Using define_singleton_method would only affect the temporary configuration instance.
622
+ #
623
+ # Capturing current_select at configuration time (rather than calling account_select
624
+ # dynamically) is intentional - it preserves the configured state from post_configure,
625
+ # which runs after all user configuration is complete. This prevents infinite recursion
626
+ # and ensures we build on the base configuration's account_select value.
627
+ self.class.send(:define_method, :account_select) do
628
+ # Use the captured value from configuration time
629
+ cols = current_select
630
+
631
+ # If configured value is nil, preserve that (select all columns)
632
+ return nil if cols.nil?
633
+
634
+ # Normalize to array
635
+ cols = case cols
636
+ when Array then cols.dup
637
+ else [cols]
638
+ end
639
+
640
+ # Add external identity columns (idempotent via include? check)
641
+ columns_to_add.each do |column|
642
+ cols << column unless cols.include?(column)
643
+ end
644
+
645
+ cols
646
+ end
647
+ end
648
+
649
+ # Generate external identities for columns with before_create_account callbacks
650
+ #
651
+ # Called during before_create_account hook
652
+ def generate_external_identities
653
+ external_identity_columns_config.each do |column, config|
654
+ next unless config[:before_create_account]
655
+
656
+ # Skip if value already set (manual override)
657
+ next if account[column]
658
+
659
+ # Execute generator callback
660
+ generated_value = instance_exec(&config[:before_create_account])
661
+
662
+ # Skip if generator returned nil (intentionally not setting)
663
+ next if generated_value.nil?
664
+
665
+ # Apply formatter if configured
666
+ value = if config[:formatter]
667
+ instance_exec(generated_value, &config[:formatter])
668
+ else
669
+ generated_value
670
+ end
671
+
672
+ # Apply validator if configured
673
+ if config[:validator]
674
+ is_valid = instance_exec(value, &config[:validator])
675
+ raise ArgumentError, "Generated value for #{column} failed validation: #{value.inspect}" unless is_valid
676
+ end
677
+
678
+ # Set the account column
679
+ account[column] = value
680
+ end
681
+ end
682
+
683
+ # Process after_create_account callbacks for external identity columns
684
+ #
685
+ # Called during after_create_account hook. For each column with
686
+ # after_create_account callback configured, execute the callback
687
+ # with the current column value as argument.
688
+ def process_after_create_callbacks
689
+ external_identity_columns_config.each do |column, config|
690
+ next unless config[:after_create_account]
691
+
692
+ # Get current value from account
693
+ current_value = account[column]
694
+
695
+ # Execute callback with current value
696
+ instance_exec(current_value, &config[:after_create_account])
697
+ end
698
+ end
699
+
700
+ # Internal method called by auth_cached_method
701
+ #
702
+ # Retrieves the storage hash for external identity configurations from the Auth class
703
+ # This is called lazily per-instance and cached
704
+ #
705
+ # @return [Hash] Configuration hash from the Auth class
706
+ def _external_identity_columns_config
707
+ # Get from class instance variable set during configuration
708
+ self.class.instance_variable_get(:@_external_identity_columns) || {}
709
+ end
710
+
711
+ # Check that declared columns exist in the database
712
+ #
713
+ # Runs when external_identity_check_columns is true
714
+ #
715
+ # @raise [ArgumentError] If columns are missing
716
+ def check_columns_exist!
717
+ missing = find_missing_columns
718
+ return if missing.empty?
719
+
720
+ column_list = missing.map { |col| ":#{col}" }.join(', ')
721
+ raise ArgumentError, "External identity columns not found in #{accounts_table} table: #{column_list}. " \
722
+ 'Add columns to database, set external_identity_check_columns to false, or use :autocreate mode.'
723
+ end
724
+
725
+ # Check columns and inform table_guard if any are missing
726
+ #
727
+ # Runs when external_identity_check_columns is :autocreate
728
+ def check_and_autocreate_columns!
729
+ missing = find_missing_columns
730
+ return if missing.empty?
731
+
732
+ # If table_guard is enabled, inform it about missing columns
733
+ raise ArgumentError, build_external_identity_columns_error(missing) unless respond_to?(:table_guard_mode)
734
+
735
+ # Register missing external identity columns with table_guard
736
+ register_external_columns_with_table_guard(missing)
737
+
738
+ # No table_guard available - provide helpful error with migration code
739
+ end
740
+
741
+ # Find columns that don't exist in the database
742
+ #
743
+ # @return [Array<Symbol>] Array of missing column names
744
+ def find_missing_columns
745
+ return [] unless db # Skip if no database available
746
+
747
+ schema = begin
748
+ db.schema(accounts_table)
749
+ rescue StandardError
750
+ # Can't check - database might not exist yet
751
+ return []
752
+ end
753
+
754
+ column_names = schema.map { |col| col[0] }
755
+
756
+ missing = []
757
+ external_identity_columns_config.each do |column, _config|
758
+ missing << column unless column_names.include?(column)
759
+ end
760
+
761
+ missing
762
+ end
763
+
764
+ # Register missing external identity columns with table_guard
765
+ #
766
+ # @param missing [Array<Symbol>] Array of missing column names
767
+ def register_external_columns_with_table_guard(missing)
768
+ # Add missing columns to table_guard's configuration
769
+ # This allows table_guard to generate migrations or create columns
770
+ # based on its sequel_mode setting
771
+
772
+ missing.each do |column|
773
+ # Get column configuration
774
+ config = external_identity_columns_config[column]
775
+
776
+ # Build column definition with Sequel options
777
+ column_def = {
778
+ name: column,
779
+ type: extract_column_type(config),
780
+ null: extract_column_null(config),
781
+ default: extract_column_default(config),
782
+ unique: extract_column_unique(config),
783
+ size: extract_column_size(config),
784
+ index: extract_column_index(config),
785
+ feature: :external_identity
786
+ }
787
+
788
+ # Register column with table_guard
789
+ # table_guard will validate and optionally create based on its configuration
790
+ register_required_column(accounts_table, column_def)
791
+ end
792
+
793
+ # Trigger table_guard's validation which will handle creation/logging
794
+ # based on table_guard_mode and table_guard_sequel_mode
795
+ #
796
+ # Since external_identity's post_configure runs after table_guard's,
797
+ # we need to explicitly trigger the checking again now that we've
798
+ # registered our columns
799
+ #
800
+ # Check if we should handle columns: either validation is enabled OR sequel_mode is set
801
+ # (sequel_mode works even in silent mode since it indicates intent to create/generate)
802
+ return unless should_check_tables? || table_guard_sequel_mode
803
+
804
+ # Get the missing columns we just registered
805
+ missing_cols = missing_columns
806
+ return if missing_cols.empty?
807
+
808
+ # Handle columns according to table_guard's configuration
809
+ # (skip validation in silent mode, but allow sequel operations)
810
+ handle_column_guard_mode(missing_cols) if should_check_tables?
811
+
812
+ # Generate/create columns if sequel mode is configured
813
+ handle_sequel_generation([], missing_cols) if table_guard_sequel_mode
814
+ end
815
+
816
+ # Extract column type from configuration
817
+ #
818
+ # Checks in order: nested sequel hash, flat options, default to String
819
+ #
820
+ # @param config [Hash] Column configuration
821
+ # @return [Class] Column type (Integer, String, etc.)
822
+ def extract_column_type(config)
823
+ config.dig(:options, :sequel, :type) ||
824
+ config.dig(:options, :type) ||
825
+ String
826
+ end
827
+
828
+ # Extract column null setting from configuration
829
+ #
830
+ # @param config [Hash] Column configuration
831
+ # @return [Boolean] Whether column allows NULL
832
+ def extract_column_null(config)
833
+ # Check nested sequel hash first, then flat options
834
+ if config.dig(:options, :sequel)&.key?(:null)
835
+ config.dig(:options, :sequel, :null)
836
+ elsif config.dig(:options)&.key?(:null)
837
+ config.dig(:options, :null)
838
+ else
839
+ true # External IDs are optional by default
840
+ end
841
+ end
842
+
843
+ # Extract column default value from configuration
844
+ #
845
+ # @param config [Hash] Column configuration
846
+ # @return [Object, nil] Default value or nil
847
+ def extract_column_default(config)
848
+ config.dig(:options, :sequel, :default) ||
849
+ config.dig(:options, :default)
850
+ end
851
+
852
+ # Extract column unique setting from configuration
853
+ #
854
+ # @param config [Hash] Column configuration
855
+ # @return [Boolean] Whether column has unique constraint
856
+ def extract_column_unique(config)
857
+ config.dig(:options, :sequel, :unique) ||
858
+ config.dig(:options, :unique) ||
859
+ false
860
+ end
861
+
862
+ # Extract column size from configuration
863
+ #
864
+ # @param config [Hash] Column configuration
865
+ # @return [Integer, nil] Column size or nil
866
+ def extract_column_size(config)
867
+ config.dig(:options, :sequel, :size) ||
868
+ config.dig(:options, :size)
869
+ end
870
+
871
+ # Extract column index setting from configuration
872
+ #
873
+ # @param config [Hash] Column configuration
874
+ # @return [Boolean, Hash] Index configuration
875
+ def extract_column_index(config)
876
+ config.dig(:options, :sequel, :index) ||
877
+ config.dig(:options, :index) ||
878
+ false
879
+ end
880
+
881
+ # Build error message for missing external identity columns with migration example
882
+ #
883
+ # @param missing [Array<Symbol>] Array of missing column names
884
+ # @return [String] Error message with migration code
885
+ def build_external_identity_columns_error(missing)
886
+ column_list = missing.map { |col| ":#{col}" }.join(', ')
887
+
888
+ migration_code = generate_external_identity_migration_code(missing)
889
+
890
+ <<~ERROR
891
+ External identity columns not found in #{accounts_table} table: #{column_list}
892
+
893
+ With external_identity_check_columns set to :autocreate but table_guard not enabled.
894
+
895
+ Either:
896
+ 1. Enable table_guard feature with sequel_mode to auto-create
897
+ 2. Create migration manually:
898
+
899
+ #{migration_code}
900
+
901
+ 3. Set external_identity_check_columns to false to skip checking
902
+ ERROR
903
+ end
904
+
905
+ # Generate migration code for missing external identity columns
906
+ #
907
+ # @param missing [Array<Symbol>] Array of missing column names
908
+ # @return [String] Sequel migration code
909
+ def generate_external_identity_migration_code(missing)
910
+ lines = ['Sequel.migration do', ' up do', " alter_table :#{accounts_table} do"]
911
+
912
+ missing.each do |column|
913
+ lines << " add_column :#{column}, String"
914
+ end
915
+
916
+ lines += [' end', ' end', '', ' down do', " alter_table :#{accounts_table} do"]
917
+
918
+ missing.each do |column|
919
+ lines << " drop_column :#{column}"
920
+ end
921
+
922
+ lines += [' end', ' end', 'end']
923
+
924
+ lines.join("\n")
925
+ end
926
+
927
+ # Validate that columns are included in account_select if they should be
928
+ #
929
+ # Provides helpful warnings if configuration might not work as expected
930
+ def validate_account_select_inclusion!
931
+ current_select = account_select
932
+ return unless current_select # Skip if account_select not defined (e.g., no login feature)
933
+
934
+ missing = []
935
+ external_identity_columns_config.each do |column, config|
936
+ missing << ":#{column}" if config[:include_in_select] && !current_select.include?(column)
937
+ end
938
+
939
+ return if missing.empty?
940
+
941
+ warn "[external_identity] WARNING: Columns #{missing.join(", ")} marked for inclusion " \
942
+ 'but not in account_select. This may indicate a configuration order issue. ' \
943
+ 'The feature should have added them automatically.'
944
+ end
945
+ end
946
+ end