add_auth 0.2.1

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 +7 -0
  2. data/CHANGELOG.md +125 -0
  3. data/CODE_OF_CONDUCT.md +4 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +545 -0
  6. data/ROADMAP.md +244 -0
  7. data/SECURITY.md +75 -0
  8. data/app/controllers/add_auth/assets_controller.rb +54 -0
  9. data/app/controllers/add_auth/passkeys_controller.rb +144 -0
  10. data/app/controllers/add_auth/reauthentications_controller.rb +81 -0
  11. data/app/controllers/add_auth/recoveries_controller.rb +50 -0
  12. data/app/controllers/add_auth/sessions_controller.rb +62 -0
  13. data/app/controllers/add_auth/sign_ins_controller.rb +65 -0
  14. data/app/helpers/add_auth/sign_ins_helper.rb +42 -0
  15. data/app/javascript/controllers/.keep +0 -0
  16. data/app/jobs/add_auth/delivery_job.rb +31 -0
  17. data/app/jobs/add_auth/email_delivery_job.rb +15 -0
  18. data/app/jobs/add_auth/email_request_job.rb +21 -0
  19. data/app/jobs/add_auth/security_notification_job.rb +13 -0
  20. data/app/mailers/add_auth/security_mailer.rb +16 -0
  21. data/app/mailers/add_auth/sign_in_mailer.rb +36 -0
  22. data/app/models/concerns/.keep +0 -0
  23. data/app/views/.keep +0 -0
  24. data/app/views/add_auth/passkeys/_controls.html.erb +17 -0
  25. data/app/views/add_auth/passkeys/_list.html.erb +32 -0
  26. data/app/views/add_auth/reauthentications/_check_email.html.erb +3 -0
  27. data/app/views/add_auth/reauthentications/_confirmation.html.erb +7 -0
  28. data/app/views/add_auth/reauthentications/_different_browser.html.erb +3 -0
  29. data/app/views/add_auth/reauthentications/_form.html.erb +27 -0
  30. data/app/views/add_auth/reauthentications/_invalid_link.html.erb +3 -0
  31. data/app/views/add_auth/recoveries/_check_email.html.erb +3 -0
  32. data/app/views/add_auth/recoveries/_confirmation.html.erb +9 -0
  33. data/app/views/add_auth/recoveries/_form.html.erb +11 -0
  34. data/app/views/add_auth/recoveries/_invalid_link.html.erb +3 -0
  35. data/app/views/add_auth/security_mailer/notice.text.erb +3 -0
  36. data/app/views/add_auth/sessions/_list.html.erb +25 -0
  37. data/app/views/add_auth/sessions/_revoke_all.html.erb +18 -0
  38. data/app/views/add_auth/sessions/index.html.erb +12 -0
  39. data/app/views/add_auth/sign_in_mailer/link.text.erb +5 -0
  40. data/app/views/add_auth/sign_ins/_check_email.html.erb +5 -0
  41. data/app/views/add_auth/sign_ins/_confirmation.html.erb +9 -0
  42. data/app/views/add_auth/sign_ins/_different_browser.html.erb +3 -0
  43. data/app/views/add_auth/sign_ins/_form.html.erb +34 -0
  44. data/app/views/add_auth/sign_ins/_invalid_link.html.erb +3 -0
  45. data/app/views/add_auth/sign_ins/show.html.erb +1 -0
  46. data/app/views/layouts/add_auth/authentication.html.erb +19 -0
  47. data/lib/add_auth/configuration.rb +61 -0
  48. data/lib/add_auth/core/access_policy.rb +55 -0
  49. data/lib/add_auth/core/browser_binding.rb +28 -0
  50. data/lib/add_auth/core/challenge/base.rb +100 -0
  51. data/lib/add_auth/core/challenge/http.rb +130 -0
  52. data/lib/add_auth/core/challenge/null.rb +21 -0
  53. data/lib/add_auth/core/challenge/recaptcha.rb +76 -0
  54. data/lib/add_auth/core/challenge/test.rb +32 -0
  55. data/lib/add_auth/core/challenge/turnstile.rb +46 -0
  56. data/lib/add_auth/core/delivery.rb +55 -0
  57. data/lib/add_auth/core/digest/base.rb +34 -0
  58. data/lib/add_auth/core/digest/hmac.rb +41 -0
  59. data/lib/add_auth/core/intake.rb +59 -0
  60. data/lib/add_auth/core/maintenance.rb +45 -0
  61. data/lib/add_auth/core/rate_limit.rb +30 -0
  62. data/lib/add_auth/core/security_events.rb +48 -0
  63. data/lib/add_auth/core/sessions.rb +287 -0
  64. data/lib/add_auth/core/step_up.rb +129 -0
  65. data/lib/add_auth/core/strategies/email_link.rb +201 -0
  66. data/lib/add_auth/core/strategies/passkey.rb +286 -0
  67. data/lib/add_auth/rails/authentication.rb +67 -0
  68. data/lib/add_auth/rails/authentication_pages.rb +66 -0
  69. data/lib/add_auth/rails/delivery_cipher.rb +33 -0
  70. data/lib/add_auth/rails/doctor.rb +154 -0
  71. data/lib/add_auth/rails/ejection.rb +138 -0
  72. data/lib/add_auth/rails/elevation.rb +35 -0
  73. data/lib/add_auth/rails/engine.rb +34 -0
  74. data/lib/add_auth/rails/password_entry.rb +34 -0
  75. data/lib/add_auth/rails/runtime.rb +216 -0
  76. data/lib/add_auth/rails/stores/account_lock.rb +40 -0
  77. data/lib/add_auth/rails/stores/delivery_state.rb +21 -0
  78. data/lib/add_auth/rails/stores/email_tokens.rb +92 -0
  79. data/lib/add_auth/rails/stores/maintenance.rb +46 -0
  80. data/lib/add_auth/rails/stores/passkeys.rb +63 -0
  81. data/lib/add_auth/rails/stores/security_events.rb +40 -0
  82. data/lib/add_auth/rails/stores/sessions.rb +62 -0
  83. data/lib/add_auth/rails/user_lifecycle.rb +35 -0
  84. data/lib/add_auth/result.rb +55 -0
  85. data/lib/add_auth/testing.rb +30 -0
  86. data/lib/add_auth/version.rb +5 -0
  87. data/lib/add_auth.rb +52 -0
  88. data/lib/generators/add_auth/challenge/challenge_generator.rb +48 -0
  89. data/lib/generators/add_auth/challenge/templates/recaptcha.rb.tt +28 -0
  90. data/lib/generators/add_auth/challenge/templates/turnstile.rb.tt +24 -0
  91. data/lib/generators/add_auth/controllers/controllers_generator.rb +14 -0
  92. data/lib/generators/add_auth/ejection.rb +18 -0
  93. data/lib/generators/add_auth/email_link/email_link_generator.rb +56 -0
  94. data/lib/generators/add_auth/email_link/templates/add_add_auth_email_binding.rb.tt +5 -0
  95. data/lib/generators/add_auth/email_link/templates/add_add_auth_email_delivery.rb.tt +10 -0
  96. data/lib/generators/add_auth/email_link/templates/add_auth.css +28 -0
  97. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +129 -0
  98. data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +35 -0
  99. data/lib/generators/add_auth/email_tokens/templates/add_auth_sign_in_token.rb +4 -0
  100. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +18 -0
  101. data/lib/generators/add_auth/install/install_generator.rb +27 -0
  102. data/lib/generators/add_auth/install/templates/initializer.rb +46 -0
  103. data/lib/generators/add_auth/javascript/javascript_generator.rb +14 -0
  104. data/lib/generators/add_auth/javascript/templates/application.js +2 -0
  105. data/lib/generators/add_auth/javascript/templates/codec.js +28 -0
  106. data/lib/generators/add_auth/javascript/templates/passkey.js +81 -0
  107. data/lib/generators/add_auth/mailer_views/mailer_views_generator.rb +14 -0
  108. data/lib/generators/add_auth/notifications/notifications_generator.rb +32 -0
  109. data/lib/generators/add_auth/notifications/templates/add_auth_security_event.rb +4 -0
  110. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +18 -0
  111. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +75 -0
  112. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +45 -0
  113. data/lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb +4 -0
  114. data/lib/generators/add_auth/passkeys/templates/add_auth_credential.rb +4 -0
  115. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +79 -0
  116. data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt +12 -0
  117. data/lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt +14 -0
  118. data/lib/generators/add_auth/step_up/step_up_generator.rb +44 -0
  119. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +9 -0
  120. data/lib/generators/add_auth/views/views_generator.rb +16 -0
  121. data/lib/tasks/add_auth.rake +38 -0
  122. metadata +361 -0
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/ejection"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ class Doctor
8
+ attr_reader :ejections
9
+
10
+ SESSION_COLUMNS = %w[user_id token_digest authenticated_with authenticated_at expires_at last_seen_at revoked_at
11
+ elevated_at elevated_with elevation_purpose elevation_credential_id elevation_uv].freeze
12
+ TOKEN_COLUMNS = %w[user_id digest identifier_digest purpose expires_at consumed_at revoked_at delivery_payload
13
+ request_id delivery_lease_key delivery_lease_until delivered_at].freeze
14
+
15
+ def call
16
+ @problems = []
17
+ config = AddAuth.configuration
18
+ check("Configure valid maintenance batch size and retention durations") do
19
+ Core::Maintenance.new(stores: {}, options: config.maintenance, enqueue: ->(*) {})
20
+ end
21
+ if config.session.enabled
22
+ columns(::Session, SESSION_COLUMNS, "session")
23
+ unique_index(::Session, "token_digest")
24
+ unique_index(::User, "email_address")
25
+ check("Include AddAuth authentication after the host Authentication concern") do
26
+ ::ApplicationController.instance_method(:find_session_by_cookie).owner == Authentication
27
+ end
28
+ %i[add_auth_write_cookie add_auth_accept add_auth_replacement_session terminate_session require_add_auth_authentication].each do |hook|
29
+ check("Restore the hardened cookie/session hook #{hook}") do
30
+ ::ApplicationController.instance_method(hook).owner == Authentication
31
+ end
32
+ end
33
+ check("Include AddAuth::Rails::UserLifecycle in User") { ::User < UserLifecycle }
34
+ check("Set passwords_enabled to true or false") { [true, false].include?(config.passwords_enabled) }
35
+ if config.passwords_enabled || defined?(::SessionsController)
36
+ check("Install the shared password entry in SessionsController") { ::SessionsController < PasswordEntry }
37
+ end
38
+ if config.passwords_enabled
39
+ check("Keep Rails password authentication on User when passwords are enabled") do
40
+ ::User.column_names.include?("password_digest") && ::User.respond_to?(:authenticate_by)
41
+ end
42
+ end
43
+ %w[/sign-in /add_auth.css /add_auth/boot.js /add_auth/turbo.js /add_auth/stimulus.js /add_auth/challenge.js /sessions].each do |path|
44
+ check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
45
+ end
46
+ check("Configure valid session timeouts and digest adapters") { Runtime.sessions }
47
+ cache = config.rate_limit_store || ::Rails.cache
48
+ check("Configure an atomic rate-limit cache") do
49
+ key = "add_auth:doctor:#{SecureRandom.hex(16)}"
50
+ begin
51
+ cache.increment(key, 1, expires_in: 30, initial: 0) == 1 && cache.increment(key, 1, expires_in: 30, initial: 0) == 2
52
+ ensure
53
+ cache.delete(key)
54
+ end
55
+ end
56
+ if ::Rails.env.production?
57
+ check("Enable CSRF protection for authentication") { ::ApplicationController.allow_forgery_protection }
58
+ check("Use a shared rate-limit cache in production") do
59
+ !cache.is_a?(ActiveSupport::Cache::MemoryStore) && !cache.is_a?(ActiveSupport::Cache::NullStore)
60
+ end
61
+ end
62
+ end
63
+ if config.email_link.enabled
64
+ check("Enable session_upgrade before email_link") { config.session.enabled }
65
+ check("Configure base_url as a fixed HTTPS origin (HTTP only outside production)") { Runtime.sign_in_url("validation") }
66
+ columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken, TOKEN_COLUMNS, "email delivery")
67
+ if defined?(::AddAuthSignInToken)
68
+ unique_index(::AddAuthSignInToken, "digest")
69
+ unique_index(::AddAuthSignInToken, "request_id")
70
+ end
71
+ if config.email_link.same_browser
72
+ columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken, ["browser_digest"], "email browser binding")
73
+ end
74
+ end
75
+ if config.email_link.enabled || config.notifications.enabled
76
+ check("Configure mail_from") { config.mail_from.is_a?(String) && !config.mail_from.strip.empty? }
77
+ check("Enable Action Mailer delivery") { ActionMailer::Base.perform_deliveries && ActionMailer::Base.raise_delivery_errors }
78
+ if ::Rails.env.production?
79
+ check("Use a durable job adapter in production") { !ActiveJob::Base.queue_adapter.class.name.match?(/Async|Inline|Test/) }
80
+ check("Configure production mail delivery") { !%i[test file].include?(ActionMailer::Base.delivery_method) }
81
+ end
82
+ end
83
+ maintenance_needed = config.passkeys.enabled || config.email_link.enabled || config.notifications.enabled || config.maintenance.session_retention
84
+ if ::Rails.env.production? && maintenance_needed
85
+ check("Schedule add_auth:deliver_pending every minute; no successful cleanup in the last two minutes") { Runtime.maintenance_current? }
86
+ end
87
+ if config.passkeys.enabled
88
+ check("Configure passkey prerequisites, RP ID, exact origins, anonymous_limit and a safe support path") { Runtime.passkeys }
89
+ columns(::User, %w[webauthn_id add_auth_strict add_auth_policy_version], "passkeys")
90
+ columns(::Session, %w[authentication_policy_version authentication_credential_id authentication_uv], "passkeys")
91
+ columns(defined?(::AddAuthCredential) && ::AddAuthCredential,
92
+ %w[user_id external_id public_key sign_count revoked_at backup_eligible backup_state], "passkeys")
93
+ columns(defined?(::AddAuthCeremony) && ::AddAuthCeremony,
94
+ %w[digest challenge kind browser_digest configuration_digest session_id session_digest expires_at consumed_at], "passkeys")
95
+ unique_index(::User, "webauthn_id")
96
+ unique_index(::AddAuthCredential, "external_id") if defined?(::AddAuthCredential)
97
+ unique_index(::AddAuthCeremony, "digest") if defined?(::AddAuthCeremony)
98
+ check("Supply a trusted recovery address callback") { config.trusted_recovery_address.respond_to?(:call) }
99
+ %w[/passkeys /recover /add_auth/passkey.js /add_auth/codec.js].each do |path|
100
+ check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
101
+ end
102
+ check("Keep a support route for strict accounts") do
103
+ !::User.exists?(add_auth_strict: true) || Core::Sessions.safe_return(config.support_url)
104
+ end
105
+ end
106
+ if config.notifications.enabled
107
+ columns(defined?(::AddAuthSecurityEvent) && ::AddAuthSecurityEvent,
108
+ %w[kind digest delivery_payload delivery_lease_key delivery_lease_until delivered_at revoked_at expires_at], "security notifications")
109
+ check("Enable security notification delivery errors") { ::AddAuth::SecurityMailer.raise_delivery_errors }
110
+ end
111
+ if config.step_up.enabled
112
+ check("Enable session and email prerequisites for step_up") { config.session.enabled && config.email_link.enabled }
113
+ columns(::Session, %w[elevation_version elevation_expires_at], "reauthentication")
114
+ columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken,
115
+ %w[browser_digest session_id session_digest authentication_purpose], "reauthentication")
116
+ check("Declare valid step-up purposes and safe return destinations") { Runtime.step_up_purposes.any? && Runtime.step_up_policy }
117
+ %w[/reauthenticate /reauthenticate/link].each do |path|
118
+ check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
119
+ end
120
+ end
121
+ if config.challenge_on.any?
122
+ check("Unknown challenge actions") { (config.challenge_on - %i[sign_in email_link reauthenticate passkey_enrollment]).empty? }
123
+ check("Configure a challenge provider before setting challenge_on") { !config.challenge.is_a?(Core::Challenge::Null) }
124
+ if ::Rails.env.production?
125
+ check("Configure challenge hostname restrictions") { config.challenge.allowed_hostnames&.any? }
126
+ end
127
+ end
128
+ check("Review the ejection manifest and changed upstream files") do
129
+ @ejections = Ejection.new(host_root: ::Rails.root).report
130
+ @ejections.none? { |entry| entry[:missing] || entry[:upstream_changed] }
131
+ end
132
+ @problems
133
+ end
134
+
135
+ private
136
+
137
+ def columns(model, required, feature)
138
+ check("Run pending #{feature} migrations (required: #{required.join(", ")})") { model && (required - model.column_names).empty? }
139
+ end
140
+
141
+ def unique_index(model, column)
142
+ check("Add a unique #{model.name}.#{column} index") do
143
+ model.connection.indexes(model.table_name).any? { |index| index.unique && index.columns == [column] && !index.where }
144
+ end
145
+ end
146
+
147
+ def check(message)
148
+ @problems << message unless yield
149
+ rescue
150
+ @problems << message
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+
7
+ module AddAuth
8
+ module Rails
9
+ class Ejection
10
+ MANIFEST = "config/add_auth-ejections.json"
11
+ VIEW_GROUPS = {"email_link" => %w[sign_ins], "sessions" => %w[sessions],
12
+ "step_up" => %w[reauthentications], "passkeys" => %w[passkeys recoveries]}.freeze
13
+
14
+ def initialize(host_root:, engine_root: File.expand_path("../../..", __dir__))
15
+ @host, @engine = host_root.to_s, engine_root.to_s
16
+ end
17
+
18
+ def files(kind:, only: "all")
19
+ case kind
20
+ when :views
21
+ groups = (only == "all") ? VIEW_GROUPS.keys : only.split(",")
22
+ raise ArgumentError, "unknown view group" unless (groups - VIEW_GROUPS.keys).empty?
23
+ groups.flat_map { |group| VIEW_GROUPS.fetch(group) }.flat_map do |directory|
24
+ glob("app/views/add_auth/#{directory}/**/*.*erb")
25
+ end.concat(glob("app/views/layouts/add_auth/**/*.*erb")).uniq.to_h { |path| [path, path] }
26
+ when :controllers
27
+ glob("app/controllers/add_auth/*_controller.rb").reject { |path| path.end_with?("assets_controller.rb") }.to_h { |path| [path, path] }
28
+ when :javascript
29
+ %w[application codec passkey].to_h do |name|
30
+ ["app/javascript/add_auth/#{name}.js", "lib/generators/add_auth/javascript/templates/#{name}.js"]
31
+ end.merge("app/javascript/add_auth/challenge.js" => "lib/generators/add_auth/email_link/templates/add_auth_challenge.js")
32
+ when :mailer_views
33
+ glob("app/views/add_auth/*_mailer/*.*erb").to_h { |path| [path, path] }
34
+ else
35
+ raise ArgumentError, "unknown ejection kind"
36
+ end
37
+ end
38
+
39
+ def install(kind:, only: "all")
40
+ manifest = read_manifest
41
+ results = files(kind: kind, only: only).map do |destination, source|
42
+ body = File.read(File.join(@engine, source))
43
+ generated = decorate(body, destination)
44
+ path = File.join(@host, destination)
45
+ existed = File.exist?(path)
46
+ unless existed
47
+ FileUtils.mkdir_p(File.dirname(path))
48
+ File.write(path, generated)
49
+ end
50
+ # Reruns never advance the baseline of a file already owned by a host.
51
+ manifest[destination] ||= {"version" => AddAuth::VERSION, "source" => source,
52
+ "source_hash" => hash(body), "generated_hash" => hash(generated), "baseline" => body}
53
+ {path: destination, preserved: existed}
54
+ end
55
+ path = File.join(@host, MANIFEST)
56
+ FileUtils.mkdir_p(File.dirname(path))
57
+ temporary = "#{path}.#{Process.pid}.tmp"
58
+ File.write(temporary, JSON.pretty_generate({"files" => manifest}) + "\n")
59
+ File.rename(temporary, path)
60
+ results
61
+ ensure
62
+ FileUtils.rm_f(temporary) if temporary
63
+ end
64
+
65
+ def report
66
+ inventory = %i[views controllers javascript mailer_views].each_with_object({}) { |kind, all| all.merge!(files(kind: kind)) }
67
+ read_manifest.map do |destination, entry|
68
+ unless inventory[destination] == entry["source"] && entry["baseline"].is_a?(String) && hash(entry["baseline"]) == entry["source_hash"]
69
+ raise AddAuth::Error, "invalid ejection manifest entry"
70
+ end
71
+ source = File.read(File.join(@engine, entry.fetch("source")))
72
+ path = File.join(@host, destination)
73
+ upstream_changed = hash(source) != entry.fetch("source_hash")
74
+ {path: destination, missing: !File.file?(path), customized: File.file?(path) && hash(File.read(path)) != entry["generated_hash"],
75
+ upstream_changed: upstream_changed,
76
+ diff: upstream_changed ? difference(destination, entry.fetch("baseline"), source, entry.fetch("version")) : nil}
77
+ end
78
+ end
79
+
80
+ def asset(name)
81
+ destination = "app/javascript/add_auth/#{name}.js"
82
+ source = files(kind: :javascript).fetch(destination)
83
+ host_path = File.join(@host, destination)
84
+ File.file?(host_path) ? host_path : File.join(@engine, source)
85
+ end
86
+
87
+ private
88
+
89
+ def glob(pattern)
90
+ Dir.glob(File.join(@engine, pattern)).sort.map { |path| path.delete_prefix(@engine + "/") }
91
+ end
92
+
93
+ def hash(body) = ::Digest::SHA256.hexdigest(body)
94
+
95
+ def decorate(body, path)
96
+ text = "AddAuth ejection: #{AddAuth::VERSION}; source SHA256: #{hash(body)}"
97
+ prefix = if path.end_with?(".erb")
98
+ "<%# #{text} %>"
99
+ elsif path.end_with?(".js")
100
+ "// #{text}"
101
+ else
102
+ "# #{text}"
103
+ end
104
+ "#{prefix}\n#{body}"
105
+ end
106
+
107
+ def read_manifest
108
+ path = File.join(@host, MANIFEST)
109
+ return {} unless File.exist?(path)
110
+ raise AddAuth::Error, "ejection manifest is too large" if File.size(path) > 5_000_000
111
+ value = JSON.parse(File.read(path)).fetch("files")
112
+ raise AddAuth::Error, "invalid ejection manifest" unless value.is_a?(Hash) && value.values.all? { |entry| entry.is_a?(Hash) }
113
+ value
114
+ rescue JSON::ParserError, KeyError
115
+ raise AddAuth::Error, "invalid ejection manifest"
116
+ end
117
+
118
+ # A valid unified replacement hunk, trimmed to three unchanged context
119
+ # lines at each end. Compare pristine upstream versions, never host secrets.
120
+ def difference(path, before, after, version)
121
+ old, current = before.lines, after.lines
122
+ prefix = 0
123
+ prefix += 1 while prefix < [old.size, current.size].min && old[prefix] == current[prefix]
124
+ suffix = 0
125
+ suffix += 1 while suffix < [old.size, current.size].min - prefix && old[-suffix - 1] == current[-suffix - 1]
126
+ first = [prefix - 3, 0].max
127
+ tail = [suffix - 3, 0].max
128
+ old_part = old[first...(old.size - tail)]
129
+ new_part = current[first...(current.size - tail)]
130
+ old_start = old_part.empty? ? first : first + 1
131
+ new_start = new_part.empty? ? first : first + 1
132
+ header = "--- #{path} (#{version})\n+++ #{path} (#{AddAuth::VERSION})\n"
133
+ header += "@@ -#{old_start},#{old_part.size} +#{new_start},#{new_part.size} @@\n"
134
+ header + old_part.map { |line| "-#{line}" }.join + new_part.map { |line| "+#{line}" }.join
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/runtime"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Elevation
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # A navigation guard, not mutation authorization. Use the block helper
12
+ # below for writes, alongside the host's resource authorization.
13
+ def require_elevated_session(purpose:, **options)
14
+ before_action(**options) { add_auth_require_elevation(purpose) }
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def with_elevated_session(purpose:, &block)
21
+ raise ArgumentError, "a database mutation block is required" unless block
22
+ Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
23
+ purpose: purpose, policy: Runtime.step_up_policy, &block)
24
+ end
25
+
26
+ def add_auth_require_elevation(purpose)
27
+ return require_authentication unless authenticated?
28
+ result = Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
29
+ purpose: purpose, policy: Runtime.step_up_policy)
30
+ return if result.success?
31
+ redirect_to "/reauthenticate?#{URI.encode_www_form(purpose: purpose)}", status: request.get? ? :found : :see_other
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "turbo-rails"
4
+ require "add_auth/rails/authentication_pages"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ # Non-isolated: host controllers/views remain in the host namespace.
9
+ class Engine < ::Rails::Engine
10
+ engine_name "add_auth"
11
+
12
+ 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
+ end
15
+
16
+ config.to_prepare do
17
+ if AddAuth.configuration.session.enabled
18
+ require "add_auth/rails/password_entry"
19
+ require "add_auth/rails/elevation"
20
+ ::ApplicationController.include AddAuth::Rails::Elevation
21
+ if AddAuth.configuration.passwords_enabled || defined?(::SessionsController)
22
+ ::SessionsController.include AddAuth::Rails::PasswordEntry
23
+ end
24
+ end
25
+ end
26
+
27
+ initializer "add_auth.configuration", before: :load_config_initializers do |app|
28
+ AddAuth.configuration.digest_secret ||= -> {
29
+ app.key_generator.generate_key("add_auth.digest-base-secret.v1", 32)
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/authentication_pages"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ # Protects every route to the generated SessionsController#create, including
8
+ # host aliases. Keep the host file intact, but make the shared entry point the
9
+ # authority. Verify CSRF explicitly before rendering halts later callbacks.
10
+ module PasswordEntry
11
+ extend ActiveSupport::Concern
12
+ include AuthenticationPages
13
+
14
+ included do
15
+ helper AddAuth::SignInsHelper
16
+ protect_from_forgery with: :exception
17
+ prepend_before_action :add_auth_password_entry, only: %i[new create]
18
+ rescue_from AddAuth::Error, with: :service_unavailable
19
+ end
20
+
21
+ private
22
+
23
+ def add_auth_password_entry
24
+ if action_name == "new"
25
+ private_response
26
+ return page("form")
27
+ end
28
+ verify_authenticity_token
29
+ private_response
30
+ add_auth_password_sign_in
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "add_auth/rails/stores/sessions"
5
+ require "add_auth/rails/stores/email_tokens"
6
+ require "add_auth/rails/delivery_cipher"
7
+
8
+ module AddAuth
9
+ module Rails
10
+ module Runtime
11
+ module_function
12
+
13
+ def config = AddAuth.configuration
14
+
15
+ def sessions
16
+ Core::Sessions.new(store: Stores::Sessions.new(user_model: ::User, session_model: ::Session),
17
+ digest: config.session_token_digest, eligible: config.eligible, lifetime: config.session.lifetime,
18
+ idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy)
19
+ end
20
+
21
+ def security_events
22
+ raise AddAuth::Error, "enable notifications first" unless config.notifications.enabled
23
+ require "add_auth/rails/stores/security_events"
24
+ Core::SecurityEvents.new(store: Stores::SecurityEvents.new(user_model: ::User, event_model: ::AddAuthSecurityEvent),
25
+ digest: config.sign_in_token_digest, delivery_cipher: DeliveryCipher.new(key: key("security-notifications")))
26
+ end
27
+
28
+ def access_policy
29
+ Core::AccessPolicy.new(credentials: ->(id) {
30
+ ::AddAuthCredential.find_by(external_id: id) if defined?(::AddAuthCredential) && ::AddAuthCredential.table_exists?
31
+ }, passkeys_enabled: config.passkeys.enabled, email_enabled: Core::Intake.email_available?(config),
32
+ trusted_recovery_address: config.trusted_recovery_address, password_enabled: config.passwords_enabled)
33
+ end
34
+
35
+ def email(purpose: :sign_in)
36
+ if purpose.to_s != "sign_in" && !config.step_up.enabled
37
+ raise AddAuth::Error, "enable step_up first"
38
+ end
39
+ raise AddAuth::Error, "enable session_upgrade and email_link first" unless Core::Intake.email_available?(config)
40
+ Core::Strategies::EmailLink.new(store: Stores::EmailTokens.new(user_model: ::User,
41
+ token_model: ::AddAuthSignInToken, session_model: ::Session), digest: config.sign_in_token_digest,
42
+ delivery_cipher: DeliveryCipher.new(key: key("delivery")), eligible: config.eligible,
43
+ normalize_identifier: method(:normalize), identifier_for: ->(user) { user.email_address },
44
+ token_lifetime: {"reauthentication" => 300, "recovery" => 1200}.fetch(purpose.to_s, config.email_link.token_lifetime),
45
+ same_browser: (purpose.to_s == "recovery") ? false : config.email_link.same_browser, purpose: purpose,
46
+ proof_allowed: ->(user) { (purpose.to_s == "recovery") ? access_policy.recoverable?(user) && config.passkeys.enabled : purpose.to_s != "sign_in" || access_policy.sign_in_allowed?(user, :email_link) },
47
+ sessions: (purpose.to_s == "sign_in") ? nil : sessions,
48
+ policy: (purpose.to_s == "sign_in") ? nil : step_up_policy)
49
+ end
50
+
51
+ def revoke_all(user:, session:, **proof)
52
+ policy = step_up_policy
53
+ current = sessions.with_elevation(user: user, session: session, purpose: :sign_out_everywhere, policy: policy)
54
+ if current.success?
55
+ count = sessions.revoke_all(user: user, session: session, grant: current.credential)
56
+ return count ? Result.success(user: user, strategy: :step_up) : Result.failure(reason: :elevation_required)
57
+ end
58
+ authorization = reauthenticate(user: user, session: session, **proof)
59
+ return authorization unless authorization.success?
60
+ count = sessions.revoke_all(user: user, session: session, grant: authorization.credential)
61
+ count ? Result.success(user: user, strategy: :step_up) : Result.failure(reason: :elevation_required)
62
+ end
63
+
64
+ def reauthenticate(user:, session:, password:, ip:, challenge_token:)
65
+ admitted = intake.call(identifier: user.email_address, ip: ip, action: :reauthenticate, challenge_token: challenge_token)
66
+ return Result.failure(reason: admitted) if admitted.is_a?(Symbol)
67
+ policy = config.step_up.enabled ? step_up_policy : Core::StepUp.new(purposes: {sign_out_everywhere: {methods: [:password]}})
68
+ sessions.reauthenticate(user: user, session: session, purpose: :sign_out_everywhere, policy: policy) do |account|
69
+ authenticate_password(identifier: account.email_address, password: password)
70
+ end
71
+ end
72
+
73
+ def step_up_purposes
74
+ defaults = if config.passkeys.enabled
75
+ {
76
+ manage_passkeys: {methods: [:password, :email_link, :passkey], return_to: "/passkeys", label: "manage your passkeys"},
77
+ manage_policy: {methods: [:passkey], require_passkey: true, return_to: "/passkeys", label: "change your recovery policy"},
78
+ recover_passkeys: {methods: [:email_link], return_to: "/passkeys", label: "replace a lost passkey", reauthentication: false}
79
+ }
80
+ else
81
+ {}
82
+ end
83
+ {sign_out_everywhere: {methods: [:password, :email_link, :passkey], return_to: "/sessions/revoke-all", label: "sign out everywhere"}}.merge(defaults).merge(config.step_up.purposes)
84
+ end
85
+
86
+ def passkeys
87
+ raise AddAuth::Error, "enable passkeys and their prerequisites first" unless config.passkeys.enabled && config.step_up.enabled && config.notifications.enabled
88
+ require "add_auth/rails/stores/passkeys"
89
+ Core::Strategies::Passkey.new(store: Stores::Passkeys.new(user_model: ::User, session_model: ::Session,
90
+ credential_model: ::AddAuthCredential, ceremony_model: ::AddAuthCeremony, token_model: ::AddAuthSignInToken), sessions: sessions,
91
+ policy: step_up_policy, access_policy: access_policy, digest: config.sign_in_token_digest,
92
+ eligible: config.eligible, rp_id: config.passkeys.rp_id, origins: config.passkeys.origins, name: config.passkeys.name,
93
+ limiter: method(:limit), anonymous_limit: config.passkeys.anonymous_limit,
94
+ notify: ->(**event) { security_events.issue(**event) }, allow_localhost: !::Rails.env.production?, support_url: config.support_url,
95
+ on_failure: ->(reason) { ::ActiveSupport::Notifications.instrument("passkey_failure.add_auth", reason: reason) })
96
+ end
97
+
98
+ def step_up_policy
99
+ Core::StepUp.new(purposes: config.step_up.enabled ? step_up_purposes : {}, fresh_for: config.step_up.fresh_for,
100
+ strong_for: config.step_up.strong_for,
101
+ password_version: ->(user) { config.sign_in_token_digest.digest("password:#{user.password_digest}") },
102
+ credential_current: ->(user:, evidence:) {
103
+ evidence.method == :password ||
104
+ (evidence.method == :passkey && access_policy.credential_current?(user: user, id: evidence.credential_id) &&
105
+ ::AddAuthCredential.find_by(external_id: evidence.credential_id)&.public_key == evidence.credential_version) ||
106
+ (evidence.method == :email_link && Core::Intake.email_available?(config) &&
107
+ config.sign_in_token_digest.matches?(evidence.credential_version, "identifier:#{normalize(user.email_address)}"))
108
+ }, methods_available: ->(user) { access_policy.methods_for(user) })
109
+ end
110
+
111
+ def elevate_password(user:, session:, purpose:, password:, ip:, challenge_token:)
112
+ return Result.failure(reason: :elevation_required) unless step_up_policy.reauthentication_rule_for(purpose)
113
+ admitted = intake.call(identifier: user.email_address, ip: ip, action: :reauthenticate, challenge_token: challenge_token)
114
+ return Result.failure(reason: admitted) if admitted.is_a?(Symbol)
115
+ proof = sessions.reauthenticate(user: user, session: session, purpose: purpose, policy: step_up_policy) do |account|
116
+ authenticate_password(identifier: account.email_address, password: password)
117
+ end
118
+ return proof unless proof.success?
119
+ grant = sessions.rotate_for_step_up(user: user, session: session, grant: proof.credential)
120
+ grant ? Result.success(user: user, strategy: :step_up, session: grant.session, credential: grant) : Result.failure(reason: :elevation_required)
121
+ end
122
+
123
+ def enqueue_recovery(identifier) = enqueue_email_payload({identifier: identifier, purpose: "recovery"})
124
+
125
+ def enqueue_reauthentication(user:, session:, purpose:, browser_secret:)
126
+ payload = {identifier: user.email_address, purpose: "reauthentication", authentication_purpose: purpose.to_s,
127
+ session_id: session.id, session_digest: session.token_digest, browser_digest: browser_binding.digest(browser_secret)}
128
+ enqueue_email_payload(payload)
129
+ end
130
+
131
+ def authenticate_password(identifier:, password:)
132
+ return unless config.passwords_enabled && ::User.respond_to?(:authenticate_by)
133
+ ::User.authenticate_by(email_address: identifier, password: password.is_a?(String) ? password : "")
134
+ end
135
+
136
+ def sign_in_path = "/sign-in"
137
+
138
+ def normalize(value) = ::User.normalize_value_for(:email_address, value)
139
+ def key(purpose) = ::Rails.application.key_generator.generate_key("add_auth.#{purpose}.v1", 32)
140
+
141
+ def intake_cipher
142
+ ActiveSupport::MessageEncryptor.new(key("intake"), cipher: "aes-256-gcm", serializer: :json)
143
+ end
144
+
145
+ def browser_binding = Core::BrowserBinding.new(digest: config.sign_in_token_digest)
146
+
147
+ def enqueue_email(identifier, browser_secret: nil)
148
+ payload = {identifier: identifier, browser_digest: email.browser_digest(browser_secret)}
149
+ enqueue_email_payload(payload)
150
+ end
151
+
152
+ def enqueue_email_payload(payload)
153
+ job = ::AddAuth::EmailRequestJob.perform_later(encrypt_intake(payload))
154
+ raise AddAuth::Error, "sign-in queue unavailable" unless job
155
+ job
156
+ rescue
157
+ raise AddAuth::Error, "sign-in queue unavailable", cause: nil
158
+ end
159
+
160
+ def encrypt_intake(identifier)
161
+ intake_cipher.encrypt_and_sign(identifier, purpose: "sign_in_request", expires_in: 300)
162
+ end
163
+
164
+ def decrypt_intake(payload)
165
+ intake_cipher.decrypt_and_verify(payload, purpose: "sign_in_request")
166
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
167
+ nil
168
+ end
169
+
170
+ def intake
171
+ Core::Intake.new(digest: config.sign_in_token_digest, normalizer: method(:normalize),
172
+ limiter: method(:limit), challenge: config.challenge, challenge_on: config.challenge_on,
173
+ challenge_when_unavailable: config.challenge_when_unavailable,
174
+ on_challenge_unavailable: method(:record_challenge_bypass))
175
+ end
176
+
177
+ def record_challenge_bypass(action:)
178
+ ::ActiveSupport::Notifications.instrument("challenge_bypass.add_auth", action: action)
179
+ end
180
+
181
+ def limit(key:, limit:)
182
+ Core::RateLimit.new(counter: ->(key:, expires_in:) {
183
+ rate_limit_cache.increment(key, 1, expires_in: expires_in, initial: 0)
184
+ }).call(key: key, limit: limit)
185
+ rescue
186
+ raise AddAuth::Error, "rate limit store unavailable", cause: nil
187
+ end
188
+
189
+ def rate_limit_cache = config.rate_limit_store || ::Rails.cache
190
+
191
+ def maintenance_cache_key = "add_auth:maintenance:v1:#{config.sign_in_token_digest.digest("last-success")}"
192
+
193
+ def record_maintenance
194
+ unless rate_limit_cache.write(maintenance_cache_key, Time.now.to_i, expires_in: 180)
195
+ raise AddAuth::Error, "maintenance heartbeat store unavailable"
196
+ end
197
+ end
198
+
199
+ def maintenance_current?
200
+ completed = rate_limit_cache.read(maintenance_cache_key)
201
+ completed.is_a?(Integer) && (0..120).cover?(Time.now.to_i - completed)
202
+ end
203
+
204
+ def sign_in_url(token, purpose: "sign_in")
205
+ uri = URI.parse(config.base_url.to_s)
206
+ valid = uri.host && !uri.userinfo && !uri.query && !uri.fragment && ["", "/"].include?(uri.path) &&
207
+ (uri.scheme == "https" || (!::Rails.env.production? && uri.scheme == "http"))
208
+ unless valid
209
+ raise AddAuth::Error, "configure base_url as a fixed HTTPS origin (HTTP allowed only outside production)"
210
+ end
211
+ path = {"sign_in" => "/sign-in/link", "reauthentication" => "/reauthenticate/link", "recovery" => "/recover/link"}.fetch(purpose.to_s)
212
+ "#{uri.to_s.delete_suffix("/")}#{path}?token=#{URI.encode_www_form_component(token)}"
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Stores
6
+ class AccountLock
7
+ def initialize(user_model)
8
+ @users = user_model
9
+ end
10
+
11
+ def call(id:, additional_id: nil)
12
+ return yield nil unless id
13
+ if @users.connection.transaction_open?
14
+ raise AddAuth::Error, "authentication must own its transaction, not run inside an outer transaction"
15
+ end
16
+ completed = false
17
+ result = nil
18
+ @users.uncached do
19
+ @users.transaction(requires_new: true) do |transaction|
20
+ transaction.after_commit { completed = true }
21
+ # Opposing account switches acquire their locks in the same order.
22
+ accounts = [id, additional_id].compact.uniq.sort_by(&:to_s).map do |account_id|
23
+ if @users.connection.adapter_name == "SQLite"
24
+ pk = @users.connection.quote_column_name(@users.primary_key)
25
+ @users.where(@users.primary_key => account_id).update_all("#{pk} = #{pk}")
26
+ @users.find_by(@users.primary_key => account_id)
27
+ else
28
+ @users.lock.find_by(@users.primary_key => account_id)
29
+ end
30
+ end
31
+ result = yield accounts.compact.find { |account| account.id == id }
32
+ end
33
+ end
34
+ raise AddAuth::Error, "authentication transaction rolled back" unless completed
35
+ result
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end