add_auth 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +64 -17
  4. data/ROADMAP.md +98 -43
  5. data/SECURITY.md +8 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +146 -0
  7. data/app/controllers/add_auth/assets_controller.rb +2 -1
  8. data/app/controllers/add_auth/external_identities_controller.rb +56 -0
  9. data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
  10. data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
  11. data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
  12. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  13. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  14. data/app/jobs/add_auth/account_request_job.rb +17 -0
  15. data/app/mailers/add_auth/account_mailer.rb +12 -0
  16. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  17. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  18. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  19. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  20. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  21. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  22. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  23. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  24. data/app/views/add_auth/accounts/_request.html.erb +17 -0
  25. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  26. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  27. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  28. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  29. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  30. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  31. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  32. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  33. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  34. data/app/views/add_auth/sessions/index.html.erb +7 -0
  35. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  36. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  37. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  38. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  39. data/exe/add_auth-preflight +26 -0
  40. data/lib/add_auth/configuration.rb +78 -4
  41. data/lib/add_auth/core/access_policy.rb +11 -1
  42. data/lib/add_auth/core/account_lifecycle.rb +295 -0
  43. data/lib/add_auth/core/account_policy.rb +68 -0
  44. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  45. data/lib/add_auth/core/external_identities.rb +278 -0
  46. data/lib/add_auth/core/intake.rb +6 -1
  47. data/lib/add_auth/core/maintenance.rb +5 -3
  48. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  49. data/lib/add_auth/core/migration/readiness.rb +41 -0
  50. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  51. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  52. data/lib/add_auth/core/mobile_profile.rb +46 -0
  53. data/lib/add_auth/core/mobile_response.rb +12 -0
  54. data/lib/add_auth/core/native_authentication.rb +77 -0
  55. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  56. data/lib/add_auth/core/passwords/credential.rb +49 -0
  57. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  58. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  59. data/lib/add_auth/core/remaining_factors.rb +24 -0
  60. data/lib/add_auth/core/sessions.rb +102 -17
  61. data/lib/add_auth/core/step_up.rb +13 -1
  62. data/lib/add_auth/migration/static_inventory.rb +116 -0
  63. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  64. data/lib/add_auth/rails/authentication.rb +1 -1
  65. data/lib/add_auth/rails/authentication_pages.rb +6 -1
  66. data/lib/add_auth/rails/doctor.rb +80 -6
  67. data/lib/add_auth/rails/ejection.rb +1 -1
  68. data/lib/add_auth/rails/engine.rb +6 -1
  69. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  70. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  71. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  72. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  73. data/lib/add_auth/rails/password_adoption.rb +37 -0
  74. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  77. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  78. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  80. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  81. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  82. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  83. data/lib/add_auth/rails/runtime.rb +179 -8
  84. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  85. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  86. data/lib/add_auth/rails/stores/account_tokens.rb +104 -0
  87. data/lib/add_auth/rails/stores/authority.rb +28 -0
  88. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  89. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  90. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  91. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  92. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  93. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  94. data/lib/add_auth/result.rb +1 -0
  95. data/lib/add_auth/version.rb +1 -1
  96. data/lib/add_auth.rb +4 -0
  97. data/lib/generators/add_auth/accounts/accounts_generator.rb +56 -0
  98. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  99. data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  101. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  102. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  103. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  104. data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
  105. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  106. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  107. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  110. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  111. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  112. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  113. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  114. data/lib/generators/add_auth/feature_configuration.rb +21 -0
  115. data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
  116. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  117. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  119. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  120. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  121. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  122. data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
  123. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  124. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
  125. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  126. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
  127. data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
  128. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  129. data/lib/tasks/add_auth.rake +18 -1
  130. metadata +101 -2
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/account_lock"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Migration
8
+ class AccountStore
9
+ FIELDS = {id: :id, email: :email, encrypted_password: :encrypted_password,
10
+ authority: :add_auth_authority, migration_stamp: :add_auth_migration_stamp,
11
+ email_address: :email_address, password_digest: :password_digest, scheme: :add_auth_password_scheme}.freeze
12
+
13
+ def initialize(user_model:)
14
+ @users = user_model
15
+ @lock = Stores::AccountLock.new(user_model)
16
+ unless @users.primary_key == "id" && (FIELDS.values.map(&:to_s) - @users.column_names).empty?
17
+ raise ArgumentError, "prepare the reviewed User account schema before conversion"
18
+ end
19
+ end
20
+
21
+ def source_page(after:, limit:)
22
+ scope = @users.unscoped.order(:id)
23
+ scope = scope.where(@users.arel_table[:id].gt(after)) if after
24
+ scope.limit(limit).pluck(*FIELDS.values).map { |row| FIELDS.keys.zip(row).to_h }
25
+ end
26
+
27
+ def with_account(id)
28
+ @lock.call(id: id) { |user| yield(user && FIELDS.to_h { |name, field| [name, user.read_attribute(field)] }) }
29
+ rescue ActiveRecord::RecordNotUnique
30
+ :conflicts
31
+ end
32
+
33
+ def prepare(id:, **attributes)
34
+ raise AddAuth::Error, "conversion requires an account transaction" unless @users.connection.transaction_open?
35
+ # Source authority is retained. No callbacks, mail or host provisioning.
36
+ @users.unscoped.where(id: id).update_all(attributes)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/migration/readiness"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Migration
8
+ class EffectiveInventory
9
+ LIMIT = 10_000
10
+ ACCOUNT_FIELDS = %w[email email_address encrypted_password password_digest confirmed_at unconfirmed_email locked_at failed_attempts disabled_at deleted_at remember_created_at reset_password_token confirmation_token unlock_token authentication_token provider uid].freeze
11
+ OPTIONS = %i[authentication_keys case_insensitive_keys strip_whitespace_keys stretches password_length reconfirmable allow_unconfirmed_access_for confirm_within reset_password_within maximum_attempts unlock_strategy unlock_in remember_for sign_in_after_reset_password].freeze
12
+
13
+ def initialize(limit: LIMIT)
14
+ raise ArgumentError, "limit must be between 1 and 100000" unless limit.is_a?(Integer) && limit.between?(1, 100_000)
15
+ @limit = limit
16
+ end
17
+
18
+ def call
19
+ unless %w[test development].include?(::Rails.env.to_s) && defined?(::Devise)
20
+ raise ArgumentError, "effective preflight requires a local test/development Devise host"
21
+ end
22
+ @complete = true
23
+ mappings = ::Devise.mappings.values
24
+ models = mappings.map(&:to).uniq
25
+ connections = models.map(&:connection_pool).uniq
26
+ raise ArgumentError, "inspect one Active Record connection pool at a time" unless connections.length == 1
27
+ accounts = read_only(connections.first) { models.map { |model| account(model) } }
28
+ all_modules = accounts.flat_map { |entry| entry[:modules] }.uniq
29
+ facts = {mode: "effective", complete: @complete, row_limit: @limit,
30
+ versions: %w[devise rails bcrypt].to_h { |name| [name.to_sym, Gem.loaded_specs[name]&.version&.to_s] }.merge(ruby: RUBY_VERSION),
31
+ scope_count: mappings.length, modules: all_modules & Core::Migration::Readiness::MODULES,
32
+ unknown_modules: accounts.sum { |entry| entry[:unknown_modules] },
33
+ extensions: Core::Migration::Readiness::EXTENSIONS.select { |name| Gem.loaded_specs.key?(name) },
34
+ rails_contract: accounts.length == 1 && accounts.first[:rails_contract], accounts: accounts}
35
+ Core::Migration::Readiness.new.call(facts)
36
+ end
37
+
38
+ private
39
+
40
+ def read_only(pool)
41
+ pool.with_connection do |connection|
42
+ raise ArgumentError, "effective preflight requires its own transaction" if connection.transaction_open?
43
+ case connection.adapter_name
44
+ when "PostgreSQL"
45
+ connection.transaction(requires_new: true) do
46
+ connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
47
+ connection.execute("SET LOCAL statement_timeout = '10s'")
48
+ yield
49
+ end
50
+ when "SQLite"
51
+ previous = connection.select_value("PRAGMA query_only").to_i
52
+ begin
53
+ connection.execute("PRAGMA query_only = ON")
54
+ # Rails 8.1 starts ordinary SQLite transactions with BEGIN
55
+ # IMMEDIATE, which requests write authority even for SELECTs.
56
+ connection.execute("BEGIN DEFERRED TRANSACTION")
57
+ begin
58
+ yield
59
+ ensure
60
+ connection.execute("ROLLBACK")
61
+ end
62
+ ensure
63
+ connection.execute("PRAGMA query_only = #{previous}")
64
+ end
65
+ else
66
+ raise ArgumentError, "effective preflight supports SQLite and PostgreSQL only"
67
+ end
68
+ end
69
+ end
70
+
71
+ def account(model)
72
+ columns = model.columns_hash
73
+ fields = ACCOUNT_FIELDS & columns.keys
74
+ # No model instances, callbacks, password verification or normalization hooks.
75
+ rows = model.unscoped.limit(@limit + 1).pluck(*fields).map { |values| fields.zip(Array(values)).to_h }
76
+ @complete = false if rows.length > @limit
77
+ rows = rows.first(@limit)
78
+ modules = model.devise_modules.map(&:to_s)
79
+ lookup_keys = Array(model.authentication_keys)
80
+ identifier = (lookup_keys.length == 1 && fields.include?(lookup_keys.first.to_s)) ? lookup_keys.first.to_s : "email"
81
+ @complete = false unless lookup_keys == [:email] || lookup_keys == [:email_address]
82
+ password = fields.include?("encrypted_password") ? "encrypted_password" : "password_digest"
83
+ keys = rows.map { |row| row[identifier].to_s.strip.downcase }
84
+ nonblank = keys.reject(&:empty?)
85
+ options = OPTIONS.filter_map { |name| [name, safe_option(model.public_send(name))] if model.respond_to?(name) }.to_h
86
+ verifier = model.instance_method(:valid_password?).source_location&.first
87
+ devise_root = Gem.loaded_specs.fetch("devise").full_gem_path + "/"
88
+ indexes = model.connection.indexes(model.table_name)
89
+ {model: safe_name(model.name), table: safe_name(model.table_name),
90
+ primary_key_type: columns[model.primary_key]&.type&.to_s,
91
+ database: model.connection.adapter_name, identifier: identifier,
92
+ identifier_type: columns[identifier]&.type&.to_s,
93
+ identifier_collation: columns[identifier]&.respond_to?(:collation) ? safe_name(columns[identifier].collation) : nil,
94
+ unique_identifier_index: indexes.any? { |index| index.unique && index.columns == [identifier] && !index.where },
95
+ modules: modules & Core::Migration::Readiness::MODULES,
96
+ unknown_modules: (modules - Core::Migration::Readiness::MODULES).length,
97
+ options: options, pepper_configured: model.respond_to?(:pepper) && !model.pepper.to_s.empty?,
98
+ custom_verifier: !verifier&.start_with?(devise_root) || !model.instance_method(:password=).source_location&.first&.start_with?(devise_root),
99
+ rails_contract: (model.name == "User" && fields.include?("email_address") && fields.include?("password_digest") && model.respond_to?(:authenticate_by) && defined?(::Session) && defined?(::Authentication)) ? true : false,
100
+ related_tables: related_tables(model),
101
+ counts: {inspected: rows.length, missing_identifier: keys.count(&:empty?), identifier_collisions: nonblank.length - nonblank.uniq.length,
102
+ unknown_password: rows.count { |row| !row[password].to_s.match?(/\A\$2[aby]\$\d{2}\$[.\/A-Za-z0-9]{53}\z/) },
103
+ unconfirmed: fields.include?("confirmed_at") ? rows.count { |row| row["confirmed_at"].nil? } : 0,
104
+ reconfirming: rows.count { |row| !row["unconfirmed_email"].to_s.empty? },
105
+ locked: rows.count { |row| row["locked_at"] }, disabled: rows.count { |row| row["disabled_at"] },
106
+ deleted: rows.count { |row| row["deleted_at"] },
107
+ outstanding_authority: rows.count { |row| %w[remember_created_at reset_password_token confirmation_token unlock_token authentication_token].any? { |key| !row[key].to_s.empty? } }}}
108
+ end
109
+
110
+ def related_tables(model)
111
+ connection = model.connection
112
+ tables = connection.tables.sort
113
+ @complete = false if tables.length > 200
114
+ tables.first(200).filter_map do |table|
115
+ next if table == model.table_name
116
+ columns = connection.columns(table).to_h { |column| [column.name, column] }
117
+ foreign_keys = connection.foreign_keys(table).select { |key| key.to_table == model.table_name }
118
+ reference = foreign_keys.first&.column || ("user_id" if model.name == "User" && columns.key?("user_id"))
119
+ next unless reference && columns.key?(reference)
120
+ quoted_table = connection.quote_table_name(table)
121
+ quote = ->(name) { connection.quote_column_name(name) }
122
+ identity_fields = %w[provider issuer subject uid] & columns.keys
123
+ selected = [reference, *identity_fields]
124
+ records = connection.select_rows("SELECT #{selected.map { |field| quote.call(field) }.join(", ")} FROM #{quoted_table} LIMIT #{@limit + 1}")
125
+ @complete = false if records.length > @limit
126
+ records = records.first(@limit)
127
+ identities = identity_fields.empty? ? [] : records.map { |row| row.drop(1) }
128
+ account_table = connection.quote_table_name(model.table_name)
129
+ key = quote.call(model.primary_key)
130
+ reference_column = quote.call(reference)
131
+ orphan_count = connection.select_value(<<~SQL).to_i
132
+ SELECT COUNT(*) FROM
133
+ (SELECT #{reference_column} FROM #{quoted_table} LIMIT #{@limit + 1}) source
134
+ LEFT JOIN #{account_table} account ON account.#{key} = source.#{reference_column}
135
+ WHERE source.#{reference_column} IS NOT NULL AND account.#{key} IS NULL
136
+ SQL
137
+ {table: safe_name(table), reference: safe_name(reference), key_type: columns[reference].type.to_s,
138
+ foreign_key_enforced: foreign_keys.any?, inspected: records.length,
139
+ orphaned_references: orphan_count, duplicate_identities: identities.length - identities.uniq.length}
140
+ end
141
+ end
142
+
143
+ def safe_name(value)
144
+ value.to_s.match?(/\A[A-Za-z_]\w*(?:::\w+)*\z/) ? value.to_s : nil
145
+ end
146
+
147
+ def safe_option(value)
148
+ case value
149
+ when nil, true, false, Numeric then value
150
+ when Symbol then safe_name(value)
151
+ when Range then [safe_option(value.begin), safe_option(value.end), value.exclude_end?]
152
+ when Array then value.map { |item| safe_option(item) }
153
+ when Hash then value.to_h { |key, item| [safe_name(key), safe_option(item)] }
154
+ else
155
+ # ActiveSupport::Duration exposes only a numeric duration.
156
+ value.is_a?(ActiveSupport::Duration) ? value.to_i : "custom"
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Migration
6
+ module KeyType
7
+ module_function
8
+
9
+ # Resolve when the migration runs, after the host's account table exists.
10
+ # Session IDs remain integer until opaque cursor support is established.
11
+ def for(connection, table, session: false)
12
+ key = connection.primary_key(table)
13
+ column = connection.columns(table).find { |entry| entry.name == key }
14
+ raise ArgumentError, "AddAuth requires a single integer or UUID primary key on #{table}" unless column
15
+ return :bigint if column.type == :integer && column.limit == 8
16
+ return :integer if column.type == :integer
17
+ return :uuid if column.type == :uuid && !session
18
+ raise ArgumentError, session ? "AddAuth requires integer Session IDs; UUID accounts can use integer Sessions" : "AddAuth requires an integer or UUID account primary key"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/migration/account_adoption"
4
+ require "add_auth/rails/stores/account_lock"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ module Migration
9
+ # Include after Devise in an expanded, reviewed source model. This does
10
+ # not select routes or retire other host credential readers at cutover.
11
+ module SourceBridge
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ before_save :add_auth_project_source_credentials
16
+ end
17
+
18
+ def valid_password?(password)
19
+ Core::Migration::AccountAdoption.source_authority?(self[:add_auth_authority]) && super
20
+ end
21
+
22
+ def active_for_authentication?
23
+ Core::Migration::AccountAdoption.source_authority?(self[:add_auth_authority]) && super
24
+ end
25
+
26
+ # Devise Lockable increments this counter through update_all before its
27
+ # later save callback. Fence that bypass on the same account row lock.
28
+ def increment_failed_attempts
29
+ self.class.transaction do
30
+ current = Stores::AccountLock.new(self.class).current_in_transaction(id: id)
31
+ raise AddAuth::Error, "source account no longer exists" unless current
32
+ Core::Migration::AccountAdoption.authorize_source_write!(authority: current[:add_auth_authority], changed_fields: [:failed_attempts])
33
+ super
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def add_auth_project_source_credentials
40
+ current = persisted? ? Stores::AccountLock.new(self.class).current_in_transaction(id: id) : self
41
+ raise AddAuth::Error, "source account no longer exists" unless current
42
+ source = %i[email encrypted_password].to_h do |name|
43
+ [name, will_save_change_to_attribute?(name) ? self[name] : current[name]]
44
+ end
45
+ attributes = Core::Migration::AccountAdoption.projection_for_write(**source,
46
+ authority: current[:add_auth_authority], authority_changed: persisted? && will_save_change_to_add_auth_authority?,
47
+ changed_fields: changed_attribute_names_to_save)
48
+ attributes.each { |name, value| self[name] = value }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/passwords/legacy_bcrypt"
4
+ require "add_auth/core/passwords/credential"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ # Include after has_secure_password. Rails owns new assignment and lookup;
9
+ # Core chooses exactly one verifier for the persisted credential profile.
10
+ module PasswordAdoption
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ self.filter_attributes += [:add_auth_password_scheme]
15
+ before_save :add_auth_retire_legacy_password
16
+ end
17
+
18
+ def authenticate_password(password)
19
+ result = Core::Passwords::Credential.new(legacy_verifier: AddAuth.configuration.legacy_password_verifier)
20
+ .authenticate(user: self, password: password, current: ->(value) { super(value) })
21
+ result.success? ? result.user : false
22
+ end
23
+
24
+ def authenticate(password) = authenticate_password(password)
25
+
26
+ private
27
+
28
+ def add_auth_retire_legacy_password
29
+ # A host's ordinary password assignment replaces the legacy credential.
30
+ # Direct migration copies do not set the virtual plaintext attribute.
31
+ self.add_auth_password_scheme = Core::Passwords::Credential.scheme_after_assignment(
32
+ scheme: add_auth_password_scheme, password: password, digest_changed: will_save_change_to_password_digest?
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "active_support/message_encryptor"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ # Bounded transport storage for the form collected before provider consent.
9
+ # This grants no identity/account authority: G3/G4 still consume the exact
10
+ # verified enrollment transaction inside the new-account transaction.
11
+ class ProviderEnrollment
12
+ MAX_BYTES = 4096
13
+
14
+ def initialize(model:, digest:, key:)
15
+ @model, @digest = model, digest
16
+ @cipher = ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: :json)
17
+ end
18
+
19
+ def write(pending:, identifier:, profile:)
20
+ return false unless enrollment?(pending) && bounded_profile?(profile) && identifier.is_a?(String)
21
+ payload = {"identifier" => identifier, "profile" => profile}
22
+ return false unless JSON.generate(payload).bytesize <= MAX_BYTES
23
+ ciphertext = @cipher.encrypt_and_sign(payload, purpose: purpose(pending), expires_at: pending.expires_at)
24
+ row(pending).where(enrollment_payload: nil).update_all(enrollment_payload: ciphertext) == 1
25
+ rescue JSON::GeneratorError, EncodingError
26
+ false
27
+ end
28
+
29
+ def read(pending:)
30
+ return unless enrollment?(pending)
31
+ ciphertext = row(pending).pick(:enrollment_payload)
32
+ return unless ciphertext.is_a?(String) && ciphertext.bytesize <= MAX_BYTES * 3
33
+ payload = @cipher.decrypt_and_verify(ciphertext, purpose: purpose(pending))
34
+ return unless payload.is_a?(Hash) && payload["identifier"].is_a?(String) && bounded_profile?(payload["profile"])
35
+ {identifier: payload.fetch("identifier"), profile: payload.fetch("profile").symbolize_keys}
36
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
37
+ nil
38
+ end
39
+
40
+ def erase(pending:)
41
+ row(pending).update_all(enrollment_payload: nil) if enrollment?(pending)
42
+ end
43
+
44
+ private
45
+
46
+ def enrollment?(pending)
47
+ pending.is_a?(Core::ExternalIdentities::Pending) && pending.purpose == Core::ExternalIdentities::ENROLL
48
+ end
49
+
50
+ def bounded_profile?(profile)
51
+ profile.is_a?(Hash) && profile.size <= 20 && profile.all? do |name, value|
52
+ (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.valid_encoding? && name.to_s.bytesize.between?(1, 64) &&
53
+ (value.nil? || value == true || value == false || (value.is_a?(String) && value.valid_encoding? && value.bytesize <= 2048))
54
+ end
55
+ end
56
+
57
+ def purpose(pending) = "add_auth.external-enrollment:#{@digest.digest(pending.id)}"
58
+ def row(pending) = @model.where(digest: @digest.digest(pending.id))
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/message_encryptor"
4
+ require "rack/request"
5
+ require "rack/utils"
6
+
7
+ module AddAuth
8
+ module Rails
9
+ module ProviderLibraries
10
+ # OmniAuth's Apple strategy binds state and nonce to rack.session. Apple
11
+ # posts its callback cross-site, where a normal SameSite=Lax Rails session
12
+ # is intentionally absent. This tiny Rack wrapper copies only those
13
+ # verifier inputs and the Core browser correlation into a separately
14
+ # encrypted, HttpOnly, Secure, SameSite=None cookie scoped to Apple's
15
+ # callback path. It never changes the application's normal session cookie.
16
+ class AppleFormPostCorrelation
17
+ COOKIE = "add_auth_apple_callback"
18
+ PENDING_KEY = "add_auth.apple_pending"
19
+ START_PATH = "/auth/apple"
20
+ CALLBACK_PATH = "/auth/apple/callback"
21
+ PURPOSE = "add_auth.apple-form-post.v1"
22
+
23
+ def self.remember!(session:, transaction:, browser_secret:, configuration_id:, purpose:, remember: false)
24
+ raise ArgumentError, "Apple provider transaction is invalid" unless text?(transaction)
25
+ raise ArgumentError, "Apple provider browser secret is invalid" unless text?(browser_secret)
26
+ raise ArgumentError, "Apple provider configuration is invalid" unless text?(configuration_id)
27
+ raise ArgumentError, "Apple provider purpose is invalid" unless text?(purpose.to_s)
28
+
29
+ session[PENDING_KEY] = {"transaction" => transaction, "browser_secret" => browser_secret,
30
+ "configuration_id" => configuration_id, "purpose" => purpose.to_s, "remember" => remember == true}
31
+ end
32
+
33
+ def self.text?(value)
34
+ value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
35
+ end
36
+ private_class_method :text?
37
+
38
+ def initialize(app, key:, lifetime: 600, clock: -> { Time.now })
39
+ raise ArgumentError, "Apple callback key must be 32 bytes" unless key.is_a?(String) && key.bytesize == 32
40
+ raise ArgumentError, "Apple callback lifetime must be positive" unless lifetime.is_a?(Integer) && lifetime.positive?
41
+
42
+ @app = app
43
+ @cipher = ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: :json)
44
+ @lifetime, @clock = lifetime, clock
45
+ end
46
+
47
+ def call(env)
48
+ request = Rack::Request.new(env)
49
+ return request.post? ? start(env) : @app.call(env) if request.path_info == START_PATH
50
+ return callback(env, request) if request.path_info == CALLBACK_PATH
51
+
52
+ @app.call(env)
53
+ end
54
+
55
+ private
56
+
57
+ def start(env)
58
+ pending = env.fetch("rack.session", {}).delete(PENDING_KEY)
59
+ return @app.call(env) if pending.nil?
60
+ return failure unless pending.is_a?(Hash) && valid_pending?(pending)
61
+
62
+ status, headers, body = @app.call(env)
63
+ return [status, headers, body] unless status.between?(300, 399)
64
+
65
+ payload = pending.merge("state" => env.fetch("rack.session", {})["omniauth.state"],
66
+ "nonce" => env.fetch("rack.session", {})["omniauth.nonce"])
67
+ return [status, headers, body] unless valid_payload?(payload)
68
+
69
+ write_cookie(headers, encrypt(payload))
70
+ [status, headers, body]
71
+ end
72
+
73
+ def callback(env, request)
74
+ # Do not claim a host-owned Apple callback without our capsule. A
75
+ # request reaching AddAuth's controller still needs valid correlation.
76
+ return @app.call(env) unless request.cookies.key?(COOKIE)
77
+ payload = decrypt(request.cookies[COOKIE])
78
+ return failure unless valid_payload?(payload)
79
+
80
+ session = env.fetch("rack.session", {})
81
+ session["omniauth.state"] = payload.fetch("state")
82
+ session["omniauth.nonce"] = payload.fetch("nonce")
83
+ env["add_auth.apple_correlation"] = payload.slice("transaction", "browser_secret", "configuration_id", "purpose", "remember")
84
+ status, headers, body = @app.call(env)
85
+ clear_cookie(headers)
86
+ [status, headers, body]
87
+ end
88
+
89
+ def valid_pending?(payload)
90
+ self.class.send(:text?, payload["transaction"]) && self.class.send(:text?, payload["browser_secret"]) &&
91
+ self.class.send(:text?, payload["configuration_id"]) && self.class.send(:text?, payload["purpose"]) &&
92
+ [true, false].include?(payload["remember"])
93
+ end
94
+
95
+ def valid_payload?(payload)
96
+ payload.is_a?(Hash) && valid_pending?(payload) && self.class.send(:text?, payload["state"]) && self.class.send(:text?, payload["nonce"])
97
+ end
98
+
99
+ def encrypt(payload)
100
+ @cipher.encrypt_and_sign(payload, purpose: PURPOSE, expires_in: @lifetime)
101
+ end
102
+
103
+ def decrypt(value)
104
+ return unless self.class.send(:text?, value)
105
+
106
+ @cipher.decrypt_and_verify(value, purpose: PURPOSE)
107
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
108
+ nil
109
+ end
110
+
111
+ def write_cookie(headers, value)
112
+ Rack::Utils.set_cookie_header!(headers, COOKIE, value: value, path: CALLBACK_PATH, secure: true,
113
+ httponly: true, same_site: :none, max_age: @lifetime)
114
+ end
115
+
116
+ def clear_cookie(headers)
117
+ Rack::Utils.set_cookie_header!(headers, COOKIE, value: "", path: CALLBACK_PATH, secure: true,
118
+ httponly: true, same_site: :none, max_age: 0)
119
+ end
120
+
121
+ def failure
122
+ [422, {"content-type" => "text/plain", "cache-control" => "no-store"}, ["Provider sign-in could not be verified."]]
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ module ProviderLibraries
9
+ class AppleJwks
10
+ URL = URI("https://appleid.apple.com/auth/keys").freeze
11
+ CACHE_KEY = "add_auth:apple:jwks:v1"
12
+ MAX_BYTES = 65_536
13
+
14
+ def initialize(cache:)
15
+ @cache = cache
16
+ end
17
+
18
+ # ruby-jwt's key finder requests one invalidation on an unknown kid.
19
+ # No request/header claim selects a URL or changes TLS verification.
20
+ def call(options = {})
21
+ cached = cache_operation { @cache.read(CACHE_KEY) } unless options[:invalidate]
22
+ return cached if valid?(cached)
23
+ keys = fetch
24
+ cache_operation { @cache.write(CACHE_KEY, keys, expires_in: 3600) }
25
+ keys
26
+ end
27
+
28
+ private
29
+
30
+ def cache_operation
31
+ yield
32
+ rescue
33
+ raise AddAuth::Error, "Apple signing keys unavailable", cause: nil
34
+ end
35
+
36
+ def fetch
37
+ body = +""
38
+ Timeout.timeout(5) do
39
+ Net::HTTP.start(URL.host, URL.port, use_ssl: true, open_timeout: 2, read_timeout: 3, write_timeout: 3) do |http|
40
+ http.max_retries = 0
41
+ http.request(Net::HTTP::Get.new(URL.request_uri)) do |response|
42
+ raise AddAuth::Error, "Apple signing keys unavailable" unless response.code == "200"
43
+ response.read_body do |part|
44
+ raise AddAuth::Error, "Apple signing keys unavailable" if body.bytesize + part.bytesize > MAX_BYTES
45
+ body << part
46
+ end
47
+ end
48
+ end
49
+ end
50
+ keys = JSON.parse(body)
51
+ raise AddAuth::Error, "Apple signing keys unavailable" unless valid?(keys)
52
+ keys
53
+ rescue JSON::ParserError, Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError
54
+ raise AddAuth::Error, "Apple signing keys unavailable", cause: nil
55
+ end
56
+
57
+ def valid?(value)
58
+ value.is_a?(Hash) && value["keys"].is_a?(Array) && value["keys"].size.between?(1, 16) &&
59
+ value["keys"].all? { |key|
60
+ key.is_a?(Hash) && key["kty"] == "RSA" && key["alg"] == "RS256" &&
61
+ key["kid"].is_a?(String) && key["kid"].bytesize.between?(1, 128) && key["n"].is_a?(String) && key["n"].bytesize.between?(1, 1024) &&
62
+ key["e"].is_a?(String) && key["e"].bytesize.between?(1, 16)
63
+ } && value["keys"].map { |key| key["kid"] }.uniq.length == value["keys"].length
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional adapter: the host installs and requires ruby-jwt. No OAuth strategy,
4
+ # global library setting or provider credential storage is introduced.
5
+ module AddAuth
6
+ module Rails
7
+ module ProviderLibraries
8
+ module AppleNative
9
+ ISSUER = "https://appleid.apple.com"
10
+ PURPOSES = [Core::ExternalIdentities::NATIVE, Core::ExternalIdentities::NATIVE_ENROLL].freeze
11
+
12
+ class CallbackResult
13
+ def self.capture(identity_token:, pending:, nonce:, jwks:)
14
+ return unless defined?(::JWT) && pending.is_a?(Core::ExternalIdentities::Pending) &&
15
+ PURPOSES.include?(pending.purpose) && pending.configuration.issuer == ISSUER &&
16
+ identity_token.is_a?(String) && identity_token.bytesize.between?(1, 16_384) && nonce.is_a?(String)
17
+ claims, = ::JWT.decode(identity_token, nil, true, algorithms: ["RS256"], jwks: jwks, allow_nil_kid: false,
18
+ iss: ISSUER, verify_iss: true, aud: pending.configuration.audience, verify_aud: true, verify_iat: true,
19
+ required_claims: %w[iss aud sub exp iat nonce])
20
+ signed_nonce = claims["nonce"]
21
+ return unless signed_nonce.is_a?(String) && signed_nonce.bytesize == nonce.bytesize &&
22
+ OpenSSL.fixed_length_secure_compare(signed_nonce, nonce) && claims["iat"].is_a?(Integer) && claims["exp"].is_a?(Integer) &&
23
+ claims["iat"] >= pending.issued_at.to_i && claims["aud"] == pending.configuration.audience
24
+ new(pending: pending, subject: claims["sub"])
25
+ rescue ::JWT::DecodeError, ArgumentError
26
+ nil
27
+ end
28
+
29
+ def initialize(pending:, subject:)
30
+ @transaction_id = pending.id
31
+ @configuration = pending.configuration
32
+ @purpose = pending.purpose
33
+ @subject = Core::ExternalIdentities::Configuration.text(subject)
34
+ freeze
35
+ end
36
+ private_class_method :new
37
+
38
+ def verified_claims(transaction:)
39
+ return unless transaction.is_a?(Core::ExternalIdentities::Pending) && transaction.id == @transaction_id &&
40
+ transaction.configuration.equal?(@configuration) && transaction.purpose == @purpose
41
+ {issuer: ISSUER, audience: @configuration.audience, subject: @subject, provenance: "ruby-jwt/apple-native-v1"}
42
+ end
43
+
44
+ def inspect = "#<AddAuth::Rails::ProviderLibraries::AppleNative::CallbackResult [FILTERED]>"
45
+ end
46
+
47
+ class Verifier
48
+ def call(server_result:, transaction:)
49
+ server_result.verified_claims(transaction: transaction) if server_result.is_a?(CallbackResult)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module ProviderLibraries
6
+ # Routing selects only host-registered AddAuth providers. Authentication
7
+ # and transaction ownership are checked later by the library and Core.
8
+ class CallbackRoute
9
+ def self.matches?(request)
10
+ options = AddAuth.configuration.external_identities
11
+ options.enabled && options.providers.any? { |provider| provider.middleware_name == request.path_parameters[:provider] }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end