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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +38 -14
- data/ROADMAP.md +87 -38
- data/SECURITY.md +6 -0
- data/app/controllers/add_auth/accounts_controller.rb +138 -0
- data/app/controllers/add_auth/assets_controller.rb +2 -1
- data/app/controllers/add_auth/external_identities_controller.rb +56 -0
- data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
- data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
- data/app/jobs/add_auth/account_delivery_job.rb +14 -0
- data/app/jobs/add_auth/account_request_job.rb +17 -0
- data/app/mailers/add_auth/account_mailer.rb +12 -0
- data/app/views/add_auth/account_mailer/link.text.erb +7 -0
- data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
- data/app/views/add_auth/accounts/_delete.html.erb +6 -0
- data/app/views/add_auth/accounts/_email.html.erb +12 -0
- data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
- data/app/views/add_auth/accounts/_password.html.erb +12 -0
- data/app/views/add_auth/accounts/_proof.html.erb +16 -0
- data/app/views/add_auth/accounts/_register.html.erb +17 -0
- data/app/views/add_auth/accounts/_request.html.erb +14 -0
- data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
- data/app/views/add_auth/external_identities/_index.html.erb +22 -0
- data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
- data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
- data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
- data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
- data/app/views/add_auth/sessions/_list.html.erb +1 -1
- data/app/views/add_auth/sessions/index.html.erb +7 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
- data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
- data/app/views/layouts/add_auth/authentication.html.erb +4 -3
- data/exe/add_auth-preflight +26 -0
- data/lib/add_auth/configuration.rb +78 -4
- data/lib/add_auth/core/access_policy.rb +11 -1
- data/lib/add_auth/core/account_lifecycle.rb +251 -0
- data/lib/add_auth/core/account_policy.rb +50 -0
- data/lib/add_auth/core/external_identities/evidence.rb +98 -0
- data/lib/add_auth/core/external_identities.rb +278 -0
- data/lib/add_auth/core/intake.rb +6 -1
- data/lib/add_auth/core/maintenance.rb +5 -3
- data/lib/add_auth/core/migration/account_adoption.rb +79 -0
- data/lib/add_auth/core/migration/readiness.rb +41 -0
- data/lib/add_auth/core/mobile_authentication.rb +35 -0
- data/lib/add_auth/core/mobile_handoffs.rb +83 -0
- data/lib/add_auth/core/mobile_profile.rb +46 -0
- data/lib/add_auth/core/mobile_response.rb +12 -0
- data/lib/add_auth/core/native_authentication.rb +77 -0
- data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
- data/lib/add_auth/core/passwords/credential.rb +49 -0
- data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
- data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
- data/lib/add_auth/core/remaining_factors.rb +24 -0
- data/lib/add_auth/core/sessions.rb +102 -17
- data/lib/add_auth/core/step_up.rb +13 -1
- data/lib/add_auth/migration/static_inventory.rb +116 -0
- data/lib/add_auth/rails/account_password_entry.rb +24 -0
- data/lib/add_auth/rails/authentication.rb +1 -1
- data/lib/add_auth/rails/authentication_pages.rb +5 -0
- data/lib/add_auth/rails/doctor.rb +70 -3
- data/lib/add_auth/rails/ejection.rb +1 -1
- data/lib/add_auth/rails/engine.rb +6 -1
- data/lib/add_auth/rails/migration/account_store.rb +41 -0
- data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
- data/lib/add_auth/rails/migration/key_type.rb +23 -0
- data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
- data/lib/add_auth/rails/password_adoption.rb +37 -0
- data/lib/add_auth/rails/provider_enrollment.rb +61 -0
- data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
- data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
- data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
- data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
- data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
- data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
- data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
- data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
- data/lib/add_auth/rails/runtime.rb +170 -8
- data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
- data/lib/add_auth/rails/stores/account_lock.rb +16 -7
- data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
- data/lib/add_auth/rails/stores/authority.rb +28 -0
- data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
- data/lib/add_auth/rails/stores/external_identities.rb +91 -0
- data/lib/add_auth/rails/stores/maintenance.rb +1 -1
- data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
- data/lib/add_auth/rails/stores/security_events.rb +3 -6
- data/lib/add_auth/rails/user_lifecycle.rb +8 -10
- data/lib/add_auth/result.rb +1 -0
- data/lib/add_auth/version.rb +1 -1
- data/lib/add_auth.rb +4 -0
- data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
- data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
- data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
- data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
- data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
- data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +4 -0
- data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
- data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
- data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
- data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
- data/lib/tasks/add_auth.rake +18 -1
- 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 %>
|
|
@@ -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
|
|
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
|
-
|
|
16
|
-
|
|
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
|