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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class SessionsController < ::ApplicationController
5
+ include Rails::AuthenticationPages
6
+
7
+ rescue_from AddAuth::Error, with: :service_unavailable
8
+ helper AddAuth::SignInsHelper
9
+ before_action :require_add_auth_authentication
10
+ before_action :private_response
11
+ protect_from_forgery with: :exception
12
+ layout "application"
13
+
14
+ def index
15
+ @session_page = Rails::Runtime.sessions.list_page(user: Current.user,
16
+ current_session_id: Current.session.id, before: params[:before])
17
+ @sessions = @session_page.entries
18
+ respond_to do |format|
19
+ format.html { render "add_auth/sessions/index" }
20
+ format.turbo_stream do
21
+ render turbo_stream: turbo_stream.update("add_auth-session-content", partial: "add_auth/sessions/list")
22
+ end
23
+ end
24
+ end
25
+
26
+ def destroy
27
+ target_id = params[:id].to_s
28
+ if target_id.match?(/\A[1-9]\d*\z/) && target_id.to_i == Current.session.id
29
+ terminate_session
30
+ return redirect_to Rails::Runtime.sign_in_path, status: :see_other
31
+ end
32
+
33
+ revoked = Rails::Runtime.sessions.revoke_one(user: Current.user, session: Current.session, session_id: target_id)
34
+ return head :not_found unless revoked
35
+
36
+ redirect_to security_sessions_path, status: :see_other
37
+ end
38
+
39
+ def new_revoke_all
40
+ @elevated = Rails::Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
41
+ purpose: :sign_out_everywhere, policy: Rails::Runtime.step_up_policy).success?
42
+ @password_available = Rails::Runtime.access_policy.methods_for(Current.user).include?(:password)
43
+ page("revoke_all")
44
+ end
45
+
46
+ def revoke_all
47
+ authorization = Rails::Runtime.revoke_all(user: Current.user, session: Current.session,
48
+ password: params[:password], ip: request.remote_ip, challenge_token: challenge_token)
49
+ return intake_failure(authorization.reason) unless authorization.success?
50
+
51
+ terminate_session
52
+ redirect_to Rails::Runtime.sign_in_path, status: :see_other
53
+ end
54
+
55
+ private
56
+
57
+ def authentication_partial(_partial)
58
+ @password_available = Rails::Runtime.access_policy.methods_for(Current.user).include?(:password)
59
+ "add_auth/sessions/revoke_all"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class SignInsController < ::ApplicationController
5
+ include Rails::AuthenticationPages
6
+
7
+ skip_before_action :require_authentication, raise: false
8
+ layout "add_auth/authentication"
9
+ helper AddAuth::SignInsHelper
10
+ before_action :private_response
11
+ protect_from_forgery with: :exception
12
+ rescue_from AddAuth::Error, "ActiveJob::EnqueueError", with: :service_unavailable
13
+
14
+ def new
15
+ page("form")
16
+ end
17
+
18
+ def password = add_auth_password_sign_in
19
+
20
+ def request_link
21
+ return head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
22
+ intake = Rails::Runtime.intake.call(identifier: params[:email_address], ip: request.remote_ip,
23
+ action: :email_link, challenge_token: challenge_token)
24
+ return intake_failure(intake) if %i[challenge_rejected challenge_unavailable].include?(intake)
25
+ unless intake.is_a?(Symbol)
26
+ session[:add_auth_browser] ||= Rails::Runtime.browser_binding.generate
27
+ Rails::Runtime.enqueue_email(intake, browser_secret: session[:add_auth_browser])
28
+ end
29
+ redirect_to "/sign-in/check-email", status: :see_other
30
+ end
31
+
32
+ def check_email
33
+ return head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
34
+ page("check_email")
35
+ end
36
+
37
+ def link
38
+ return head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
39
+ @preview = Rails::Runtime.email.preview(token: params[:token], browser_secret: session[:add_auth_browser])
40
+ # GET does not resume/upgrade an existing session or consume this link.
41
+ page(Rails::Runtime.email.confirmation_page(@preview))
42
+ end
43
+
44
+ def confirm
45
+ return head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
46
+ previous = add_auth_replacement_session
47
+ grant = nil
48
+ result = Rails::Runtime.email.consume(token: params[:token], current_session: previous,
49
+ switch_account: params[:switch_account] == "1", browser_secret: session[:add_auth_browser]) do |user, persist|
50
+ grant = Rails::Runtime.sessions.create_in_transaction(user: user, method: :email_link, persist: persist,
51
+ replacing: previous, **add_auth_session_hints)
52
+ grant&.session
53
+ end
54
+ if result.success?
55
+ destination = after_authentication_url
56
+ add_auth_accept(grant)
57
+ redirect_to destination, status: :see_other
58
+ else
59
+ page("invalid_link", status: :unprocessable_entity)
60
+ end
61
+ end
62
+
63
+ private
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module SignInsHelper
5
+ def add_auth_stylesheet
6
+ path = AddAuth.configuration.stylesheet
7
+ raise AddAuth::Error, "authentication stylesheets must use a same-origin path" if path && !Core::Sessions.safe_return(path)
8
+ path
9
+ end
10
+
11
+ def add_auth_class(role)
12
+ AddAuth.configuration.css_classes.fetch(role, "add_auth-#{role}")
13
+ end
14
+
15
+ def add_auth_challenge_form_data(action)
16
+ data = {turbo_frame: "_top"}
17
+ return data unless AddAuth.configuration.challenge_on.include?(action)
18
+ provider = AddAuth.configuration.challenge
19
+ return data unless provider.site_key
20
+ kind = case provider
21
+ when Core::Challenge::Turnstile then "turnstile"
22
+ when Core::Challenge::Recaptcha then "recaptcha-#{provider.version}"
23
+ else return data
24
+ end
25
+ data.merge(controller: "add-auth-challenge",
26
+ action: "submit->add-auth-challenge#submit add_auth:proof-used->add-auth-challenge#reset turbo:submit-end->add-auth-challenge#reset turbo:before-cache@document->add-auth-challenge#beforeCache",
27
+ add_auth_challenge_provider_value: kind, add_auth_challenge_site_key_value: provider.site_key,
28
+ add_auth_challenge_action_value: (provider.respond_to?(:expected_action) && provider.expected_action) || action,
29
+ add_auth_challenge_script_url_value: provider.script_url)
30
+ end
31
+
32
+ def add_auth_challenge_tag(action, id: action)
33
+ return safe_join([]) unless AddAuth.configuration.challenge_on.include?(action)
34
+ safe_join([
35
+ hidden_field_tag(:challenge_token, nil, id: "#{id}_challenge_token", data: {add_auth_challenge_target: "token"}),
36
+ tag.div(hidden: true, data: {add_auth_challenge_target: "widget"}),
37
+ tag.p("Verification is loading.", hidden: true, role: "status", class: add_auth_class(:notice),
38
+ data: {add_auth_challenge_target: "status"})
39
+ ])
40
+ end
41
+ end
42
+ end
File without changes
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/smtp"
4
+
5
+ module AddAuth
6
+ class DeliveryJob < ActiveJob::Base
7
+ self.log_arguments = false
8
+ retry_on StandardError, wait: :polynomially_longer, attempts: 8
9
+
10
+ private
11
+
12
+ def deliver(service:, record:, mailer:)
13
+ delivery = service.claim_delivery(digest: record.digest)
14
+ return unless delivery
15
+ begin
16
+ message = yield delivery
17
+ raise AddAuth::Error, "mail delivery is disabled" unless mailer.perform_deliveries
18
+ message.deliver_now
19
+ outcome = message.add_auth_delivery_completed? ? :delivered : :cancelled
20
+ if service.finish_delivery(digest: record.digest, lease: delivery.fetch(:lease), outcome: outcome) && outcome == :cancelled
21
+ ActiveSupport::Notifications.instrument("delivery_cancelled.add_auth", issuance_id: record.id)
22
+ end
23
+ rescue Net::SMTPFatalError
24
+ service.delivery_failed(digest: record.digest, lease: delivery.fetch(:lease))
25
+ rescue
26
+ service.delivery_retry(digest: record.digest, lease: delivery.fetch(:lease))
27
+ raise
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class EmailDeliveryJob < DeliveryJob
5
+ def perform(id)
6
+ record = ::AddAuthSignInToken.find_by(id: id)
7
+ return unless record
8
+ service = Rails::Runtime.email(purpose: record.purpose)
9
+ deliver(service: service, record: record, mailer: SignInMailer) do |delivery|
10
+ SignInMailer.link(recipient: delivery.fetch(:recipient),
11
+ url: Rails::Runtime.sign_in_url(delivery.fetch(:token), purpose: record.purpose), purpose: record.purpose)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class EmailRequestJob < ActiveJob::Base
5
+ self.log_arguments = false
6
+ retry_on StandardError, wait: :polynomially_longer, attempts: 8
7
+
8
+ def perform(encrypted_identifier)
9
+ payload = Rails::Runtime.decrypt_intake(encrypted_identifier)
10
+ # Continue accepting encrypted identifier-only jobs from the prior deploy.
11
+ identifier = payload.is_a?(Hash) ? payload["identifier"] : payload
12
+ browser_digest = payload["browser_digest"] if payload.is_a?(Hash)
13
+ # Dispatch an already committed intent even after the intake has expired.
14
+ purpose = payload.is_a?(Hash) ? payload.fetch("purpose", "sign_in") : "sign_in"
15
+ context = payload.is_a?(Hash) ? payload.slice("session_id", "session_digest", "authentication_purpose").symbolize_keys : {}
16
+ Rails::Runtime.email(purpose: purpose).issue(identifier: identifier, request_id: job_id, browser_digest: browser_digest, **context) if identifier
17
+ record = ::AddAuthSignInToken.find_by(request_id: job_id)
18
+ EmailDeliveryJob.perform_later(record.id) if record
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class SecurityNotificationJob < DeliveryJob
5
+ def perform(id)
6
+ record = ::AddAuthSecurityEvent.find_by(id: id)
7
+ return unless record
8
+ deliver(service: Rails::Runtime.security_events, record: record, mailer: SecurityMailer) do |delivery|
9
+ SecurityMailer.notice(recipient: delivery.fetch(:recipient), kind: delivery.fetch(:kind))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class SecurityMailer < SignInMailer
5
+ def notice(recipient:, kind:)
6
+ @description = {
7
+ "password_changed" => "Your password changed.", "email_changed" => "Your account email address changed.",
8
+ "passkey_added" => "A passkey was added to your account.", "passkey_removed" => "A passkey was removed from your account.",
9
+ "policy_changed" => "Your account sign-in and recovery policy changed.", "recovery_completed" => "Passkey recovery completed on your account."
10
+ }.fetch(kind.to_s)
11
+ from = Rails::Runtime.config.mail_from
12
+ raise AddAuth::Error, "configure mail_from before delivering security notifications" if from.to_s.empty?
13
+ mail(to: recipient, from: from, subject: "Account security change", content_type: "text/plain").extend(DeliveryReceipt)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class SignInMailer < ActionMailer::Base
5
+ module DeliveryReceipt
6
+ def add_auth_delivery_completed? = @add_auth_delivery_completed == true
7
+ def add_auth_delivery_completed! = @add_auth_delivery_completed = true
8
+ end
9
+
10
+ # Action Mailer's default instrumentation includes the entire bearer-bearing
11
+ # message in debug logs. Keep this mailer's delivery event secret-free.
12
+ def self.deliver_mail(mail)
13
+ # Interceptors have already run. Suppression is cancellation, and errors
14
+ # must propagate rather than silently turning a failed transport into success.
15
+ return unless mail.perform_deliveries
16
+ raise AddAuth::Error, "sign-in mail must report delivery errors" unless mail.raise_delivery_errors
17
+ ActiveSupport::Notifications.instrument("deliver.add_auth", message_id: mail.message_id) { yield }
18
+ mail.add_auth_delivery_completed!
19
+ end
20
+
21
+ self.raise_delivery_errors = true
22
+
23
+ def link(recipient:, url:, purpose: "sign_in")
24
+ from = Rails::Runtime.config.mail_from
25
+ raise AddAuth::Error, "configure mail_from before delivering sign-in links" if from.to_s.empty?
26
+ subject, instruction, minutes = {
27
+ "sign_in" => ["Your sign-in link", "Open this link, then choose Sign in to confirm your account", Rails::Runtime.config.email_link.token_lifetime / 60],
28
+ "reauthentication" => ["Verify your current session", "Open this link in the signed-in browser where you started, then confirm verification", 5],
29
+ "recovery" => ["Recover your passkeys", "Open this link, then explicitly confirm that you want to recover your passkeys", 20]
30
+ }.fetch(purpose.to_s)
31
+ @url, @instruction, @minutes = url, instruction, minutes
32
+ # No token-bearing mail body in Action Mailer's debug logs.
33
+ mail(to: recipient, from: from, subject: subject, content_type: "text/plain").extend(DeliveryReceipt)
34
+ end
35
+ end
36
+ end
File without changes
data/app/views/.keep ADDED
File without changes
@@ -0,0 +1,17 @@
1
+ <div data-controller="add-auth-passkey"
2
+ data-add-auth-passkey-options-url-value="<%= options_url %>"
3
+ data-add-auth-passkey-finish-url-value="<%= finish_url %>"
4
+ data-add-auth-passkey-mode-value="<%= mode %>"
5
+ data-add-auth-passkey-purpose-value="<%= local_assigns[:purpose] %>"
6
+ data-add-auth-passkey-conditional-value="<%= local_assigns.fetch(:conditional, false) && !AddAuth.configuration.challenge_on.include?(action) %>"
7
+ data-add-auth-passkey-csrf-value="<%= form_authenticity_token %>"
8
+ data-action="turbo:before-cache@document->add-auth-passkey#beforeCache submit@document->add-auth-passkey#otherSubmission">
9
+ <p data-add-auth-passkey-target="unavailable">Passkeys need JavaScript and a compatible browser or device. Use another method allowed by your account policy if available.</p>
10
+ <%= form_with url: options_url, data: add_auth_challenge_form_data(action).merge(add_auth_passkey_target: "form", action: [add_auth_challenge_form_data(action)[:action], "submit->add-auth-passkey#submit"].compact.join(" ")) do |form| %>
11
+ <%= add_auth_challenge_tag action, id: "passkey_#{action}" %>
12
+ <div hidden data-add-auth-passkey-target="controls">
13
+ <%= form.submit label, class: add_auth_class(:button), data: {add_auth_challenge_target: "submit", add_auth_passkey_target: "button"} %>
14
+ </div>
15
+ <% end %>
16
+ <p role="status" aria-live="polite" tabindex="-1" hidden data-add-auth-passkey-target="status"></p>
17
+ </div>
@@ -0,0 +1,32 @@
1
+ <h1 id="add_auth-title">Your passkeys</h1>
2
+ <% if @error %><p role="alert"><%= @error %></p><% end %>
3
+ <p>Add a passkey for this browser, another device, or a security key. First verify your account when prompted.</p>
4
+ <%= render "add_auth/passkeys/controls", mode: "create", options_url: "/passkeys/options", finish_url: "/passkeys", label: "Add a passkey", action: :passkey_enrollment %>
5
+ <% @credentials.each do |credential| %>
6
+ <section id="passkey_<%= credential.id %>">
7
+ <h2><%= credential.nickname %></h2>
8
+ <p>Added <%= credential.created_at.strftime("%Y-%m-%d") %><% if credential.last_used_at %> · Last used <%= credential.last_used_at.strftime("%Y-%m-%d") %><% end %></p>
9
+ <% if credential.backup_eligible %><p>Supports backup. This does not prove you have a separately usable device.</p><% end %>
10
+ <%= form_with url: "/passkeys/#{credential.id}", method: :patch, data: {turbo_frame: "_top"} do |form| %>
11
+ <%= form.label :nickname, "Passkey name", for: "nickname_#{credential.id}" %>
12
+ <%= form.text_field :nickname, id: "nickname_#{credential.id}", value: credential.nickname, maxlength: 60, required: true %>
13
+ <%= form.submit "Rename" %>
14
+ <% end %>
15
+ <details>
16
+ <summary>Remove this passkey</summary>
17
+ <p>This removes access to this account. You may also delete the saved passkey from your password manager afterward. Keep another usable sign-in method.</p>
18
+ <%= button_to "Confirm removal", "/passkeys/#{credential.id}", method: :delete, form: {data: {turbo_frame: "_top"}} %>
19
+ </details>
20
+ </section>
21
+ <% end %>
22
+ <h2>Recovery policy</h2>
23
+ <p><%= @strict ? "Strict: passkeys are required. Password reset and email cannot replace a lost passkey." : "Default: enabled methods can sign in. Email replacement is available only through an established, trusted recovery address." %></p>
24
+ <p>Keep a separately usable passkey and test it before relying on strict policy. Two synced entries may share the same failure point.</p>
25
+ <%= form_with url: "/passkeys/policy", data: {turbo_frame: "_top"} do |form| %>
26
+ <%= form.hidden_field :policy, value: @strict ? "default" : "strict" %>
27
+ <%= form.check_box :acknowledged, required: true %>
28
+ <%= form.label :acknowledged, "I understand the recovery limits and have tested another way to access my account." %>
29
+ <%= form.submit @strict ? "Use default recovery policy" : "Require passkeys for this account" %>
30
+ <% end %>
31
+ <p><%= link_to "Verify to change policy", "/reauthenticate?purpose=manage_policy" %></p>
32
+ <p><%= link_to "I can’t use my passkeys", "/recover" %> · <%= link_to "Your sessions", "/sessions" %></p>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Check your email</h1>
2
+ <p>If this verification is permitted, we’ve sent a link. Use the newest email within five minutes and open it in this browser.</p>
3
+ <%= link_to "Try again or use another method", "/reauthenticate?#{URI.encode_www_form(purpose: @purpose)}", class: add_auth_class(:link) %>
@@ -0,0 +1,7 @@
1
+ <h1 id="add_auth-title">Confirm this verification</h1>
2
+ <p>Verify <strong><%= @preview.fetch(:masked_address) %></strong> in the browser where you started. You’ll return to review the action before submitting it.</p>
3
+ <%= form_with url: "/reauthenticate/link", data: {turbo_frame: "_top"} do |form| %>
4
+ <%= form.hidden_field :token, value: params[:token] %>
5
+ <%= form.submit "Confirm verification", class: add_auth_class(:button) %>
6
+ <% end %>
7
+ <%= link_to "Cancel", "/", class: add_auth_class(:link) %>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Return to the browser where you started</h1>
2
+ <p role="status">This verification can only continue in the signed-in browser that requested it. Copy the link into that browser. Opening it here does not verify another device.</p>
3
+ <%= link_to "Cancel", "/", class: add_auth_class(:link) %>
@@ -0,0 +1,27 @@
1
+ <h1 id="add_auth-title">Verify it’s you</h1>
2
+ <p>Confirm your identity to <%= @rule&.label || "continue" %>.</p>
3
+ <% if @error %><p role="alert"><%= @error %></p><% end %>
4
+ <% if Array(@methods).include?(:password) %>
5
+ <%= form_with url: "/reauthenticate/password", data: add_auth_challenge_form_data(:reauthenticate) do |form| %>
6
+ <%= form.hidden_field :purpose, value: @purpose %>
7
+ <div class="<%= add_auth_class(:field) %>">
8
+ <%= form.label :password, "Current password" %>
9
+ <%= form.password_field :password, required: true, autocomplete: "current-password", class: add_auth_class(:input) %>
10
+ </div>
11
+ <%= add_auth_challenge_tag :reauthenticate, id: :password %>
12
+ <%= form.submit "Verify with password", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
13
+ <% end %>
14
+ <% end %>
15
+ <% if Array(@methods).include?(:email_link) %>
16
+ <%= form_with url: "/reauthenticate/email", data: add_auth_challenge_form_data(:reauthenticate) do |form| %>
17
+ <%= form.hidden_field :purpose, value: @purpose %>
18
+ <%= add_auth_challenge_tag :reauthenticate, id: :email %>
19
+ <%= form.submit "Email me a verification link", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
20
+ <% end %>
21
+ <% end %>
22
+ <% if Array(@methods).empty? %><p role="status">No permitted verification method is available here. Contact your account administrator for an allowed way to continue.</p><% end %>
23
+ <p><%= link_to "Cancel", @rule&.return_to || "/", class: add_auth_class(:link) %></p>
24
+
25
+ <% if Array(@methods).include?(:passkey) %>
26
+ <%= render "add_auth/passkeys/controls", mode: "get", options_url: "/reauthenticate/passkey/options", finish_url: "/reauthenticate/passkey", label: "Verify with a passkey", action: :reauthenticate, purpose: @purpose %>
27
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Start verification again</h1>
2
+ <p role="alert">This link cannot verify your session. It may have expired, been used, or belong to another browser. Return to your original action to start again.</p>
3
+ <%= link_to "Return to your account", "/", class: add_auth_class(:link) %>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Check your email</h1>
2
+ <p>If this account can use email recovery, we’ve sent a link. Use the newest link within twenty minutes. Strict accounts need another passkey or account support.</p>
3
+ <%= link_to "Try again", "/recover" %> · <%= link_to "Back to sign in", "/sign-in" %>
@@ -0,0 +1,9 @@
1
+ <h1 id="add_auth-title">Confirm passkey recovery</h1>
2
+ <p>Recover access to <strong><%= @preview.fetch(:masked_address) %></strong>. This replaces any account signed in on this browser. You’ll have ten minutes to add a replacement passkey.</p>
3
+ <p>Existing passkeys stay active until you explicitly review and remove them. Other sessions are revoked only after replacement succeeds.</p>
4
+ <%= form_with url: "/recover/link", data: {turbo_frame: "_top"} do |form| %>
5
+ <%= form.hidden_field :token, value: params[:token] %>
6
+ <%= form.hidden_field :switch_account, value: "1" %>
7
+ <%= form.submit "Continue recovering this account" %>
8
+ <% end %>
9
+ <%= link_to "Cancel", "/sign-in" %>
@@ -0,0 +1,11 @@
1
+ <h1 id="add_auth-title">Recover your passkeys</h1>
2
+ <p>Request a replacement link at your established recovery address. Accounts using strict policy require another passkey or your account administrator’s documented recovery process.</p>
3
+ <% if @error %><p role="alert"><%= @error %></p><% end %>
4
+ <%= form_with url: "/recover/email", data: add_auth_challenge_form_data(:email_link) do |form| %>
5
+ <%= form.label :email_address, "Recovery email address" %>
6
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email" %>
7
+ <%= add_auth_challenge_tag :email_link %>
8
+ <%= form.submit "Send recovery link", data: {add_auth_challenge_target: "submit"} %>
9
+ <% end %>
10
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
11
+ <% if AddAuth.configuration.support_url %><p><%= link_to "Contact account support", AddAuth.configuration.support_url %></p><% end %>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Request a new recovery link</h1>
2
+ <p role="alert">This recovery link is unavailable, expired, or already used. Existing passkeys have not been removed.</p>
3
+ <%= link_to "Start recovery again", "/recover" %>
@@ -0,0 +1,3 @@
1
+ <%= @description %>
2
+
3
+ If you did not make this change, contact your account administrator immediately. This email contains no sign-in or recovery link.
@@ -0,0 +1,25 @@
1
+ <ul class="add_auth-session-list">
2
+ <% @sessions.each do |session| %>
3
+ <li class="add_auth-session" id="session_<%= session.id %>">
4
+ <div>
5
+ <strong><%= session.current ? "This browser" : "Signed-in browser" %></strong>
6
+ <p class="<%= add_auth_class(:muted) %>">
7
+ <%= session.authenticated_with ? "Signed in with #{session.authenticated_with.tr("_", " ")}" : "Imported session" %>
8
+ · <%= session.last_seen_at ? "Last active #{session.last_seen_at.strftime("%Y-%m-%d %H:%M UTC")}" : "Activity unknown" %>
9
+ </p>
10
+ <% if session.user_agent.present? %><p class="<%= add_auth_class(:muted) %>"><%= session.user_agent %></p><% end %>
11
+ </div>
12
+ <%= button_to(session.current ? "Sign out" : "Revoke", security_session_path(session.id), method: :delete,
13
+ class: add_auth_class(:button),
14
+ form: {data: {turbo_frame: "_top"}}) %>
15
+ </li>
16
+ <% end %>
17
+ </ul>
18
+ <nav aria-label="Session pages">
19
+ <% if params[:before].present? %>
20
+ <%= link_to "Newest sessions", security_sessions_path %>
21
+ <% end %>
22
+ <% if @session_page.next_cursor %>
23
+ <%= link_to "Older sessions", security_sessions_path(before: @session_page.next_cursor) %>
24
+ <% end %>
25
+ </nav>
@@ -0,0 +1,18 @@
1
+ <% content_for :title, "Sign out everywhere" %>
2
+ <h1 id="add_auth-title">Sign out everywhere</h1>
3
+ <p class="<%= add_auth_class(:muted) %>">Revoke every active session, including this browser. This requires a recent verification.</p>
4
+ <% if @error %><p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p><% end %>
5
+ <% if @elevated || @password_available != false %>
6
+ <%= form_with url: "/sessions/revoke-all", data: add_auth_challenge_form_data(:reauthenticate) do |form| %>
7
+ <% unless @elevated %>
8
+ <div class="<%= add_auth_class(:field) %>">
9
+ <%= form.label :password, "Current password" %>
10
+ <%= form.password_field :password, required: true, autocomplete: "current-password", class: add_auth_class(:input) %>
11
+ </div>
12
+ <% end %>
13
+ <%= add_auth_challenge_tag :reauthenticate %>
14
+ <%= form.submit "Sign out everywhere", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
15
+ <% end %>
16
+ <% end %>
17
+ <% if AddAuth.configuration.step_up.enabled %><p><%= link_to "Verify with an allowed method", "/reauthenticate?purpose=sign_out_everywhere" %></p><% end %>
18
+ <p><%= link_to "Cancel", "/sessions", class: add_auth_class(:link) %></p>
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Sessions" %>
2
+ <% content_for :head do %>
3
+ <% turbo_exempts_page_from_cache %>
4
+ <% turbo_page_requires_reload if request.headers["Turbo-Frame"].present? %>
5
+ <%= stylesheet_link_tag add_auth_stylesheet, skip_pipeline: true if add_auth_stylesheet %>
6
+ <% end %>
7
+ <main class="<%= add_auth_class(:panel) %>" aria-labelledby="add_auth-title">
8
+ <h1 id="add_auth-title">Your sessions</h1>
9
+ <p class="<%= add_auth_class(:muted) %>">Review browsers signed in to your account and revoke any you no longer use.</p>
10
+ <p><%= link_to "Sign out everywhere", "/sessions/revoke-all", class: add_auth_class(:link) %></p>
11
+ <div id="add_auth-session-content"><%= render "add_auth/sessions/list" %></div>
12
+ </main>
@@ -0,0 +1,5 @@
1
+ <%= @instruction %>:
2
+
3
+ <%= @url %>
4
+
5
+ Use only the newest email. This link expires in <%= @minutes %> minutes. If you did not request it, you can ignore this email.
@@ -0,0 +1,5 @@
1
+ <h1 id="add_auth-title">Check your email</h1>
2
+ <p role="status">If this address can sign in, a link will arrive shortly.</p>
3
+ <p class="<%= add_auth_class(:muted) %>">Use the newest email. Open its link, then confirm to sign in on that device.</p>
4
+ <p>Nothing yet? Check your spam folder or wait a few minutes before trying again.</p>
5
+ <%= link_to "Return to sign in", "/sign-in", class: add_auth_class(:link) %>
@@ -0,0 +1,9 @@
1
+ <h1 id="add_auth-title">Confirm your sign-in</h1>
2
+ <p>Sign in as <strong><%= @preview.fetch(:masked_address) %></strong> on this device.</p>
3
+ <p class="<%= add_auth_class(:muted) %>">This will replace any account currently signed in on this browser.</p>
4
+ <%= form_with url: "/sign-in/link", data: {turbo_frame: "_top"} do |form| %>
5
+ <%= form.hidden_field :token, value: params[:token] %>
6
+ <%= form.hidden_field :switch_account, value: "1" %>
7
+ <%= form.submit "Sign in to this account", class: add_auth_class(:button) %>
8
+ <% end %>
9
+ <p><%= link_to "Cancel", "/sign-in", class: add_auth_class(:link) %></p>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Open this link in the requesting browser</h1>
2
+ <p role="status">This sign-in link is tied to the browser that requested it. Copy the link into that browser, or request a new link here.</p>
3
+ <%= link_to "Request a link here", "/sign-in", class: add_auth_class(:link) %>
@@ -0,0 +1,34 @@
1
+ <h1 id="add_auth-title">Sign in</h1>
2
+ <p class="<%= add_auth_class(:muted) %>">Choose an available sign-in method.</p>
3
+ <% if @error %><p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p><% end %>
4
+ <% if AddAuth.configuration.passwords_enabled %>
5
+ <%= form_with url: "/sign-in/password", id: "add_auth-password-form", data: add_auth_challenge_form_data(:sign_in) do |form| %>
6
+ <div class="<%= add_auth_class(:field) %>">
7
+ <%= form.label :email_address, "Email address" %>
8
+ <%= form.email_field :email_address, required: true, autocomplete: "username webauthn", value: (params[:email_address] if params[:email_address].is_a?(String)), maxlength: 254, class: add_auth_class(:input) %>
9
+ </div>
10
+ <div class="<%= add_auth_class(:field) %>">
11
+ <%= form.label :password, "Password" %>
12
+ <%= form.password_field :password, required: true, autocomplete: "current-password", class: add_auth_class(:input) %>
13
+ </div>
14
+ <%= add_auth_challenge_tag :sign_in %>
15
+ <%= form.submit "Sign in with password", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
16
+ <% end %>
17
+ <% end %>
18
+ <% if AddAuth::Core::Intake.email_available?(AddAuth.configuration) %>
19
+ <hr class="<%= add_auth_class(:divider) %>">
20
+ <%= form_with url: "/sign-in/email", id: "add_auth-email-form", data: add_auth_challenge_form_data(:email_link) do |form| %>
21
+ <div class="<%= add_auth_class(:field) %>">
22
+ <%= form.label :email_address, "Email me a sign-in link", for: "link_email_address" %>
23
+ <%= form.email_field :email_address, id: "link_email_address", value: (params[:email_address] if params[:email_address].is_a?(String)), required: true, autocomplete: "email", maxlength: 254, class: add_auth_class(:input) %>
24
+ </div>
25
+ <%= add_auth_challenge_tag :email_link %>
26
+ <%= form.submit "Send sign-in link", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
27
+ <% end %>
28
+
29
+ <% end %>
30
+
31
+ <% if AddAuth.configuration.passkeys.enabled %>
32
+ <%= render "add_auth/passkeys/controls", mode: "get", options_url: "/passkeys/sign-in/options", finish_url: "/passkeys/sign-in", label: "Sign in with a passkey", action: :sign_in, conditional: true %>
33
+ <p><%= link_to "I can’t use my passkeys", "/recover" %></p>
34
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Request a new link</h1>
2
+ <p role="alert">This link cannot sign you in. It may have expired or already been used.</p>
3
+ <%= link_to "Get a new sign-in link", "/sign-in", class: add_auth_class(:link) %>
@@ -0,0 +1 @@
1
+ <%= render @page_partial %>
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>Sign in</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <% turbo_exempts_page_from_cache %>
8
+ <% turbo_page_requires_reload if request.headers["Turbo-Frame"].present? %>
9
+ <%= yield :head %>
10
+ <%= csrf_meta_tags %>
11
+ <script src="/add_auth/boot.js" type="module" data-turbo-track="reload"></script>
12
+ <%= stylesheet_link_tag add_auth_stylesheet, skip_pipeline: true if add_auth_stylesheet %>
13
+ </head>
14
+ <body class="<%= add_auth_class(:body) %>">
15
+ <main class="<%= add_auth_class(:panel) %>" aria-labelledby="add_auth-title">
16
+ <div id="add_auth-content"><%= yield %></div>
17
+ </main>
18
+ </body>
19
+ </html>