add_auth 0.3.0 → 0.5.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +61 -9
  4. data/ROADMAP.md +43 -31
  5. data/SECURITY.md +7 -1
  6. data/app/controllers/add_auth/accounts_controller.rb +10 -2
  7. data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
  8. data/app/controllers/add_auth/sessions_controller.rb +1 -1
  9. data/app/views/add_auth/accounts/_register.html.erb +1 -1
  10. data/app/views/add_auth/accounts/_request.html.erb +3 -0
  11. data/lib/add_auth/configuration.rb +2 -2
  12. data/lib/add_auth/core/account_lifecycle.rb +51 -7
  13. data/lib/add_auth/core/account_policy.rb +21 -3
  14. data/lib/add_auth/core/session_cursor.rb +38 -0
  15. data/lib/add_auth/core/session_key.rb +32 -0
  16. data/lib/add_auth/core/sessions.rb +19 -8
  17. data/lib/add_auth/rails/authentication_pages.rb +1 -1
  18. data/lib/add_auth/rails/doctor.rb +10 -3
  19. data/lib/add_auth/rails/migration/key_type.rb +3 -3
  20. data/lib/add_auth/rails/runtime.rb +13 -4
  21. data/lib/add_auth/rails/stores/account_tokens.rb +10 -2
  22. data/lib/add_auth/rails/stores/sessions.rb +10 -2
  23. data/lib/add_auth/version.rb +1 -1
  24. data/lib/generators/add_auth/accounts/accounts_generator.rb +7 -1
  25. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +2 -2
  26. data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
  27. data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
  28. data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +2 -0
  29. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +1 -1
  30. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +2 -0
  31. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +6 -12
  32. data/lib/generators/add_auth/feature_configuration.rb +21 -0
  33. data/lib/generators/add_auth/install/templates/initializer.rb +154 -50
  34. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +2 -0
  35. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +4 -4
  36. data/lib/generators/add_auth/notifications/notifications_generator.rb +5 -4
  37. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +1 -1
  38. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +5 -21
  39. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +2 -2
  40. data/lib/generators/add_auth/primary_key.rb +19 -0
  41. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +6 -1
  42. data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_session_cursor_index.rb.tt +5 -0
  43. data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
  44. metadata +21 -1
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ class SessionKey
6
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
7
+ MAX_INTEGER = (2**63) - 1
8
+
9
+ def initialize(type:)
10
+ raise ArgumentError, "unsupported session key type" unless %i[integer bigint uuid].include?(type)
11
+ @uuid = type == :uuid
12
+ end
13
+
14
+ def parse(value)
15
+ if @uuid
16
+ value.downcase if value.is_a?(String) && value.ascii_only? && UUID.match?(value)
17
+ elsif value.is_a?(Integer)
18
+ value if value.between?(1, MAX_INTEGER)
19
+ elsif value.is_a?(String) && value.ascii_only? && value.match?(/\A[1-9]\d{0,18}\z/)
20
+ integer = value.to_i
21
+ integer if integer <= MAX_INTEGER
22
+ end
23
+ end
24
+
25
+ # Stock Rails signed cookies serialize integer IDs as integers, UUIDs as
26
+ # strings. Numeric strings must not broaden the legacy integer bridge.
27
+ def legacy(value)
28
+ parse(value) if @uuid || value.is_a?(Integer)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "add_auth/core/mobile_profile"
5
+ require "add_auth/core/session_key"
6
+ require "add_auth/core/session_cursor"
5
7
 
6
8
  module AddAuth
7
9
  module Core
@@ -37,6 +39,8 @@ module AddAuth
37
39
  raise ArgumentError, "legacy_bridge_until must be an absolute Time or nil"
38
40
  end
39
41
  @store, @digest, @eligible, @clock = store, digest, eligible, clock
42
+ @key = SessionKey.new(type: store.respond_to?(:id_type) ? store.id_type : :integer)
43
+ @cursor = SessionCursor.new(key: @key)
40
44
  @access_policy = access_policy
41
45
  @password_lifecycle = password_lifecycle
42
46
  @on_sign_in = on_sign_in
@@ -191,13 +195,14 @@ module AddAuth
191
195
  def list_page(user:, current_session_id: nil, before: nil)
192
196
  empty = Page.new(entries: [])
193
197
  return empty unless user && @eligible.call(user) == true
194
- return empty unless before.nil? || before.to_s.match?(/\A[1-9]\d{0,18}\z/)
198
+ boundary = @cursor.decode(before) unless before.nil?
199
+ return empty unless before.nil? || boundary
195
200
 
196
201
  now = @clock.now
197
- candidates = @store.list_for_user(user_id: user.id, before: before&.to_i,
202
+ candidates = @store.list_for_user(user_id: user.id, before: boundary,
198
203
  excluding: current_session_id, limit: PAGE_SIZE + 1, now: now,
199
204
  active_after: now - [@idle, @remembered&.fetch(:idle_timeout) || @idle, @mobile&.idle_timeout || @idle].max, legacy: !!(@deadline && now < @deadline))
200
- cursor = candidates[PAGE_SIZE - 1].id if candidates.length > PAGE_SIZE
205
+ cursor = @cursor.encode(candidates[PAGE_SIZE - 1]) if candidates.length > PAGE_SIZE
201
206
  rows = candidates.first(PAGE_SIZE)
202
207
  if before.nil? && current_session_id
203
208
  current = @store.find_for_user(user_id: user.id, session_id: current_session_id)
@@ -217,16 +222,22 @@ module AddAuth
217
222
 
218
223
  # Both the initiating bearer and target ownership are checked under lock.
219
224
  def revoke_one(user:, session:, session_id:)
220
- return false unless session_id.is_a?(Integer) || session_id.to_s.match?(/\A[1-9]\d*\z/)
225
+ target_id = @key.parse(session_id)
226
+ return false unless target_id
221
227
 
222
228
  with_live_session(user: user, session: session) do |account, _current, now|
223
- target = @store.find_for_user_in_transaction(user_id: account.id, session_id: session_id.to_i)
229
+ target = @store.find_for_user_in_transaction(user_id: account.id, session_id: target_id)
224
230
  next false unless target
225
231
  @store.update(target, revoked_at: now) unless target.revoked_at
226
232
  true
227
233
  end || false
228
234
  end
229
235
 
236
+ def current_session?(session:, session_id:)
237
+ id = @key.parse(session_id)
238
+ !!(id && session && id == session.id)
239
+ end
240
+
230
241
  # Password proof is obtained from the locked, current account. Intake
231
242
  # limits must be applied by the caller before reaching this expensive port.
232
243
  def reauthenticate(user:, session:, purpose:, policy:)
@@ -315,10 +326,10 @@ module AddAuth
315
326
  private
316
327
 
317
328
  def cookie_lookup(value)
318
- if value.is_a?(String) && PATTERN.match?(value)
329
+ if value.is_a?(String) && value.ascii_only? && PATTERN.match?(value)
319
330
  {digest: @digest.digest(value)}
320
- elsif value.is_a?(Integer) && value.positive? && @deadline && @clock.now < @deadline
321
- {id: value}
331
+ elsif @deadline && @clock.now < @deadline && (id = @key.legacy(value))
332
+ {id: id}
322
333
  end
323
334
  end
324
335
 
@@ -16,7 +16,7 @@ module AddAuth
16
16
  add_auth_accept(grant)
17
17
  redirect_to destination, status: :see_other
18
18
  else
19
- @error = "Email or password is incorrect. Try again or request a sign-in link."
19
+ @error = "Email or password is incorrect. Try again."
20
20
  page("form", status: :unprocessable_entity)
21
21
  end
22
22
  end
@@ -120,6 +120,11 @@ module AddAuth
120
120
  check("Install provider account management routes") { ::Rails.application.routes.recognize_path("/account/external-identities", method: :get) }
121
121
  end
122
122
  if config.lifecycle.enabled
123
+ check("Set explicit confirmation and unconfirmed-reset policies") { Runtime.account_policy }
124
+ if config.lifecycle.confirmation_required == false
125
+ columns(::User, %w[add_auth_provisioned_at], "optional confirmation provisioning")
126
+ check("Enable password authentication for optional signup confirmation") { config.passwords_enabled }
127
+ end
123
128
  if defined?(::PasswordsController)
124
129
  check("Retire stock password reset entry points through the shared account flow") { ::PasswordsController < AccountPasswordEntry }
125
130
  end
@@ -179,10 +184,12 @@ module AddAuth
179
184
  check("Enable security notification delivery errors") { ::AddAuth::SecurityMailer.raise_delivery_errors }
180
185
  end
181
186
  if config.step_up.enabled
182
- check("Enable session and email prerequisites for step_up") { config.session.enabled && config.email_link.enabled }
187
+ check("Enable session prerequisites for step_up") { config.session.enabled }
183
188
  columns(::Session, %w[elevation_version elevation_expires_at], "reauthentication")
184
- columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken,
185
- %w[browser_digest session_id session_digest authentication_purpose], "reauthentication")
189
+ if config.email_link.enabled
190
+ columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken,
191
+ %w[browser_digest session_id session_digest authentication_purpose], "reauthentication")
192
+ end
186
193
  check("Declare valid step-up purposes and safe return destinations") { Runtime.step_up_purposes.any? && Runtime.step_up_policy }
187
194
  %w[/reauthenticate /reauthenticate/link].each do |path|
188
195
  check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
@@ -7,15 +7,15 @@ module AddAuth
7
7
  module_function
8
8
 
9
9
  # Resolve when the migration runs, after the host's account table exists.
10
- # Session IDs remain integer until opaque cursor support is established.
10
+ # Keep the session keyword for migrations generated by earlier releases.
11
11
  def for(connection, table, session: false)
12
12
  key = connection.primary_key(table)
13
13
  column = connection.columns(table).find { |entry| entry.name == key }
14
14
  raise ArgumentError, "AddAuth requires a single integer or UUID primary key on #{table}" unless column
15
15
  return :bigint if column.type == :integer && column.limit == 8
16
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"
17
+ return :uuid if column.type == :uuid
18
+ raise ArgumentError, "AddAuth requires an integer or UUID primary key on #{table}"
19
19
  end
20
20
  end
21
21
  end
@@ -15,11 +15,17 @@ module AddAuth
15
15
  def config = AddAuth.configuration
16
16
 
17
17
  def account_policy
18
- Core::AccountPolicy.new(enabled: config.lifecycle.enabled, eligible: config.lifecycle.eligible)
18
+ Core::AccountPolicy.new(enabled: config.lifecycle.enabled,
19
+ eligible: ->(user) { config.eligible.call(user) == true && config.lifecycle.eligible.call(user) == true },
20
+ confirmation_required: config.lifecycle.confirmation_required, reset_unconfirmed: config.lifecycle.reset_unconfirmed)
19
21
  end
20
22
 
21
23
  def eligible(user)
22
- config.eligible.call(user) == true && account_policy.allowed?(user)
24
+ account_policy.allowed?(user)
25
+ end
26
+
27
+ def trusted_recovery_address(user)
28
+ config.trusted_recovery_address.call(user) if account_policy.trusted_address?(user)
23
29
  end
24
30
 
25
31
  def authority
@@ -74,11 +80,14 @@ module AddAuth
74
80
 
75
81
  def accounts
76
82
  raise AddAuth::Error, "enable account lifecycle first" unless config.lifecycle.enabled
83
+ if !account_policy.confirmation_required? && !::User.column_names.include?("add_auth_provisioned_at")
84
+ raise AddAuth::Error, "rerun add_auth:accounts and migrate before enabling optional confirmation"
85
+ end
77
86
  require "add_auth/rails/stores/account_tokens"
78
87
  Core::AccountLifecycle.new(store: Stores::AccountTokens.new(user_model: ::User, token_model: ::AddAuthAccountToken,
79
88
  session_model: ::Session, address_model: ::AddAuthAddressClaim, authority: authority, provision: config.lifecycle.provision, delete_account: config.lifecycle.delete_account),
80
89
  digest: config.sign_in_token_digest, delivery_cipher: DeliveryCipher.new(key: key("account-proofs")),
81
- policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: config.trusted_recovery_address,
90
+ policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: method(:trusted_recovery_address),
82
91
  lifetime: config.lifecycle.proof_lifetime, notify: ->(**event) { security_events.issue(**event) if config.notifications.enabled },
83
92
  sessions: sessions, step_up_policy: step_up_policy, profile_attributes: config.lifecycle.profile_attributes, deletion_allowed: config.lifecycle.deletion_allowed,
84
93
  external_identities: -> { external_identities })
@@ -168,7 +177,7 @@ module AddAuth
168
177
  Core::AccessPolicy.new(credentials: ->(id) {
169
178
  ::AddAuthCredential.find_by(external_id: id) if defined?(::AddAuthCredential) && ::AddAuthCredential.table_exists?
170
179
  }, passkeys_enabled: config.passkeys.enabled, email_enabled: Core::Intake.email_available?(config),
171
- trusted_recovery_address: config.trusted_recovery_address, password_enabled: config.passwords_enabled,
180
+ trusted_recovery_address: method(:trusted_recovery_address), password_enabled: config.passwords_enabled,
172
181
  external_enabled: config.external_identities.enabled,
173
182
  external_current: ->(**arguments) { external_identities.credential_current?(**arguments) })
174
183
  end
@@ -16,16 +16,19 @@ module AddAuth
16
16
  @account_credentials = AccountCredentials.new(user_model: user_model, authority: authority)
17
17
  end
18
18
 
19
- def create_account(email:, password:, profile: {})
19
+ def create_account(email:, password:, profile: {}, replacing: nil)
20
20
  raise AddAuth::Error, "registration must own its transaction" if @users.connection.transaction_open?
21
21
  completed = false
22
+ saved = false
22
23
  @users.transaction do |transaction|
23
24
  transaction.after_commit { completed = true }
25
+ AccountLock.new(@users).current_in_transaction(id: replacing.user_id) if replacing
24
26
  user = @users.new(profile)
25
27
  user.email_address = email
26
28
  user.password = password
27
29
  user.add_auth_authority = "add_auth"
28
30
  user.save!
31
+ saved = true
29
32
  yield user
30
33
  end
31
34
  raise AddAuth::Error, "registration transaction rolled back" unless completed
@@ -33,6 +36,7 @@ module AddAuth
33
36
  rescue ActiveRecord::RecordNotUnique, Core::AccountLifecycle::Conflict
34
37
  :duplicate
35
38
  rescue ActiveRecord::RecordInvalid => error
39
+ raise if saved
36
40
  (error.record.errors.attribute_names - [:email_address]).empty? ? :duplicate : :invalid
37
41
  end
38
42
 
@@ -81,7 +85,11 @@ module AddAuth
81
85
  end
82
86
 
83
87
  def revoke_authority(user:, at:) = @authority.revoke(user_id: user.id, at: at)
84
- def provision(user:) = @provision.call(user)
88
+
89
+ def provision(user:)
90
+ @provision.call(user)
91
+ user.reload
92
+ end
85
93
 
86
94
  def delete_account(user:)
87
95
  raise AddAuth::Error, "account deletion requires its owning transaction" unless @users.connection.transaction_open?
@@ -6,8 +6,11 @@ module AddAuth
6
6
  module Rails
7
7
  module Stores
8
8
  class Sessions
9
+ attr_reader :id_type
10
+
9
11
  def initialize(user_model:, session_model:)
10
12
  @users, @sessions = user_model, session_model
13
+ @id_type = @sessions.columns_hash.fetch(@sessions.primary_key).type
11
14
  raise ArgumentError, "authentication models must share one connection pool" unless @users.connection_pool.equal?(@sessions.connection_pool)
12
15
  @lock = AccountLock.new(@users)
13
16
  end
@@ -38,11 +41,16 @@ module AddAuth
38
41
  # Query cutoffs come from Core; Core still authorizes every returned row.
39
42
  def list_for_user(user_id:, before:, excluding:, limit:, now:, active_after:, legacy:)
40
43
  scope = @sessions.where(user_id: user_id, revoked_at: nil)
41
- scope = scope.where("id < ?", before) if before
44
+ if before&.key?(:legacy_id)
45
+ scope = scope.where("id < ?", before.fetch(:legacy_id))
46
+ elsif before
47
+ scope = scope.where("created_at < ? OR (created_at = ? AND id < ?)",
48
+ before.fetch(:created_at), before.fetch(:created_at), before.fetch(:id))
49
+ end
42
50
  scope = scope.where.not(id: excluding) if excluding
43
51
  scope = scope.where("(expires_at IS NULL OR expires_at > ?) AND (last_seen_at IS NULL OR last_seen_at > ?)", now, active_after)
44
52
  scope = scope.where.not(expires_at: nil).where.not(last_seen_at: nil) unless legacy
45
- scope.order(id: :desc).limit(limit).to_a
53
+ scope.order(created_at: :desc, id: :desc).limit(limit).to_a
46
54
  end
47
55
 
48
56
  def find_for_user(user_id:, session_id:) = @sessions.find_by(user_id: user_id, id: session_id)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AddAuth
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -1,27 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/primary_key"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
7
8
  module Generators
8
9
  class AccountsGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include PrimaryKey
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
14
+ class_option :email_link, type: :boolean, default: true, desc: "Enable email sign-in as a step-up dependency"
12
15
  desc "Prepare optional account lifecycle schema and pages; review existing account confirmation and policy before enabling"
13
16
 
14
17
  def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
15
18
 
16
19
  def prerequisites
17
20
  invoke "add_auth:notifications"
18
- invoke "add_auth:step_up"
21
+ invoke "add_auth:step_up", [], email_link: options[:email_link]
19
22
  end
20
23
 
21
24
  def persistence
22
25
  unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_accounts.rb")].any?
23
26
  migration_template "add_add_auth_accounts.rb.tt", "db/migrate/add_add_auth_accounts.rb"
24
27
  end
28
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_provisioning.rb")].any?
29
+ migration_template "add_add_auth_provisioning.rb.tt", "db/migrate/add_add_auth_provisioning.rb"
30
+ end
25
31
  %w[add_auth_account_token add_auth_address_claim].each do |name|
26
32
  template "#{name}.rb", "app/models/#{name}.rb" unless File.exist?(File.join(destination_root, "app/models/#{name}.rb"))
27
33
  end
@@ -14,7 +14,7 @@ class AddAddAuthAccounts < ActiveRecord::Migration[8.0]
14
14
  add_column :users, :add_auth_authority, :string, default: "add_auth", null: false unless column_exists?(:users, :add_auth_authority)
15
15
  add_column :sessions, :remembered, :boolean, default: false_default, null: false unless column_exists?(:sessions, :remembered)
16
16
  add_column :sessions, :idle_timeout, :integer unless column_exists?(:sessions, :idle_timeout)
17
- create_table :add_auth_address_claims do |t|
17
+ create_table :add_auth_address_claims, id: <%= authentication_primary_key_type %> do |t|
18
18
  t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
19
19
  t.string :digest, null: false
20
20
  t.string :state, null: false
@@ -22,7 +22,7 @@ class AddAddAuthAccounts < ActiveRecord::Migration[8.0]
22
22
  end
23
23
  add_index :add_auth_address_claims, :digest, unique: true
24
24
  add_index :add_auth_address_claims, [:user_id, :state], unique: true
25
- create_table :add_auth_account_tokens do |t|
25
+ create_table :add_auth_account_tokens, id: <%= authentication_primary_key_type %> do |t|
26
26
  t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
27
27
  t.string :digest, null: false
28
28
  t.string :purpose, null: false
@@ -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
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/primary_key"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
@@ -8,6 +9,7 @@ module AddAuth
8
9
  # Internal persistence foundation only. Does not install sign-in routes.
9
10
  class EmailTokensGenerator < ::Rails::Generators::Base
10
11
  include ::Rails::Generators::Migration
12
+ include PrimaryKey
11
13
 
12
14
  source_root File.expand_path("templates", __dir__)
13
15
 
@@ -2,7 +2,7 @@ require "add_auth/rails/migration/key_type"
2
2
 
3
3
  class CreateAddAuthSignInTokens < ActiveRecord::Migration[8.0]
4
4
  def change
5
- create_table :add_auth_sign_in_tokens do |t|
5
+ create_table :add_auth_sign_in_tokens, id: <%= authentication_primary_key_type %> do |t|
6
6
  t.references :user, type: AddAuth::Rails::Migration::KeyType.for(connection, :users), null: false, foreign_key: true
7
7
  t.string :digest, limit: 64, null: false
8
8
  t.string :identifier_digest, limit: 64, null: false
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "generators/add_auth/primary_key"
4
5
  require "rails/generators/active_record"
5
6
 
6
7
  module AddAuth
@@ -8,6 +9,7 @@ module AddAuth
8
9
  # Optional persistence and routes; provider strategies remain disabled until configured.
9
10
  class ExternalIdentitiesGenerator < ::Rails::Generators::Base
10
11
  include ::Rails::Generators::Migration
12
+ include PrimaryKey
11
13
 
12
14
  source_root File.expand_path("templates", __dir__)
13
15
  def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
@@ -1,8 +1,10 @@
1
+ require "add_auth/rails/migration/key_type"
2
+
1
3
  class CreateAddAuthExternalIdentities < ActiveRecord::Migration[8.0]
2
4
  def change
3
- user_type = reference_type(:users)
4
- session_type = reference_type(:sessions)
5
- create_table :add_auth_external_identities do |t|
5
+ user_type = AddAuth::Rails::Migration::KeyType.for(connection, :users)
6
+ session_type = AddAuth::Rails::Migration::KeyType.for(connection, :sessions, session: true)
7
+ create_table :add_auth_external_identities, id: <%= authentication_primary_key_type %> do |t|
6
8
  t.references :user, type: user_type, null: false, foreign_key: {on_delete: :cascade}
7
9
  # SHA256 of the exact JSON [configuration, issuer, audience, subject].
8
10
  # Indexing this key preserves case-sensitive subjects on either store.
@@ -18,7 +20,7 @@ class CreateAddAuthExternalIdentities < ActiveRecord::Migration[8.0]
18
20
  t.timestamps
19
21
  end
20
22
  add_index :add_auth_external_identities, :namespace, unique: true
21
- create_table :add_auth_external_transactions do |t|
23
+ create_table :add_auth_external_transactions, id: <%= authentication_primary_key_type %> do |t|
22
24
  t.string :digest, null: false
23
25
  t.string :browser_digest, null: false
24
26
  t.string :provider_id, limit: 2048, null: false
@@ -39,12 +41,4 @@ class CreateAddAuthExternalIdentities < ActiveRecord::Migration[8.0]
39
41
  add_column :sessions, :authentication_external_id, :string
40
42
  add_column :sessions, :authentication_external_version, :string
41
43
  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
44
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Generators
5
+ module FeatureConfiguration
6
+ private
7
+
8
+ def enable_feature(name)
9
+ path = "config/initializers/add_auth.rb"
10
+ source = File.read(File.join(destination_root, path))
11
+ setting = "config.#{name}.enabled"
12
+ pattern = /^(\s*)(?:# #{Regexp.escape(setting)} = true|#{Regexp.escape(setting)} = false)$/
13
+ if source.match?(pattern)
14
+ gsub_file path, pattern, "\\1#{setting} = true"
15
+ elsif !source.match?(/^\s*#{Regexp.escape(setting)}\s*=/)
16
+ append_to_file path, "\nAddAuth.configure do |config|\n #{setting} = true\nend\n"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end