add_auth 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +38 -14
  4. data/ROADMAP.md +87 -38
  5. data/SECURITY.md +6 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +138 -0
  7. data/app/controllers/add_auth/assets_controller.rb +2 -1
  8. data/app/controllers/add_auth/external_identities_controller.rb +56 -0
  9. data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
  10. data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
  11. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  12. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  13. data/app/jobs/add_auth/account_request_job.rb +17 -0
  14. data/app/mailers/add_auth/account_mailer.rb +12 -0
  15. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  16. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  17. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  18. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  19. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  20. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  21. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  22. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  23. data/app/views/add_auth/accounts/_request.html.erb +14 -0
  24. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  25. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  26. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  27. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  28. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  29. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  30. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  31. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  32. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  33. data/app/views/add_auth/sessions/index.html.erb +7 -0
  34. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  35. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  36. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  37. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  38. data/exe/add_auth-preflight +26 -0
  39. data/lib/add_auth/configuration.rb +78 -4
  40. data/lib/add_auth/core/access_policy.rb +11 -1
  41. data/lib/add_auth/core/account_lifecycle.rb +251 -0
  42. data/lib/add_auth/core/account_policy.rb +50 -0
  43. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  44. data/lib/add_auth/core/external_identities.rb +278 -0
  45. data/lib/add_auth/core/intake.rb +6 -1
  46. data/lib/add_auth/core/maintenance.rb +5 -3
  47. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  48. data/lib/add_auth/core/migration/readiness.rb +41 -0
  49. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  50. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  51. data/lib/add_auth/core/mobile_profile.rb +46 -0
  52. data/lib/add_auth/core/mobile_response.rb +12 -0
  53. data/lib/add_auth/core/native_authentication.rb +77 -0
  54. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  55. data/lib/add_auth/core/passwords/credential.rb +49 -0
  56. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  57. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  58. data/lib/add_auth/core/remaining_factors.rb +24 -0
  59. data/lib/add_auth/core/sessions.rb +102 -17
  60. data/lib/add_auth/core/step_up.rb +13 -1
  61. data/lib/add_auth/migration/static_inventory.rb +116 -0
  62. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  63. data/lib/add_auth/rails/authentication.rb +1 -1
  64. data/lib/add_auth/rails/authentication_pages.rb +5 -0
  65. data/lib/add_auth/rails/doctor.rb +70 -3
  66. data/lib/add_auth/rails/ejection.rb +1 -1
  67. data/lib/add_auth/rails/engine.rb +6 -1
  68. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  69. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  70. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  71. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  72. data/lib/add_auth/rails/password_adoption.rb +37 -0
  73. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  74. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  77. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  78. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  80. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  81. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  82. data/lib/add_auth/rails/runtime.rb +170 -8
  83. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  84. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  85. data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
  86. data/lib/add_auth/rails/stores/authority.rb +28 -0
  87. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  88. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  89. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  90. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  91. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  92. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  93. data/lib/add_auth/result.rb +1 -0
  94. data/lib/add_auth/version.rb +1 -1
  95. data/lib/add_auth.rb +4 -0
  96. data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
  97. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  98. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  99. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  100. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  101. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  102. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  103. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  104. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  105. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  106. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  107. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  109. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  110. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  111. data/lib/generators/add_auth/install/templates/initializer.rb +4 -0
  112. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  113. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  114. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  115. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  116. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  117. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  118. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  119. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  120. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  121. data/lib/tasks/add_auth.rake +18 -1
  122. metadata +85 -2
@@ -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
@@ -0,0 +1,251 @@
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
+
20
+ class NewAccount
21
+ attr_reader :user, :user_id
22
+ def initialize(user:)
23
+ @user, @user_id = user, user.id
24
+ freeze
25
+ end
26
+ private_class_method :new
27
+ def inspect = "#<AddAuth::Core::AccountLifecycle::NewAccount [FILTERED]>"
28
+ end
29
+
30
+ def self.validate_host_write!(changes:, enabled:)
31
+ if enabled && (changes.keys.map(&:to_s) & %w[email_address unconfirmed_email]).any?
32
+ raise AddAuth::Error, "change account email through the account lifecycle command"
33
+ end
34
+ end
35
+
36
+ def initialize(store:, digest:, delivery_cipher:, policy:, password_policy:, trusted_address:, clock: Time, random: SecureRandom,
37
+ lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil)
38
+ raise ArgumentError, "proof lifetime must be between 60 and 86400 seconds" unless lifetime.is_a?(Numeric) && lifetime.between?(60, 86_400)
39
+ @store, @digest, @cipher, @policy, @password_policy, @trusted_address = store, digest, delivery_cipher, policy, password_policy, trusted_address
40
+ @clock, @random, @lifetime, @notify = clock, random, lifetime, notify
41
+ @sessions, @step_up_policy = sessions, step_up_policy
42
+ @profile_attributes = profile_attributes
43
+ @deletion_allowed = deletion_allowed
44
+ @external_identities = external_identities
45
+ end
46
+
47
+ def register(identifier:, password:, profile: {})
48
+ return failure unless valid_new_password?(password)
49
+ register_account(identifier: identifier, password: password, profile: profile)
50
+ end
51
+
52
+ def register_external(identifier:, evidence:, profile: {})
53
+ return failure unless @external_identities
54
+ external = @external_identities.call
55
+ register_account(identifier: identifier, password: nil, profile: profile) do |user|
56
+ external.bind_new_account_in_transaction(registration: NewAccount.send(:new, user: user), evidence: evidence)
57
+ end
58
+ rescue ExternalIdentities::EnrollmentRejected
59
+ failure
60
+ end
61
+
62
+ def issue(identifier:, purpose:, request_id: @random.uuid)
63
+ return failure unless PURPOSES.include?(purpose.to_s)
64
+ email = normalize(identifier)
65
+ return accepted unless email
66
+ @store.with_user(identifier: email) do |user|
67
+ next if @store.issued?(request_id: request_id)
68
+ next unless @policy.allowed?(user, purpose: purpose)
69
+ recipient = recipient_for(user, purpose.to_s)
70
+ next unless recipient
71
+ issue_in_transaction(user: user, purpose: purpose.to_s, recipient: recipient, request_id: request_id)
72
+ end
73
+ accepted
74
+ end
75
+
76
+ def preview(token:, purpose:)
77
+ return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
78
+ user, record = @store.inspect_token(digest: @digest.digest(token))
79
+ reason = rejection(user, record, purpose: purpose.to_s)
80
+ reason ? Result.failure(reason: reason) : Result.success(user: user, strategy: purpose.to_sym)
81
+ end
82
+
83
+ def consume(token:, purpose:, password: nil)
84
+ return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
85
+ @store.with_token(digest: @digest.digest(token)) do |user, record|
86
+ reason = rejection(user, record, purpose: purpose.to_s)
87
+ next Result.failure(reason: reason) if reason
88
+ now = @clock.now
89
+ case purpose.to_s
90
+ when "confirm"
91
+ pending = user.unconfirmed_email
92
+ if pending && !pending.empty?
93
+ old_address = user.email_address
94
+ @store.promote_address(user: user, digest: address_digest(pending))
95
+ @store.update_account(user: user, email_address: pending, unconfirmed_email: nil, confirmed_at: now)
96
+ @store.revoke_authority(user: user, at: now)
97
+ @notify.call(user: user, kind: :email_changed, at: now, recipient: old_address)
98
+ @notify.call(user: user, kind: :email_changed, at: now, recipient: pending)
99
+ else
100
+ @store.update_account(user: user, confirmed_at: now)
101
+ @store.provision(user: user)
102
+ end
103
+ when "reset_password"
104
+ next failure unless valid_new_password?(password)
105
+ @store.replace_password(user: user, password: password)
106
+ @store.revoke_authority(user: user, at: now)
107
+ @notify.call(user: user, kind: :password_changed, at: now)
108
+ when "unlock"
109
+ @store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
110
+ end
111
+ @store.consume(record: record, at: now)
112
+ Result.success(user: user, strategy: purpose.to_sym)
113
+ end
114
+ rescue Conflict, InvalidPassword
115
+ failure
116
+ end
117
+
118
+ def change_email(user:, session:, identifier:)
119
+ email = normalize(identifier)
120
+ return failure unless email
121
+ with_account_change(user: user, session: session, purpose: :change_email) do |account|
122
+ next accepted if email == normalize(account.email_address)
123
+ @store.claim_address(user: account, digest: address_digest(email), address: email, state: "pending")
124
+ @store.update_account(user: account, unconfirmed_email: email)
125
+ issue_in_transaction(user: account, purpose: "confirm", recipient: email)
126
+ accepted
127
+ end
128
+ rescue Conflict
129
+ failure
130
+ end
131
+
132
+ def change_password(user:, session:, password:)
133
+ return failure unless valid_new_password?(password)
134
+ with_account_change(user: user, session: session, purpose: :change_password) do |account|
135
+ @store.replace_password(user: account, password: password)
136
+ @store.revoke_authority(user: account, at: @clock.now)
137
+ @notify.call(user: account, kind: :password_changed, at: @clock.now)
138
+ accepted
139
+ end
140
+ rescue InvalidPassword
141
+ failure
142
+ end
143
+
144
+ def valid_new_password?(password)
145
+ password.is_a?(String) && password.valid_encoding? && !password.include?("\0") && password.bytesize.between?(1, 1024) && @password_policy.call(password) == true
146
+ end
147
+
148
+ def delete_account(user:, session:)
149
+ with_account_change(user: user, session: session, purpose: :delete_account) do |account|
150
+ next failure unless @deletion_allowed.call(account) == true
151
+ @store.revoke_authority(user: account, at: @clock.now)
152
+ @store.delete_account(user: account)
153
+ accepted
154
+ end
155
+ rescue DeletionRejected
156
+ failure
157
+ end
158
+
159
+ # The password lifecycle already owns the account transaction and lock.
160
+ def issue_unlock_in_transaction(user:)
161
+ return unless @policy.allowed?(user, purpose: :unlock)
162
+ recipient = recipient_for(user, "unlock")
163
+ issue_in_transaction(user: user, purpose: "unlock", recipient: recipient) if recipient
164
+ end
165
+
166
+ private
167
+
168
+ def register_account(identifier:, password:, profile:)
169
+ email = normalize(identifier)
170
+ return failure unless email && profile.is_a?(Hash) && profile.length <= 20
171
+ attributes = @profile_attributes.call(profile)
172
+ 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_") }
173
+ raise AddAuth::Error, "registration profile must contain host profile fields only" unless profile_valid
174
+ outcome = @store.create_account(email: email, password: password, profile: attributes) do |user|
175
+ raise Conflict unless @policy.allowed?(user, purpose: :confirm)
176
+ yield user if block_given?
177
+ @store.claim_address(user: user, digest: address_digest(email), address: email, state: "current")
178
+ issue_in_transaction(user: user, purpose: "confirm", recipient: email)
179
+ end
180
+ (outcome == :invalid) ? failure : accepted
181
+ end
182
+
183
+ def with_account_change(user:, session:, purpose:)
184
+ outcome = Result.failure(reason: :elevation_required)
185
+ return outcome unless @sessions && @step_up_policy
186
+ authorization = @sessions.with_elevation(user: user, session: session, purpose: purpose, policy: @step_up_policy) do |account|
187
+ outcome = @policy.allowed?(account, purpose: :manage) ? yield(account) : failure
188
+ end
189
+ authorization.success? ? outcome : authorization
190
+ end
191
+
192
+ def accepted = Result.success(user: nil, strategy: :account_request)
193
+ def failure = Result.failure(reason: :invalid_credentials)
194
+
195
+ def normalize(value)
196
+ return unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 254)
197
+ value = value.strip.downcase
198
+ value if value.match?(/\A[^\s@]+@[^\s@]+\z/) && value.bytesize <= 254
199
+ end
200
+
201
+ def address_digest(email) = @digest.digest("account-address:#{normalize(email)}")
202
+
203
+ def recipient_for(user, purpose)
204
+ if purpose == "confirm"
205
+ normalize(user.unconfirmed_email.to_s.empty? ? user.email_address : user.unconfirmed_email)
206
+ else
207
+ address = normalize(@trusted_address.call(user))
208
+ address if address == normalize(user.email_address)
209
+ end
210
+ end
211
+
212
+ def version(user, purpose)
213
+ @digest.digest(JSON.generate([purpose, user.id, user.email_address, user.unconfirmed_email,
214
+ user.password_digest, user.confirmed_at&.to_f, user.locked_at&.to_f, user.disabled_at&.to_f,
215
+ user.deleted_at&.to_f, user.add_auth_manual_lock]))
216
+ end
217
+
218
+ def issue_in_transaction(user:, purpose:, recipient:, request_id: @random.uuid)
219
+ token = "ac1:#{@random.urlsafe_base64(32)}"
220
+ digest = @digest.digest(token)
221
+ expires_at = @clock.now + @lifetime
222
+ payload = @cipher.encrypt(token: JSON.generate(token: token, recipient: recipient, purpose: purpose), digest: digest, expires_at: expires_at)
223
+ @store.replace_pending(user: user, purpose: purpose, digest: digest, request_id: request_id,
224
+ address_digest: address_digest(recipient), account_version: version(user, purpose),
225
+ expires_at: expires_at, delivery_payload: payload, created_at: @clock.now)
226
+ end
227
+
228
+ def rejection(user, record, purpose: record&.purpose)
229
+ return :invalid_credentials unless user && record && record.purpose == purpose && PURPOSES.include?(purpose)
230
+ return :consumed_token if record.consumed_at
231
+ return :revoked_token if record.revoked_at
232
+ return :expired_token if record.expires_at <= @clock.now
233
+ return :invalid_credentials unless @policy.allowed?(user, purpose: purpose)
234
+ recipient = recipient_for(user, purpose)
235
+ return :invalid_credentials unless recipient && record.address_digest == address_digest(recipient) && record.account_version == version(user, purpose)
236
+ nil
237
+ end
238
+
239
+ def delivery_details(user, record)
240
+ raw = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
241
+ return unless raw
242
+ payload = JSON.parse(raw)
243
+ return unless payload["purpose"] == record.purpose && payload["recipient"] == recipient_for(user, record.purpose) &&
244
+ payload["token"].is_a?(String) && @digest.matches?(record.digest, payload["token"])
245
+ {recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose}
246
+ rescue JSON::ParserError, KeyError
247
+ nil
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ class AccountPolicy
6
+ def initialize(enabled:, eligible: ->(_user) { true }, clock: Time)
7
+ @enabled, @eligible, @clock = enabled, eligible, clock
8
+ end
9
+
10
+ def allowed?(user, purpose: :sign_in)
11
+ return denial(user).nil? if %i[sign_in manage].include?(purpose.to_sym)
12
+ return false unless available?(user)
13
+ return true unless @enabled
14
+ case purpose.to_sym
15
+ when :confirm
16
+ !value(user, :confirmed_at) || !value(user, :unconfirmed_email).to_s.empty?
17
+ when :unlock
18
+ locked?(user) && !value(user, :add_auth_manual_lock)
19
+ when :reset_password
20
+ !!value(user, :confirmed_at) && !value(user, :add_auth_strict)
21
+ else false
22
+ end
23
+ end
24
+
25
+ # Only a verified-password result may disclose this to an anonymous client.
26
+ def denial(user)
27
+ return :disabled unless available?(user)
28
+ return unless @enabled
29
+ return :unconfirmed unless value(user, :confirmed_at)
30
+ :locked if locked?(user)
31
+ end
32
+
33
+ def locked?(user)
34
+ value(user, :locked_at) && (value(user, :add_auth_manual_lock) || !value(user, :add_auth_locked_until) || value(user, :add_auth_locked_until) > @clock.now)
35
+ end
36
+
37
+ private
38
+
39
+ def available?(user)
40
+ user && @eligible.call(user) == true &&
41
+ (!user.respond_to?(:add_auth_authority) || user.add_auth_authority == "add_auth") &&
42
+ (!@enabled || !(value(user, :disabled_at) || value(user, :deleted_at)))
43
+ end
44
+
45
+ def value(user, name)
46
+ user.public_send(name) if user.respond_to?(name)
47
+ end
48
+ end
49
+ end
50
+ end