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
@@ -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.3.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,52 @@
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
+ desc "Prepare optional account lifecycle schema and pages; review existing account confirmation and policy before enabling"
13
+
14
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+
16
+ def prerequisites
17
+ invoke "add_auth:notifications"
18
+ invoke "add_auth:step_up"
19
+ end
20
+
21
+ def persistence
22
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_accounts.rb")].any?
23
+ migration_template "add_add_auth_accounts.rb.tt", "db/migrate/add_add_auth_accounts.rb"
24
+ end
25
+ %w[add_auth_account_token add_auth_address_claim].each do |name|
26
+ template "#{name}.rb", "app/models/#{name}.rb" unless File.exist?(File.join(destination_root, "app/models/#{name}.rb"))
27
+ end
28
+ say "Account lifecycle is prepared but disabled. Map existing account policy and confirmation state, then set config.lifecycle.enabled = true."
29
+ end
30
+
31
+ def routes
32
+ return if File.read(File.join(destination_root, "config/routes.rb")).include?("# AddAuth account lifecycle")
33
+ route <<~ROUTES
34
+ # AddAuth account lifecycle
35
+ get "account/sign-up", to: "add_auth/accounts#new"
36
+ post "account/sign-up", to: "add_auth/accounts#create"
37
+ get "account/check-email", to: "add_auth/accounts#check_email"
38
+ get "account/requests/:purpose", to: "add_auth/accounts#request_form", constraints: {purpose: /confirm|reset_password|unlock/}
39
+ post "account/requests/:purpose", to: "add_auth/accounts#request_proof", constraints: {purpose: /confirm|reset_password|unlock/}
40
+ get "account/proofs/:purpose", to: "add_auth/accounts#proof", constraints: {purpose: /confirm|reset_password|unlock/}
41
+ post "account/proofs/:purpose", to: "add_auth/accounts#consume", constraints: {purpose: /confirm|reset_password|unlock/}
42
+ get "account/email", to: "add_auth/accounts#edit_email"
43
+ post "account/email", to: "add_auth/accounts#change_email"
44
+ get "account/password", to: "add_auth/accounts#edit_password"
45
+ post "account/password", to: "add_auth/accounts#change_password"
46
+ get "account/delete", to: "add_auth/accounts#confirm_deletion"
47
+ delete "account", to: "add_auth/accounts#destroy"
48
+ ROUTES
49
+ end
50
+ end
51
+ end
52
+ 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,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
@@ -104,6 +104,7 @@ class ChallengeController extends Controller {
104
104
  }
105
105
 
106
106
  beforeCache() { this.teardown() }
107
+ restore(event) { if (event.persisted) this.connect() }
107
108
  disconnect() { this.teardown() }
108
109
  teardown() {
109
110
  this.active = false
@@ -1,7 +1,9 @@
1
+ require "add_auth/rails/migration/key_type"
2
+
1
3
  class CreateAddAuthSignInTokens < ActiveRecord::Migration[8.0]
2
4
  def change
3
5
  create_table :add_auth_sign_in_tokens do |t|
4
- t.references :user, null: false, foreign_key: true
6
+ t.references :user, type: AddAuth::Rails::Migration::KeyType.for(connection, :users), null: false, foreign_key: true
5
7
  t.string :digest, limit: 64, null: false
6
8
  t.string :identifier_digest, limit: 64, null: false
7
9
  t.string :purpose, null: false, default: "sign_in"
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ # Optional persistence and routes; provider strategies remain disabled until configured.
9
+ class ExternalIdentitiesGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
14
+
15
+ def persistence
16
+ unless Dir[File.join(destination_root, "db/migrate/*_create_add_auth_external_identities.rb")].any?
17
+ migration_template "create_add_auth_external_identities.rb.tt", "db/migrate/create_add_auth_external_identities.rb"
18
+ end
19
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_external_identity_validity.rb")].any?
20
+ migration_template "add_add_auth_external_identity_validity.rb.tt", "db/migrate/add_add_auth_external_identity_validity.rb"
21
+ end
22
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_external_enrollment.rb")].any?
23
+ migration_template "add_add_auth_external_enrollment.rb.tt", "db/migrate/add_add_auth_external_enrollment.rb"
24
+ end
25
+ %w[external_identity external_transaction].each do |name|
26
+ copy_file "add_auth_#{name}.rb", "app/models/add_auth_#{name}.rb", skip: true
27
+ end
28
+ end
29
+
30
+ def routes
31
+ source = File.read(File.join(destination_root, "config/routes.rb"))
32
+ return if source.include?("# AddAuth external provider callbacks")
33
+
34
+ route <<~ROUTES
35
+ # AddAuth external provider callbacks
36
+ get "account/sign-up/providers/:provider", to: "add_auth/provider_sign_ins#enrollment", defaults: {flow: "enroll"}
37
+ post "account/sign-up/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "enroll"}
38
+ post "sign-in/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "sign_in"}
39
+ post "account/external-identities/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "link"}
40
+ get "account/external-identities", to: "add_auth/external_identities#index"
41
+ delete "account/external-identities/:id", to: "add_auth/external_identities#destroy"
42
+ post "reauthenticate/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "reauthenticate"}
43
+ match "auth/:provider/callback", to: "add_auth/provider_sign_ins#callback", via: [:get, :post], constraints: AddAuth::Rails::ProviderLibraries::CallbackRoute
44
+ ROUTES
45
+ end
46
+
47
+ def provider_configuration
48
+ copy_file "providers.rb", "config/initializers/add_auth_providers.rb", skip: true
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ class AddAddAuthExternalEnrollment < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :add_auth_external_transactions, :enrollment_payload, :text
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class AddAddAuthExternalIdentityValidity < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :add_auth_external_identities, :invalidated_at, :datetime
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ class AddAuthExternalIdentity < ApplicationRecord
2
+ belongs_to :user
3
+ def inspect = "#<AddAuthExternalIdentity [FILTERED]>"
4
+ end
@@ -0,0 +1,6 @@
1
+ class AddAuthExternalTransaction < ApplicationRecord
2
+ self.filter_attributes += [:enrollment_payload, :digest, :browser_digest, :session_digest]
3
+ belongs_to :user, optional: true
4
+ belongs_to :session, optional: true
5
+ def inspect = "#<AddAuthExternalTransaction [FILTERED]>"
6
+ end
@@ -0,0 +1,50 @@
1
+ class CreateAddAuthExternalIdentities < ActiveRecord::Migration[8.0]
2
+ def change
3
+ user_type = reference_type(:users)
4
+ session_type = reference_type(:sessions)
5
+ create_table :add_auth_external_identities do |t|
6
+ t.references :user, type: user_type, null: false, foreign_key: {on_delete: :cascade}
7
+ # SHA256 of the exact JSON [configuration, issuer, audience, subject].
8
+ # Indexing this key preserves case-sensitive subjects on either store.
9
+ t.string :namespace, limit: 64, null: false
10
+ t.string :provider_id, limit: 2048, null: false
11
+ t.string :issuer, limit: 2048, null: false
12
+ t.string :audience, limit: 2048, null: false
13
+ t.string :subject, limit: 2048, null: false
14
+ t.string :provenance, limit: 2048, null: false
15
+ t.string :credential_version, null: false
16
+ t.datetime :linked_at, null: false
17
+ t.datetime :revoked_at
18
+ t.timestamps
19
+ end
20
+ add_index :add_auth_external_identities, :namespace, unique: true
21
+ create_table :add_auth_external_transactions do |t|
22
+ t.string :digest, null: false
23
+ t.string :browser_digest, null: false
24
+ t.string :provider_id, limit: 2048, null: false
25
+ t.string :issuer, limit: 2048, null: false
26
+ t.string :audience, limit: 2048, null: false
27
+ t.string :purpose, null: false
28
+ t.references :user, type: user_type, foreign_key: {on_delete: :cascade}
29
+ t.references :session, type: session_type, foreign_key: {on_delete: :cascade}
30
+ t.string :session_digest
31
+ t.integer :policy_version
32
+ t.datetime :issued_at, null: false
33
+ t.datetime :expires_at, null: false
34
+ t.datetime :consumed_at
35
+ t.timestamps
36
+ end
37
+ add_index :add_auth_external_transactions, :digest, unique: true
38
+ add_index :add_auth_external_transactions, :expires_at
39
+ add_column :sessions, :authentication_external_id, :string
40
+ add_column :sessions, :authentication_external_version, :string
41
+ end
42
+
43
+ private
44
+
45
+ def reference_type(table)
46
+ column = connection.columns(table).find { |item| item.name == connection.primary_key(table) }
47
+ raise "Unsupported authentication primary key" unless column && [:integer, :uuid].include?(column.type)
48
+ (column.type == :integer && column.limit == 8) ? :bigint : column.type
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # External provider support is opt-in. Add only the provider gems you use to
4
+ # the host application's Gemfile, then configure their OmniAuth middleware
5
+ # using their normal configuration. Reuse existing registrations rather than
6
+ # adding a second OmniAuth::Builder for the same provider. Keep the host's CSRF
7
+ # validator, scopes, request methods and failure handler. AddAuth changes none
8
+ # of them. Keep issuer/audience fixed; never derive them from a request, Host
9
+ # header, profile attribute, or callback parameter.
10
+ #
11
+ # AddAuth's correlation middleware goes before OmniAuth::Builder. It retains
12
+ # opaque Core transaction data only. OmniAuth (or the selected OIDC library)
13
+ # owns code exchange, state, nonce, JWKS/signature, issuer, audience and expiry.
14
+ # Requests without an AddAuth transaction continue through the existing stack.
15
+ # Generated callback routes select only enabled, explicitly registered providers.
16
+ # Review routing precedence before adopting a provider with an existing callback.
17
+ # Custom provider paths/callbacks require an explicit host integration; do not
18
+ # rewrite an existing library configuration to fit this conventional-path example.
19
+ #
20
+ # require "omniauth"
21
+ # require "omniauth-google-oauth2"
22
+ # require "add_auth/rails/provider_libraries/omniauth"
23
+ # require "add_auth/rails/provider_libraries/omniauth_correlation"
24
+ # Rails.application.config.middleware.use AddAuth::Rails::ProviderLibraries::OmniAuthCorrelation,
25
+ # providers: ["google_oauth2"]
26
+ # Rails.application.config.middleware.use OmniAuth::Builder do
27
+ # provider :google_oauth2, ENV.fetch("GOOGLE_CLIENT_ID"), ENV.fetch("GOOGLE_CLIENT_SECRET")
28
+ # end
29
+ #
30
+ # verifier = AddAuth::Rails::ProviderLibraries::OmniAuth::Verifier.new(
31
+ # provider: "google_oauth2", provenance: "omniauth-google-oauth2",
32
+ # mapping: {issuer: %i[extra id_info iss], audience: %i[extra id_info aud], subject: %i[extra id_info sub],
33
+ # authenticated_at: %i[extra id_info auth_time]}
34
+ # )
35
+ # AddAuth.configure do |config|
36
+ # config.external_identities.register(
37
+ # id: "google", label: "Google", middleware_name: "google_oauth2",
38
+ # configuration: AddAuth::Core::ExternalIdentities::Configuration.new(
39
+ # id: "google", issuer: "https://accounts.google.com", audience: ENV.fetch("GOOGLE_CLIENT_ID"), verifier: verifier
40
+ # )
41
+ # )
42
+ # config.external_identities.enabled = true
43
+ # end
44
+ #
45
+ # Apple form_post needs its own callback-only cookie wrapper before
46
+ # OmniAuth::Builder. Do not weaken your normal Rails session cookie:
47
+ #
48
+ # require "omniauth-apple"
49
+ # require "add_auth/rails/provider_libraries/apple_form_post_correlation"
50
+ # Rails.application.config.middleware.use AddAuth::Rails::ProviderLibraries::AppleFormPostCorrelation,
51
+ # key: Rails.application.key_generator.generate_key("add_auth.apple-form-post.v1", 32)
@@ -4,6 +4,10 @@ AddAuth.configure do |config|
4
4
  # routes remain guarded; the host owns password reset and account provisioning.
5
5
  # config.passwords_enabled = true
6
6
 
7
+ # Ordinary Rails HTML navigation, including hosts without Hotwire:
8
+ # config.turbo_enabled = false
9
+ # Passkeys/captcha still use scoped gem JavaScript; no host JS build is needed.
10
+
7
11
  # BEGIN add_auth session
8
12
  # config.session.enabled = true
9
13
  # END add_auth session
@@ -4,9 +4,9 @@ import { options, credential } from "/add_auth/codec.js"
4
4
 
5
5
  export class PasskeyController extends Controller {
6
6
  static targets = ["form", "controls", "unavailable", "status", "button"]
7
- static values = { optionsUrl: String, finishUrl: String, mode: String, purpose: String, csrf: String, conditional: Boolean }
7
+ static values = { optionsUrl: String, finishUrl: String, mode: String, purpose: String, csrf: String, conditional: Boolean, turbo: { type: Boolean, default: true } }
8
8
  connect() {
9
- this.generation = 0
9
+ this.generation = (this.generation || 0) + 1
10
10
  this.active = true
11
11
  if (!window.PublicKeyCredential || !navigator.credentials || !window.isSecureContext) return
12
12
  this.controlsTarget.hidden = false
@@ -25,13 +25,16 @@ export class PasskeyController extends Controller {
25
25
  otherSubmission(event) { if (event.target !== this.formTarget) this.stop() }
26
26
  disconnect() { this.active = false; this.stop() }
27
27
  beforeCache() { this.active = false; this.stop(); this.controlsTarget.hidden = true; this.unavailableTarget.hidden = false; this.message("") }
28
+ restore(event) { if (event.persisted) this.connect() }
28
29
  async run(conditional) {
29
30
  this.stop()
30
31
  const generation = this.generation
31
32
  this.abort = new AbortController()
32
33
  if (!conditional) { this.buttonTarget.disabled = true; this.message("Follow your browser’s passkey prompt.") }
33
34
  try {
34
- const challenge = new FormData(this.formTarget).get("challenge_token")
35
+ const form = new FormData(this.formTarget)
36
+ const challenge = form.get("challenge_token")
37
+ const remember = form.getAll("remember").at(-1)
35
38
  const start = await this.post(this.optionsUrlValue, { purpose: this.purposeValue || null, challenge_token: challenge }, this.abort.signal)
36
39
  this.formTarget.dispatchEvent(new CustomEvent("add_auth:proof-used"))
37
40
  if (!this.current(generation)) return
@@ -42,7 +45,7 @@ export class PasskeyController extends Controller {
42
45
  if (conditional) request.mediation = "conditional"
43
46
  const proof = await navigator.credentials[create ? "create" : "get"](request)
44
47
  if (!this.current(generation)) return
45
- const result = await this.post(this.finishUrlValue, { transaction: this.transaction, credential: credential(proof) }, this.abort.signal)
48
+ const result = await this.post(this.finishUrlValue, { transaction: this.transaction, credential: credential(proof), remember }, this.abort.signal)
46
49
  if (!this.current(generation)) return
47
50
  this.transaction = null
48
51
  this.navigate(result.redirect)
@@ -76,6 +79,6 @@ export class PasskeyController extends Controller {
76
79
  }
77
80
  current(generation) { return this.active && this.element.isConnected && this.generation === generation }
78
81
  message(text) { this.statusTarget.textContent = text; this.statusTarget.hidden = !text }
79
- navigate(path) { if (path) window.Turbo ? window.Turbo.visit(path) : window.location.assign(path) }
82
+ navigate(path) { if (path) (this.turboValue && window.Turbo) ? window.Turbo.visit(path) : window.location.assign(path) }
80
83
  }
81
84
  application.register("add-auth-passkey", PasskeyController)
@@ -0,0 +1,48 @@
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 MobileSessionsGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
+
14
+ def persistence
15
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_mobile_sessions.rb")].any?
16
+ migration_template "add_add_auth_mobile_sessions.rb.tt", "db/migrate/add_add_auth_mobile_sessions.rb"
17
+ end
18
+ unless Dir[File.join(destination_root, "db/migrate/*_create_add_auth_mobile_handoffs.rb")].any?
19
+ migration_template "create_add_auth_mobile_handoffs.rb.tt", "db/migrate/create_add_auth_mobile_handoffs.rb"
20
+ end
21
+ copy_file "add_auth_mobile_handoff.rb", "app/models/add_auth_mobile_handoff.rb", skip: true
22
+ end
23
+
24
+ def routes
25
+ source = File.read(File.join(destination_root, "config/routes.rb"))
26
+ return if source.include?("# AddAuth mobile sessions")
27
+ route <<~ROUTES
28
+ # AddAuth mobile sessions
29
+ post "mobile/session", to: "add_auth/mobile_sessions#create"
30
+ get "mobile/session", to: "add_auth/mobile_sessions#show"
31
+ delete "mobile/session", to: "add_auth/mobile_sessions#destroy"
32
+ get "mobile/sessions", to: "add_auth/mobile_sessions#index"
33
+ delete "mobile/sessions/:id", to: "add_auth/mobile_sessions#revoke"
34
+ post "mobile/sessions/revoke-all", to: "add_auth/mobile_sessions#revoke_all"
35
+ get "mobile/providers/:provider", to: "add_auth/provider_sign_ins#prepare", defaults: {flow: "mobile"}
36
+ post "mobile/handoff", to: "add_auth/mobile_sessions#exchange"
37
+ post "mobile/apple/challenge", to: "add_auth/mobile_sessions#apple_challenge"
38
+ post "mobile/apple/session", to: "add_auth/mobile_sessions#apple"
39
+ post "mobile/apple/enrollment", to: "add_auth/mobile_sessions#apple_enroll"
40
+ ROUTES
41
+ end
42
+
43
+ def configuration
44
+ copy_file "mobile.rb", "config/initializers/add_auth_mobile.rb", skip: true
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,9 @@
1
+ class AddAddAuthMobileSessions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :sessions, :transport, :string, null: false, default: "browser"
4
+ add_column :sessions, :client_id, :string
5
+ add_column :sessions, :mobile_idle_timeout, :integer
6
+ add_check_constraint :sessions, "transport IN ('browser', 'mobile')", name: "add_auth_session_transport"
7
+ add_check_constraint :sessions, "transport != 'mobile' OR (client_id IS NOT NULL AND mobile_idle_timeout IS NOT NULL AND mobile_idle_timeout BETWEEN 60 AND 7776000 AND token_digest IS NOT NULL AND authenticated_at IS NOT NULL AND expires_at IS NOT NULL AND last_seen_at IS NOT NULL)", name: "add_auth_mobile_profile"
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ class AddAuthMobileHandoff < ApplicationRecord
2
+ self.filter_attributes += [:external_digest, :digest, :state, :challenge_digest]
3
+ belongs_to :user, optional: true
4
+ def inspect = "#<AddAuthMobileHandoff [FILTERED]>"
5
+ end