add_auth 0.2.1 → 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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +57 -18
  4. data/ROADMAP.md +260 -242
  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 +73 -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/rate_limit_cache.rb +17 -0
  83. data/lib/add_auth/rails/runtime.rb +173 -9
  84. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  85. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  86. data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
  87. data/lib/add_auth/rails/stores/authority.rb +28 -0
  88. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  89. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  90. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  91. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  92. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  93. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  94. data/lib/add_auth/result.rb +1 -0
  95. data/lib/add_auth/version.rb +1 -1
  96. data/lib/add_auth.rb +4 -0
  97. data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
  98. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  99. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  101. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  102. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  103. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  104. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  105. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  106. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  107. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  110. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  111. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  112. data/lib/generators/add_auth/install/templates/initializer.rb +7 -1
  113. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  114. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  115. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  116. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  117. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  119. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  120. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  121. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  122. data/lib/tasks/add_auth.rake +18 -1
  123. metadata +86 -2
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Change your email" %>
2
+ <h1 id="add_auth-title">Change your email</h1>
3
+ <p class="<%= add_auth_class(:muted) %>">Your current email stays active until you confirm the new address. Confirmation signs out your existing sessions.</p>
4
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
+ <%= form_with url: "/account/email", data: {turbo_frame: "_top"} do |form| %>
6
+ <div class="<%= add_auth_class(:field) %>">
7
+ <%= form.label :email_address, "New email address" %>
8
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email", value: (params[:email_address] if params[:email_address].is_a?(String)), class: add_auth_class(:input) %>
9
+ </div>
10
+ <%= form.submit "Send confirmation link", class: add_auth_class(:button) %>
11
+ <% end %>
12
+ <p><%= link_to "Back to your sessions", "/sessions" %></p>
@@ -0,0 +1,5 @@
1
+ <% content_for :title, "Request a new link" %>
2
+ <h1 id="add_auth-title">Request a new link</h1>
3
+ <p>This link is unavailable, expired, or already used. Request a new link to finish your account update.</p>
4
+ <p><%= link_to "Request another link", "/account/requests/#{@purpose}" %></p>
5
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Change your password" %>
2
+ <h1 id="add_auth-title">Change your password</h1>
3
+ <p class="<%= add_auth_class(:muted) %>">Verify it’s you before changing your password. This signs out your existing sessions.</p>
4
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
+ <%= form_with url: "/account/password", data: {turbo_frame: "_top"} do |form| %>
6
+ <div class="<%= add_auth_class(:field) %>">
7
+ <%= form.label :password, "New password" %>
8
+ <%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
9
+ </div>
10
+ <%= form.submit "Change password", class: add_auth_class(:button) %>
11
+ <% end %>
12
+ <p><%= link_to "Back to your sessions", "/sessions" %></p>
@@ -0,0 +1,16 @@
1
+ <% title = {"confirm" => "Confirm this email address", "reset_password" => "Choose a new password", "unlock" => "Unlock this account"}.fetch(@purpose) %>
2
+ <% content_for :title, title %>
3
+ <h1 id="add_auth-title"><%= title %></h1>
4
+ <p class="<%= add_auth_class(:muted) %>">Use the button below to complete your request. You’ll sign in afterward.</p>
5
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
6
+ <%= form_with url: "/account/proofs/#{@purpose}", data: {turbo_frame: "_top"} do |form| %>
7
+ <%= form.hidden_field :token, value: (params[:token] if params[:token].is_a?(String)) %>
8
+ <% if @purpose == "reset_password" %>
9
+ <div class="<%= add_auth_class(:field) %>">
10
+ <%= form.label :password, "New password" %>
11
+ <%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
12
+ </div>
13
+ <% end %>
14
+ <%= form.submit title, class: add_auth_class(:button) %>
15
+ <% end %>
16
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,17 @@
1
+ <% content_for :title, "Create an account" %>
2
+ <h1 id="add_auth-title">Create an account</h1>
3
+ <p class="<%= add_auth_class(:muted) %>">We’ll email you a link to confirm your address before you sign in.</p>
4
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
+ <%= form_with url: "/account/sign-up", data: add_auth_challenge_form_data(:register) do |form| %>
6
+ <div class="<%= add_auth_class(:field) %>">
7
+ <%= form.label :email_address, "Email address" %>
8
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email", value: (params[:email_address] if params[:email_address].is_a?(String)), class: add_auth_class(:input) %>
9
+ </div>
10
+ <div class="<%= add_auth_class(:field) %>">
11
+ <%= form.label :password, "New password" %>
12
+ <%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
13
+ </div>
14
+ <%= add_auth_challenge_tag :register %>
15
+ <%= form.submit "Create account", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
16
+ <% end %>
17
+ <p><%= link_to "Already have an account? Sign in", "/sign-in" %></p>
@@ -0,0 +1,14 @@
1
+ <% title = {"confirm" => "Confirm your email", "reset_password" => "Reset your password", "unlock" => "Unlock your account"}.fetch(@purpose) %>
2
+ <% content_for :title, title %>
3
+ <h1 id="add_auth-title"><%= title %></h1>
4
+ <p class="<%= add_auth_class(:muted) %>">Enter your account email address to request a secure link.</p>
5
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
6
+ <%= form_with url: "/account/requests/#{@purpose}", data: add_auth_challenge_form_data(:account_request) do |form| %>
7
+ <div class="<%= add_auth_class(:field) %>">
8
+ <%= form.label :email_address, "Email address" %>
9
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email", value: (params[:email_address] if params[:email_address].is_a?(String)), class: add_auth_class(:input) %>
10
+ </div>
11
+ <%= add_auth_challenge_tag :account_request %>
12
+ <%= form.submit "Send account link", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
13
+ <% end %>
14
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Try your request again</h1>
2
+ <p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p>
3
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,22 @@
1
+ <h1 id="add_auth-title">External sign-in methods</h1>
2
+ <% if @error %><p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p><% end %>
3
+ <p>Add another way to sign in. You’ll verify your account before linking or removing a provider.</p>
4
+ <% @providers.each do |provider| %>
5
+ <%= form_with url: "/account/external-identities/providers/#{provider.middleware_name}", method: :post, data: add_auth_challenge_form_data(:provider).merge(turbo: false) do |form| %>
6
+ <%= add_auth_challenge_tag :provider, id: "link_#{provider.middleware_name}" %>
7
+ <%= form.submit "Link #{provider.label}", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
8
+ <% end %>
9
+ <% end %>
10
+ <% @identities.each do |identity| %>
11
+ <% provider = @providers.find { |item| item.configuration.id == identity.provider_id } %>
12
+ <section id="external_identity_<%= identity.id %>">
13
+ <h2><%= provider&.label || "External provider" %></h2>
14
+ <p>Linked <%= identity.linked_at.strftime("%Y-%m-%d") %>.</p>
15
+ <details>
16
+ <summary>Remove this sign-in method</summary>
17
+ <p>Keep another usable sign-in method before removing this one.</p>
18
+ <%= button_to "Confirm removal", "/account/external-identities/#{identity.id}", method: :delete, form: {data: {turbo: false}} %>
19
+ </details>
20
+ </section>
21
+ <% end %>
22
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Try again shortly</h1>
2
+ <p role="alert"><%= @error %></p>
3
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -1,15 +1,17 @@
1
1
  <div data-controller="add-auth-passkey"
2
+ data-add-auth-passkey-turbo-value="<%= AddAuth.configuration.turbo_enabled %>"
2
3
  data-add-auth-passkey-options-url-value="<%= options_url %>"
3
4
  data-add-auth-passkey-finish-url-value="<%= finish_url %>"
4
5
  data-add-auth-passkey-mode-value="<%= mode %>"
5
6
  data-add-auth-passkey-purpose-value="<%= local_assigns[:purpose] %>"
6
7
  data-add-auth-passkey-conditional-value="<%= local_assigns.fetch(:conditional, false) && !AddAuth.configuration.challenge_on.include?(action) %>"
7
8
  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
+ data-action="turbo:before-cache@document->add-auth-passkey#beforeCache pagehide@window->add-auth-passkey#beforeCache pageshow@window->add-auth-passkey#restore submit@document->add-auth-passkey#otherSubmission">
9
10
  <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
11
  <%= 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
12
  <%= add_auth_challenge_tag action, id: "passkey_#{action}" %>
12
13
  <div hidden data-add-auth-passkey-target="controls">
14
+ <%= render "add_auth/sign_ins/remember", form: form, id: "passkey" if action == :sign_in %>
13
15
  <%= form.submit label, class: add_auth_class(:button), data: {add_auth_challenge_target: "submit", add_auth_passkey_target: "button"} %>
14
16
  </div>
15
17
  <% end %>
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Create an account" %>
2
+ <h1 id="add_auth-title">Create an account with <%= @provider.label %></h1>
3
+ <p>Enter your email, then continue to your provider. We’ll email you a separate confirmation link before you can sign in.</p>
4
+ <%= form_with url: "/account/sign-up/providers/#{@provider.middleware_name}", method: :post, data: add_auth_challenge_form_data(:register).merge(turbo: false) do |form| %>
5
+ <div class="<%= add_auth_class(:field) %>">
6
+ <%= form.label :email_address, "Email address" %>
7
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email", class: add_auth_class(:input) %>
8
+ </div>
9
+ <%= add_auth_challenge_tag :register %>
10
+ <%= form.submit "Continue with #{@provider.label}", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
11
+ <% end %>
12
+ <p><%= link_to "Already have an account? Sign in", "/sign-in" %></p>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Provider sign-in could not be completed</h1>
2
+ <p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p>
3
+ <p><%= link_to "Return to sign in", "/sign-in" %></p>
@@ -0,0 +1,7 @@
1
+ <h1 id="add_auth-title"><%= @heading %></h1>
2
+ <p class="<%= add_auth_class(:muted) %>">You will continue to <%= @provider.label %> to verify this request.</p>
3
+ <%= form_with url: @provider_path, method: :post, authenticity_token: false, data: {turbo: false} do |form| %>
4
+ <%= hidden_field_tag @omniauth_csrf_parameter, @omniauth_csrf_token %>
5
+ <%= form.submit "Continue with #{@provider.label}", class: add_auth_class(:button) %>
6
+ <% end %>
7
+ <p><%= link_to "Cancel", "/sign-in" %></p>
@@ -19,6 +19,15 @@
19
19
  <%= form.submit "Email me a verification link", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
20
20
  <% end %>
21
21
  <% end %>
22
+ <% if Array(@methods).include?(:external_identity) && AddAuth.configuration.external_identities.enabled %>
23
+ <% AddAuth.configuration.external_identities.providers.select(&:reauthentication?).each do |provider| %>
24
+ <%= form_with url: "/reauthenticate/providers/#{provider.middleware_name}", method: :post, data: add_auth_challenge_form_data(:reauthenticate).merge(turbo: false) do |form| %>
25
+ <%= form.hidden_field :purpose, value: @purpose %>
26
+ <%= add_auth_challenge_tag :reauthenticate, id: "verify_#{provider.middleware_name}" %>
27
+ <%= form.submit "Verify with #{provider.label}", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
28
+ <% end %>
29
+ <% end %>
30
+ <% end %>
22
31
  <% 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
32
  <p><%= link_to "Cancel", @rule&.return_to || "/", class: add_auth_class(:link) %></p>
24
33
 
@@ -2,7 +2,7 @@
2
2
  <% @sessions.each do |session| %>
3
3
  <li class="add_auth-session" id="session_<%= session.id %>">
4
4
  <div>
5
- <strong><%= session.current ? "This browser" : "Signed-in browser" %></strong>
5
+ <strong><%= session.transport == "mobile" ? "Signed-in mobile device (#{session.client_id})" : (session.current ? "This browser" : "Signed-in browser") %></strong>
6
6
  <p class="<%= add_auth_class(:muted) %>">
7
7
  <%= session.authenticated_with ? "Signed in with #{session.authenticated_with.tr("_", " ")}" : "Imported session" %>
8
8
  · <%= session.last_seen_at ? "Last active #{session.last_seen_at.strftime("%Y-%m-%d %H:%M UTC")}" : "Activity unknown" %>
@@ -8,5 +8,12 @@
8
8
  <h1 id="add_auth-title">Your sessions</h1>
9
9
  <p class="<%= add_auth_class(:muted) %>">Review browsers signed in to your account and revoke any you no longer use.</p>
10
10
  <p><%= link_to "Sign out everywhere", "/sessions/revoke-all", class: add_auth_class(:link) %></p>
11
+ <% if AddAuth.configuration.lifecycle.enabled %>
12
+ <p><%= link_to "Change email", "/account/email" %> · <%= link_to "Change password", "/account/password" %> · <%= link_to "Delete account", "/account/delete" %></p>
13
+ <% end %>
11
14
  <div id="add_auth-session-content"><%= render "add_auth/sessions/list" %></div>
12
15
  </main>
16
+
17
+ <% if AddAuth.configuration.external_identities.enabled %>
18
+ <p><%= link_to "Manage sign-in providers", "/account/external-identities" %></p>
19
+ <% end %>
@@ -4,6 +4,7 @@
4
4
  <%= form_with url: "/sign-in/link", data: {turbo_frame: "_top"} do |form| %>
5
5
  <%= form.hidden_field :token, value: params[:token] %>
6
6
  <%= form.hidden_field :switch_account, value: "1" %>
7
+ <%= render "add_auth/sign_ins/remember", form: form, id: "email" %>
7
8
  <%= form.submit "Sign in to this account", class: add_auth_class(:button) %>
8
9
  <% end %>
9
10
  <p><%= link_to "Cancel", "/sign-in", class: add_auth_class(:link) %></p>
@@ -1,5 +1,8 @@
1
1
  <h1 id="add_auth-title">Sign in</h1>
2
2
  <p class="<%= add_auth_class(:muted) %>">Choose an available sign-in method.</p>
3
+ <% if AddAuth.configuration.lifecycle.enabled %>
4
+ <p><%= link_to "Create an account", "/account/sign-up" %> · <%= link_to "Forgot your password?", "/account/requests/reset_password" %></p>
5
+ <% end %>
3
6
  <% if @error %><p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p><% end %>
4
7
  <% if AddAuth.configuration.passwords_enabled %>
5
8
  <%= form_with url: "/sign-in/password", id: "add_auth-password-form", data: add_auth_challenge_form_data(:sign_in) do |form| %>
@@ -12,6 +15,7 @@
12
15
  <%= form.password_field :password, required: true, autocomplete: "current-password", class: add_auth_class(:input) %>
13
16
  </div>
14
17
  <%= add_auth_challenge_tag :sign_in %>
18
+ <%= render "add_auth/sign_ins/remember", form: form, id: "password" %>
15
19
  <%= form.submit "Sign in with password", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
16
20
  <% end %>
17
21
  <% end %>
@@ -32,3 +36,16 @@
32
36
  <%= 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
37
  <p><%= link_to "I can’t use my passkeys", "/recover" %></p>
34
38
  <% end %>
39
+ <% if AddAuth.configuration.external_identities.enabled && AddAuth.configuration.external_identities.providers.any? %>
40
+ <hr class="<%= add_auth_class(:divider) %>">
41
+ <% AddAuth.configuration.external_identities.providers.each do |provider| %>
42
+ <%= form_with url: "/sign-in/providers/#{provider.middleware_name}", method: :post, data: add_auth_challenge_form_data(:provider).merge(turbo: false) do |form| %>
43
+ <%= render "add_auth/sign_ins/remember", form: form, id: "provider_#{provider.middleware_name}" %>
44
+ <%= add_auth_challenge_tag :provider, id: "provider_#{provider.middleware_name}" %>
45
+ <%= form.submit "Continue with #{provider.label}", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
46
+ <% end %>
47
+ <% if AddAuth.configuration.lifecycle.enabled %>
48
+ <p><%= link_to "Create an account with #{provider.label}", "/account/sign-up/providers/#{provider.middleware_name}" %></p>
49
+ <% end %>
50
+ <% end %>
51
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <% if AddAuth.configuration.lifecycle.enabled %>
2
+ <div class="<%= add_auth_class(:field) %>">
3
+ <%= form.check_box :remember, id: "#{id}_remember" %>
4
+ <%= form.label :remember, "Keep me signed in on this browser", for: "#{id}_remember" %>
5
+ </div>
6
+ <% end %>
@@ -1,9 +1,9 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en"<% unless AddAuth.configuration.turbo_enabled %> data-turbo="false"<% end %>>
3
3
  <head>
4
- <title>Sign in</title>
4
+ <title><%= content_for?(:title) ? yield(:title) : "Sign in" %></title>
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta name="referrer" content="no-referrer">
6
+ <meta name="referrer" content="<%= response.headers["Referrer-Policy"] || "no-referrer" %>">
7
7
  <% turbo_exempts_page_from_cache %>
8
8
  <% turbo_page_requires_reload if request.headers["Turbo-Frame"].present? %>
9
9
  <%= yield :head %>
@@ -13,6 +13,7 @@
13
13
  </head>
14
14
  <body class="<%= add_auth_class(:body) %>">
15
15
  <main class="<%= add_auth_class(:panel) %>" aria-labelledby="add_auth-title">
16
+ <% if flash[:notice] %><p role="status" data-turbo-temporary><%= flash[:notice] %></p><% end %>
16
17
  <div id="add_auth-content"><%= yield %></div>
17
18
  </main>
18
19
  </body>
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require "add_auth/migration/static_inventory"
7
+
8
+ options = {root: Dir.pwd}
9
+ parser = OptionParser.new do |opts|
10
+ opts.banner = "Usage: add_auth-preflight [--root PATH] (static inspection; does not boot Rails)"
11
+ opts.on("--root PATH", "Host source directory") { |value| options[:root] = value }
12
+ opts.on("-h", "--help") {
13
+ puts opts
14
+ exit
15
+ }
16
+ end
17
+ begin
18
+ parser.parse!
19
+ raise OptionParser::InvalidArgument unless ARGV.empty?
20
+ report = AddAuth::Migration::StaticInventory.new(**options).call
21
+ puts JSON.pretty_generate(report)
22
+ exit(report.dig(:facts, :complete) ? 0 : 2)
23
+ rescue OptionParser::ParseError, SystemCallError, ArgumentError
24
+ warn "Unable to inspect source. Supply a readable host directory with --root; see --help."
25
+ exit 2
26
+ end
@@ -1,24 +1,98 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "add_auth/core/passwords/bcrypt_support"
4
+
3
5
  module AddAuth
4
6
  class Configuration
5
7
  attr_accessor :challenge, :challenge_on,
6
- :digest_secret, :eligible, :stylesheet, :css_classes, :mail_from, :base_url, :rate_limit_store, :trusted_recovery_address, :support_url
8
+ :digest_secret, :eligible, :stylesheet, :css_classes, :mail_from, :base_url, :rate_limit_store, :trusted_recovery_address, :support_url, :legacy_password_verifier, :current_password_support
7
9
  attr_writer :session_token_digest, :sign_in_token_digest
8
10
 
9
11
  SessionOptions = Struct.new(:enabled, :lifetime, :idle_timeout, :legacy_bridge_until)
12
+ MobileOptions = Struct.new(:enabled, :lifetime, :idle_timeout, :clients, :callbacks, :apple_providers)
10
13
  EmailOptions = Struct.new(:enabled, :token_lifetime, :same_browser)
11
14
  StepUpOptions = Struct.new(:enabled, :purposes, :fresh_for, :strong_for)
12
15
  PasskeyOptions = Struct.new(:enabled, :rp_id, :origins, :name, :anonymous_limit)
13
16
  NotificationOptions = Struct.new(:enabled)
14
- MaintenanceOptions = Struct.new(:batch_size, :session_retention, :email_retention, :notification_retention)
15
- attr_accessor :passwords_enabled
16
- attr_reader :maintenance, :notifications, :passkeys, :session, :email_link, :step_up, :challenge_when_unavailable
17
+ MaintenanceOptions = Struct.new(:batch_size, :session_retention, :email_retention, :notification_retention, :account_retention)
18
+ LifecycleOptions = Struct.new(:enabled, :password_policy, :eligible, :provision, :profile_attributes, :maximum_attempts, :unlock_in, :proof_lifetime, :remember_lifetime, :remember_idle_timeout, :deletion_allowed, :delete_account)
19
+ class ExternalProvider
20
+ attr_reader :id, :label, :middleware_name, :configuration, :apple_form_post, :reauthentication
21
+
22
+ def initialize(id:, label:, middleware_name:, configuration:, apple_form_post: false, reauthentication: false)
23
+ @id, @label, @middleware_name = [id, label, middleware_name].map { |value| text(value) }
24
+ raise ArgumentError, "external provider middleware name is invalid" unless middleware_name.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
25
+ raise ArgumentError, "external provider configuration does not match its id" unless configuration.respond_to?(:id) && configuration.id == id
26
+ raise ArgumentError, "Apple form_post must be explicit" unless apple_form_post == true || apple_form_post == false
27
+
28
+ raise ArgumentError, "provider reauthentication must be explicit" unless [true, false].include?(reauthentication)
29
+ @reauthentication = reauthentication
30
+ @configuration, @apple_form_post = configuration, apple_form_post
31
+ freeze
32
+ end
33
+
34
+ def reauthentication? = reauthentication
35
+ def apple_form_post? = apple_form_post
36
+
37
+ private
38
+
39
+ def text(value)
40
+ raise ArgumentError, "external provider value is invalid" unless value.is_a?(String) && value.valid_encoding? &&
41
+ value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
42
+
43
+ value.dup.freeze
44
+ end
45
+ end
46
+
47
+ class ExternalIdentityOptions
48
+ attr_accessor :enabled
49
+
50
+ def initialize
51
+ @enabled = false
52
+ @providers = []
53
+ @native_providers = {}
54
+ end
55
+
56
+ def register(**attributes)
57
+ provider = ExternalProvider.new(**attributes)
58
+ raise ArgumentError, "external provider id is already registered" if configurations.any? { |item| item.id == provider.id }
59
+ raise ArgumentError, "external provider middleware is already registered" if @providers.any? { |item| item.middleware_name == provider.middleware_name }
60
+
61
+ @providers << provider
62
+ provider
63
+ end
64
+
65
+ def provider(id)
66
+ @providers.find { |item| item.id == id.to_s }
67
+ end
68
+
69
+ def providers = @providers.dup.freeze
70
+
71
+ def register_native(configuration:)
72
+ raise ArgumentError, "a native provider configuration is required" unless configuration.is_a?(Core::ExternalIdentities::Configuration)
73
+ raise ArgumentError, "external provider id is already registered" if configurations.any? { |item| item.id == configuration.id }
74
+ @native_providers[configuration.id] = configuration
75
+ end
76
+
77
+ def native_provider(id) = @native_providers[id]
78
+ def configurations = (providers.map(&:configuration) + @native_providers.values).freeze
79
+ end
80
+
81
+ attr_accessor :passwords_enabled, :turbo_enabled
82
+ attr_reader :maintenance, :notifications, :passkeys, :session, :email_link, :step_up, :challenge_when_unavailable, :lifecycle, :external_identities, :mobile
17
83
 
18
84
  def initialize
19
85
  @passwords_enabled = true
86
+ @current_password_support = Core::Passwords::BcryptSupport.new
87
+ @turbo_enabled = true
88
+ @lifecycle = LifecycleOptions.new(enabled: false, password_policy: ->(password) { password.length >= 12 && password.bytesize <= 72 },
89
+ eligible: ->(_user) { true }, provision: ->(_user) {}, profile_attributes: ->(_profile) { {} }, maximum_attempts: 20, unlock_in: 3600, proof_lifetime: 3600,
90
+ remember_lifetime: 14 * 86_400, remember_idle_timeout: 7 * 86_400,
91
+ deletion_allowed: ->(_user) { true }, delete_account: ->(user) { user.destroy! })
20
92
  @session = SessionOptions.new(enabled: false, lifetime: 43_200, idle_timeout: 1800)
93
+ @mobile = MobileOptions.new(enabled: false, clients: [], callbacks: {}, apple_providers: {})
21
94
  @email_link = EmailOptions.new(enabled: false, token_lifetime: 1200, same_browser: false)
95
+ @external_identities = ExternalIdentityOptions.new
22
96
  @notifications = NotificationOptions.new(enabled: false)
23
97
  @maintenance = MaintenanceOptions.new(batch_size: 100)
24
98
  @passkeys = PasskeyOptions.new(enabled: false, origins: [], name: "Your account", anonymous_limit: 1000)
@@ -5,10 +5,11 @@ module AddAuth
5
5
  # Account policy survives feature toggles; disabling passkeys cannot restore
6
6
  # weaker access to an account that explicitly adopted strict policy.
7
7
  class AccessPolicy
8
- def initialize(credentials:, passkeys_enabled:, email_enabled:, trusted_recovery_address:, password_enabled: true)
8
+ def initialize(credentials:, passkeys_enabled:, email_enabled:, trusted_recovery_address:, password_enabled: true, external_enabled: false, external_current: nil)
9
9
  @credentials, @passkeys_enabled, @email_enabled = credentials, passkeys_enabled, email_enabled
10
10
  @trusted_recovery_address = trusted_recovery_address
11
11
  @password_enabled = password_enabled
12
+ @external_enabled, @external_current = external_enabled == true, external_current
12
13
  end
13
14
 
14
15
  def strict?(user) = user.respond_to?(:add_auth_strict) && user.add_auth_strict == true
@@ -20,6 +21,7 @@ module AddAuth
20
21
  when :password then @password_enabled && user.respond_to?(:password_digest) && user.password_digest.is_a?(String) && !user.password_digest.empty?
21
22
  when :email_link then @email_enabled
22
23
  when :passkey then @passkeys_enabled
24
+ when :external_identity then @external_enabled
23
25
  else false
24
26
  end
25
27
  end
@@ -41,8 +43,16 @@ module AddAuth
41
43
  credential && credential.user_id == user.id && !credential.revoked_at
42
44
  end
43
45
 
46
+ def external_credential_current?(user:, id:, version:)
47
+ @external_enabled && @external_current && @external_current.call(user: user, id: id, version: version) == true
48
+ end
49
+
44
50
  def session_allowed?(user, row)
45
51
  return false if row.respond_to?(:authentication_policy_version) && row.authentication_policy_version != version(user)
52
+ if row.authenticated_with == "external_identity"
53
+ return false unless row.respond_to?(:authentication_external_id) &&
54
+ external_credential_current?(user: user, id: row.authentication_external_id, version: row.authentication_external_version)
55
+ end
46
56
  if row.authenticated_with == "passkey"
47
57
  return false unless row.authentication_uv && credential_current?(user: user, id: row.authentication_credential_id)
48
58
  end