add_auth 0.2.2 → 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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +38 -14
  4. data/ROADMAP.md +87 -38
  5. data/SECURITY.md +6 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +138 -0
  7. data/app/controllers/add_auth/assets_controller.rb +2 -1
  8. data/app/controllers/add_auth/external_identities_controller.rb +56 -0
  9. data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
  10. data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
  11. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  12. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  13. data/app/jobs/add_auth/account_request_job.rb +17 -0
  14. data/app/mailers/add_auth/account_mailer.rb +12 -0
  15. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  16. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  17. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  18. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  19. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  20. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  21. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  22. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  23. data/app/views/add_auth/accounts/_request.html.erb +14 -0
  24. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  25. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  26. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  27. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  28. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  29. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  30. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  31. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  32. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  33. data/app/views/add_auth/sessions/index.html.erb +7 -0
  34. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  35. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  36. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  37. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  38. data/exe/add_auth-preflight +26 -0
  39. data/lib/add_auth/configuration.rb +78 -4
  40. data/lib/add_auth/core/access_policy.rb +11 -1
  41. data/lib/add_auth/core/account_lifecycle.rb +251 -0
  42. data/lib/add_auth/core/account_policy.rb +50 -0
  43. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  44. data/lib/add_auth/core/external_identities.rb +278 -0
  45. data/lib/add_auth/core/intake.rb +6 -1
  46. data/lib/add_auth/core/maintenance.rb +5 -3
  47. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  48. data/lib/add_auth/core/migration/readiness.rb +41 -0
  49. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  50. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  51. data/lib/add_auth/core/mobile_profile.rb +46 -0
  52. data/lib/add_auth/core/mobile_response.rb +12 -0
  53. data/lib/add_auth/core/native_authentication.rb +77 -0
  54. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  55. data/lib/add_auth/core/passwords/credential.rb +49 -0
  56. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  57. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  58. data/lib/add_auth/core/remaining_factors.rb +24 -0
  59. data/lib/add_auth/core/sessions.rb +102 -17
  60. data/lib/add_auth/core/step_up.rb +13 -1
  61. data/lib/add_auth/migration/static_inventory.rb +116 -0
  62. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  63. data/lib/add_auth/rails/authentication.rb +1 -1
  64. data/lib/add_auth/rails/authentication_pages.rb +5 -0
  65. data/lib/add_auth/rails/doctor.rb +70 -3
  66. data/lib/add_auth/rails/ejection.rb +1 -1
  67. data/lib/add_auth/rails/engine.rb +6 -1
  68. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  69. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  70. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  71. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  72. data/lib/add_auth/rails/password_adoption.rb +37 -0
  73. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  74. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  77. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  78. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  80. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  81. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  82. data/lib/add_auth/rails/runtime.rb +170 -8
  83. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  84. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  85. data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
  86. data/lib/add_auth/rails/stores/authority.rb +28 -0
  87. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  88. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  89. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  90. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  91. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  92. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  93. data/lib/add_auth/result.rb +1 -0
  94. data/lib/add_auth/version.rb +1 -1
  95. data/lib/add_auth.rb +4 -0
  96. data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
  97. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  98. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  99. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  100. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  101. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  102. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  103. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  104. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  105. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  106. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  107. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  109. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  110. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  111. data/lib/generators/add_auth/install/templates/initializer.rb +4 -0
  112. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  113. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  114. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  115. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  116. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  117. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  118. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  119. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  120. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  121. data/lib/tasks/add_auth.rake +18 -1
  122. metadata +85 -2
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+ require "pathname"
5
+ require "add_auth/core/migration/readiness"
6
+
7
+ module AddAuth
8
+ module Migration
9
+ # Reads a bounded set of source files as data. Never require/load/eval a host.
10
+ class StaticInventory
11
+ MAX_FILES = 2000
12
+ MAX_BYTES = 1_048_576
13
+ TOTAL_BYTES = 16 * 1_048_576
14
+ PATTERNS = %w[app/models/**/*.rb app/controllers/**/*.rb app/services/**/*.rb app/jobs/**/*.rb app/workers/**/*.rb app/mailers/**/*.rb app/helpers/**/*.rb lib/**/*.rb lib/tasks/**/*.rake config/initializers/**/*.rb config/routes.rb db/schema.rb spec/factories/**/*.rb spec/support/**/*.rb].freeze
15
+ SURFACES = {
16
+ devise: %w[Devise devise devise_for devise_scope], warden: %w[warden Warden],
17
+ guards: %w[authenticate_user! current_user user_signed_in? authenticated],
18
+ passwords: %w[encrypted_password password_digest valid_password? password=],
19
+ tokens: %w[authentication_token MobileAuthToken reset_password_token confirmation_token unlock_token],
20
+ providers: %w[OmniAuth omniauth omniauth_callbacks],
21
+ eligibility: %w[active_for_authentication? inactive_message locked_at confirmed_at disabled_at]
22
+ }.freeze
23
+
24
+ def initialize(root:)
25
+ @root = File.realpath(root)
26
+ end
27
+
28
+ def call
29
+ @complete = true
30
+ @remaining_bytes = TOTAL_BYTES
31
+ paths = PATTERNS.flat_map { |pattern| Dir.glob(File.join(@root, pattern)) }.uniq.sort
32
+ @complete = false if paths.length > MAX_FILES
33
+ sources = paths.first(MAX_FILES).filter_map do |path|
34
+ source = read(path)
35
+ next unless source
36
+ lexical = Ripper.lex(source)
37
+ tokens = lexical.reject { |(_, kind, _, _)| %i[on_comment on_tstring_content].include?(kind) }
38
+ literal_keys = lexical.filter_map { |(_, kind, value, _)| value if kind == :on_tstring_content && SURFACES.values.flatten.include?(value) }
39
+ tree = Ripper.sexp(source)
40
+ @complete = false unless tree
41
+ [relative(path), {tokens: tokens.map { |token| token[2] }, literal_keys: literal_keys, tree: tree}]
42
+ end.to_h
43
+ calls = sources.values.flat_map { |source| devise_calls(source[:tree]) }
44
+ names = calls.flatten
45
+ unknown = names.count { |name| !Core::Migration::Readiness::MODULES.include?(name) }
46
+ @complete = false if names.include?(nil)
47
+ lock = read(File.join(@root, "Gemfile.lock")) || ""
48
+ versions = %w[devise rails ruby bcrypt].to_h do |name|
49
+ version = if name == "ruby"
50
+ lock[/^ ruby (\d+\.\d+\.\d+)/, 1]
51
+ else
52
+ lock[/^ #{Regexp.escape(name)} \((\d+\.\d+(?:\.\d+)*(?:[.a-z0-9-]*))\)/, 1]
53
+ end
54
+ [name.to_sym, version]
55
+ end
56
+ extensions = Core::Migration::Readiness::EXTENSIONS.select { |name| lock.match?(/^ #{Regexp.escape(name)} \(/) }
57
+ targets = Core::Migration::Readiness::TARGETS.select { |path| sources.key?(path) }
58
+ user = sources.dig("app/models/user.rb", :tokens) || []
59
+ facts = {mode: "static", complete: @complete, versions: versions,
60
+ modules: (names.compact & Core::Migration::Readiness::MODULES).sort,
61
+ unknown_modules: unknown, extensions: extensions, scope_count: calls.length,
62
+ rails_contract: targets.length == Core::Migration::Readiness::TARGETS.length && user.include?("has_secure_password") && user.include?("normalizes"),
63
+ target_conflicts: targets, files_inspected: sources.length,
64
+ surfaces: SURFACES.transform_values { |terms| sources.filter_map { |path, source| path if ((source[:tokens] + source[:literal_keys]) & terms).any? } }}
65
+ Core::Migration::Readiness.new.call(facts)
66
+ end
67
+
68
+ private
69
+
70
+ def read(path)
71
+ # Reject symlinks at any depth, even those pointing to another host file.
72
+ current = Pathname.new(path)
73
+ while current.to_s != @root
74
+ return incomplete if current.symlink?
75
+ parent = current.parent
76
+ return incomplete if parent == current
77
+ current = parent
78
+ end
79
+ return incomplete unless File.file?(path)
80
+ return incomplete if @remaining_bytes <= 0
81
+ File.open(path, "rb") do |file|
82
+ limit = [MAX_BYTES, @remaining_bytes].min
83
+ source = file.read(limit + 1)
84
+ @remaining_bytes -= source.bytesize
85
+ return incomplete if source.bytesize > limit
86
+ source.force_encoding(Encoding::UTF_8)
87
+ return incomplete unless source.valid_encoding?
88
+ source
89
+ end
90
+ rescue SystemCallError
91
+ incomplete
92
+ end
93
+
94
+ def incomplete
95
+ @complete = false
96
+ nil
97
+ end
98
+
99
+ def relative(path) = Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
100
+
101
+ def devise_calls(node)
102
+ return [] unless node.is_a?(Array)
103
+ arguments = if node[0] == :command && node.dig(1, 1) == "devise"
104
+ node[2]
105
+ elsif node[0] == :method_add_arg && node.dig(1, 0) == :fcall && node.dig(1, 1, 1) == "devise"
106
+ node.dig(2, 1)
107
+ end
108
+ if arguments
109
+ args = (arguments[0] == :args_add_block) ? arguments[1] : []
110
+ return [args.reject { |arg| arg[0] == :bare_assoc_hash }.map { |arg| (arg[0] == :symbol_literal) ? arg.dig(1, 1, 1) : nil }]
111
+ end
112
+ node.flat_map { |child| child.is_a?(Array) ? devise_calls(child) : [] }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module AccountPasswordEntry
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ protect_from_forgery with: :exception
10
+ prepend_before_action :add_auth_account_password_entry, only: %i[new create edit update]
11
+ end
12
+
13
+ private
14
+
15
+ def add_auth_account_password_entry
16
+ return unless AddAuth.configuration.lifecycle.enabled
17
+ verify_authenticity_token unless request.get? || request.head?
18
+ response.set_header("Cache-Control", "no-store")
19
+ response.set_header("Referrer-Policy", "no-referrer")
20
+ redirect_to "/account/requests/reset_password", status: request.get? ? :found : :see_other
21
+ end
22
+ end
23
+ end
24
+ end
@@ -27,7 +27,7 @@ module AddAuth
27
27
  end
28
28
 
29
29
  def add_auth_session_hints
30
- {user_agent: request.user_agent.to_s.truncate_bytes(512), ip_address: request.remote_ip}
30
+ {user_agent: request.user_agent.to_s.truncate_bytes(512), ip_address: request.remote_ip, remember: params[:remember] == "1"}
31
31
  end
32
32
 
33
33
  def add_auth_replacement_session
@@ -44,6 +44,11 @@ module AddAuth
44
44
 
45
45
  def page(partial, status: :ok)
46
46
  @page_partial = authentication_partial(partial)
47
+ # Turbo carries the form's stream Accept header across a 303. Prefer
48
+ # its HTML option on the destination GET so navigation commits the URL.
49
+ if (request.get? || request.head?) && request.accepts.include?(Mime[:html])
50
+ return render "add_auth/sign_ins/show", layout: "add_auth/authentication", formats: [:html], content_type: "text/html", status: status
51
+ end
47
52
  respond_to do |format|
48
53
  format.html { render "add_auth/sign_ins/show", layout: "add_auth/authentication", status: status }
49
54
  format.turbo_stream { render turbo_stream: turbo_stream.update("add_auth-content", partial: @page_partial), status: status }
@@ -33,6 +33,7 @@ module AddAuth
33
33
  end
34
34
  check("Include AddAuth::Rails::UserLifecycle in User") { ::User < UserLifecycle }
35
35
  check("Set passwords_enabled to true or false") { [true, false].include?(config.passwords_enabled) }
36
+ check("Set turbo_enabled to true or false") { [true, false].include?(config.turbo_enabled) }
36
37
  if config.passwords_enabled || defined?(::SessionsController)
37
38
  check("Install the shared password entry in SessionsController") { ::SessionsController < PasswordEntry }
38
39
  end
@@ -63,6 +64,25 @@ module AddAuth
63
64
  end
64
65
  end
65
66
  end
67
+ if config.mobile.enabled
68
+ columns(::Session, %w[transport client_id mobile_idle_timeout], "mobile session")
69
+ check("Configure explicit bounded mobile timeouts and registered client identifiers") { Runtime.mobile_profile }
70
+ check("Install mobile session routes") { ::Rails.application.routes.recognize_path("/mobile/session", method: :post) }
71
+ check("Configure mobile callbacks and Apple providers as explicit maps") do
72
+ config.mobile.callbacks.is_a?(Hash) && config.mobile.apple_providers.is_a?(Hash)
73
+ end
74
+ if config.mobile.callbacks.is_a?(Hash) && config.mobile.callbacks.any?
75
+ check("Enable reviewed provider integration before mobile handoffs") { config.external_identities.enabled }
76
+ columns(defined?(::AddAuthMobileHandoff) && ::AddAuthMobileHandoff,
77
+ %w[user_id external_digest digest client_id callback state challenge_digest credential_id credential_version policy_version authenticated_at issued_at expires_at consumed_at], "mobile handoff")
78
+ unique_index(::AddAuthMobileHandoff, "digest") if defined?(::AddAuthMobileHandoff)
79
+ unique_index(::AddAuthMobileHandoff, "external_digest") if defined?(::AddAuthMobileHandoff)
80
+ check("Use one database pool for mobile handoffs, accounts and sessions") { Runtime.mobile_handoffs }
81
+ end
82
+ if config.mobile.apple_providers.is_a?(Hash) && config.mobile.apple_providers.any?
83
+ check("Configure the optional native Apple library and exact client mapping") { Runtime.native_apple }
84
+ end
85
+ end
66
86
  if config.email_link.enabled
67
87
  check("Enable session_upgrade before email_link") { config.session.enabled }
68
88
  check("Configure base_url as a fixed HTTPS origin (HTTP only outside production)") { Runtime.sign_in_url("validation") }
@@ -75,7 +95,54 @@ module AddAuth
75
95
  columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken, ["browser_digest"], "email browser binding")
76
96
  end
77
97
  end
78
- if config.email_link.enabled || config.notifications.enabled
98
+ if config.external_identities.enabled
99
+ check("Enable session and step-up prerequisites for provider sign-in") { config.session.enabled && config.step_up.enabled }
100
+ check("Declare current password verifier support metadata") { config.current_password_support.respond_to?(:available?) }
101
+ if config.external_identities.providers.any?
102
+ require "add_auth/rails/provider_libraries/request_protection"
103
+ check("Configure a supported OmniAuth request validator") { !!ProviderLibraries::RequestProtection.validator }
104
+ end
105
+ check("Register at least one reviewed provider profile") { config.external_identities.configurations.any? }
106
+ columns(defined?(::AddAuthExternalIdentity) && ::AddAuthExternalIdentity,
107
+ %w[user_id namespace provider_id issuer audience subject provenance credential_version linked_at revoked_at invalidated_at], "external identity")
108
+ columns(defined?(::AddAuthExternalTransaction) && ::AddAuthExternalTransaction,
109
+ %w[digest browser_digest provider_id issuer audience purpose user_id session_id session_digest policy_version issued_at expires_at consumed_at enrollment_payload], "external transaction")
110
+ columns(::Session, %w[authentication_external_id authentication_external_version], "external session evidence")
111
+ unique_index(::AddAuthExternalIdentity, "namespace") if defined?(::AddAuthExternalIdentity)
112
+ unique_index(::AddAuthExternalTransaction, "digest") if defined?(::AddAuthExternalTransaction)
113
+ check("Use one database pool for provider, account and session persistence") { Runtime.external_identities }
114
+ config.external_identities.providers.each do |provider|
115
+ check("Install the configured provider callback and preparation routes") do
116
+ ::Rails.application.routes.recognize_path("/sign-in/providers/#{provider.middleware_name}", method: :post) &&
117
+ ::Rails.application.routes.recognize_path("/auth/#{provider.middleware_name}/callback", method: provider.apple_form_post? ? :post : :get)
118
+ end
119
+ end
120
+ check("Install provider account management routes") { ::Rails.application.routes.recognize_path("/account/external-identities", method: :get) }
121
+ end
122
+ if config.lifecycle.enabled
123
+ if defined?(::PasswordsController)
124
+ check("Retire stock password reset entry points through the shared account flow") { ::PasswordsController < AccountPasswordEntry }
125
+ end
126
+ columns(::Session, %w[remembered idle_timeout], "remembered browser sessions")
127
+ check("Enable session, step-up and notification prerequisites for account lifecycle") { config.session.enabled && config.step_up.enabled && config.notifications.enabled }
128
+ columns(::User, %w[confirmed_at unconfirmed_email locked_at failed_attempts disabled_at deleted_at add_auth_manual_lock add_auth_locked_until add_auth_authority], "account lifecycle")
129
+ columns(defined?(::AddAuthAccountToken) && ::AddAuthAccountToken,
130
+ (TOKEN_COLUMNS - %w[identifier_digest]) + %w[address_digest account_version], "account proofs")
131
+ columns(defined?(::AddAuthAddressClaim) && ::AddAuthAddressClaim, %w[user_id digest state], "address claims")
132
+ if defined?(::AddAuthAccountToken)
133
+ unique_index(::AddAuthAccountToken, "digest")
134
+ unique_index(::AddAuthAccountToken, "request_id")
135
+ end
136
+ unique_index(::AddAuthAddressClaim, "digest") if defined?(::AddAuthAddressClaim)
137
+ check("Configure account policy, profile mapping and local transaction provisioning callbacks") do
138
+ %i[eligible password_policy profile_attributes provision deletion_allowed delete_account].all? { |name| config.lifecycle.public_send(name).respond_to?(:call) }
139
+ end
140
+ check("Configure account proof delivery, lock policy and shared account storage") { Runtime.accounts && Runtime.password_lifecycle && Runtime.account_proof_url("validation", purpose: "confirm") }
141
+ %w[/account/sign-up /account/requests/confirm /account/email /account/password].each do |path|
142
+ check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
143
+ end
144
+ end
145
+ if config.email_link.enabled || config.notifications.enabled || config.lifecycle.enabled
79
146
  check("Configure mail_from") { config.mail_from.is_a?(String) && !config.mail_from.strip.empty? }
80
147
  check("Enable Action Mailer delivery") { ActionMailer::Base.perform_deliveries && ActionMailer::Base.raise_delivery_errors }
81
148
  if ::Rails.env.production?
@@ -83,7 +150,7 @@ module AddAuth
83
150
  check("Configure production mail delivery") { !%i[test file].include?(ActionMailer::Base.delivery_method) }
84
151
  end
85
152
  end
86
- maintenance_needed = config.passkeys.enabled || config.email_link.enabled || config.notifications.enabled || config.maintenance.session_retention
153
+ maintenance_needed = config.passkeys.enabled || config.email_link.enabled || config.notifications.enabled || config.lifecycle.enabled || config.external_identities.enabled || config.mobile.enabled || config.maintenance.session_retention
87
154
  if ::Rails.env.production? && maintenance_needed
88
155
  check("Schedule add_auth:deliver_pending every minute; no successful cleanup in the last two minutes") { Runtime.maintenance_current? }
89
156
  end
@@ -122,7 +189,7 @@ module AddAuth
122
189
  end
123
190
  end
124
191
  if config.challenge_on.any?
125
- check("Unknown challenge actions") { (config.challenge_on - %i[sign_in email_link reauthenticate passkey_enrollment]).empty? }
192
+ check("Unknown challenge actions (email proof pages cannot load third-party challenges)") { (config.challenge_on - %i[sign_in email_link reauthenticate passkey_enrollment register account_request provider]).empty? }
126
193
  check("Configure a challenge provider before setting challenge_on") { !config.challenge.is_a?(Core::Challenge::Null) }
127
194
  if ::Rails.env.production?
128
195
  check("Configure challenge hostname restrictions") { config.challenge.allowed_hostnames&.any? }
@@ -9,7 +9,7 @@ module AddAuth
9
9
  class Ejection
10
10
  MANIFEST = "config/add_auth-ejections.json"
11
11
  VIEW_GROUPS = {"email_link" => %w[sign_ins], "sessions" => %w[sessions],
12
- "step_up" => %w[reauthentications], "passkeys" => %w[passkeys recoveries]}.freeze
12
+ "step_up" => %w[reauthentications], "passkeys" => %w[passkeys recoveries], "accounts" => %w[accounts], "providers" => %w[provider_sign_ins external_identities]}.freeze
13
13
 
14
14
  def initialize(host_root:, engine_root: File.expand_path("../../..", __dir__))
15
15
  @host, @engine = host_root.to_s, engine_root.to_s
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "turbo-rails"
4
4
  require "add_auth/rails/authentication_pages"
5
+ require "add_auth/rails/provider_libraries/callback_route"
5
6
 
6
7
  module AddAuth
7
8
  module Rails
@@ -10,7 +11,7 @@ module AddAuth
10
11
  engine_name "add_auth"
11
12
 
12
13
  initializer "add_auth.filter_parameters" do |app|
13
- app.config.filter_parameters += [:token, :password, :delivery_payload, :token_digest, :encrypted_identifier, :browser_digest, :session_digest, :elevation_version, :add_auth_browser, :credential, :transaction, :challenge, :external_id, :public_key, :"cf-turnstile-response", :"g-recaptcha-response"]
14
+ app.config.filter_parameters += [:token, :password, :enrollment_payload, :code, :state, :nonce, :id_token, :access_token, :refresh_token, :client_secret, :authorization_code, :raw_info, :id_info, :delivery_payload, :token_digest, :encrypted_identifier, :browser_digest, :session_digest, :elevation_version, :add_auth_browser, :credential, :transaction, :challenge, :external_id, :public_key, :"cf-turnstile-response", :"g-recaptcha-response"]
14
15
  end
15
16
 
16
17
  config.to_prepare do
@@ -21,6 +22,10 @@ module AddAuth
21
22
  if AddAuth.configuration.passwords_enabled || defined?(::SessionsController)
22
23
  ::SessionsController.include AddAuth::Rails::PasswordEntry
23
24
  end
25
+ if defined?(::PasswordsController)
26
+ require "add_auth/rails/account_password_entry"
27
+ ::PasswordsController.include AddAuth::Rails::AccountPasswordEntry
28
+ end
24
29
  end
25
30
  end
26
31
 
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/account_lock"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Migration
8
+ class AccountStore
9
+ FIELDS = {id: :id, email: :email, encrypted_password: :encrypted_password,
10
+ authority: :add_auth_authority, migration_stamp: :add_auth_migration_stamp,
11
+ email_address: :email_address, password_digest: :password_digest, scheme: :add_auth_password_scheme}.freeze
12
+
13
+ def initialize(user_model:)
14
+ @users = user_model
15
+ @lock = Stores::AccountLock.new(user_model)
16
+ unless @users.primary_key == "id" && (FIELDS.values.map(&:to_s) - @users.column_names).empty?
17
+ raise ArgumentError, "prepare the reviewed User account schema before conversion"
18
+ end
19
+ end
20
+
21
+ def source_page(after:, limit:)
22
+ scope = @users.unscoped.order(:id)
23
+ scope = scope.where(@users.arel_table[:id].gt(after)) if after
24
+ scope.limit(limit).pluck(*FIELDS.values).map { |row| FIELDS.keys.zip(row).to_h }
25
+ end
26
+
27
+ def with_account(id)
28
+ @lock.call(id: id) { |user| yield(user && FIELDS.to_h { |name, field| [name, user.read_attribute(field)] }) }
29
+ rescue ActiveRecord::RecordNotUnique
30
+ :conflicts
31
+ end
32
+
33
+ def prepare(id:, **attributes)
34
+ raise AddAuth::Error, "conversion requires an account transaction" unless @users.connection.transaction_open?
35
+ # Source authority is retained. No callbacks, mail or host provisioning.
36
+ @users.unscoped.where(id: id).update_all(attributes)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/migration/readiness"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Migration
8
+ class EffectiveInventory
9
+ LIMIT = 10_000
10
+ ACCOUNT_FIELDS = %w[email email_address encrypted_password password_digest confirmed_at unconfirmed_email locked_at failed_attempts disabled_at deleted_at remember_created_at reset_password_token confirmation_token unlock_token authentication_token provider uid].freeze
11
+ OPTIONS = %i[authentication_keys case_insensitive_keys strip_whitespace_keys stretches password_length reconfirmable allow_unconfirmed_access_for confirm_within reset_password_within maximum_attempts unlock_strategy unlock_in remember_for sign_in_after_reset_password].freeze
12
+
13
+ def initialize(limit: LIMIT)
14
+ raise ArgumentError, "limit must be between 1 and 100000" unless limit.is_a?(Integer) && limit.between?(1, 100_000)
15
+ @limit = limit
16
+ end
17
+
18
+ def call
19
+ unless %w[test development].include?(::Rails.env.to_s) && defined?(::Devise)
20
+ raise ArgumentError, "effective preflight requires a local test/development Devise host"
21
+ end
22
+ @complete = true
23
+ mappings = ::Devise.mappings.values
24
+ models = mappings.map(&:to).uniq
25
+ connections = models.map(&:connection_pool).uniq
26
+ raise ArgumentError, "inspect one Active Record connection pool at a time" unless connections.length == 1
27
+ accounts = read_only(connections.first) { models.map { |model| account(model) } }
28
+ all_modules = accounts.flat_map { |entry| entry[:modules] }.uniq
29
+ facts = {mode: "effective", complete: @complete, row_limit: @limit,
30
+ versions: %w[devise rails bcrypt].to_h { |name| [name.to_sym, Gem.loaded_specs[name]&.version&.to_s] }.merge(ruby: RUBY_VERSION),
31
+ scope_count: mappings.length, modules: all_modules & Core::Migration::Readiness::MODULES,
32
+ unknown_modules: accounts.sum { |entry| entry[:unknown_modules] },
33
+ extensions: Core::Migration::Readiness::EXTENSIONS.select { |name| Gem.loaded_specs.key?(name) },
34
+ rails_contract: accounts.length == 1 && accounts.first[:rails_contract], accounts: accounts}
35
+ Core::Migration::Readiness.new.call(facts)
36
+ end
37
+
38
+ private
39
+
40
+ def read_only(pool)
41
+ pool.with_connection do |connection|
42
+ raise ArgumentError, "effective preflight requires its own transaction" if connection.transaction_open?
43
+ case connection.adapter_name
44
+ when "PostgreSQL"
45
+ connection.transaction(requires_new: true) do
46
+ connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
47
+ connection.execute("SET LOCAL statement_timeout = '10s'")
48
+ yield
49
+ end
50
+ when "SQLite"
51
+ previous = connection.select_value("PRAGMA query_only").to_i
52
+ begin
53
+ connection.execute("PRAGMA query_only = ON")
54
+ # Rails 8.1 starts ordinary SQLite transactions with BEGIN
55
+ # IMMEDIATE, which requests write authority even for SELECTs.
56
+ connection.execute("BEGIN DEFERRED TRANSACTION")
57
+ begin
58
+ yield
59
+ ensure
60
+ connection.execute("ROLLBACK")
61
+ end
62
+ ensure
63
+ connection.execute("PRAGMA query_only = #{previous}")
64
+ end
65
+ else
66
+ raise ArgumentError, "effective preflight supports SQLite and PostgreSQL only"
67
+ end
68
+ end
69
+ end
70
+
71
+ def account(model)
72
+ columns = model.columns_hash
73
+ fields = ACCOUNT_FIELDS & columns.keys
74
+ # No model instances, callbacks, password verification or normalization hooks.
75
+ rows = model.unscoped.limit(@limit + 1).pluck(*fields).map { |values| fields.zip(Array(values)).to_h }
76
+ @complete = false if rows.length > @limit
77
+ rows = rows.first(@limit)
78
+ modules = model.devise_modules.map(&:to_s)
79
+ lookup_keys = Array(model.authentication_keys)
80
+ identifier = (lookup_keys.length == 1 && fields.include?(lookup_keys.first.to_s)) ? lookup_keys.first.to_s : "email"
81
+ @complete = false unless lookup_keys == [:email] || lookup_keys == [:email_address]
82
+ password = fields.include?("encrypted_password") ? "encrypted_password" : "password_digest"
83
+ keys = rows.map { |row| row[identifier].to_s.strip.downcase }
84
+ nonblank = keys.reject(&:empty?)
85
+ options = OPTIONS.filter_map { |name| [name, safe_option(model.public_send(name))] if model.respond_to?(name) }.to_h
86
+ verifier = model.instance_method(:valid_password?).source_location&.first
87
+ devise_root = Gem.loaded_specs.fetch("devise").full_gem_path + "/"
88
+ indexes = model.connection.indexes(model.table_name)
89
+ {model: safe_name(model.name), table: safe_name(model.table_name),
90
+ primary_key_type: columns[model.primary_key]&.type&.to_s,
91
+ database: model.connection.adapter_name, identifier: identifier,
92
+ identifier_type: columns[identifier]&.type&.to_s,
93
+ identifier_collation: columns[identifier]&.respond_to?(:collation) ? safe_name(columns[identifier].collation) : nil,
94
+ unique_identifier_index: indexes.any? { |index| index.unique && index.columns == [identifier] && !index.where },
95
+ modules: modules & Core::Migration::Readiness::MODULES,
96
+ unknown_modules: (modules - Core::Migration::Readiness::MODULES).length,
97
+ options: options, pepper_configured: model.respond_to?(:pepper) && !model.pepper.to_s.empty?,
98
+ custom_verifier: !verifier&.start_with?(devise_root) || !model.instance_method(:password=).source_location&.first&.start_with?(devise_root),
99
+ rails_contract: (model.name == "User" && fields.include?("email_address") && fields.include?("password_digest") && model.respond_to?(:authenticate_by) && defined?(::Session) && defined?(::Authentication)) ? true : false,
100
+ related_tables: related_tables(model),
101
+ counts: {inspected: rows.length, missing_identifier: keys.count(&:empty?), identifier_collisions: nonblank.length - nonblank.uniq.length,
102
+ unknown_password: rows.count { |row| !row[password].to_s.match?(/\A\$2[aby]\$\d{2}\$[.\/A-Za-z0-9]{53}\z/) },
103
+ unconfirmed: fields.include?("confirmed_at") ? rows.count { |row| row["confirmed_at"].nil? } : 0,
104
+ reconfirming: rows.count { |row| !row["unconfirmed_email"].to_s.empty? },
105
+ locked: rows.count { |row| row["locked_at"] }, disabled: rows.count { |row| row["disabled_at"] },
106
+ deleted: rows.count { |row| row["deleted_at"] },
107
+ outstanding_authority: rows.count { |row| %w[remember_created_at reset_password_token confirmation_token unlock_token authentication_token].any? { |key| !row[key].to_s.empty? } }}}
108
+ end
109
+
110
+ def related_tables(model)
111
+ connection = model.connection
112
+ tables = connection.tables.sort
113
+ @complete = false if tables.length > 200
114
+ tables.first(200).filter_map do |table|
115
+ next if table == model.table_name
116
+ columns = connection.columns(table).to_h { |column| [column.name, column] }
117
+ foreign_keys = connection.foreign_keys(table).select { |key| key.to_table == model.table_name }
118
+ reference = foreign_keys.first&.column || ("user_id" if model.name == "User" && columns.key?("user_id"))
119
+ next unless reference && columns.key?(reference)
120
+ quoted_table = connection.quote_table_name(table)
121
+ quote = ->(name) { connection.quote_column_name(name) }
122
+ identity_fields = %w[provider issuer subject uid] & columns.keys
123
+ selected = [reference, *identity_fields]
124
+ records = connection.select_rows("SELECT #{selected.map { |field| quote.call(field) }.join(", ")} FROM #{quoted_table} LIMIT #{@limit + 1}")
125
+ @complete = false if records.length > @limit
126
+ records = records.first(@limit)
127
+ identities = identity_fields.empty? ? [] : records.map { |row| row.drop(1) }
128
+ account_table = connection.quote_table_name(model.table_name)
129
+ key = quote.call(model.primary_key)
130
+ reference_column = quote.call(reference)
131
+ orphan_count = connection.select_value(<<~SQL).to_i
132
+ SELECT COUNT(*) FROM
133
+ (SELECT #{reference_column} FROM #{quoted_table} LIMIT #{@limit + 1}) source
134
+ LEFT JOIN #{account_table} account ON account.#{key} = source.#{reference_column}
135
+ WHERE source.#{reference_column} IS NOT NULL AND account.#{key} IS NULL
136
+ SQL
137
+ {table: safe_name(table), reference: safe_name(reference), key_type: columns[reference].type.to_s,
138
+ foreign_key_enforced: foreign_keys.any?, inspected: records.length,
139
+ orphaned_references: orphan_count, duplicate_identities: identities.length - identities.uniq.length}
140
+ end
141
+ end
142
+
143
+ def safe_name(value)
144
+ value.to_s.match?(/\A[A-Za-z_]\w*(?:::\w+)*\z/) ? value.to_s : nil
145
+ end
146
+
147
+ def safe_option(value)
148
+ case value
149
+ when nil, true, false, Numeric then value
150
+ when Symbol then safe_name(value)
151
+ when Range then [safe_option(value.begin), safe_option(value.end), value.exclude_end?]
152
+ when Array then value.map { |item| safe_option(item) }
153
+ when Hash then value.to_h { |key, item| [safe_name(key), safe_option(item)] }
154
+ else
155
+ # ActiveSupport::Duration exposes only a numeric duration.
156
+ value.is_a?(ActiveSupport::Duration) ? value.to_i : "custom"
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Migration
6
+ module KeyType
7
+ module_function
8
+
9
+ # Resolve when the migration runs, after the host's account table exists.
10
+ # Session IDs remain integer until opaque cursor support is established.
11
+ def for(connection, table, session: false)
12
+ key = connection.primary_key(table)
13
+ column = connection.columns(table).find { |entry| entry.name == key }
14
+ raise ArgumentError, "AddAuth requires a single integer or UUID primary key on #{table}" unless column
15
+ return :bigint if column.type == :integer && column.limit == 8
16
+ return :integer if column.type == :integer
17
+ return :uuid if column.type == :uuid && !session
18
+ raise ArgumentError, session ? "AddAuth requires integer Session IDs; UUID accounts can use integer Sessions" : "AddAuth requires an integer or UUID account primary key"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/migration/account_adoption"
4
+ require "add_auth/rails/stores/account_lock"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ module Migration
9
+ # Include after Devise in an expanded, reviewed source model. This does
10
+ # not select routes or retire other host credential readers at cutover.
11
+ module SourceBridge
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ before_save :add_auth_project_source_credentials
16
+ end
17
+
18
+ def valid_password?(password)
19
+ Core::Migration::AccountAdoption.source_authority?(self[:add_auth_authority]) && super
20
+ end
21
+
22
+ def active_for_authentication?
23
+ Core::Migration::AccountAdoption.source_authority?(self[:add_auth_authority]) && super
24
+ end
25
+
26
+ # Devise Lockable increments this counter through update_all before its
27
+ # later save callback. Fence that bypass on the same account row lock.
28
+ def increment_failed_attempts
29
+ self.class.transaction do
30
+ current = Stores::AccountLock.new(self.class).current_in_transaction(id: id)
31
+ raise AddAuth::Error, "source account no longer exists" unless current
32
+ Core::Migration::AccountAdoption.authorize_source_write!(authority: current[:add_auth_authority], changed_fields: [:failed_attempts])
33
+ super
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def add_auth_project_source_credentials
40
+ current = persisted? ? Stores::AccountLock.new(self.class).current_in_transaction(id: id) : self
41
+ raise AddAuth::Error, "source account no longer exists" unless current
42
+ source = %i[email encrypted_password].to_h do |name|
43
+ [name, will_save_change_to_attribute?(name) ? self[name] : current[name]]
44
+ end
45
+ attributes = Core::Migration::AccountAdoption.projection_for_write(**source,
46
+ authority: current[:add_auth_authority], authority_changed: persisted? && will_save_change_to_add_auth_authority?,
47
+ changed_fields: changed_attribute_names_to_save)
48
+ attributes.each { |name, value| self[name] = value }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/passwords/legacy_bcrypt"
4
+ require "add_auth/core/passwords/credential"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ # Include after has_secure_password. Rails owns new assignment and lookup;
9
+ # Core chooses exactly one verifier for the persisted credential profile.
10
+ module PasswordAdoption
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ self.filter_attributes += [:add_auth_password_scheme]
15
+ before_save :add_auth_retire_legacy_password
16
+ end
17
+
18
+ def authenticate_password(password)
19
+ result = Core::Passwords::Credential.new(legacy_verifier: AddAuth.configuration.legacy_password_verifier)
20
+ .authenticate(user: self, password: password, current: ->(value) { super(value) })
21
+ result.success? ? result.user : false
22
+ end
23
+
24
+ def authenticate(password) = authenticate_password(password)
25
+
26
+ private
27
+
28
+ def add_auth_retire_legacy_password
29
+ # A host's ordinary password assignment replaces the legacy credential.
30
+ # Direct migration copies do not set the virtual plaintext attribute.
31
+ self.add_auth_password_scheme = Core::Passwords::Credential.scheme_after_assignment(
32
+ scheme: add_auth_password_scheme, password: password, digest_changed: will_save_change_to_password_digest?
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end