add_auth 0.2.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 (122) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +125 -0
  3. data/CODE_OF_CONDUCT.md +4 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +545 -0
  6. data/ROADMAP.md +244 -0
  7. data/SECURITY.md +75 -0
  8. data/app/controllers/add_auth/assets_controller.rb +54 -0
  9. data/app/controllers/add_auth/passkeys_controller.rb +144 -0
  10. data/app/controllers/add_auth/reauthentications_controller.rb +81 -0
  11. data/app/controllers/add_auth/recoveries_controller.rb +50 -0
  12. data/app/controllers/add_auth/sessions_controller.rb +62 -0
  13. data/app/controllers/add_auth/sign_ins_controller.rb +65 -0
  14. data/app/helpers/add_auth/sign_ins_helper.rb +42 -0
  15. data/app/javascript/controllers/.keep +0 -0
  16. data/app/jobs/add_auth/delivery_job.rb +31 -0
  17. data/app/jobs/add_auth/email_delivery_job.rb +15 -0
  18. data/app/jobs/add_auth/email_request_job.rb +21 -0
  19. data/app/jobs/add_auth/security_notification_job.rb +13 -0
  20. data/app/mailers/add_auth/security_mailer.rb +16 -0
  21. data/app/mailers/add_auth/sign_in_mailer.rb +36 -0
  22. data/app/models/concerns/.keep +0 -0
  23. data/app/views/.keep +0 -0
  24. data/app/views/add_auth/passkeys/_controls.html.erb +17 -0
  25. data/app/views/add_auth/passkeys/_list.html.erb +32 -0
  26. data/app/views/add_auth/reauthentications/_check_email.html.erb +3 -0
  27. data/app/views/add_auth/reauthentications/_confirmation.html.erb +7 -0
  28. data/app/views/add_auth/reauthentications/_different_browser.html.erb +3 -0
  29. data/app/views/add_auth/reauthentications/_form.html.erb +27 -0
  30. data/app/views/add_auth/reauthentications/_invalid_link.html.erb +3 -0
  31. data/app/views/add_auth/recoveries/_check_email.html.erb +3 -0
  32. data/app/views/add_auth/recoveries/_confirmation.html.erb +9 -0
  33. data/app/views/add_auth/recoveries/_form.html.erb +11 -0
  34. data/app/views/add_auth/recoveries/_invalid_link.html.erb +3 -0
  35. data/app/views/add_auth/security_mailer/notice.text.erb +3 -0
  36. data/app/views/add_auth/sessions/_list.html.erb +25 -0
  37. data/app/views/add_auth/sessions/_revoke_all.html.erb +18 -0
  38. data/app/views/add_auth/sessions/index.html.erb +12 -0
  39. data/app/views/add_auth/sign_in_mailer/link.text.erb +5 -0
  40. data/app/views/add_auth/sign_ins/_check_email.html.erb +5 -0
  41. data/app/views/add_auth/sign_ins/_confirmation.html.erb +9 -0
  42. data/app/views/add_auth/sign_ins/_different_browser.html.erb +3 -0
  43. data/app/views/add_auth/sign_ins/_form.html.erb +34 -0
  44. data/app/views/add_auth/sign_ins/_invalid_link.html.erb +3 -0
  45. data/app/views/add_auth/sign_ins/show.html.erb +1 -0
  46. data/app/views/layouts/add_auth/authentication.html.erb +19 -0
  47. data/lib/add_auth/configuration.rb +61 -0
  48. data/lib/add_auth/core/access_policy.rb +55 -0
  49. data/lib/add_auth/core/browser_binding.rb +28 -0
  50. data/lib/add_auth/core/challenge/base.rb +100 -0
  51. data/lib/add_auth/core/challenge/http.rb +130 -0
  52. data/lib/add_auth/core/challenge/null.rb +21 -0
  53. data/lib/add_auth/core/challenge/recaptcha.rb +76 -0
  54. data/lib/add_auth/core/challenge/test.rb +32 -0
  55. data/lib/add_auth/core/challenge/turnstile.rb +46 -0
  56. data/lib/add_auth/core/delivery.rb +55 -0
  57. data/lib/add_auth/core/digest/base.rb +34 -0
  58. data/lib/add_auth/core/digest/hmac.rb +41 -0
  59. data/lib/add_auth/core/intake.rb +59 -0
  60. data/lib/add_auth/core/maintenance.rb +45 -0
  61. data/lib/add_auth/core/rate_limit.rb +30 -0
  62. data/lib/add_auth/core/security_events.rb +48 -0
  63. data/lib/add_auth/core/sessions.rb +287 -0
  64. data/lib/add_auth/core/step_up.rb +129 -0
  65. data/lib/add_auth/core/strategies/email_link.rb +201 -0
  66. data/lib/add_auth/core/strategies/passkey.rb +286 -0
  67. data/lib/add_auth/rails/authentication.rb +67 -0
  68. data/lib/add_auth/rails/authentication_pages.rb +66 -0
  69. data/lib/add_auth/rails/delivery_cipher.rb +33 -0
  70. data/lib/add_auth/rails/doctor.rb +154 -0
  71. data/lib/add_auth/rails/ejection.rb +138 -0
  72. data/lib/add_auth/rails/elevation.rb +35 -0
  73. data/lib/add_auth/rails/engine.rb +34 -0
  74. data/lib/add_auth/rails/password_entry.rb +34 -0
  75. data/lib/add_auth/rails/runtime.rb +216 -0
  76. data/lib/add_auth/rails/stores/account_lock.rb +40 -0
  77. data/lib/add_auth/rails/stores/delivery_state.rb +21 -0
  78. data/lib/add_auth/rails/stores/email_tokens.rb +92 -0
  79. data/lib/add_auth/rails/stores/maintenance.rb +46 -0
  80. data/lib/add_auth/rails/stores/passkeys.rb +63 -0
  81. data/lib/add_auth/rails/stores/security_events.rb +40 -0
  82. data/lib/add_auth/rails/stores/sessions.rb +62 -0
  83. data/lib/add_auth/rails/user_lifecycle.rb +35 -0
  84. data/lib/add_auth/result.rb +55 -0
  85. data/lib/add_auth/testing.rb +30 -0
  86. data/lib/add_auth/version.rb +5 -0
  87. data/lib/add_auth.rb +52 -0
  88. data/lib/generators/add_auth/challenge/challenge_generator.rb +48 -0
  89. data/lib/generators/add_auth/challenge/templates/recaptcha.rb.tt +28 -0
  90. data/lib/generators/add_auth/challenge/templates/turnstile.rb.tt +24 -0
  91. data/lib/generators/add_auth/controllers/controllers_generator.rb +14 -0
  92. data/lib/generators/add_auth/ejection.rb +18 -0
  93. data/lib/generators/add_auth/email_link/email_link_generator.rb +56 -0
  94. data/lib/generators/add_auth/email_link/templates/add_add_auth_email_binding.rb.tt +5 -0
  95. data/lib/generators/add_auth/email_link/templates/add_add_auth_email_delivery.rb.tt +10 -0
  96. data/lib/generators/add_auth/email_link/templates/add_auth.css +28 -0
  97. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +129 -0
  98. data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +35 -0
  99. data/lib/generators/add_auth/email_tokens/templates/add_auth_sign_in_token.rb +4 -0
  100. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +18 -0
  101. data/lib/generators/add_auth/install/install_generator.rb +27 -0
  102. data/lib/generators/add_auth/install/templates/initializer.rb +46 -0
  103. data/lib/generators/add_auth/javascript/javascript_generator.rb +14 -0
  104. data/lib/generators/add_auth/javascript/templates/application.js +2 -0
  105. data/lib/generators/add_auth/javascript/templates/codec.js +28 -0
  106. data/lib/generators/add_auth/javascript/templates/passkey.js +81 -0
  107. data/lib/generators/add_auth/mailer_views/mailer_views_generator.rb +14 -0
  108. data/lib/generators/add_auth/notifications/notifications_generator.rb +32 -0
  109. data/lib/generators/add_auth/notifications/templates/add_auth_security_event.rb +4 -0
  110. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +18 -0
  111. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +75 -0
  112. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +45 -0
  113. data/lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb +4 -0
  114. data/lib/generators/add_auth/passkeys/templates/add_auth_credential.rb +4 -0
  115. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +79 -0
  116. data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt +12 -0
  117. data/lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt +14 -0
  118. data/lib/generators/add_auth/step_up/step_up_generator.rb +44 -0
  119. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +9 -0
  120. data/lib/generators/add_auth/views/views_generator.rb +16 -0
  121. data/lib/tasks/add_auth.rake +38 -0
  122. metadata +361 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module AddAuth
6
+ module Core
7
+ module Digest
8
+ # Rails derives the base secret; Core only receives key material.
9
+ # Purpose separation remains identical for injected and Rails-derived keys.
10
+ class Hmac < Base
11
+ KEY_LENGTH = 32
12
+
13
+ def initialize(salt:, secret:)
14
+ unless salt.is_a?(String) && !salt.strip.empty?
15
+ raise ArgumentError, "salt must be a nonblank string"
16
+ end
17
+ unless secret.is_a?(String) && secret.bytesize >= KEY_LENGTH
18
+ raise ArgumentError, "secret must contain at least 32 bytes of key material"
19
+ end
20
+ @key = OpenSSL::HMAC.digest("SHA256", secret, salt).freeze
21
+ end
22
+
23
+ def digest(token)
24
+ unless token.is_a?(String) && !token.empty?
25
+ raise ArgumentError, "token must be a nonempty string"
26
+ end
27
+ OpenSSL::HMAC.hexdigest("SHA256", @key, token)
28
+ end
29
+
30
+ def matches?(digest, token)
31
+ return false unless digest.is_a?(String) && digest.bytesize == 64
32
+ return false unless token.is_a?(String) && !token.empty?
33
+
34
+ OpenSSL.fixed_length_secure_compare(digest, self.digest(token))
35
+ end
36
+
37
+ def inspect = "#<#{self.class} [FILTERED]>"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ class Intake
6
+ def initialize(digest:, normalizer:, limiter:, challenge:, challenge_on:, challenge_when_unavailable: :closed,
7
+ on_challenge_unavailable: nil)
8
+ @digest, @normalizer, @limiter = digest, normalizer, limiter
9
+ @challenge, @challenge_on = challenge, challenge_on
10
+ @challenge_when_unavailable = challenge_when_unavailable.to_sym
11
+ unless %i[closed open].include?(@challenge_when_unavailable)
12
+ raise ArgumentError, "challenge_when_unavailable must be :closed or :open"
13
+ end
14
+ @on_challenge_unavailable = on_challenge_unavailable
15
+ end
16
+
17
+ def call(identifier:, ip:, action:, challenge_token: nil)
18
+ value = (identifier.is_a?(String) && identifier.valid_encoding? && identifier.bytesize.between?(1, 254)) ? @normalizer.call(identifier) : ""
19
+ 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 :invalid_credentials if value.to_s.empty?
24
+ rejection = verify_challenge(ip: ip, action: action, challenge_token: challenge_token)
25
+ rejection || value
26
+ end
27
+
28
+ def anonymous(ip:, action:, challenge_token: nil)
29
+ return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{action}"), limit: 30)
30
+ verify_challenge(ip: ip, action: action, challenge_token: challenge_token) || true
31
+ end
32
+
33
+ private def verify_challenge(ip:, action:, challenge_token:)
34
+ if @challenge_on.include?(action)
35
+ result = @challenge.verify(token: challenge_token, remote_ip: ip, action: action)
36
+ if result.unavailable?
37
+ return :challenge_unavailable if @challenge_when_unavailable == :closed
38
+ @on_challenge_unavailable&.call(action: action)
39
+ elsif result.rejected?
40
+ return :challenge_rejected
41
+ end
42
+ end
43
+ nil
44
+ end
45
+
46
+ def self.email_available?(config) = config.session.enabled && config.email_link.enabled
47
+
48
+ # no-referrer form navigation may send null; this is never a substitute
49
+ # for the adapter's mandatory session-bound CSRF-token verification.
50
+ def self.origin_allowed?(origin:, base_url:)
51
+ origin.nil? || origin == "null" || origin == base_url
52
+ end
53
+
54
+ def self.revoke_after_change?(changes)
55
+ (changes.keys.map(&:to_s) & %w[password_digest email_address]).any?
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ # One bounded, replayable pass. Stores repeat cutoffs at mutation time;
6
+ # returning counts records committed work, never a claim of an empty backlog.
7
+ class Maintenance
8
+ def initialize(stores:, options:, enqueue:, clock: Time)
9
+ @stores, @options, @enqueue, @clock = stores, options, enqueue, clock
10
+ unless options.batch_size.is_a?(Integer) && (1..1000).cover?(options.batch_size)
11
+ raise ArgumentError, "maintenance.batch_size must be an integer from 1 to 1000"
12
+ end
13
+ %i[session email notification].each do |kind|
14
+ value = options.public_send(:"#{kind}_retention")
15
+ valid = value.nil? || (value.is_a?(Numeric) && value.finite? && value >= 0)
16
+ unless valid
17
+ raise ArgumentError, "maintenance.#{kind}_retention must be nonnegative seconds or nil"
18
+ end
19
+ end
20
+ end
21
+
22
+ def call
23
+ now, limit = @clock.now, @options.batch_size
24
+ counts = {}
25
+ @stores.each do |kind, store|
26
+ if kind == :ceremony
27
+ counts[:ceremonies_deleted] = store.purge_expired(before: now, now: now, limit: limit)
28
+ next
29
+ end
30
+ retention = @options.public_send(:"#{kind}_retention")
31
+ if kind != :session
32
+ counts[:"#{kind}_payloads_erased"] = store.erase_expired_payloads(now: now, limit: limit)
33
+ ids = store.pending_ids(now: now, limit: limit)
34
+ ids.each { |id| @enqueue.call(kind, id) }
35
+ counts[:"#{kind}_enqueued"] = ids.length
36
+ end
37
+ if retention
38
+ counts[:"#{kind}_deleted"] = store.purge_expired(before: now - retention, now: now, limit: limit)
39
+ end
40
+ end
41
+ counts
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ class RateLimit
6
+ WINDOW = 300
7
+ STAGGER = 60
8
+ DURATION = WINDOW + STAGGER
9
+
10
+ def initialize(counter:, clock: Time)
11
+ @counter, @clock = counter, clock
12
+ end
13
+
14
+ def call(key:, limit:)
15
+ now = @clock.now.to_i
16
+ # Every rolling five-minute interval fits in at least one of these
17
+ # overlapping six-minute windows. Atomic increments keep that bound
18
+ # across workers without a read/modify/write race or a boundary burst.
19
+ counts = (DURATION / STAGGER).times.map do |phase|
20
+ bucket = (now - phase * STAGGER) / DURATION
21
+ @counter.call(key: "add_auth:rate:v2:#{phase}:#{bucket}:#{key}", expires_in: DURATION)
22
+ end
23
+ unless counts.all? { |count| count.is_a?(Integer) && count.positive? }
24
+ raise AddAuth::Error, "rate limit store must support atomic increment"
25
+ end
26
+ counts.all? { |count| count <= limit }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module AddAuth
7
+ module Core
8
+ class SecurityEvents
9
+ include Delivery
10
+
11
+ KINDS = %i[password_changed email_changed passkey_added passkey_removed policy_changed recovery_completed].freeze
12
+
13
+ def initialize(store:, digest:, delivery_cipher:, clock: Time, random: SecureRandom)
14
+ @store, @digest, @cipher, @clock, @random = store, digest, delivery_cipher, clock, random
15
+ end
16
+
17
+ # Called inside the authentication/account mutation transaction. The
18
+ # encrypted outbox survives an enqueue failure or a process crash.
19
+ def issue(user:, kind:, at:, recipient: user.email_address)
20
+ raise ArgumentError, "unsupported security event" unless KINDS.include?(kind.to_sym)
21
+ unless recipient.is_a?(String) && recipient.valid_encoding? && recipient.bytesize.between?(3, 254) &&
22
+ recipient.include?("@") && !recipient.match?(/[\r\n]/)
23
+ raise ArgumentError, "invalid notification recipient"
24
+ end
25
+ digest = @digest.digest(@random.urlsafe_base64(32))
26
+ expires_at = at + 7 * 86_400
27
+ payload = @cipher.encrypt(token: JSON.generate(recipient: recipient, kind: kind.to_s), digest: digest, expires_at: expires_at)
28
+ @store.append(user: user, kind: kind.to_s, digest: digest, expires_at: expires_at, delivery_payload: payload, created_at: at)
29
+ end
30
+
31
+ private
32
+
33
+ def rejection(_user, record)
34
+ !record || record.revoked_at || record.expires_at <= @clock.now
35
+ end
36
+
37
+ def delivery_details(_user, record)
38
+ raw = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
39
+ return unless raw
40
+ value = JSON.parse(raw)
41
+ return unless value["kind"] == record.kind && KINDS.map(&:to_s).include?(record.kind)
42
+ {recipient: value.fetch("recipient"), kind: record.kind}
43
+ rescue JSON::ParserError, KeyError
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module AddAuth
6
+ module Core
7
+ class Sessions
8
+ Grant = Struct.new(:session, :bearer) do
9
+ def inspect = "#<AddAuth::Core::Sessions::Grant [FILTERED]>"
10
+ end
11
+ class Entry
12
+ attr_reader :id, :current, :authenticated_with, :authenticated_at, :created_at,
13
+ :last_seen_at, :expires_at, :ip_address, :user_agent
14
+
15
+ def initialize(id:, current:, authenticated_with:, authenticated_at:, created_at:,
16
+ last_seen_at:, expires_at:, ip_address:, user_agent:)
17
+ @id, @current, @authenticated_with, @authenticated_at, @created_at = id, current,
18
+ authenticated_with, authenticated_at, created_at
19
+ @last_seen_at, @expires_at, @ip_address, @user_agent = last_seen_at, expires_at,
20
+ ip_address, user_agent
21
+ end
22
+
23
+ def inspect = "#<AddAuth::Core::Sessions::Entry id=#{id} current=#{current.inspect}>"
24
+ end
25
+ PATTERN = /\Alk1:[A-Za-z0-9_-]{43}\z/
26
+ PAGE_SIZE = 50
27
+ Page = Struct.new(:entries, :next_cursor)
28
+
29
+ def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
30
+ legacy_bridge_until: nil, clock: Time, access_policy: nil)
31
+ unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
32
+ raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
33
+ end
34
+ unless legacy_bridge_until.nil? || legacy_bridge_until.is_a?(Time)
35
+ raise ArgumentError, "legacy_bridge_until must be an absolute Time or nil"
36
+ end
37
+ @store, @digest, @eligible, @clock = store, digest, eligible, clock
38
+ @access_policy = access_policy
39
+ @lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
40
+ end
41
+
42
+ def start(user:, method:, replacing: nil, **hints)
43
+ # The original Rails controller verifies before calling its session hook.
44
+ # A reset between that proof and this lock must invalidate the proof too.
45
+ return if method.to_s == "password" && !user.respond_to?(:password_digest)
46
+ verified_password_digest = user.password_digest if method.to_s == "password"
47
+ @store.with_user(id: user.id, replacing: replacing) do |account|
48
+ next if method.to_s == "password" && account && account.password_digest != verified_password_digest
49
+ create_in_transaction(user: account, method: method, replacing: replacing, **hints)
50
+ end
51
+ end
52
+
53
+ def authenticate(identifier:, password:, replacing: nil, **hints)
54
+ @store.with_identifier(identifier: identifier, replacing: replacing) do |user|
55
+ verified = yield
56
+ create_in_transaction(user: user, method: :password, replacing: replacing, **hints) if user && verified && verified.id == user.id
57
+ end
58
+ end
59
+
60
+ # Runs inside the store's account transaction. When replacing a browser,
61
+ # that session's account must also be locked on the same connection.
62
+ def create_in_transaction(user:, method:, persist: nil, replacing: nil, proof: nil, **hints)
63
+ return unless user && @eligible.call(user) == true
64
+ raise ArgumentError, "unsupported proof" unless %w[password email_link passkey].include?(method.to_s)
65
+ return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
66
+ if method.to_s == "passkey"
67
+ return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
68
+ proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
69
+ end
70
+ if user.respond_to?(:add_auth_policy_version)
71
+ hints = hints.merge(authentication_policy_version: @access_policy ? @access_policy.version(user) : user.add_auth_policy_version,
72
+ authentication_credential_id: proof&.credential_id, authentication_uv: proof&.user_verification || false)
73
+ end
74
+ raw = bearer
75
+ now = @clock.now
76
+ if replacing
77
+ prior = @store.find_for_user_in_transaction(user_id: replacing.user_id, session_id: replacing.id)
78
+ @store.update(prior, revoked_at: now) if prior && !prior.revoked_at && prior.token_digest == replacing.token_digest
79
+ end
80
+ writer = persist || ->(**attributes) { @store.create(user: user, **attributes) }
81
+ row = writer.call(token_digest: @digest.digest(raw), authenticated_with: method.to_s,
82
+ authenticated_at: now, expires_at: now + @lifetime, last_seen_at: now, **hints)
83
+ Grant.new(session: row, bearer: raw)
84
+ end
85
+
86
+ # The adapter must pass only the value returned by Rails cookies.signed.
87
+ def resume(signed_value:)
88
+ lookup = cookie_lookup(signed_value)
89
+ return unless lookup
90
+ legacy = lookup.key?(:id)
91
+ @store.with_session(**lookup) do |user, row|
92
+ now = @clock.now
93
+ next unless live?(user, row, now: now)
94
+ if legacy
95
+ next if row.token_digest || now >= @deadline
96
+ raw = bearer
97
+ @store.update(row, token_digest: @digest.digest(raw), expires_at: [now + @lifetime, @deadline, row.expires_at].compact.min,
98
+ last_seen_at: now, authenticated_with: nil, authenticated_at: nil)
99
+ Grant.new(session: row, bearer: raw)
100
+ else
101
+ @store.update(row, last_seen_at: now) if row.last_seen_at <= now - 60
102
+ Grant.new(session: row)
103
+ end
104
+ end
105
+ end
106
+
107
+ # A trusted cookie identifies what to retire, even if that account is now
108
+ # ineligible. This snapshot is replacement input, never authentication.
109
+ def replacement_for(signed_value:)
110
+ lookup = cookie_lookup(signed_value)
111
+ return unless lookup
112
+ @store.with_session(**lookup) do |_user, row|
113
+ row unless lookup.key?(:id) && row&.token_digest
114
+ end
115
+ end
116
+
117
+ def revoke(session:)
118
+ @store.with_session(id: session.id) do |_user, row|
119
+ @store.update(row, revoked_at: @clock.now) if row && !row.revoked_at && row.token_digest == session.token_digest
120
+ end
121
+ end
122
+
123
+ # Returns display-safe metadata only. Bearers and digests never cross this
124
+ # boundary, even when a host chooses to render its own management page.
125
+ def list(user:, current_session_id: nil)
126
+ list_page(user: user, current_session_id: current_session_id).entries
127
+ end
128
+
129
+ def list_page(user:, current_session_id: nil, before: nil)
130
+ empty = Page.new(entries: [])
131
+ return empty unless user && @eligible.call(user) == true
132
+ return empty unless before.nil? || before.to_s.match?(/\A[1-9]\d{0,18}\z/)
133
+
134
+ now = @clock.now
135
+ candidates = @store.list_for_user(user_id: user.id, before: before&.to_i,
136
+ excluding: current_session_id, limit: PAGE_SIZE + 1, now: now,
137
+ active_after: now - @idle, legacy: !!(@deadline && now < @deadline))
138
+ cursor = candidates[PAGE_SIZE - 1].id if candidates.length > PAGE_SIZE
139
+ rows = candidates.first(PAGE_SIZE)
140
+ if before.nil? && current_session_id
141
+ current = @store.find_for_user(user_id: user.id, session_id: current_session_id)
142
+ rows.unshift(current) if current
143
+ end
144
+ entries = rows.filter_map do |row|
145
+ next unless live?(user, row, now: now)
146
+
147
+ Entry.new(id: row.id, current: row.id == current_session_id,
148
+ authenticated_with: row.authenticated_with, authenticated_at: row.authenticated_at,
149
+ created_at: row.created_at, last_seen_at: row.last_seen_at, expires_at: row.expires_at,
150
+ ip_address: row.ip_address, user_agent: row.user_agent)
151
+ end
152
+ Page.new(entries: entries, next_cursor: cursor)
153
+ end
154
+
155
+ # Both the initiating bearer and target ownership are checked under lock.
156
+ def revoke_one(user:, session:, session_id:)
157
+ return false unless session_id.is_a?(Integer) || session_id.to_s.match?(/\A[1-9]\d*\z/)
158
+
159
+ with_live_session(user: user, session: session) do |account, _current, now|
160
+ target = @store.find_for_user_in_transaction(user_id: account.id, session_id: session_id.to_i)
161
+ next false unless target
162
+ @store.update(target, revoked_at: now) unless target.revoked_at
163
+ true
164
+ end || false
165
+ end
166
+
167
+ # Password proof is obtained from the locked, current account. Intake
168
+ # limits must be applied by the caller before reaching this expensive port.
169
+ def reauthenticate(user:, session:, purpose:, policy:)
170
+ with_live_session(user: user, session: session) do |account, row, _now|
171
+ next Result.failure(reason: :elevation_required) if @access_policy && !@access_policy.sign_in_allowed?(account, :password)
172
+ verified = yield account
173
+ next Result.failure(reason: :invalid_credentials) unless verified && verified.id == account.id
174
+ evidence = StepUp::Evidence.new(user_id: account.id, session_id: row.id,
175
+ method: :password, verified_at: @clock.now, session_digest: row.token_digest,
176
+ credential_version: policy.password_version(account))
177
+ policy.authorize(user: account, session_id: row.id, purpose: purpose, evidence: evidence)
178
+ end || Result.failure(reason: :elevation_required)
179
+ end
180
+
181
+ def revoke_all(user:, session:, grant:)
182
+ with_authority(user: user, session: session, grant: grant, purpose: :sign_out_everywhere) do |account, _row, now|
183
+ @store.revoke_all_in_transaction(user_id: account.id, at: now)
184
+ end || false
185
+ end
186
+
187
+ def rotate_for_step_up(user:, session:, grant:)
188
+ return unless grant.is_a?(StepUp::Grant)
189
+ with_authority(user: user, session: session, grant: grant, purpose: grant.purpose) do |account, row, _now|
190
+ rotate_in_transaction(user: account, session: row, grant: grant)
191
+ end
192
+ end
193
+
194
+ # Caller owns the account transaction, as with email-token consumption.
195
+ def current_in_transaction(user:, session:)
196
+ return unless user && session && session.user_id == user.id
197
+ row = @store.find_for_user_in_transaction(user_id: user.id, session_id: session.id)
198
+ row if live?(user, row, now: @clock.now) && row.token_digest == session.token_digest
199
+ end
200
+
201
+ def rotate_in_transaction(user:, session:, grant:)
202
+ row = current_in_transaction(user: user, session: session)
203
+ return unless row && grant.is_a?(StepUp::Grant) && grant.valid_for?(user: user,
204
+ user_id: user.id, session_id: row.id, session_digest: row.token_digest, purpose: grant.purpose, now: @clock.now)
205
+ raw = bearer
206
+ attributes = {token_digest: @digest.digest(raw), elevated_at: grant.verified_at,
207
+ elevated_with: grant.method.to_s, elevation_purpose: grant.purpose.to_s,
208
+ elevation_credential_id: grant.credential_id, elevation_uv: grant.user_verification,
209
+ last_seen_at: @clock.now}
210
+ if row.respond_to?(:elevation_version)
211
+ attributes[:elevation_version] = grant.credential_version
212
+ attributes[:elevation_expires_at] = grant.expires_at
213
+ end
214
+ @store.update(row, **attributes)
215
+ Grant.new(session: row, bearer: raw)
216
+ end
217
+
218
+ # A guard can call this without a block. Mutations must use the block so
219
+ # eligibility, bearer generation and proof are checked under the same lock.
220
+ # Hosts own resource authorization and target/version checks inside it.
221
+ def with_elevation(user:, session:, purpose:, policy:)
222
+ with_live_session(user: user, session: session) do |account, row, _now|
223
+ result = elevation_in_transaction(user: account, session: row, purpose: purpose, policy: policy)
224
+ next result unless result.success?
225
+ yield account if block_given?
226
+ result
227
+ end || Result.failure(reason: :elevation_required)
228
+ end
229
+
230
+ def elevation_in_transaction(user:, session:, purpose:, policy:)
231
+ row = current_in_transaction(user: user, session: session)
232
+ now = @clock.now
233
+ return Result.failure(reason: :elevation_required) unless row&.respond_to?(:elevation_version) && row.elevated_at &&
234
+ row.elevation_purpose == purpose.to_s && row.elevation_expires_at && now < row.elevation_expires_at
235
+ evidence = StepUp::Evidence.new(user_id: user.id, session_id: row.id,
236
+ method: row.elevated_with, verified_at: row.elevated_at, session_digest: row.token_digest,
237
+ credential_id: row.elevation_credential_id, user_verification: row.elevation_uv,
238
+ credential_version: row.elevation_version)
239
+ policy.authorize(user: user, session_id: row.id, purpose: purpose, evidence: evidence)
240
+ end
241
+
242
+ def self.safe_return(value)
243
+ value if value.is_a?(String) && value.start_with?("/") && !value.start_with?("//") &&
244
+ !value.match?(/[\\\x00-\x20\x7f]/) && !value.match?(/%[0-9a-f]{2}/i)
245
+ end
246
+
247
+ private
248
+
249
+ def cookie_lookup(value)
250
+ if value.is_a?(String) && PATTERN.match?(value)
251
+ {digest: @digest.digest(value)}
252
+ elsif value.is_a?(Integer) && value.positive? && @deadline && @clock.now < @deadline
253
+ {id: value}
254
+ end
255
+ end
256
+
257
+ def with_authority(user:, session:, grant:, purpose:)
258
+ return unless grant.is_a?(StepUp::Grant)
259
+ with_live_session(user: user, session: session) do |account, row, now|
260
+ next unless grant.valid_for?(user: account, user_id: account.id, session_id: row.id,
261
+ session_digest: row.token_digest, purpose: purpose, now: now)
262
+ yield account, row, now
263
+ end
264
+ end
265
+
266
+ def with_live_session(user:, session:)
267
+ return unless user && session
268
+ @store.with_session(id: session.id) do |account, row|
269
+ now = @clock.now
270
+ next unless account && account.id == user.id && live?(account, row, now: now) &&
271
+ row.token_digest == session.token_digest
272
+ yield account, row, now
273
+ end
274
+ end
275
+
276
+ def live?(user, row, now:)
277
+ return false unless user && row && @eligible.call(user) == true && !row.revoked_at
278
+ return false if @access_policy && !@access_policy.session_allowed?(user, row)
279
+ legacy = !row.token_digest && @deadline && now < @deadline
280
+ (legacy || (row.expires_at && row.last_seen_at)) &&
281
+ (!row.expires_at || row.expires_at > now) && (!row.last_seen_at || row.last_seen_at > now - @idle)
282
+ end
283
+
284
+ def bearer = "lk1:#{SecureRandom.urlsafe_base64(32)}"
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ # Evidence is issued by a trusted verifier, never deserialized from a client.
6
+ # A grant retains that verifier's current policy and the proof's original age.
7
+ class StepUp
8
+ METHODS = %i[password email_link passkey].freeze
9
+
10
+ class Evidence
11
+ attr_reader :user_id, :session_id, :method, :verified_at, :credential_id,
12
+ :user_verification, :session_digest, :credential_version
13
+
14
+ def initialize(user_id:, session_id:, method:, verified_at:, credential_id: nil,
15
+ user_verification: false, session_digest: nil, credential_version: nil)
16
+ @user_id, @session_id, @method, @verified_at = user_id, session_id, method.to_sym, verified_at
17
+ @credential_id, @user_verification = credential_id, user_verification == true
18
+ @session_digest, @credential_version = session_digest&.dup&.freeze, credential_version&.dup&.freeze
19
+ @verified_at = verified_at.dup.freeze
20
+ @credential_id = credential_id&.dup&.freeze
21
+ freeze
22
+ end
23
+
24
+ def strong?
25
+ method == :passkey && user_verification && credential_id.is_a?(String) && !credential_id.empty?
26
+ end
27
+
28
+ def inspect = "#<AddAuth::Core::StepUp::Evidence [FILTERED]>"
29
+ end
30
+
31
+ class Grant
32
+ attr_reader :purpose, :expires_at
33
+ def initialize(evidence:, purpose:, expires_at:, policy:)
34
+ @evidence, @purpose, @expires_at, @policy = evidence, purpose, expires_at, policy
35
+ freeze
36
+ end
37
+
38
+ %i[user_id session_id method verified_at credential_id user_verification session_digest credential_version].each do |attribute|
39
+ define_method(attribute) { @evidence.public_send(attribute) }
40
+ end
41
+
42
+ def valid_for?(user_id:, session_id:, purpose:, now:, user: nil, session_digest: nil)
43
+ user && user.id == user_id && @evidence.user_id == user_id && @evidence.session_id == session_id &&
44
+ purpose.to_sym == @purpose && @evidence.session_digest == session_digest &&
45
+ now.is_a?(Time) && now >= verified_at && now < @expires_at &&
46
+ @policy.current?(user: user, purpose: @purpose, evidence: @evidence)
47
+ end
48
+
49
+ def inspect = "#<AddAuth::Core::StepUp::Grant purpose=#{purpose.inspect} method=#{method.inspect}>"
50
+ end
51
+
52
+ class Rule
53
+ attr_reader :methods, :require_passkey, :return_to, :label, :reauthentication
54
+ def initialize(methods:, require_passkey: false, return_to: "/", label: "Continue", reauthentication: true)
55
+ @reauthentication = reauthentication == true
56
+ @methods = Array(methods).map(&:to_sym).uniq.freeze
57
+ raise ArgumentError, "unsupported step-up method" unless (@methods - METHODS).empty?
58
+ raise ArgumentError, "return_to must be a safe local GET path" unless Sessions.safe_return(return_to)
59
+ @return_to, @label = return_to.dup.freeze, label.to_s.dup.freeze
60
+ @require_passkey = require_passkey == true
61
+ freeze
62
+ end
63
+ end
64
+
65
+ def initialize(purposes:, fresh_for: 600, strong_for: 300, clock: Time, credential_current: nil, password_version: ->(user) { user.password_digest }, methods_available: ->(_user) { METHODS })
66
+ unless [fresh_for, strong_for].all? { |value| value.is_a?(Numeric) && value.finite? && value.positive? }
67
+ raise ArgumentError, "step-up windows must be positive"
68
+ end
69
+ raise ArgumentError, "strong_for must not exceed fresh_for" if strong_for > fresh_for
70
+ @purposes = purposes.transform_keys(&:to_sym).transform_values do |rule|
71
+ rule.is_a?(Rule) ? rule : Rule.new(**rule)
72
+ end.freeze
73
+ @fresh_for, @strong_for, @clock, @credential_current = fresh_for, strong_for, clock, credential_current
74
+ @password_version, @methods_available = password_version, methods_available
75
+ end
76
+
77
+ def rule_for(purpose)
78
+ return unless purpose.is_a?(String) || purpose.is_a?(Symbol)
79
+ return if purpose.to_s.bytesize > 64
80
+ @purposes[purpose.to_sym]
81
+ end
82
+
83
+ def reauthentication_rule_for(purpose)
84
+ rule = rule_for(purpose)
85
+ rule if rule&.reauthentication
86
+ end
87
+
88
+ def return_to(purpose) = rule_for(purpose)&.return_to || "/"
89
+
90
+ def methods_for(user:, purpose:)
91
+ rule = rule_for(purpose)
92
+ return [] unless user && rule
93
+ available = rule.methods & @methods_available.call(user)
94
+ rule.require_passkey ? available & [:passkey] : available
95
+ end
96
+
97
+ def password_version(user) = @password_version.call(user)
98
+
99
+ def authorize(user:, session_id:, purpose:, evidence:)
100
+ now = @clock.now
101
+ unless evidence.is_a?(Evidence) && user && evidence.user_id == user.id &&
102
+ evidence.session_id == session_id && evidence.verified_at.is_a?(Time) && now.is_a?(Time) &&
103
+ current?(user: user, purpose: purpose, evidence: evidence)
104
+ return Result.failure(reason: :elevation_required)
105
+ end
106
+ window = evidence.strong? ? @strong_for : @fresh_for
107
+ return Result.failure(reason: :elevation_required) unless evidence.verified_at <= now && now < evidence.verified_at + window
108
+
109
+ grant = Grant.new(evidence: evidence, purpose: purpose.to_sym,
110
+ expires_at: evidence.verified_at + window, policy: self)
111
+ Result.success(user: user, strategy: :step_up, credential: grant)
112
+ end
113
+
114
+ def current?(user:, purpose:, evidence:)
115
+ rule = rule_for(purpose)
116
+ return false unless rule && methods_for(user: user, purpose: purpose).include?(evidence.method)
117
+ return false if rule.require_passkey && !evidence.strong?
118
+ return false unless evidence.session_digest.is_a?(String) && !evidence.session_digest.empty?
119
+ return false if evidence.method == :passkey && !evidence.strong?
120
+ if evidence.method == :password
121
+ return false unless evidence.credential_version.is_a?(String) && !evidence.credential_version.empty?
122
+ return false unless user.respond_to?(:password_digest) && password_version(user) == evidence.credential_version
123
+ return true unless @credential_current
124
+ end
125
+ @credential_current && @credential_current.call(user: user, evidence: evidence) == true
126
+ end
127
+ end
128
+ end
129
+ end