add_auth 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b83eabd84666412ae2a83ffc20ac54c9765ef0133e0e401cffebce9e3f73f444
4
- data.tar.gz: b187c3b70d0c43139f870a50231e5e024f71885218ed68770c98218c257b4be3
3
+ metadata.gz: 0f425748354f43e9f37579150ae2fe1cae7797f5da8c37af7e9f330b783a2d96
4
+ data.tar.gz: 8b7187ffe5b74c138d68cf4ffac7edf7c65e3d258941edec49c72f55a743c621
5
5
  SHA512:
6
- metadata.gz: 65eb201de7ebebca70a786c9664a46df8c27404e74dcd2704b25ef33673ab0f42f7a9c5a865c707cb33a943a4a933c8903fc5dafd1479c32bf0ad0266263f7d6
7
- data.tar.gz: a48427dfe1145c18be90ddb4ec027b70a7179f7e742d3806ee99ee2ddbf7e9f865abbb0486d96cce600e7e5284ee7487ea7a4b17c71639b94c1801dc95b1b8ee
6
+ metadata.gz: 51c64dbd5366aac908be7df8fd4f82c9501ce081400fb77976ece4b92fa9066a7aca0fc799a7ba4e99ce3c19609190db702373e6e45fddc055795df8b23898c7
7
+ data.tar.gz: 9a37bfc7978081e75ea517ae9ba414fa4b2771ec7d9bb8ea6a56acf6da1ee3fe2b0882989e9177198a701ce409e2b05af225214389bb5361b0845bb5d6df5f01
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
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
+
3
23
  ## 0.5.0 — 2026-09-18
4
24
 
5
25
  - Honor Rails' `primary_key_type` generator setting for all AddAuth-owned tables.
data/README.md CHANGED
@@ -29,15 +29,15 @@ for verification in a disposable host.
29
29
 
30
30
  ## Quickstart
31
31
 
32
- These commands target 0.5.0 and are available after its
33
- [RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.5.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.5.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
@@ -48,7 +48,7 @@ below before enabling sign-in for users.
48
48
 
49
49
  ```ruby
50
50
  # Gemfile
51
- gem "add_auth", "~> 0.5.0"
51
+ gem "add_auth", "~> 0.5.1"
52
52
  ```
53
53
 
54
54
  Bundler downloads the package from RubyGems and records the resolved
@@ -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
@@ -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
@@ -31,7 +31,8 @@ module AddAuth
31
31
  Page = Struct.new(:entries, :next_cursor)
32
32
 
33
33
  def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
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)
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)
35
36
  unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
36
37
  raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
37
38
  end
@@ -45,6 +46,7 @@ module AddAuth
45
46
  @password_lifecycle = password_lifecycle
46
47
  @on_sign_in = on_sign_in
47
48
  @verified_denial = verified_denial
49
+ @revoke_authority = revoke_authority
48
50
  raise ArgumentError, "invalid mobile profile" unless mobile_profile.nil? || mobile_profile.is_a?(MobileProfile)
49
51
  @mobile = mobile_profile
50
52
  @lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
@@ -98,6 +100,7 @@ module AddAuth
98
100
  return if transport == :mobile && replacing
99
101
  raise ArgumentError, "unsupported proof" unless %w[password email_link passkey external_identity].include?(method.to_s)
100
102
  return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
103
+ return if method.to_s == "password" && @password_lifecycle&.locked?(user)
101
104
  if method.to_s == "passkey"
102
105
  return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
103
106
  proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
@@ -255,9 +258,13 @@ module AddAuth
255
258
  end || Result.failure(reason: :elevation_required)
256
259
  end
257
260
 
261
+ # Sign-out-everywhere retires every outstanding proof, not only sessions:
262
+ # the shared authority port also revokes tokens, ceremonies and handoffs.
258
263
  def revoke_all(user:, session:, grant:)
259
264
  with_authority(user: user, session: session, grant: grant, purpose: :sign_out_everywhere) do |account, _row, now|
260
- @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
261
268
  end || false
262
269
  end
263
270
 
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AddAuth
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  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.
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.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taimoor Qureshi