add_auth 0.4.0 → 0.5.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +34 -5
  4. data/ROADMAP.md +6 -0
  5. data/SECURITY.md +4 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +5 -1
  7. data/app/controllers/add_auth/passkeys_controller.rb +6 -4
  8. data/app/controllers/add_auth/sessions_controller.rb +1 -1
  9. data/app/jobs/add_auth/account_delivery_job.rb +2 -1
  10. data/app/mailers/add_auth/account_mailer.rb +2 -1
  11. data/app/views/add_auth/account_mailer/link.text.erb +4 -0
  12. data/app/views/add_auth/accounts/_proof.html.erb +8 -1
  13. data/lib/add_auth/core/account_lifecycle.rb +28 -4
  14. data/lib/add_auth/core/account_policy.rb +15 -1
  15. data/lib/add_auth/core/external_identities.rb +15 -0
  16. data/lib/add_auth/core/intake.rb +11 -6
  17. data/lib/add_auth/core/passwords/lifecycle.rb +15 -6
  18. data/lib/add_auth/core/rate_limit.rb +11 -0
  19. data/lib/add_auth/core/session_cursor.rb +38 -0
  20. data/lib/add_auth/core/session_key.rb +32 -0
  21. data/lib/add_auth/core/sessions.rb +28 -10
  22. data/lib/add_auth/core/strategies/passkey.rb +5 -2
  23. data/lib/add_auth/rails/migration/key_type.rb +3 -3
  24. data/lib/add_auth/rails/runtime.rb +24 -4
  25. data/lib/add_auth/rails/stores/sessions.rb +10 -2
  26. data/lib/add_auth/version.rb +1 -1
  27. data/lib/generators/add_auth/accounts/accounts_generator.rb +2 -0
  28. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +2 -2
  29. data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +2 -0
  30. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +1 -1
  31. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +2 -0
  32. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +6 -12
  33. data/lib/generators/add_auth/install/templates/initializer.rb +1 -1
  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 +2 -0
  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 +2 -0
  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 +3 -0
  42. data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_session_cursor_index.rb.tt +5 -0
  43. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ed4ef07ed6308f6bb4b82de3e67552af3604472f38e7a379f1d9c863f76954e
4
- data.tar.gz: 8189c0ddf517a9d06d291738e0d0d20bb383ffc9a7fdd085c9df33ad914312d4
3
+ metadata.gz: 0f425748354f43e9f37579150ae2fe1cae7797f5da8c37af7e9f330b783a2d96
4
+ data.tar.gz: 8b7187ffe5b74c138d68cf4ffac7edf7c65e3d258941edec49c72f55a743c621
5
5
  SHA512:
6
- metadata.gz: 643216bb646e0ca6032f0b53f1928eb6da0b4efeffccadd8477fbb7776f6aa411c6ecf36bb4c3f785d94fa435320c04745a2565c70cef47e387f14fc534b805b
7
- data.tar.gz: 48375790d81d186fa5cfd6f4f3ecf4672c46b2ec8602a3597c5a40b69562661212de41f5df8676a7080ef96ab36915af6dec798400aa97610b5b3c22d76be2d4
6
+ metadata.gz: 51c64dbd5366aac908be7df8fd4f82c9501ce081400fb77976ece4b92fa9066a7aca0fc799a7ba4e99ce3c19609190db702373e6e45fddc055795df8b23898c7
7
+ data.tar.gz: 9a37bfc7978081e75ea517ae9ba414fa4b2771ec7d9bb8ea6a56acf6da1ee3fe2b0882989e9177198a701ce409e2b05af225214389bb5361b0845bb5d6df5f01
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 — 2026-10-21
4
+
5
+ Security hardening; upgrading is recommended. Refresh ejected account and
6
+ passkey controllers/views through the normal review process.
7
+
8
+ - The first email confirmation of an account with a linked sign-in provider
9
+ names that provider in the email and on the page. Only an explicit "Confirm
10
+ and keep" preserves the link; any other confirmation removes it.
11
+ - Automatic password lockout gates only password sign-in. It no longer revokes
12
+ existing sessions or blocks passkey, email-link or provider sign-in, never
13
+ counts failures for accounts that cannot use a password, and is cleared by a
14
+ successful password reset. Manual locks are unchanged.
15
+ - Sign out everywhere also revokes outstanding email proofs, account proofs
16
+ (such as a pending address change), passkey/provider ceremonies and mobile
17
+ handoffs.
18
+ - Rate limits check the IP budget first and charge the identifier budget only
19
+ after a configured challenge passes. Passkey sign-in option requests use their
20
+ own IP budget, and `passkeys.anonymous_limit` now applies per IPv4 address or
21
+ IPv6 /64 rather than globally.
22
+
23
+ ## 0.5.0 — 2026-09-18
24
+
25
+ - Honor Rails' `primary_key_type` generator setting for all AddAuth-owned tables.
26
+ Support PostgreSQL UUID sessions and mixed integer/UUID account/session keys,
27
+ retaining bigint defaults and deriving references from the existing schema.
28
+ - Use creation time and a typed ID in opaque session-page cursors; validate
29
+ revocation and explicitly enabled legacy session adoption for either key type.
30
+ For apps already using hardened sessions, re-run `add_auth:session_upgrade`
31
+ for the new pagination index. Existing tables retain their IDs; refresh ejected
32
+ controllers through the normal review process.
33
+ Follow the [upgrade guide](https://addauthgem.com/upgrading/#uuid-support) for
34
+ the release gate, migration review, opaque client cursors and rollback boundary.
35
+
3
36
  ## 0.4.0 — 2026-09-13
4
37
 
5
38
  - Constrain JSON below 3.0 for the supported Rails decoding API; independently
data/README.md CHANGED
@@ -29,26 +29,26 @@ for verification in a disposable host.
29
29
 
30
30
  ## Quickstart
31
31
 
32
- These commands target 0.4.0 and are available after its
33
- [RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.4.0).
32
+ These commands target 0.5.1 and are available after its
33
+ [RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.5.1).
34
34
  Check package availability before changing your app’s bundle.
35
35
 
36
36
  Install the `add_auth` gem from RubyGems through your Rails app’s Gemfile,
37
37
  then enable password and email-link sign-in. Use Ruby 3.3+ and Rails 8.0+
38
38
  with Active Record, and run the commands below from your Rails app’s root.
39
39
 
40
- **Release availability:** these commands require the published `0.4.0` package.
40
+ **Release availability:** these commands require the published `0.5.1` package.
41
41
  If it is not yet listed on [RubyGems](https://rubygems.org/gems/add_auth/versions),
42
42
  wait for publication; see [release status](https://addauthgem.com/release-status/).
43
43
  Start in your app’s development environment; use the deployment settings
44
44
  below before enabling sign-in for users.
45
45
 
46
46
  1. Keep `source "https://rubygems.org"` in your app’s Gemfile and add the
47
- 0.4 release line:
47
+ 0.5 release line:
48
48
 
49
49
  ```ruby
50
50
  # Gemfile
51
- gem "add_auth", "~> 0.4.0"
51
+ gem "add_auth", "~> 0.5.1"
52
52
  ```
53
53
 
54
54
  Bundler downloads the package from RubyGems and records the resolved
@@ -568,6 +568,35 @@ adds registration, confirmation/reconfirmation, password reset, lock/unlock and
568
568
  account changes. Hosts retain account eligibility, resource authorization and
569
569
  provider registrations.
570
570
 
571
+ ### UUID and integer primary keys in 0.5.0
572
+
573
+ Version 0.5.0 adds PostgreSQL UUID sessions and configurable primary keys for all
574
+ AddAuth authentication tables. Integer apps retain the normal Rails defaults.
575
+ These instructions become available after the 0.5.0 package is published.
576
+
577
+ For a new PostgreSQL app, set Rails' generator preference in `config/application.rb`
578
+ before generating authentication or AddAuth features:
579
+
580
+ ```ruby
581
+ config.generators do |g|
582
+ g.orm :active_record, primary_key_type: :uuid
583
+ end
584
+ ```
585
+
586
+ AddAuth uses this setting for every table it creates. Leave it unset for Rails'
587
+ bigint default, or set `:bigint` or `:integer` explicitly. References follow the
588
+ actual target table, so UUID users can have bigint sessions and vice versa.
589
+ The generated migration records its chosen type; later configuration changes do
590
+ not change that migration or convert existing rows. Other key types are unsupported.
591
+
592
+ For an existing installation, follow the
593
+ [version-gated package upgrade](https://addauthgem.com/upgrading/#uuid-support).
594
+ It covers the additive pagination index, ejected controller review, opaque
595
+ pagination cursors, verification and rollback from older gems. Integer apps need
596
+ no ID conversion. The [primary-key guide](https://addauthgem.com/uuid-primary-keys/)
597
+ explains mixed schemas and the separate, app-owned migration needed to convert
598
+ existing IDs. The published 0.4.0 package cannot manage UUID sessions.
599
+
571
600
  ## Optional integrations in 0.3.0
572
601
 
573
602
  Enable bounded [Devise migration](https://addauthgem.com/migrating-from-devise/),
data/ROADMAP.md CHANGED
@@ -25,6 +25,12 @@ this public checklist is derived from its section 14 and stands on its own.
25
25
 
26
26
  ## Now — maintain AddAuth and learn from adoption
27
27
 
28
+ - [x] Honor the Rails UUID/bigint generator setting across all authentication
29
+ tables, including UUID sessions, mixed references and three-mode bundled/ejected
30
+ session management. Local PostgreSQL and browser acceptance passes on Rails
31
+ 8.0/8.1. Added in 0.5.0; see
32
+ [release availability](https://github.com/taimoorq/add_auth/releases).
33
+
28
34
  - [x] Optional password-signup confirmation, immediate transactional provisioning
29
35
  and session issuance, explicit unconfirmed-reset policy, password-only lifecycle
30
36
  and the complete commented Rails initializer. Local supported-matrix, stock/
data/SECURITY.md CHANGED
@@ -54,6 +54,10 @@ the currency policy).
54
54
 
55
55
  ## Supported versions
56
56
 
57
+ UUID/integer session lookup, account-scoped pagination and revocation are also
58
+ in scope. Version 0.5.0 adds UUID sessions and UUID authentication-table generation;
59
+ primary keys must never substitute for browser/mobile bearer secrets.
60
+
57
61
  Until a 1.0 is released, only the latest published version receives security
58
62
  fixes. This table will be expanded once there are stable release lines to
59
63
  support.
@@ -52,6 +52,7 @@ module AddAuth
52
52
  def proof
53
53
  @purpose = purpose
54
54
  result = Rails::Runtime.accounts.preview(token: params[:token], purpose: @purpose)
55
+ @providers = Rails::Runtime.provider_labels(result.credential) if result.success?
55
56
  page(result.success? ? "proof" : "invalid_proof")
56
57
  end
57
58
 
@@ -59,11 +60,14 @@ module AddAuth
59
60
  @purpose = purpose
60
61
  admitted = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: :account_consume)
61
62
  return intake_failure(admitted) if admitted.is_a?(Symbol)
62
- result = Rails::Runtime.accounts.consume(token: params[:token], purpose: @purpose, password: params[:password])
63
+ result = Rails::Runtime.accounts.consume(token: params[:token], purpose: @purpose, password: params[:password],
64
+ keep_providers: params[:providers] == "keep")
63
65
  if result.success?
64
66
  redirect_to "/sign-in", status: :see_other, notice: "Your account has been updated. Sign in to start a new session."
65
67
  else
66
68
  @error = "This request could not be completed. Check the password requirements or request a new link."
69
+ preview = Rails::Runtime.accounts.preview(token: params[:token], purpose: @purpose)
70
+ @providers = Rails::Runtime.provider_labels(preview.credential) if preview.success?
67
71
  page("proof", status: :unprocessable_entity)
68
72
  end
69
73
  end
@@ -30,9 +30,11 @@ module AddAuth
30
30
  finish(result, destination: "/passkeys", grant: result.grant)
31
31
  end
32
32
 
33
+ # Conditional UI requests options on page load. Keep that off the
34
+ # password sign_in budget while still applying the sign_in challenge.
33
35
  def authentication_options
34
- return unless admitted(:sign_in)
35
- options(service.authentication_options(browser_secret: browser_secret))
36
+ return unless admitted(:sign_in, budget: :passkey_options)
37
+ options(service.authentication_options(browser_secret: browser_secret, ip: request.remote_ip))
36
38
  end
37
39
 
38
40
  def authenticate
@@ -92,8 +94,8 @@ module AddAuth
92
94
  value.to_unsafe_h if value.is_a?(ActionController::Parameters)
93
95
  end
94
96
 
95
- def admitted(action)
96
- result = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: action, challenge_token: challenge_token)
97
+ def admitted(action, budget: action)
98
+ result = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: action, challenge_token: challenge_token, budget: budget)
97
99
  return true if result == true
98
100
  render json: {error: "Verification could not start. Try again shortly."}, status: if result == :rate_limited
99
101
  429
@@ -25,7 +25,7 @@ module AddAuth
25
25
 
26
26
  def destroy
27
27
  target_id = params[:id].to_s
28
- if target_id.match?(/\A[1-9]\d*\z/) && target_id.to_i == Current.session.id
28
+ if Rails::Runtime.sessions.current_session?(session: Current.session, session_id: target_id)
29
29
  terminate_session
30
30
  return redirect_to Rails::Runtime.sign_in_path, status: :see_other
31
31
  end
@@ -7,7 +7,8 @@ module AddAuth
7
7
  return unless record
8
8
  deliver(service: Rails::Runtime.accounts, record: record, mailer: AccountMailer) do |delivery|
9
9
  AccountMailer.link(recipient: delivery.fetch(:recipient), purpose: delivery.fetch(:purpose),
10
- url: Rails::Runtime.account_proof_url(delivery.fetch(:token), purpose: delivery.fetch(:purpose)))
10
+ url: Rails::Runtime.account_proof_url(delivery.fetch(:token), purpose: delivery.fetch(:purpose)),
11
+ providers: Rails::Runtime.provider_labels(delivery.fetch(:providers, [])))
11
12
  end
12
13
  end
13
14
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module AddAuth
4
4
  class AccountMailer < SignInMailer
5
- def link(recipient:, url:, purpose:)
5
+ def link(recipient:, url:, purpose:, providers: [])
6
+ @providers = providers
6
7
  @instruction = {"confirm" => "Confirm your email address", "reset_password" => "Choose a new password", "unlock" => "Unlock your account"}.fetch(purpose)
7
8
  @url = url
8
9
  mail(to: recipient, from: Rails::Runtime.config.mail_from, subject: @instruction,
@@ -2,6 +2,10 @@
2
2
 
3
3
  Open this link, then use the button on the page to confirm your request:
4
4
  <%= @url %>
5
+ <% if @providers.present? %>
6
+
7
+ This account was started by signing in with <%= @providers.to_sentence %>. Keep <%= @providers.to_sentence %> only if you used it to create this account. If you did not, choose "Confirm without <%= @providers.to_sentence %>".
8
+ <% end %>
5
9
 
6
10
  This link expires in <%= AddAuth.configuration.lifecycle.proof_lifetime.to_i / 60 %> minutes and can be used once.
7
11
  If you did not request this message, you can ignore it.
@@ -11,6 +11,13 @@
11
11
  <%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
12
12
  </div>
13
13
  <% end %>
14
- <%= form.submit title, class: add_auth_class(:button) %>
14
+ <% if @purpose == "confirm" && @providers.present? %>
15
+ <% providers = @providers.to_sentence %>
16
+ <p>This account was started by signing in with <%= providers %>. Keep <%= providers %> only if you used it to create this account.</p>
17
+ <%= form.button "Confirm and keep #{providers}", name: "providers", value: "keep", class: add_auth_class(:button) %>
18
+ <%= form.button "Confirm without #{providers}", name: "providers", value: "remove", class: add_auth_class(:button) %>
19
+ <% else %>
20
+ <%= form.submit title, class: add_auth_class(:button) %>
21
+ <% end %>
15
22
  <% end %>
16
23
  <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -35,7 +35,8 @@ module AddAuth
35
35
  end
36
36
 
37
37
  def initialize(store:, digest:, delivery_cipher:, policy:, password_policy:, trusted_address:, clock: Time, random: SecureRandom,
38
- lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil)
38
+ lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil,
39
+ external_bindings: nil)
39
40
  raise ArgumentError, "proof lifetime must be between 60 and 86400 seconds" unless lifetime.is_a?(Numeric) && lifetime.between?(60, 86_400)
40
41
  @store, @digest, @cipher, @policy, @password_policy, @trusted_address = store, digest, delivery_cipher, policy, password_policy, trusted_address
41
42
  @clock, @random, @lifetime, @notify = clock, random, lifetime, notify
@@ -43,6 +44,7 @@ module AddAuth
43
44
  @profile_attributes = profile_attributes
44
45
  @deletion_allowed = deletion_allowed
45
46
  @external_identities = external_identities
47
+ @external_bindings = external_bindings
46
48
  end
47
49
 
48
50
  def register(identifier:, password:, profile: {}, replacing: nil, user_agent: nil, ip_address: nil, remember: false)
@@ -86,14 +88,21 @@ module AddAuth
86
88
  accepted
87
89
  end
88
90
 
91
+ # A confirm preview's credential lists the provider configurations whose
92
+ # bindings the address owner must explicitly keep (see #consume).
89
93
  def preview(token:, purpose:)
90
94
  return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
91
95
  user, record = @store.inspect_token(digest: @digest.digest(token))
92
96
  reason = rejection(user, record, purpose: purpose.to_s)
93
- reason ? Result.failure(reason: reason) : Result.success(user: user, strategy: purpose.to_sym)
97
+ return Result.failure(reason: reason) if reason
98
+ Result.success(user: user, strategy: purpose.to_sym, credential: (unadopted_providers(user) if purpose.to_s == "confirm"))
94
99
  end
95
100
 
96
- def consume(token:, purpose:, password: nil)
101
+ # A first confirmation is the address owner's adoption decision for any
102
+ # provider binding made before the address was proved. Only an explicit
103
+ # keep_providers: true preserves those bindings; any other confirmation
104
+ # revokes them in the same transaction (canonical plan section 18, A1).
105
+ def consume(token:, purpose:, password: nil, keep_providers: false)
97
106
  return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
98
107
  @store.with_token(digest: @digest.digest(token)) do |user, record|
99
108
  reason = rejection(user, record, purpose: purpose.to_s)
@@ -110,12 +119,14 @@ module AddAuth
110
119
  @notify.call(user: user, kind: :email_changed, at: now, recipient: old_address)
111
120
  @notify.call(user: user, kind: :email_changed, at: now, recipient: pending)
112
121
  else
122
+ @external_bindings.revoke_all_in_transaction(user_id: user.id, at: now) if keep_providers != true && unadopted_providers(user).any?
113
123
  @store.update_account(user: user, confirmed_at: now)
114
124
  provision_account(user)
115
125
  end
116
126
  when "reset_password"
117
127
  next failure unless valid_new_password?(password)
118
128
  @store.replace_password(user: user, password: password)
129
+ clear_password_lock(user)
119
130
  @store.revoke_authority(user: user, at: now)
120
131
  @notify.call(user: user, kind: :password_changed, at: now)
121
132
  when "unlock"
@@ -214,6 +225,18 @@ module AddAuth
214
225
  failure
215
226
  end
216
227
 
228
+ # A proved reset ends brute-force lockout, never a manual lock.
229
+ def clear_password_lock(user)
230
+ attempts = user.respond_to?(:failed_attempts) ? user.failed_attempts.to_i : 0
231
+ return if user.add_auth_manual_lock || (!user.locked_at && attempts.zero?)
232
+ @store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
233
+ end
234
+
235
+ def unadopted_providers(user)
236
+ return [].freeze unless @external_bindings && user && !user.confirmed_at
237
+ @external_bindings.provider_ids(user_id: user.id)
238
+ end
239
+
217
240
  def provision_account(user)
218
241
  tracked = user.respond_to?(:add_auth_provisioned_at)
219
242
  return if tracked && user.add_auth_provisioned_at
@@ -286,7 +309,8 @@ module AddAuth
286
309
  payload = JSON.parse(raw)
287
310
  return unless payload["purpose"] == record.purpose && payload["recipient"] == recipient_for(user, record.purpose) &&
288
311
  payload["token"].is_a?(String) && @digest.matches?(record.digest, payload["token"])
289
- {recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose}
312
+ {recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose,
313
+ providers: (record.purpose == "confirm") ? unadopted_providers(user) : []}
290
314
  rescue JSON::ParserError, KeyError
291
315
  nil
292
316
  end
@@ -41,13 +41,27 @@ module AddAuth
41
41
  return :disabled unless available?(user)
42
42
  return unless @enabled
43
43
  return :unconfirmed if !value(user, :confirmed_at) && (confirmation_required? || !password_account?(user))
44
- :locked if locked?(user)
44
+ :locked if account_locked?(user)
45
45
  end
46
46
 
47
+ # Password results may also disclose an automatic lock to the holder of
48
+ # the correct password; other methods never consult this.
49
+ def password_denial(user) = denial(user) || ((@enabled && locked?(user)) ? :locked : nil)
50
+
51
+ # Any active lock, including the automatic password lock.
47
52
  def locked?(user)
48
53
  value(user, :locked_at) && (value(user, :add_auth_manual_lock) || !value(user, :add_auth_locked_until) || value(user, :add_auth_locked_until) > @clock.now)
49
54
  end
50
55
 
56
+ # Manual and imported (untimed) locks deny the whole account. An automatic
57
+ # brute-force lock gates only the password method (plan section 18, A2).
58
+ def account_locked?(user) = locked?(user) && !self.class.automatic_lock?(user, now: @clock.now)
59
+
60
+ def self.automatic_lock?(user, now:)
61
+ read = ->(name) { user.public_send(name) if user.respond_to?(name) }
62
+ !!(read.call(:locked_at) && !read.call(:add_auth_manual_lock) && read.call(:add_auth_locked_until) && read.call(:add_auth_locked_until) > now)
63
+ end
64
+
51
65
  private
52
66
 
53
67
  def password_account?(user)
@@ -185,6 +185,21 @@ module AddAuth
185
185
  self.class.invalidate_credentials_in_transaction(store: @store, user_id: user_id, at: at)
186
186
  end
187
187
 
188
+ # Bindings persist while provider sign-in is disabled, so G3 reaches them
189
+ # through this narrow port rather than the enabled service. G3 owns the
190
+ # adoption decision; this only reports and revokes live bindings.
191
+ class Bindings
192
+ def initialize(store:)
193
+ @store = store
194
+ end
195
+
196
+ def provider_ids(user_id:) = @store.bindings(user_id: user_id).map(&:provider_id).uniq.sort.freeze
197
+
198
+ def revoke_all_in_transaction(user_id:, at:)
199
+ @store.bindings(user_id: user_id).each { |identity| @store.revoke(identity: identity, at: at) }.size
200
+ end
201
+ end
202
+
188
203
  def credential_current?(user:, id:, version:)
189
204
  return false unless @enabled
190
205
  identity = @store.binding_for_user(user_id: user.id, id: id)
@@ -14,19 +14,24 @@ module AddAuth
14
14
  @on_challenge_unavailable = on_challenge_unavailable
15
15
  end
16
16
 
17
+ # An exhausted IP budget stops before the identifier budget, and the
18
+ # identifier is charged only after any configured challenge passes, so
19
+ # other clients cannot spend a target's budget (plan section 18, A4).
17
20
  def call(identifier:, ip:, action:, challenge_token: nil)
18
21
  value = (identifier.is_a?(String) && identifier.valid_encoding? && identifier.bytesize.between?(1, 254)) ? @normalizer.call(identifier) : ""
19
22
  value = "" unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 254)
20
- ip_ok = @limiter.call(key: @digest.digest("ip:#{ip}:#{action}"), limit: 30)
21
- identifier_ok = @limiter.call(key: @digest.digest("identifier:#{value}:#{action}"), limit: 5)
22
- return :rate_limited unless ip_ok && identifier_ok
23
+ return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{action}"), limit: 30)
23
24
  return :invalid_credentials if value.to_s.empty?
24
25
  rejection = verify_challenge(ip: ip, action: action, challenge_token: challenge_token)
25
- rejection || value
26
+ return rejection if rejection
27
+ return :rate_limited unless @limiter.call(key: @digest.digest("identifier:#{value}:#{action}"), limit: 5)
28
+ value
26
29
  end
27
30
 
28
- def anonymous(ip:, action:, challenge_token: nil)
29
- return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{action}"), limit: 30)
31
+ # budget separates an IP counter from the challenge action, so a
32
+ # background request cannot spend the budget of an interactive form.
33
+ def anonymous(ip:, action:, challenge_token: nil, budget: action)
34
+ return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{budget}"), limit: 30)
30
35
  verify_challenge(ip: ip, action: action, challenge_token: challenge_token) || true
31
36
  end
32
37
 
@@ -1,19 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "add_auth/core/account_policy"
4
+
3
5
  module AddAuth
4
6
  module Core
5
7
  module Passwords
6
8
  # Called only inside the owning account transaction. The adapter supplies
7
9
  # Rails hashing and persistence; these decisions are shared by entry paths.
8
10
  class Lifecycle
9
- def initialize(store:, policy:, enabled:, maximum_attempts: 20, unlock_in: 3600, clock: Time, issue_unlock: ->(_user) {})
11
+ # password_allowed reports whether this account may use password sign-in
12
+ # at all (feature enabled, stored password, not strict). Accounts that
13
+ # cannot use a password never accumulate failures or locks.
14
+ def initialize(store:, policy:, enabled:, maximum_attempts: 20, unlock_in: 3600, clock: Time, issue_unlock: ->(_user) {},
15
+ password_allowed: ->(_user) { true })
10
16
  unless maximum_attempts.is_a?(Integer) && maximum_attempts.between?(1, 100) &&
11
17
  unlock_in.is_a?(Numeric) && unlock_in.finite? && unlock_in.between?(60, 86_400)
12
18
  raise ArgumentError, "configure bounded password attempts and unlock duration"
13
19
  end
14
20
  @store, @policy, @enabled, @maximum, @unlock_in, @clock, @issue_unlock = store, policy, enabled, maximum_attempts, unlock_in, clock, issue_unlock
21
+ @password_allowed = password_allowed
15
22
  end
16
23
 
24
+ # The automatic lock gates the password method only; it never denies
25
+ # other methods or revokes existing authority.
26
+ def locked?(user) = @enabled && !!user && AccountPolicy.automatic_lock?(user, now: @clock.now)
27
+
17
28
  def prepare(user:)
18
29
  return unless @enabled && user && !user.add_auth_manual_lock && user.locked_at &&
19
30
  user.add_auth_locked_until && user.add_auth_locked_until <= @clock.now
@@ -21,7 +32,8 @@ module AddAuth
21
32
  end
22
33
 
23
34
  def verified(user:, password:, valid:, rehash: true)
24
- return false unless user && @policy.call(user) == true
35
+ return false unless user && @policy.call(user) == true && @password_allowed.call(user) == true
36
+ return false if locked?(user)
25
37
  if valid
26
38
  @store.update_account(user: user, failed_attempts: 0) if @enabled && user.failed_attempts != 0
27
39
  if rehash && user.respond_to?(:add_auth_password_scheme) && user.add_auth_password_scheme == "devise_bcrypt" && assignable?(password)
@@ -49,10 +61,7 @@ module AddAuth
49
61
  changes[:add_auth_locked_until] = @clock.now + @unlock_in
50
62
  end
51
63
  @store.update_account(user: user, **changes)
52
- if attempts >= @maximum
53
- @store.revoke_authority(user: user, at: @clock.now)
54
- @issue_unlock.call(user)
55
- end
64
+ @issue_unlock.call(user) if attempts >= @maximum
56
65
  end
57
66
  end
58
67
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
4
+
3
5
  module AddAuth
4
6
  module Core
5
7
  class RateLimit
@@ -25,6 +27,15 @@ module AddAuth
25
27
  end
26
28
  counts.all? { |count| count <= limit }
27
29
  end
30
+
31
+ # One budget per IPv4 address or IPv6 /64, which a single client
32
+ # commonly controls in full.
33
+ def self.network(ip)
34
+ address = IPAddr.new(ip.to_s).native
35
+ address.ipv6? ? "#{address.mask(64)}/64" : address.to_s
36
+ rescue IPAddr::Error
37
+ "unknown"
38
+ end
28
39
  end
29
40
  end
30
41
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "time"
6
+
7
+ module AddAuth
8
+ module Core
9
+ class SessionCursor
10
+ def initialize(key:)
11
+ @key = key
12
+ end
13
+
14
+ def encode(row)
15
+ "sc1:" + Base64.urlsafe_encode64(JSON.generate([row.created_at.utc.iso8601(6), row.id]), padding: false)
16
+ end
17
+
18
+ def decode(value)
19
+ # Retain links issued by releases with integer-only pagination.
20
+ legacy = @key.parse(value)
21
+ return {legacy_id: legacy} if legacy.is_a?(Integer)
22
+ return unless value.is_a?(String) && value.ascii_only? && value.bytesize <= 256 && value.match?(/\Asc1:[A-Za-z0-9_-]+\z/)
23
+
24
+ fields = JSON.parse(Base64.urlsafe_decode64(value.delete_prefix("sc1:")))
25
+ return unless fields.is_a?(Array) && fields.length == 2
26
+ timestamp, id = fields
27
+ return unless timestamp.is_a?(String) && timestamp.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\z/)
28
+ id = @key.parse(id)
29
+ return unless id
30
+ time = Time.iso8601(timestamp)
31
+ return unless time.year.positive? && time.utc.iso8601(6) == timestamp
32
+ {created_at: time, id: id}
33
+ rescue ArgumentError, JSON::ParserError
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
@@ -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
@@ -29,7 +31,8 @@ module AddAuth
29
31
  Page = Struct.new(:entries, :next_cursor)
30
32
 
31
33
  def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
32
- legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil)
34
+ legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil,
35
+ revoke_authority: nil)
33
36
  unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
34
37
  raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
35
38
  end
@@ -37,10 +40,13 @@ module AddAuth
37
40
  raise ArgumentError, "legacy_bridge_until must be an absolute Time or nil"
38
41
  end
39
42
  @store, @digest, @eligible, @clock = store, digest, eligible, clock
43
+ @key = SessionKey.new(type: store.respond_to?(:id_type) ? store.id_type : :integer)
44
+ @cursor = SessionCursor.new(key: @key)
40
45
  @access_policy = access_policy
41
46
  @password_lifecycle = password_lifecycle
42
47
  @on_sign_in = on_sign_in
43
48
  @verified_denial = verified_denial
49
+ @revoke_authority = revoke_authority
44
50
  raise ArgumentError, "invalid mobile profile" unless mobile_profile.nil? || mobile_profile.is_a?(MobileProfile)
45
51
  @mobile = mobile_profile
46
52
  @lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
@@ -94,6 +100,7 @@ module AddAuth
94
100
  return if transport == :mobile && replacing
95
101
  raise ArgumentError, "unsupported proof" unless %w[password email_link passkey external_identity].include?(method.to_s)
96
102
  return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
103
+ return if method.to_s == "password" && @password_lifecycle&.locked?(user)
97
104
  if method.to_s == "passkey"
98
105
  return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
99
106
  proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
@@ -191,13 +198,14 @@ module AddAuth
191
198
  def list_page(user:, current_session_id: nil, before: nil)
192
199
  empty = Page.new(entries: [])
193
200
  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/)
201
+ boundary = @cursor.decode(before) unless before.nil?
202
+ return empty unless before.nil? || boundary
195
203
 
196
204
  now = @clock.now
197
- candidates = @store.list_for_user(user_id: user.id, before: before&.to_i,
205
+ candidates = @store.list_for_user(user_id: user.id, before: boundary,
198
206
  excluding: current_session_id, limit: PAGE_SIZE + 1, now: now,
199
207
  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
208
+ cursor = @cursor.encode(candidates[PAGE_SIZE - 1]) if candidates.length > PAGE_SIZE
201
209
  rows = candidates.first(PAGE_SIZE)
202
210
  if before.nil? && current_session_id
203
211
  current = @store.find_for_user(user_id: user.id, session_id: current_session_id)
@@ -217,16 +225,22 @@ module AddAuth
217
225
 
218
226
  # Both the initiating bearer and target ownership are checked under lock.
219
227
  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/)
228
+ target_id = @key.parse(session_id)
229
+ return false unless target_id
221
230
 
222
231
  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)
232
+ target = @store.find_for_user_in_transaction(user_id: account.id, session_id: target_id)
224
233
  next false unless target
225
234
  @store.update(target, revoked_at: now) unless target.revoked_at
226
235
  true
227
236
  end || false
228
237
  end
229
238
 
239
+ def current_session?(session:, session_id:)
240
+ id = @key.parse(session_id)
241
+ !!(id && session && id == session.id)
242
+ end
243
+
230
244
  # Password proof is obtained from the locked, current account. Intake
231
245
  # limits must be applied by the caller before reaching this expensive port.
232
246
  def reauthenticate(user:, session:, purpose:, policy:)
@@ -244,9 +258,13 @@ module AddAuth
244
258
  end || Result.failure(reason: :elevation_required)
245
259
  end
246
260
 
261
+ # Sign-out-everywhere retires every outstanding proof, not only sessions:
262
+ # the shared authority port also revokes tokens, ceremonies and handoffs.
247
263
  def revoke_all(user:, session:, grant:)
248
264
  with_authority(user: user, session: session, grant: grant, purpose: :sign_out_everywhere) do |account, _row, now|
249
- @store.revoke_all_in_transaction(user_id: account.id, at: now)
265
+ count = @store.revoke_all_in_transaction(user_id: account.id, at: now)
266
+ @revoke_authority&.call(user_id: account.id, at: now)
267
+ count
250
268
  end || false
251
269
  end
252
270
 
@@ -315,10 +333,10 @@ module AddAuth
315
333
  private
316
334
 
317
335
  def cookie_lookup(value)
318
- if value.is_a?(String) && PATTERN.match?(value)
336
+ if value.is_a?(String) && value.ascii_only? && PATTERN.match?(value)
319
337
  {digest: @digest.digest(value)}
320
- elsif value.is_a?(Integer) && value.positive? && @deadline && @clock.now < @deadline
321
- {id: value}
338
+ elsif @deadline && @clock.now < @deadline && (id = @key.legacy(value))
339
+ {id: id}
322
340
  end
323
341
  end
324
342
 
@@ -44,7 +44,9 @@ module AddAuth
44
44
  end
45
45
  end
46
46
 
47
- def authentication_options(browser_secret:, user: nil, session: nil, purpose: nil)
47
+ # ip selects the anonymous ceremony budget; one network cannot exhaust
48
+ # passkey sign-in for every other client.
49
+ def authentication_options(browser_secret:, user: nil, session: nil, purpose: nil, ip: nil)
48
50
  return failure unless @binding.valid?(browser_secret)
49
51
  if user || session || purpose
50
52
  @store.with_user(id: user&.id) do |account|
@@ -54,7 +56,8 @@ module AddAuth
54
56
  create_options(options, kind: "assertion", user: account, session: session, purpose: purpose, browser_secret: browser_secret)
55
57
  end
56
58
  else
57
- return failure(:rate_limited) unless @limiter.call(key: @digest.digest("passkey:anonymous-ceremonies"), limit: @anonymous_limit)
59
+ budget = "passkey:anonymous-ceremonies:#{RateLimit.network(ip)}"
60
+ return failure(:rate_limited) unless @limiter.call(key: @digest.digest(budget), limit: @anonymous_limit)
58
61
  options = @rp.options_for_authentication(user_verification: "required")
59
62
  create_options(options, kind: "assertion", browser_secret: browser_secret)
60
63
  end
@@ -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
@@ -90,7 +90,26 @@ module AddAuth
90
90
  policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: method(:trusted_recovery_address),
91
91
  lifetime: config.lifecycle.proof_lifetime, notify: ->(**event) { security_events.issue(**event) if config.notifications.enabled },
92
92
  sessions: sessions, step_up_policy: step_up_policy, profile_attributes: config.lifecycle.profile_attributes, deletion_allowed: config.lifecycle.deletion_allowed,
93
- external_identities: -> { external_identities })
93
+ external_identities: -> { external_identities }, external_bindings: external_bindings)
94
+ end
95
+
96
+ # Present whenever the binding table exists, even with providers disabled:
97
+ # a first confirmation must still decide what happens to old bindings.
98
+ def external_bindings
99
+ return unless defined?(::AddAuthExternalIdentity) && ::AddAuthExternalIdentity.table_exists?
100
+ Core::ExternalIdentities::Bindings.new(store: external_identity_store)
101
+ end
102
+
103
+ # Display-only labels for provider configuration IDs held by Core. A
104
+ # native configuration borrows the label of a browser provider with the
105
+ # same issuer.
106
+ def provider_labels(ids)
107
+ options = config.external_identities
108
+ Array(ids).map { |id|
109
+ native = options.native_provider(id)
110
+ provider = options.provider(id) || (native && options.providers.find { |item| item.configuration.issuer == native.issuer })
111
+ provider&.label || "your sign-in provider"
112
+ }.uniq
94
113
  end
95
114
 
96
115
  def enqueue_account_request(identifier:, purpose:)
@@ -112,9 +131,9 @@ module AddAuth
112
131
  idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy,
113
132
  password_lifecycle: password_lifecycle,
114
133
  mobile_profile: mobile_profile,
115
- verified_denial: ->(user) { (config.eligible.call(user) == true) ? account_policy.denial(user) : :disabled },
134
+ verified_denial: ->(user) { (config.eligible.call(user) == true) ? account_policy.password_denial(user) : :disabled },
116
135
  remembered_profile: config.lifecycle.enabled ? {lifetime: config.lifecycle.remember_lifetime, idle_timeout: config.lifecycle.remember_idle_timeout} : nil,
117
- on_sign_in: method(:record_sign_in))
136
+ on_sign_in: method(:record_sign_in), revoke_authority: authority.method(:revoke))
118
137
  end
119
138
 
120
139
  def mobile_profile
@@ -163,7 +182,8 @@ module AddAuth
163
182
  Core::Passwords::Lifecycle.new(store: Stores::AccountCredentials.new(user_model: ::User, authority: authority),
164
183
  policy: method(:eligible), enabled: config.lifecycle.enabled,
165
184
  maximum_attempts: config.lifecycle.maximum_attempts, unlock_in: config.lifecycle.unlock_in,
166
- issue_unlock: ->(user) { accounts.issue_unlock_in_transaction(user: user) })
185
+ issue_unlock: ->(user) { accounts.issue_unlock_in_transaction(user: user) },
186
+ password_allowed: ->(user) { access_policy.sign_in_allowed?(user, :password) })
167
187
  end
168
188
 
169
189
  def security_events
@@ -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.4.0"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -1,12 +1,14 @@
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__)
12
14
  class_option :email_link, type: :boolean, default: true, desc: "Enable email sign-in as a step-up dependency"
@@ -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
@@ -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
@@ -79,7 +79,7 @@ AddAuth.configure do |config|
79
79
  config.passkeys.origins = [] # Required: exact trusted origins, never request Host.
80
80
  # config.passkeys.origins = ["https://app.example.com"]
81
81
  config.passkeys.name = "Your account"
82
- config.passkeys.anonymous_limit = 1000 # Shared ceremony budget per five minutes.
82
+ config.passkeys.anonymous_limit = 1000 # Ceremony budget per IPv4 address or IPv6 /64 per five minutes.
83
83
  config.trusted_recovery_address = ->(_user) {} # No trusted recovery by default.
84
84
  # config.trusted_recovery_address = ->(user) { user.email_address if user.confirmed_at }
85
85
  # Lifecycle additionally rejects unconfirmed addresses as trusted recovery.
@@ -1,12 +1,14 @@
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 MobileSessionsGenerator < ::Rails::Generators::Base
9
10
  include ::Rails::Generators::Migration
11
+ include PrimaryKey
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
12
14
  def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
@@ -1,9 +1,9 @@
1
+ require "add_auth/rails/migration/key_type"
2
+
1
3
  class CreateAddAuthMobileHandoffs < ActiveRecord::Migration[8.0]
2
4
  def change
3
- user_key = connection.columns(:users).find { |column| column.name == connection.primary_key(:users) }
4
- raise "Unsupported authentication primary key" unless user_key && [:integer, :uuid].include?(user_key.type)
5
- user_type = (user_key.type == :integer && user_key.limit == 8) ? :bigint : user_key.type
6
- create_table :add_auth_mobile_handoffs do |t|
5
+ user_type = AddAuth::Rails::Migration::KeyType.for(connection, :users)
6
+ create_table :add_auth_mobile_handoffs, id: <%= authentication_primary_key_type %> do |t|
7
7
  t.references :user, type: user_type, foreign_key: {on_delete: :cascade}
8
8
  t.string :external_digest, null: false
9
9
  t.string :digest
@@ -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 "generators/add_auth/feature_configuration"
5
6
  require "rails/generators/active_record"
6
7
 
@@ -8,6 +9,7 @@ module AddAuth
8
9
  module Generators
9
10
  class NotificationsGenerator < ::Rails::Generators::Base
10
11
  include ::Rails::Generators::Migration
12
+ include PrimaryKey
11
13
  include FeatureConfiguration
12
14
 
13
15
  source_root File.expand_path("templates", __dir__)
@@ -2,7 +2,7 @@ require "add_auth/rails/migration/key_type"
2
2
 
3
3
  class CreateAddAuthSecurityEvents < ActiveRecord::Migration[8.0]
4
4
  def change
5
- create_table :add_auth_security_events do |t|
5
+ create_table :add_auth_security_events, id: <%= authentication_primary_key_type %> do |t|
6
6
  t.references :user, type: AddAuth::Rails::Migration::KeyType.for(connection, :users), foreign_key: {on_delete: :nullify}
7
7
  t.string :kind, null: false
8
8
  t.string :digest, 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 "generators/add_auth/feature_configuration"
5
6
  require "rails/generators/active_record"
6
7
 
@@ -8,6 +9,7 @@ module AddAuth
8
9
  module Generators
9
10
  class PasskeysGenerator < ::Rails::Generators::Base
10
11
  include ::Rails::Generators::Migration
12
+ include PrimaryKey
11
13
  include FeatureConfiguration
12
14
 
13
15
  source_root File.expand_path("templates", __dir__)
@@ -14,7 +14,7 @@ class AddAddAuthPasskeys < ActiveRecord::Migration[8.0]
14
14
  add_column :sessions, :authentication_policy_version, :integer, default: 0, null: false
15
15
  add_column :sessions, :authentication_credential_id, :string
16
16
  add_column :sessions, :authentication_uv, :boolean, default: false_default, null: false
17
- create_table :add_auth_credentials do |t|
17
+ create_table :add_auth_credentials, 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 :external_id, null: false
20
20
  t.text :public_key, null: false
@@ -29,7 +29,7 @@ class AddAddAuthPasskeys < ActiveRecord::Migration[8.0]
29
29
  t.timestamps
30
30
  end
31
31
  add_index :add_auth_credentials, :external_id, unique: true
32
- create_table :add_auth_ceremonies do |t|
32
+ create_table :add_auth_ceremonies, id: <%= authentication_primary_key_type %> do |t|
33
33
  t.string :digest, null: false
34
34
  t.string :challenge, null: false
35
35
  t.string :kind, null: false
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Generators
5
+ module PrimaryKey
6
+ private
7
+
8
+ # Freeze Rails' generator preference into the migration source. Foreign
9
+ # keys independently follow the actual referenced table when it runs.
10
+ def authentication_primary_key_type
11
+ type = (::Rails.application.config.generators.options.dig(:active_record, :primary_key_type) || :bigint).to_s
12
+ unless %w[bigint integer uuid].include?(type)
13
+ raise Thor::Error, "AddAuth supports Rails primary_key_type :bigint, :integer or :uuid"
14
+ end
15
+ ":#{type}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -25,6 +25,9 @@ module AddAuth
25
25
  end
26
26
 
27
27
  def migration
28
+ unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_session_cursor_index.rb")].any?
29
+ migration_template "add_add_auth_session_cursor_index.rb.tt", "db/migrate/add_add_auth_session_cursor_index.rb"
30
+ end
28
31
  unless Dir[File.join(destination_root, "db/migrate/*_extend_sessions_for_add_auth.rb")].any?
29
32
  migration_template "extend_sessions_for_add_auth.rb.tt", "db/migrate/extend_sessions_for_add_auth.rb"
30
33
  end
@@ -0,0 +1,5 @@
1
+ class AddAddAuthSessionCursorIndex < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_index :sessions, [:user_id, :created_at, :id], name: "index_add_auth_session_cursor"
4
+ end
5
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: add_auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taimoor Qureshi
@@ -329,6 +329,8 @@ files:
329
329
  - lib/add_auth/core/rate_limit.rb
330
330
  - lib/add_auth/core/remaining_factors.rb
331
331
  - lib/add_auth/core/security_events.rb
332
+ - lib/add_auth/core/session_cursor.rb
333
+ - lib/add_auth/core/session_key.rb
332
334
  - lib/add_auth/core/sessions.rb
333
335
  - lib/add_auth/core/step_up.rb
334
336
  - lib/add_auth/core/strategies/email_link.rb
@@ -423,8 +425,10 @@ files:
423
425
  - lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt
424
426
  - lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb
425
427
  - lib/generators/add_auth/passkeys/templates/add_auth_credential.rb
428
+ - lib/generators/add_auth/primary_key.rb
426
429
  - lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb
427
430
  - lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt
431
+ - lib/generators/add_auth/session_upgrade/templates/add_add_auth_session_cursor_index.rb.tt
428
432
  - lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt
429
433
  - lib/generators/add_auth/step_up/step_up_generator.rb
430
434
  - lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt