add_auth 0.2.2 → 0.4.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +64 -17
  4. data/ROADMAP.md +98 -43
  5. data/SECURITY.md +8 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +146 -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/controllers/add_auth/reauthentications_controller.rb +5 -0
  12. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  13. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  14. data/app/jobs/add_auth/account_request_job.rb +17 -0
  15. data/app/mailers/add_auth/account_mailer.rb +12 -0
  16. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  17. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  18. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  19. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  20. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  21. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  22. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  23. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  24. data/app/views/add_auth/accounts/_request.html.erb +17 -0
  25. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  26. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  27. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  28. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  29. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  30. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  31. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  32. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  33. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  34. data/app/views/add_auth/sessions/index.html.erb +7 -0
  35. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  36. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  37. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  38. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  39. data/exe/add_auth-preflight +26 -0
  40. data/lib/add_auth/configuration.rb +78 -4
  41. data/lib/add_auth/core/access_policy.rb +11 -1
  42. data/lib/add_auth/core/account_lifecycle.rb +295 -0
  43. data/lib/add_auth/core/account_policy.rb +68 -0
  44. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  45. data/lib/add_auth/core/external_identities.rb +278 -0
  46. data/lib/add_auth/core/intake.rb +6 -1
  47. data/lib/add_auth/core/maintenance.rb +5 -3
  48. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  49. data/lib/add_auth/core/migration/readiness.rb +41 -0
  50. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  51. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  52. data/lib/add_auth/core/mobile_profile.rb +46 -0
  53. data/lib/add_auth/core/mobile_response.rb +12 -0
  54. data/lib/add_auth/core/native_authentication.rb +77 -0
  55. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  56. data/lib/add_auth/core/passwords/credential.rb +49 -0
  57. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  58. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  59. data/lib/add_auth/core/remaining_factors.rb +24 -0
  60. data/lib/add_auth/core/sessions.rb +102 -17
  61. data/lib/add_auth/core/step_up.rb +13 -1
  62. data/lib/add_auth/migration/static_inventory.rb +116 -0
  63. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  64. data/lib/add_auth/rails/authentication.rb +1 -1
  65. data/lib/add_auth/rails/authentication_pages.rb +6 -1
  66. data/lib/add_auth/rails/doctor.rb +80 -6
  67. data/lib/add_auth/rails/ejection.rb +1 -1
  68. data/lib/add_auth/rails/engine.rb +6 -1
  69. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  70. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  71. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  72. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  73. data/lib/add_auth/rails/password_adoption.rb +37 -0
  74. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  77. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  78. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  80. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  81. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  82. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  83. data/lib/add_auth/rails/runtime.rb +179 -8
  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 +104 -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 +56 -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_add_auth_provisioning.rb.tt +9 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  101. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  102. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  103. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  104. data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
  105. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  106. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  107. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  110. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  111. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  112. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  113. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  114. data/lib/generators/add_auth/feature_configuration.rb +21 -0
  115. data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
  116. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  117. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  119. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  120. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  121. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  122. data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
  123. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  124. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
  125. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  126. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
  127. data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
  128. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  129. data/lib/tasks/add_auth.rake +18 -1
  130. metadata +101 -2
@@ -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, :confirmation_required, :reset_unconfirmed)
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! }, confirmation_required: true, reset_unconfirmed: false)
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
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module AddAuth
7
+ module Core
8
+ class AccountLifecycle
9
+ include Delivery
10
+
11
+ PURPOSES = %w[confirm reset_password unlock].freeze
12
+ TOKEN = /\Aac1:[A-Za-z0-9_-]{43}\z/
13
+ SECURITY_ATTRIBUTES = %w[id email email_address password password_confirmation password_digest encrypted_password
14
+ confirmed_at unconfirmed_email locked_at disabled_at deleted_at failed_attempts confirmation_token
15
+ reset_password_token unlock_token authentication_token].freeze
16
+ class Conflict < StandardError; end
17
+ class InvalidPassword < StandardError; end
18
+ class DeletionRejected < StandardError; end
19
+ class RegistrationRejected < StandardError; end
20
+
21
+ class NewAccount
22
+ attr_reader :user, :user_id
23
+ def initialize(user:)
24
+ @user, @user_id = user, user.id
25
+ freeze
26
+ end
27
+ private_class_method :new
28
+ def inspect = "#<AddAuth::Core::AccountLifecycle::NewAccount [FILTERED]>"
29
+ end
30
+
31
+ def self.validate_host_write!(changes:, enabled:)
32
+ if enabled && (changes.keys.map(&:to_s) & %w[email_address unconfirmed_email]).any?
33
+ raise AddAuth::Error, "change account email through the account lifecycle command"
34
+ end
35
+ end
36
+
37
+ def initialize(store:, digest:, delivery_cipher:, policy:, password_policy:, trusted_address:, clock: Time, random: SecureRandom,
38
+ lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil)
39
+ raise ArgumentError, "proof lifetime must be between 60 and 86400 seconds" unless lifetime.is_a?(Numeric) && lifetime.between?(60, 86_400)
40
+ @store, @digest, @cipher, @policy, @password_policy, @trusted_address = store, digest, delivery_cipher, policy, password_policy, trusted_address
41
+ @clock, @random, @lifetime, @notify = clock, random, lifetime, notify
42
+ @sessions, @step_up_policy = sessions, step_up_policy
43
+ @profile_attributes = profile_attributes
44
+ @deletion_allowed = deletion_allowed
45
+ @external_identities = external_identities
46
+ end
47
+
48
+ def register(identifier:, password:, profile: {}, replacing: nil, user_agent: nil, ip_address: nil, remember: false)
49
+ return failure unless valid_new_password?(password)
50
+ register_account(identifier: identifier, password: password, profile: profile,
51
+ immediate: !@policy.confirmation_required?, replacing: replacing,
52
+ session_hints: {user_agent: user_agent, ip_address: ip_address, remember: remember})
53
+ end
54
+
55
+ # Generic non-grant result mapping; never authenticate a duplicate result.
56
+ def registration_next_path
57
+ @policy.confirmation_required? ? "/account/check-email" : "/sign-in"
58
+ end
59
+
60
+ def registration_instructions
61
+ @policy.confirmation_required? ? "We’ll email you a link to confirm your address before you sign in." :
62
+ "Create your account and sign in right away. Your email address stays unverified until you confirm it."
63
+ end
64
+
65
+ def register_external(identifier:, evidence:, profile: {})
66
+ return failure unless @external_identities
67
+ external = @external_identities.call
68
+ register_account(identifier: identifier, password: nil, profile: profile) do |user|
69
+ external.bind_new_account_in_transaction(registration: NewAccount.send(:new, user: user), evidence: evidence)
70
+ end
71
+ rescue ExternalIdentities::EnrollmentRejected
72
+ failure
73
+ end
74
+
75
+ def issue(identifier:, purpose:, request_id: @random.uuid)
76
+ return failure unless PURPOSES.include?(purpose.to_s)
77
+ email = normalize(identifier)
78
+ return accepted unless email
79
+ @store.with_user(identifier: email) do |user|
80
+ next if @store.issued?(request_id: request_id)
81
+ next unless @policy.allowed?(user, purpose: purpose)
82
+ recipient = recipient_for(user, purpose.to_s)
83
+ next unless recipient
84
+ issue_in_transaction(user: user, purpose: purpose.to_s, recipient: recipient, request_id: request_id)
85
+ end
86
+ accepted
87
+ end
88
+
89
+ def preview(token:, purpose:)
90
+ return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
91
+ user, record = @store.inspect_token(digest: @digest.digest(token))
92
+ reason = rejection(user, record, purpose: purpose.to_s)
93
+ reason ? Result.failure(reason: reason) : Result.success(user: user, strategy: purpose.to_sym)
94
+ end
95
+
96
+ def consume(token:, purpose:, password: nil)
97
+ return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
98
+ @store.with_token(digest: @digest.digest(token)) do |user, record|
99
+ reason = rejection(user, record, purpose: purpose.to_s)
100
+ next Result.failure(reason: reason) if reason
101
+ now = @clock.now
102
+ case purpose.to_s
103
+ when "confirm"
104
+ pending = user.unconfirmed_email
105
+ if pending && !pending.empty?
106
+ old_address = user.email_address
107
+ @store.promote_address(user: user, digest: address_digest(pending))
108
+ @store.update_account(user: user, email_address: pending, unconfirmed_email: nil, confirmed_at: now)
109
+ @store.revoke_authority(user: user, at: now)
110
+ @notify.call(user: user, kind: :email_changed, at: now, recipient: old_address)
111
+ @notify.call(user: user, kind: :email_changed, at: now, recipient: pending)
112
+ else
113
+ @store.update_account(user: user, confirmed_at: now)
114
+ provision_account(user)
115
+ end
116
+ when "reset_password"
117
+ next failure unless valid_new_password?(password)
118
+ @store.replace_password(user: user, password: password)
119
+ @store.revoke_authority(user: user, at: now)
120
+ @notify.call(user: user, kind: :password_changed, at: now)
121
+ when "unlock"
122
+ @store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
123
+ end
124
+ @store.consume(record: record, at: now)
125
+ Result.success(user: user, strategy: purpose.to_sym)
126
+ end
127
+ rescue Conflict, InvalidPassword
128
+ failure
129
+ end
130
+
131
+ def change_email(user:, session:, identifier:)
132
+ email = normalize(identifier)
133
+ return failure unless email
134
+ with_account_change(user: user, session: session, purpose: :change_email) do |account|
135
+ next accepted if email == normalize(account.email_address)
136
+ @store.claim_address(user: account, digest: address_digest(email), address: email, state: "pending")
137
+ @store.update_account(user: account, unconfirmed_email: email)
138
+ issue_in_transaction(user: account, purpose: "confirm", recipient: email)
139
+ accepted
140
+ end
141
+ rescue Conflict
142
+ failure
143
+ end
144
+
145
+ def change_password(user:, session:, password:)
146
+ return failure unless valid_new_password?(password)
147
+ with_account_change(user: user, session: session, purpose: :change_password) do |account|
148
+ @store.replace_password(user: account, password: password)
149
+ @store.revoke_authority(user: account, at: @clock.now)
150
+ @notify.call(user: account, kind: :password_changed, at: @clock.now)
151
+ accepted
152
+ end
153
+ rescue InvalidPassword
154
+ failure
155
+ end
156
+
157
+ def valid_new_password?(password)
158
+ password.is_a?(String) && password.valid_encoding? && !password.include?("\0") && password.bytesize.between?(1, 1024) && @password_policy.call(password) == true
159
+ end
160
+
161
+ def delete_account(user:, session:)
162
+ with_account_change(user: user, session: session, purpose: :delete_account) do |account|
163
+ next failure unless @deletion_allowed.call(account) == true
164
+ @store.revoke_authority(user: account, at: @clock.now)
165
+ @store.delete_account(user: account)
166
+ accepted
167
+ end
168
+ rescue DeletionRejected
169
+ failure
170
+ end
171
+
172
+ # The password lifecycle already owns the account transaction and lock.
173
+ def issue_unlock_in_transaction(user:)
174
+ return unless @policy.allowed?(user, purpose: :unlock)
175
+ recipient = recipient_for(user, "unlock")
176
+ issue_in_transaction(user: user, purpose: "unlock", recipient: recipient) if recipient
177
+ end
178
+
179
+ private
180
+
181
+ def register_account(identifier:, password:, profile:, immediate: false, replacing: nil, session_hints: {})
182
+ email = normalize(identifier)
183
+ return failure unless email && profile.is_a?(Hash) && profile.length <= 20
184
+ attributes = @profile_attributes.call(profile)
185
+ profile_valid = attributes.is_a?(Hash) && attributes.keys.all? { |name| (name.is_a?(String) || name.is_a?(Symbol)) && !SECURITY_ATTRIBUTES.include?(name.to_s) && !name.to_s.start_with?("add_auth_") }
186
+ raise AddAuth::Error, "registration profile must contain host profile fields only" unless profile_valid
187
+ registered = nil
188
+ outcome = @store.create_account(email: email, password: password, profile: attributes, replacing: replacing) do |user|
189
+ raise Conflict unless @policy.allowed?(user, purpose: :confirm)
190
+ yield user if block_given?
191
+ @store.claim_address(user: user, digest: address_digest(email), address: email, state: "current")
192
+ if immediate
193
+ raise AddAuth::Error, "optional confirmation requires sessions and the provisioning migration" unless @sessions && user.respond_to?(:add_auth_provisioned_at)
194
+ raise RegistrationRejected unless @policy.allowed?(user)
195
+ password_version = user.password_digest
196
+ provision_account(user)
197
+ unless user.email_address == email && user.password_digest == password_version && !user.confirmed_at && user.unconfirmed_email.to_s.empty?
198
+ raise RegistrationRejected
199
+ end
200
+ grant = nil
201
+ @store.finalize_session(user: user) do |writer|
202
+ grant = @sessions.create_in_transaction(user: user, method: :password, persist: writer, replacing: replacing, **session_hints)
203
+ raise RegistrationRejected unless grant
204
+ grant.session
205
+ end
206
+ registered = Result.success(user: user, strategy: :password, session: grant.session, grant: grant)
207
+ else
208
+ issue_in_transaction(user: user, purpose: "confirm", recipient: email)
209
+ end
210
+ end
211
+ return failure if outcome == :invalid
212
+ (outcome == :created && registered) || accepted
213
+ rescue RegistrationRejected
214
+ failure
215
+ end
216
+
217
+ def provision_account(user)
218
+ tracked = user.respond_to?(:add_auth_provisioned_at)
219
+ return if tracked && user.add_auth_provisioned_at
220
+ @store.provision(user: user)
221
+ @store.update_account(user: user, add_auth_provisioned_at: @clock.now) if tracked
222
+ end
223
+
224
+ def with_account_change(user:, session:, purpose:)
225
+ outcome = Result.failure(reason: :elevation_required)
226
+ return outcome unless @sessions && @step_up_policy
227
+ authorization = @sessions.with_elevation(user: user, session: session, purpose: purpose, policy: @step_up_policy) do |account|
228
+ outcome = @policy.allowed?(account, purpose: :manage) ? yield(account) : failure
229
+ end
230
+ authorization.success? ? outcome : authorization
231
+ end
232
+
233
+ def accepted = Result.success(user: nil, strategy: :account_request)
234
+ def failure = Result.failure(reason: :invalid_credentials)
235
+
236
+ def normalize(value)
237
+ return unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 254)
238
+ value = value.strip.downcase
239
+ value if value.match?(/\A[^\s@]+@[^\s@]+\z/) && value.bytesize <= 254
240
+ end
241
+
242
+ def address_digest(email) = @digest.digest("account-address:#{normalize(email)}")
243
+
244
+ def recipient_for(user, purpose)
245
+ if purpose == "confirm"
246
+ normalize(user.unconfirmed_email.to_s.empty? ? user.email_address : user.unconfirmed_email)
247
+ elsif purpose == "reset_password" && @policy.unconfirmed_reset?(user)
248
+ normalize(user.email_address)
249
+ else
250
+ return unless @policy.trusted_address?(user)
251
+ address = normalize(@trusted_address.call(user))
252
+ address if address == normalize(user.email_address)
253
+ end
254
+ end
255
+
256
+ def version(user, purpose)
257
+ @digest.digest(JSON.generate([purpose, user.id, user.email_address, user.unconfirmed_email,
258
+ user.password_digest, user.confirmed_at&.to_f, user.locked_at&.to_f, user.disabled_at&.to_f,
259
+ user.deleted_at&.to_f, user.add_auth_manual_lock]))
260
+ end
261
+
262
+ def issue_in_transaction(user:, purpose:, recipient:, request_id: @random.uuid)
263
+ token = "ac1:#{@random.urlsafe_base64(32)}"
264
+ digest = @digest.digest(token)
265
+ expires_at = @clock.now + @lifetime
266
+ payload = @cipher.encrypt(token: JSON.generate(token: token, recipient: recipient, purpose: purpose), digest: digest, expires_at: expires_at)
267
+ @store.replace_pending(user: user, purpose: purpose, digest: digest, request_id: request_id,
268
+ address_digest: address_digest(recipient), account_version: version(user, purpose),
269
+ expires_at: expires_at, delivery_payload: payload, created_at: @clock.now)
270
+ end
271
+
272
+ def rejection(user, record, purpose: record&.purpose)
273
+ return :invalid_credentials unless user && record && record.purpose == purpose && PURPOSES.include?(purpose)
274
+ return :consumed_token if record.consumed_at
275
+ return :revoked_token if record.revoked_at
276
+ return :expired_token if record.expires_at <= @clock.now
277
+ return :invalid_credentials unless @policy.allowed?(user, purpose: purpose)
278
+ recipient = recipient_for(user, purpose)
279
+ return :invalid_credentials unless recipient && record.address_digest == address_digest(recipient) && record.account_version == version(user, purpose)
280
+ nil
281
+ end
282
+
283
+ def delivery_details(user, record)
284
+ raw = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
285
+ return unless raw
286
+ payload = JSON.parse(raw)
287
+ return unless payload["purpose"] == record.purpose && payload["recipient"] == recipient_for(user, record.purpose) &&
288
+ payload["token"].is_a?(String) && @digest.matches?(record.digest, payload["token"])
289
+ {recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose}
290
+ rescue JSON::ParserError, KeyError
291
+ nil
292
+ end
293
+ end
294
+ end
295
+ end