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
@@ -20,13 +20,7 @@ module AddAuth
20
20
  transaction.after_commit { completed = true }
21
21
  # Opposing account switches acquire their locks in the same order.
22
22
  accounts = [id, additional_id].compact.uniq.sort_by(&:to_s).map do |account_id|
23
- if @users.connection.adapter_name == "SQLite"
24
- pk = @users.connection.quote_column_name(@users.primary_key)
25
- @users.where(@users.primary_key => account_id).update_all("#{pk} = #{pk}")
26
- @users.find_by(@users.primary_key => account_id)
27
- else
28
- @users.lock.find_by(@users.primary_key => account_id)
29
- end
23
+ current_in_transaction(id: account_id)
30
24
  end
31
25
  result = yield accounts.compact.find { |account| account.id == id }
32
26
  end
@@ -34,6 +28,21 @@ module AddAuth
34
28
  raise AddAuth::Error, "authentication transaction rolled back" unless completed
35
29
  result
36
30
  end
31
+
32
+ # For model save callbacks, whose Active Record transaction already owns
33
+ # the write. All actual row-lock acquisition lives in this adapter.
34
+ def current_in_transaction(id:)
35
+ raise AddAuth::Error, "account row locking requires an owning transaction" unless @users.connection.transaction_open?
36
+ @users.uncached do
37
+ if @users.connection.adapter_name == "SQLite"
38
+ pk = @users.connection.quote_column_name(@users.primary_key)
39
+ @users.unscoped.where(@users.primary_key => id).update_all("#{pk} = #{pk}")
40
+ @users.unscoped.find_by(@users.primary_key => id)
41
+ else
42
+ @users.unscoped.lock.find_by(@users.primary_key => id)
43
+ end
44
+ end
45
+ end
37
46
  end
38
47
  end
39
48
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/email_tokens"
4
+ require "add_auth/rails/stores/commit_dispatch"
5
+ require "add_auth/rails/stores/account_credentials"
6
+
7
+ module AddAuth
8
+ module Rails
9
+ module Stores
10
+ class AccountTokens < EmailTokens
11
+ def initialize(user_model:, token_model:, session_model:, address_model:, authority:, provision:, delete_account: ->(user) { user.destroy! })
12
+ super(user_model: user_model, token_model: token_model, session_model: session_model)
13
+ raise ArgumentError, "address claims must share the account connection pool" unless address_model.connection_pool.equal?(user_model.connection_pool)
14
+ @addresses, @authority, @provision = address_model, authority, provision
15
+ @delete_account = delete_account
16
+ @account_credentials = AccountCredentials.new(user_model: user_model, authority: authority)
17
+ end
18
+
19
+ def create_account(email:, password:, profile: {}, replacing: nil)
20
+ raise AddAuth::Error, "registration must own its transaction" if @users.connection.transaction_open?
21
+ completed = false
22
+ saved = false
23
+ @users.transaction do |transaction|
24
+ transaction.after_commit { completed = true }
25
+ AccountLock.new(@users).current_in_transaction(id: replacing.user_id) if replacing
26
+ user = @users.new(profile)
27
+ user.email_address = email
28
+ user.password = password
29
+ user.add_auth_authority = "add_auth"
30
+ user.save!
31
+ saved = true
32
+ yield user
33
+ end
34
+ raise AddAuth::Error, "registration transaction rolled back" unless completed
35
+ :created
36
+ rescue ActiveRecord::RecordNotUnique, Core::AccountLifecycle::Conflict
37
+ :duplicate
38
+ rescue ActiveRecord::RecordInvalid => error
39
+ raise if saved
40
+ (error.record.errors.attribute_names - [:email_address]).empty? ? :duplicate : :invalid
41
+ end
42
+
43
+ def replace_pending(**attributes)
44
+ raise AddAuth::Error, "account proof must join its owning transaction" unless @users.current_transaction.open?
45
+ record = super
46
+ CommitDispatch.enqueue(transaction: @users.current_transaction, job: ::AddAuth::AccountDeliveryJob,
47
+ id: record.id, failure_event: "account_enqueue_failed.add_auth", payload: {issuance_id: record.id})
48
+ record
49
+ end
50
+
51
+ def claim_address(user:, digest:, address:, state:)
52
+ raise Core::AccountLifecycle::Conflict if @users.where(email_address: address).where.not(id: user.id).exists?
53
+ @addresses.where(user_id: user.id, state: state).where.not(digest: digest).delete_all
54
+ existing = @addresses.find_by(digest: digest)
55
+ if existing
56
+ raise Core::AccountLifecycle::Conflict unless existing.user_id == user.id && existing.state == state
57
+ else
58
+ @addresses.create!(user_id: user.id, digest: digest, state: state)
59
+ end
60
+ rescue ActiveRecord::RecordNotUnique
61
+ raise Core::AccountLifecycle::Conflict
62
+ end
63
+
64
+ def promote_address(user:, digest:)
65
+ pending = @addresses.find_by!(user_id: user.id, digest: digest, state: "pending")
66
+ @addresses.where(user_id: user.id, state: "current").delete_all
67
+ pending.update!(state: "current")
68
+ end
69
+
70
+ def inspect_token(digest:)
71
+ @tokens.uncached do
72
+ record = @tokens.find_by(digest: digest)
73
+ [record && @users.find_by(id: record.user_id), record]
74
+ end
75
+ end
76
+
77
+ def update_account(user:, **attributes)
78
+ # Core owns notices and invalidation for this transition. Avoid firing
79
+ # a second independent model callback for the same committed change.
80
+ @account_credentials.update_account(user: user, **attributes)
81
+ end
82
+
83
+ def replace_password(user:, password:)
84
+ @account_credentials.replace_password(user: user, password: password)
85
+ end
86
+
87
+ def revoke_authority(user:, at:) = @authority.revoke(user_id: user.id, at: at)
88
+
89
+ def provision(user:)
90
+ @provision.call(user)
91
+ user.reload
92
+ end
93
+
94
+ def delete_account(user:)
95
+ raise AddAuth::Error, "account deletion requires its owning transaction" unless @users.connection.transaction_open?
96
+ @delete_account.call(user)
97
+ raise Core::AccountLifecycle::DeletionRejected if @users.unscoped.exists?(id: user.id)
98
+ rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::InvalidForeignKey
99
+ raise Core::AccountLifecycle::DeletionRejected, cause: nil
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Stores
6
+ # One persistence implementation for retiring outstanding account authority.
7
+ # Core chooses the event, account and time; callers own the account lock.
8
+ class Authority
9
+ def initialize(user_model:, session_model:, token_models: [], ceremony_models: [], credential_models: [], external_invalidator: nil)
10
+ @external_invalidator = external_invalidator
11
+ @users, @sessions, @tokens, @ceremonies, @credentials = user_model, session_model, token_models, ceremony_models, credential_models
12
+ unless [session_model, *token_models, *ceremony_models, *credential_models].all? { |model| model.connection_pool.equal?(user_model.connection_pool) }
13
+ raise ArgumentError, "authentication models must share one connection pool"
14
+ end
15
+ end
16
+
17
+ def revoke(user_id:, at:)
18
+ raise AddAuth::Error, "authority revocation requires an account transaction" unless @users.connection.transaction_open?
19
+ @external_invalidator&.call(user_id: user_id, at: at)
20
+ @sessions.where(user_id: user_id, revoked_at: nil).update_all(revoked_at: at)
21
+ @tokens.each { |model| model.where(user_id: user_id, consumed_at: nil, revoked_at: nil).update_all(revoked_at: at, delivery_payload: nil) }
22
+ @ceremonies.each { |model| model.where(user_id: user_id, consumed_at: nil).update_all(consumed_at: at) }
23
+ @credentials.each { |model| model.where(user_id: user_id, revoked_at: nil).update_all(revoked_at: at) }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Stores
6
+ module CommitDispatch
7
+ module_function
8
+
9
+ def enqueue(transaction:, job:, id:, failure_event:, payload:)
10
+ raise AddAuth::Error, "outbox dispatch must join its owning transaction" unless transaction.open?
11
+ transaction.after_commit do
12
+ raise AddAuth::Error unless job.perform_later(id)
13
+ rescue
14
+ notify(event: failure_event, payload: payload)
15
+ end
16
+ end
17
+
18
+ def event(transaction:, event:, payload:)
19
+ raise AddAuth::Error, "commit events must join their owning transaction" unless transaction.open?
20
+ transaction.after_commit { notify(event: event, payload: payload) }
21
+ end
22
+
23
+ def notify(event:, payload:)
24
+ ActiveSupport::Notifications.instrument(event, payload)
25
+ rescue
26
+ # Optional observers cannot report a committed operation as failed.
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "add_auth/rails/stores/account_lock"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ module Stores
9
+ class ExternalIdentities
10
+ def initialize(user_model:, identity_model:, transaction_model:)
11
+ @users, @identities, @transactions = user_model, identity_model, transaction_model
12
+ unless [identity_model, transaction_model].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
13
+ raise ArgumentError, "authentication models must share one connection pool"
14
+ end
15
+ @lock = AccountLock.new(@users)
16
+ end
17
+
18
+ def with_user(id:, &block) = @lock.call(id: id, &block)
19
+
20
+ def with_identity(namespace:, replacing: nil)
21
+ @identities.uncached do
22
+ locator = binding(namespace: namespace)
23
+ @lock.call(id: locator&.user_id, additional_id: replacing&.user_id) do |user|
24
+ yield user, user && binding(namespace: namespace)
25
+ end
26
+ end
27
+ end
28
+
29
+ # The G3 capability establishes fresh-account provenance. The adapter
30
+ # additionally enforces the exact user model/pool and owning transaction
31
+ # before a pending transaction can be consumed.
32
+ def registration_account(user:)
33
+ return unless @users.connection.transaction_open? && user.is_a?(@users) && user.persisted? && !user.destroyed?
34
+ @users.uncached { @users.find_by(id: user.id) }
35
+ end
36
+
37
+ def binding(namespace:) = @identities.find_by(namespace: namespace)
38
+ def binding_for_user(user_id:, id:) = @identities.find_by(user_id: user_id, id: id)
39
+ def bindings(user_id:) = @identities.where(user_id: user_id, revoked_at: nil).to_a
40
+
41
+ def bind(user:, evidence:, at:)
42
+ require_transaction!
43
+ # A savepoint contains unique-index failures on PostgreSQL. Account
44
+ # locks serialize same-owner callbacks; the unique namespace index
45
+ # arbitrates different owners without choosing one by email.
46
+ @identities.transaction(requires_new: true) do
47
+ identity = binding(namespace: evidence.namespace)
48
+ if identity
49
+ return unless identity.user_id == user.id
50
+ return identity unless identity.revoked_at
51
+ identity.update!(revoked_at: nil, linked_at: at, credential_version: SecureRandom.hex(16), provenance: evidence.provenance)
52
+ identity
53
+ else
54
+ @identities.create!(user_id: user.id, namespace: evidence.namespace, provider_id: evidence.provider_id,
55
+ issuer: evidence.issuer, audience: evidence.audience, subject: evidence.subject,
56
+ provenance: evidence.provenance, linked_at: at, credential_version: SecureRandom.hex(16))
57
+ end
58
+ end
59
+ rescue ActiveRecord::RecordNotUnique
60
+ nil
61
+ end
62
+
63
+ def revoke(identity:, at:)
64
+ require_transaction!
65
+ identity.update!(revoked_at: at, credential_version: SecureRandom.hex(16))
66
+ end
67
+
68
+ def invalidate_credentials(user_id:, at:, credential_version:)
69
+ require_transaction!
70
+ # Never move the cutoff backwards if a delayed invalidation arrives.
71
+ @identities.where(user_id: user_id, revoked_at: nil).where("invalidated_at IS NULL OR invalidated_at <= ?", at)
72
+ .update_all(invalidated_at: at, credential_version: credential_version, updated_at: at)
73
+ end
74
+
75
+ def create_transaction(**attributes) = @transactions.create!(**attributes)
76
+ def transaction_by_digest(digest:) = @transactions.uncached { @transactions.find_by(digest: digest) }
77
+
78
+ def consume_transaction(digest:, at:)
79
+ @transactions.where(digest: digest, consumed_at: nil).where("issued_at <= ? AND expires_at > ?", at, at)
80
+ .update_all(consumed_at: at) == 1
81
+ end
82
+
83
+ private
84
+
85
+ def require_transaction!
86
+ raise AddAuth::Error, "identity mutation requires an account transaction" unless @users.connection.transaction_open?
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -12,7 +12,7 @@ module AddAuth
12
12
  scope = @model.where("expires_at <= ?", before)
13
13
  if @kind == :session
14
14
  scope = scope.or(@model.where("revoked_at <= ?", before))
15
- elsif @kind != :ceremony
15
+ elsif !Core::Maintenance::EXPIRING.key?(@kind)
16
16
  scope = without_live_lease(scope, now)
17
17
  end
18
18
  bounded(scope, limit).delete_all
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/account_lock"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Stores
8
+ class MobileHandoffs
9
+ def initialize(user_model:, handoff_model:, session_model:)
10
+ @users, @handoffs = user_model, handoff_model
11
+ unless [handoff_model, session_model].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
12
+ raise ArgumentError, "mobile authentication models must share one connection pool"
13
+ end
14
+ @lock = AccountLock.new(@users)
15
+ end
16
+
17
+ def create_pending(**attributes)
18
+ @handoffs.transaction(requires_new: true) { @handoffs.create!(**attributes) }
19
+ rescue ActiveRecord::RecordNotUnique
20
+ nil
21
+ end
22
+
23
+ def by_external_digest(digest:) = @handoffs.uncached { @handoffs.find_by(external_digest: digest) }
24
+
25
+ def issue(row:, **attributes)
26
+ require_transaction!
27
+ @handoffs.where(id: row.id, digest: nil, consumed_at: nil, user_id: nil).update_all(**attributes) == 1
28
+ end
29
+
30
+ def with_code(digest:)
31
+ @handoffs.uncached do
32
+ locator = @handoffs.find_by(digest: digest)
33
+ @lock.call(id: locator&.user_id) do |user|
34
+ yield user, user && @handoffs.find_by(digest: digest, user_id: user.id)
35
+ end
36
+ end
37
+ end
38
+
39
+ def consume(row:, at:)
40
+ require_transaction!
41
+ @handoffs.where(id: row.id, user_id: row.user_id, consumed_at: nil).where("expires_at > ?", at)
42
+ .update_all(consumed_at: at) == 1
43
+ end
44
+
45
+ private
46
+
47
+ def require_transaction!
48
+ raise AddAuth::Error, "mobile handoff requires an account transaction" unless @users.connection.transaction_open?
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "add_auth/rails/stores/delivery_state"
4
+ require "add_auth/rails/stores/commit_dispatch"
4
5
 
5
6
  module AddAuth
6
7
  module Rails
@@ -16,12 +17,8 @@ module AddAuth
16
17
  def append(user:, **attributes)
17
18
  raise AddAuth::Error, "security notifications must join the account transaction" unless @users.current_transaction.open?
18
19
  event = @events.create!(**attributes, user_id: user.id)
19
- @users.current_transaction.after_commit do
20
- job = ::AddAuth::SecurityNotificationJob.perform_later(event.id)
21
- raise AddAuth::Error unless job
22
- rescue
23
- ActiveSupport::Notifications.instrument("notification_enqueue_failed.add_auth", event_id: event.id)
24
- end
20
+ CommitDispatch.enqueue(transaction: @users.current_transaction, job: ::AddAuth::SecurityNotificationJob,
21
+ id: event.id, failure_event: "notification_enqueue_failed.add_auth", payload: {event_id: event.id})
25
22
  event
26
23
  end
27
24
 
@@ -6,29 +6,27 @@ module AddAuth
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
+ before_update :add_auth_validate_account_write
9
10
  after_update :add_auth_invalidate_proofs
10
11
  end
11
12
 
12
13
  private
13
14
 
15
+ def add_auth_validate_account_write
16
+ Core::AccountLifecycle.validate_host_write!(changes: changes_to_save, enabled: AddAuth.configuration.lifecycle.enabled)
17
+ end
18
+
14
19
  def add_auth_invalidate_proofs
15
20
  return unless Core::Intake.revoke_after_change?(saved_changes)
16
21
  now = Time.current
17
- if AddAuth.configuration.notifications.enabled
18
- kind = saved_changes.key?("password_digest") ? :password_changed : :email_changed
22
+ kind = Core::Intake.notification_after_change(saved_changes)
23
+ if AddAuth.configuration.notifications.enabled && kind
19
24
  Runtime.security_events.issue(user: self, kind: kind, at: now)
20
25
  if saved_change_to_email_address? && email_address_before_last_save != email_address
21
26
  Runtime.security_events.issue(user: self, kind: :email_changed, at: now, recipient: email_address_before_last_save)
22
27
  end
23
28
  end
24
- if defined?(::AddAuthCeremony) && ::AddAuthCeremony.table_exists?
25
- ::AddAuthCeremony.where(user_id: id, consumed_at: nil).update_all(consumed_at: now)
26
- end
27
- sessions.where(revoked_at: nil).update_all(revoked_at: now)
28
- if defined?(::AddAuthSignInToken) && ::AddAuthSignInToken.table_exists?
29
- ::AddAuthSignInToken.where(user_id: id, consumed_at: nil, revoked_at: nil)
30
- .update_all(revoked_at: now, delivery_payload: nil)
31
- end
29
+ Runtime.authority.revoke(user_id: id, at: now)
32
30
  end
33
31
  end
34
32
  end
@@ -16,6 +16,7 @@ module AddAuth
16
16
  consumed_token revoked_token challenge_rejected
17
17
  challenge_unavailable rate_limited origin_mismatch
18
18
  counter_regression elevation_required
19
+ identity_unbound identity_conflict last_credential
19
20
  ].freeze
20
21
 
21
22
  attr_reader :user, :strategy, :credential, :reason, :session, :grant
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AddAuth
4
- VERSION = "0.2.2"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/add_auth.rb CHANGED
@@ -13,11 +13,15 @@ require "add_auth/configuration"
13
13
  # ejectable, disposable UI on top of it.
14
14
  require "add_auth/core/browser_binding"
15
15
  require "add_auth/core/access_policy"
16
+ require "add_auth/core/account_policy"
16
17
  require "add_auth/core/sessions"
18
+ require "add_auth/core/mobile_response"
17
19
  require "add_auth/core/step_up"
20
+ require "add_auth/core/external_identities"
18
21
  require "add_auth/core/intake"
19
22
  require "add_auth/core/rate_limit"
20
23
  require "add_auth/core/delivery"
24
+ require "add_auth/core/account_lifecycle"
21
25
  require "add_auth/core/maintenance"
22
26
  require "add_auth/core/security_events"
23
27
  require "add_auth/core/strategies/email_link"
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class AccountsGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ class_option :email_link, type: :boolean, default: true, desc: "Enable email sign-in as a step-up dependency"
13
+ desc "Prepare optional account lifecycle schema and pages; review existing account confirmation and policy before enabling"
14
+
15
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
16
+
17
+ def prerequisites
18
+ invoke "add_auth:notifications"
19
+ invoke "add_auth:step_up", [], email_link: options[:email_link]
20
+ end
21
+
22
+ def persistence
23
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_accounts.rb")].any?
24
+ migration_template "add_add_auth_accounts.rb.tt", "db/migrate/add_add_auth_accounts.rb"
25
+ end
26
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_provisioning.rb")].any?
27
+ migration_template "add_add_auth_provisioning.rb.tt", "db/migrate/add_add_auth_provisioning.rb"
28
+ end
29
+ %w[add_auth_account_token add_auth_address_claim].each do |name|
30
+ template "#{name}.rb", "app/models/#{name}.rb" unless File.exist?(File.join(destination_root, "app/models/#{name}.rb"))
31
+ end
32
+ say "Account lifecycle is prepared but disabled. Map existing account policy and confirmation state, then set config.lifecycle.enabled = true."
33
+ end
34
+
35
+ def routes
36
+ return if File.read(File.join(destination_root, "config/routes.rb")).include?("# AddAuth account lifecycle")
37
+ route <<~ROUTES
38
+ # AddAuth account lifecycle
39
+ get "account/sign-up", to: "add_auth/accounts#new"
40
+ post "account/sign-up", to: "add_auth/accounts#create"
41
+ get "account/check-email", to: "add_auth/accounts#check_email"
42
+ get "account/requests/:purpose", to: "add_auth/accounts#request_form", constraints: {purpose: /confirm|reset_password|unlock/}
43
+ post "account/requests/:purpose", to: "add_auth/accounts#request_proof", constraints: {purpose: /confirm|reset_password|unlock/}
44
+ get "account/proofs/:purpose", to: "add_auth/accounts#proof", constraints: {purpose: /confirm|reset_password|unlock/}
45
+ post "account/proofs/:purpose", to: "add_auth/accounts#consume", constraints: {purpose: /confirm|reset_password|unlock/}
46
+ get "account/email", to: "add_auth/accounts#edit_email"
47
+ post "account/email", to: "add_auth/accounts#change_email"
48
+ get "account/password", to: "add_auth/accounts#edit_password"
49
+ post "account/password", to: "add_auth/accounts#change_password"
50
+ get "account/delete", to: "add_auth/accounts#confirm_deletion"
51
+ delete "account", to: "add_auth/accounts#destroy"
52
+ ROUTES
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,49 @@
1
+ require "add_auth/rails/migration/key_type"
2
+
3
+ class AddAddAuthAccounts < ActiveRecord::Migration[8.0]
4
+ def up
5
+ user_key_type = AddAuth::Rails::Migration::KeyType.for(connection, :users)
6
+ false_default = (connection.adapter_name == "SQLite") ? -> { "0" } : false
7
+ %i[confirmed_at locked_at disabled_at deleted_at add_auth_locked_until].each do |name|
8
+ add_column :users, name, :datetime unless column_exists?(:users, name)
9
+ end
10
+ add_column :users, :unconfirmed_email, :string unless column_exists?(:users, :unconfirmed_email)
11
+ add_column :users, :failed_attempts, :integer, default: 0, null: false unless column_exists?(:users, :failed_attempts)
12
+ add_column :users, :add_auth_manual_lock, :boolean, default: false_default, null: false unless column_exists?(:users, :add_auth_manual_lock)
13
+ add_column :users, :add_auth_password_scheme, :string unless column_exists?(:users, :add_auth_password_scheme)
14
+ add_column :users, :add_auth_authority, :string, default: "add_auth", null: false unless column_exists?(:users, :add_auth_authority)
15
+ add_column :sessions, :remembered, :boolean, default: false_default, null: false unless column_exists?(:sessions, :remembered)
16
+ add_column :sessions, :idle_timeout, :integer unless column_exists?(:sessions, :idle_timeout)
17
+ create_table :add_auth_address_claims do |t|
18
+ t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
19
+ t.string :digest, null: false
20
+ t.string :state, null: false
21
+ t.timestamps
22
+ end
23
+ add_index :add_auth_address_claims, :digest, unique: true
24
+ add_index :add_auth_address_claims, [:user_id, :state], unique: true
25
+ create_table :add_auth_account_tokens do |t|
26
+ t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
27
+ t.string :digest, null: false
28
+ t.string :purpose, null: false
29
+ t.string :address_digest, null: false
30
+ t.string :account_version, null: false
31
+ t.string :request_id, null: false
32
+ t.datetime :expires_at, null: false
33
+ t.datetime :consumed_at
34
+ t.datetime :revoked_at
35
+ t.text :delivery_payload
36
+ t.string :delivery_lease_key
37
+ t.datetime :delivery_lease_until
38
+ t.datetime :delivered_at
39
+ t.timestamps
40
+ end
41
+ add_index :add_auth_account_tokens, :digest, unique: true
42
+ add_index :add_auth_account_tokens, :request_id, unique: true
43
+ add_index :add_auth_account_tokens, :expires_at
44
+ end
45
+
46
+ def down
47
+ raise ActiveRecord::IrreversibleMigration, "Retain account policy, address claims and spent proofs during rollback; retire them only after reviewed reconciliation"
48
+ end
49
+ end
@@ -0,0 +1,9 @@
1
+ class AddAddAuthProvisioning < ActiveRecord::Migration[8.0]
2
+ def up
3
+ add_column :users, :add_auth_provisioned_at, :datetime unless column_exists?(:users, :add_auth_provisioned_at)
4
+ end
5
+
6
+ def down
7
+ raise ActiveRecord::IrreversibleMigration, "Retain provisioning receipts to prevent repeating account setup"
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddAuthAccountToken < ApplicationRecord
4
+ belongs_to :user
5
+ self.filter_attributes += [:digest, :address_digest, :account_version, :delivery_payload, :delivery_lease_key]
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddAuthAddressClaim < ApplicationRecord
4
+ belongs_to :user
5
+ self.filter_attributes += [:digest]
6
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class DeviseAccountsGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ desc "Prepare additive account columns for a reviewed Devise migration; does not convert accounts or switch authentication"
13
+
14
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+
16
+ def migration
17
+ return if Dir[File.join(destination_root, "db/migrate/*_prepare_add_auth_devise_accounts.rb")].any?
18
+ migration_template "prepare_add_auth_devise_accounts.rb.tt", "db/migrate/prepare_add_auth_devise_accounts.rb"
19
+ say "Review the generated migration and run preflight before conversion. Authentication and host models are unchanged."
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ require "add_auth/rails/migration/key_type"
2
+
3
+ class PrepareAddAuthDeviseAccounts < ActiveRecord::Migration[8.0]
4
+ def up
5
+ AddAuth::Rails::Migration::KeyType.for(connection, :users)
6
+ add_column :users, :email_address, :string unless column_exists?(:users, :email_address)
7
+ add_column :users, :password_digest, :string unless column_exists?(:users, :password_digest)
8
+ add_column :users, :add_auth_password_scheme, :string unless column_exists?(:users, :add_auth_password_scheme)
9
+ add_column :users, :add_auth_migration_stamp, :string unless column_exists?(:users, :add_auth_migration_stamp)
10
+ add_column :users, :add_auth_authority, :string, default: "devise", null: false unless column_exists?(:users, :add_auth_authority)
11
+ add_index :users, :email_address, unique: true unless index_exists?(:users, :email_address, unique: true)
12
+ end
13
+
14
+ def down
15
+ raise ActiveRecord::IrreversibleMigration, "Retain canonical credentials for the reviewed bridge rollback; retire columns only after reconciliation"
16
+ end
17
+ end
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/feature_configuration"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
7
8
  module Generators
8
9
  class EmailLinkGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include FeatureConfiguration
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
14
+ class_option :enable, type: :boolean, default: true, desc: "Enable email sign-in after preparing its storage"
12
15
  def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
16
 
14
17
  def dependencies
@@ -48,7 +51,7 @@ module AddAuth
48
51
  unless File.read(File.join(destination_root, "config/routes.rb")).include?("add_auth/challenge.js")
49
52
  route 'get "add_auth/challenge.js", to: "add_auth/assets#challenge"'
50
53
  end
51
- gsub_file "config/initializers/add_auth.rb", "# config.email_link.enabled = true", "config.email_link.enabled = true"
54
+ enable_feature(:email_link) if options[:enable]
52
55
  say "Review and migrate, configure base_url/mail_from, a durable queue, shared rate-limit cache and a recurring add_auth:deliver_pending sweep."
53
56
  end
54
57
  end