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,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Stores
6
+ module DeliveryState
7
+ def revoke(record:, at:)
8
+ record.update!(revoked_at: at, delivery_payload: nil)
9
+ end
10
+
11
+ def lease(record:, key:, until_time:)
12
+ record.update!(delivery_lease_key: key, delivery_lease_until: until_time)
13
+ end
14
+
15
+ def delivered(record:, at:)
16
+ record.update!(delivered_at: at, delivery_payload: nil, delivery_lease_key: nil, delivery_lease_until: nil)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/account_lock"
4
+ require "add_auth/rails/stores/delivery_state"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ module Stores
9
+ # Lock the account before reading token state, serializing issuance,
10
+ # consumption and revocation. All injected models must share a pool.
11
+ # Host finalizers may perform DB writes only, on this same connection.
12
+ class EmailTokens
13
+ include DeliveryState
14
+
15
+ def initialize(user_model:, token_model:, session_model:, identifier: :email_address)
16
+ @users, @tokens, @sessions = user_model, token_model, session_model
17
+ unless [@tokens, @sessions].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
18
+ raise ArgumentError, "authentication models must share one connection pool"
19
+ end
20
+ unless @users.column_names.include?(identifier.to_s)
21
+ raise ArgumentError, "unknown identifier column"
22
+ end
23
+ @identifier = identifier
24
+ end
25
+
26
+ def with_user(identifier:)
27
+ with_account(@users.find_by(@identifier => identifier)) { |user| yield user }
28
+ end
29
+
30
+ def with_token(digest:, current_session: nil)
31
+ raise ArgumentError, "digest must be a nonempty string" unless digest.is_a?(String) && !digest.empty?
32
+
33
+ # A request may have inspected this token before another connection
34
+ # consumed it. Never authorize from Active Record's query cache.
35
+ @tokens.uncached do
36
+ locator = @tokens.find_by(digest: digest)
37
+ with_account(locator && @users.find_by(@users.primary_key => locator.user_id), additional_id: current_session&.user_id) do |user|
38
+ yield user, user && @tokens.find_by(digest: digest, user_id: user.id)
39
+ end
40
+ end
41
+ end
42
+
43
+ def replace_pending(user:, purpose: "sign_in", **attributes)
44
+ scope = attributes.slice(:session_id, :authentication_purpose)
45
+ @tokens.where(user_id: user.id, purpose: purpose, consumed_at: nil, revoked_at: nil, **scope)
46
+ .update_all(revoked_at: attributes.fetch(:created_at), delivery_payload: nil)
47
+ @tokens.create!(**attributes, user_id: user.id, purpose: purpose)
48
+ end
49
+
50
+ def session_for(user:, id:)
51
+ @sessions.uncached { @sessions.find_by(id: id, user_id: user.id) }
52
+ end
53
+
54
+ def consume(record:, at:)
55
+ record.update!(consumed_at: at, delivery_payload: nil)
56
+ end
57
+
58
+ # This capability owns creation; a model's previously_new_record? flag
59
+ # cannot establish which transaction created it. The writer expires when
60
+ # the callback exits and may create exactly one session for this account.
61
+ def finalize_session(user:)
62
+ transaction = @users.current_transaction
63
+ created = nil
64
+ active = true
65
+ writer = lambda do |**attributes|
66
+ unless active && transaction.open? && @users.current_transaction.equal?(transaction) && !created
67
+ raise AddAuth::Error, "session writer is no longer available"
68
+ end
69
+ created = @sessions.create!(**attributes.except(:user_id), user_id: user.id)
70
+ end
71
+ result = yield writer
72
+ unless created && result.equal?(created) && created.persisted? && created.user_id == user.id
73
+ raise AddAuth::Error, "finalizer must persist a session using the transaction writer"
74
+ end
75
+ result
76
+ ensure
77
+ active = false
78
+ end
79
+
80
+ def refresh_session(session) = session.reload
81
+
82
+ def issued?(request_id:) = @tokens.exists?(request_id: request_id)
83
+
84
+ private
85
+
86
+ def with_account(user, additional_id: nil)
87
+ AccountLock.new(@users).call(id: user&.id, additional_id: additional_id) { |account| yield account }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Stores
6
+ class Maintenance
7
+ def initialize(model:, kind:)
8
+ @model, @kind = model, kind
9
+ end
10
+
11
+ def purge_expired(before:, now:, limit:)
12
+ scope = @model.where("expires_at <= ?", before)
13
+ if @kind == :session
14
+ scope = scope.or(@model.where("revoked_at <= ?", before))
15
+ elsif @kind != :ceremony
16
+ scope = without_live_lease(scope, now)
17
+ end
18
+ bounded(scope, limit).delete_all
19
+ end
20
+
21
+ def erase_expired_payloads(now:, limit:)
22
+ scope = without_live_lease(@model.where("expires_at <= ?", now).where.not(delivery_payload: nil), now)
23
+ bounded(scope, limit).update_all(delivery_payload: nil)
24
+ end
25
+
26
+ def pending_ids(now:, limit:)
27
+ scope = @model.where(revoked_at: nil, delivered_at: nil).where.not(delivery_payload: nil)
28
+ .where("expires_at > ?", now)
29
+ without_live_lease(scope, now).order(:id).limit(limit).pluck(:id)
30
+ end
31
+
32
+ private
33
+
34
+ def without_live_lease(scope, now)
35
+ scope.where("delivery_lease_until IS NULL OR delivery_lease_until <= ?", now)
36
+ end
37
+
38
+ def bounded(scope, limit)
39
+ # Repeat the predicate after selection; a concurrent refresh/lease
40
+ # acquisition protects its row even if it appeared in this ID batch.
41
+ scope.where(id: scope.order(:id).limit(limit).pluck(:id))
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/account_lock"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Stores
8
+ class Passkeys
9
+ def initialize(user_model:, session_model:, credential_model:, ceremony_model:, token_model:)
10
+ @users, @sessions, @credentials, @ceremonies = user_model, session_model, credential_model, ceremony_model
11
+ @tokens = token_model
12
+ unless [@sessions, @credentials, @ceremonies, @tokens].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
13
+ raise ArgumentError, "authentication models must share one connection pool"
14
+ end
15
+ @lock = AccountLock.new(@users)
16
+ end
17
+
18
+ def with_user(id:, &block) = @lock.call(id: id, &block)
19
+ def credentials(user:) = @credentials.where(user_id: user.id, revoked_at: nil).order(:created_at).to_a
20
+ def credential(id:) = @credentials.find_by(external_id: id)
21
+ def update(record, **attributes) = record.update!(**attributes)
22
+ def create_ceremony(**attributes) = @ceremonies.create!(**attributes)
23
+
24
+ def with_ceremony(digest:, user_id:, replacing: nil)
25
+ @lock.call(id: user_id, additional_id: replacing&.user_id) do |user|
26
+ # Anonymous assertions for different accounts can share a challenge;
27
+ # locking only those different accounts would not prevent replay.
28
+ record = @ceremonies.lock.find_by(digest: digest) if user
29
+ yield user, record
30
+ end
31
+ end
32
+
33
+ def create_credential(user:, **attributes)
34
+ @credentials.transaction(requires_new: true) do
35
+ @credentials.create!(**attributes, user_id: user.id)
36
+ end
37
+ rescue ActiveRecord::RecordNotUnique
38
+ nil
39
+ end
40
+
41
+ def cancel(digest:)
42
+ @ceremonies.transaction do
43
+ if @ceremonies.connection.adapter_name == "SQLite"
44
+ pk = @ceremonies.connection.quote_column_name(@ceremonies.primary_key)
45
+ @ceremonies.where(digest: digest).update_all("#{pk} = #{pk}")
46
+ end
47
+ @ceremonies.uncached { yield @ceremonies.lock.find_by(digest: digest) }
48
+ end
49
+ end
50
+
51
+ def revoke_other_sessions(user:, except:, at:)
52
+ @sessions.where(user_id: user.id, revoked_at: nil).where.not(id: except.id).update_all(revoked_at: at)
53
+ end
54
+
55
+ def invalidate_proofs(user:, at:)
56
+ @tokens.where(user_id: user.id, consumed_at: nil, revoked_at: nil).update_all(revoked_at: at, delivery_payload: nil)
57
+ @ceremonies.where(user_id: user.id, consumed_at: nil).update_all(consumed_at: at)
58
+ @sessions.where(user_id: user.id).update_all(elevated_at: nil, elevation_version: nil, elevation_expires_at: nil)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/delivery_state"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Stores
8
+ class SecurityEvents
9
+ include DeliveryState
10
+
11
+ def initialize(user_model:, event_model:)
12
+ @users, @events = user_model, event_model
13
+ raise ArgumentError, "authentication models must share one connection pool" unless @users.connection_pool.equal?(@events.connection_pool)
14
+ end
15
+
16
+ def append(user:, **attributes)
17
+ raise AddAuth::Error, "security notifications must join the account transaction" unless @users.current_transaction.open?
18
+ event = @events.create!(**attributes, user_id: user.id)
19
+ @users.current_transaction.after_commit do
20
+ job = ::AddAuth::SecurityNotificationJob.perform_later(event.id)
21
+ raise AddAuth::Error unless job
22
+ rescue
23
+ ActiveSupport::Notifications.instrument("notification_enqueue_failed.add_auth", event_id: event.id)
24
+ end
25
+ event
26
+ end
27
+
28
+ def with_token(digest:)
29
+ @events.transaction do
30
+ if @events.connection.adapter_name == "SQLite"
31
+ pk = @events.connection.quote_column_name(@events.primary_key)
32
+ @events.where(digest: digest).update_all("#{pk} = #{pk}")
33
+ end
34
+ @events.uncached { yield nil, @events.lock.find_by(digest: digest) }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/account_lock"
4
+
5
+ module AddAuth
6
+ module Rails
7
+ module Stores
8
+ class Sessions
9
+ def initialize(user_model:, session_model:)
10
+ @users, @sessions = user_model, session_model
11
+ raise ArgumentError, "authentication models must share one connection pool" unless @users.connection_pool.equal?(@sessions.connection_pool)
12
+ @lock = AccountLock.new(@users)
13
+ end
14
+
15
+ def with_user(id:, replacing: nil, &block) = @lock.call(id: id, additional_id: replacing&.user_id, &block)
16
+
17
+ def with_identifier(identifier:, replacing: nil, &block)
18
+ with_user(id: @users.find_by(email_address: identifier)&.id, replacing: replacing, &block)
19
+ end
20
+
21
+ def with_session(digest: nil, id: nil)
22
+ @sessions.uncached do
23
+ criteria = digest ? {token_digest: digest} : {id: id}
24
+ locator = @sessions.find_by(criteria)
25
+ @lock.call(id: locator&.user_id) do |user|
26
+ yield user, user && @sessions.find_by(**criteria, user_id: user.id)
27
+ end
28
+ end
29
+ end
30
+
31
+ def create(user:, **attributes)
32
+ raise AddAuth::Error, "session finalization requires an account transaction" unless @users.connection.transaction_open?
33
+ @sessions.create!(**attributes, user_id: user.id)
34
+ end
35
+
36
+ def update(row, **attributes) = row.update!(**attributes)
37
+
38
+ # Query cutoffs come from Core; Core still authorizes every returned row.
39
+ def list_for_user(user_id:, before:, excluding:, limit:, now:, active_after:, legacy:)
40
+ scope = @sessions.where(user_id: user_id, revoked_at: nil)
41
+ scope = scope.where("id < ?", before) if before
42
+ scope = scope.where.not(id: excluding) if excluding
43
+ scope = scope.where("(expires_at IS NULL OR expires_at > ?) AND (last_seen_at IS NULL OR last_seen_at > ?)", now, active_after)
44
+ scope = scope.where.not(expires_at: nil).where.not(last_seen_at: nil) unless legacy
45
+ scope.order(id: :desc).limit(limit).to_a
46
+ end
47
+
48
+ def find_for_user(user_id:, session_id:) = @sessions.find_by(user_id: user_id, id: session_id)
49
+
50
+ def find_for_user_in_transaction(user_id:, session_id:)
51
+ raise AddAuth::Error, "session lookup requires an account transaction" unless @users.connection.transaction_open?
52
+ @sessions.uncached { @sessions.find_by(id: session_id, user_id: user_id) }
53
+ end
54
+
55
+ def revoke_all_in_transaction(user_id:, at:)
56
+ raise AddAuth::Error, "revocation requires an account transaction" unless @users.connection.transaction_open?
57
+ @sessions.where(user_id: user_id, revoked_at: nil).update_all(revoked_at: at, updated_at: at)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module UserLifecycle
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ after_update :add_auth_invalidate_proofs
10
+ end
11
+
12
+ private
13
+
14
+ def add_auth_invalidate_proofs
15
+ return unless Core::Intake.revoke_after_change?(saved_changes)
16
+ now = Time.current
17
+ if AddAuth.configuration.notifications.enabled
18
+ kind = saved_changes.key?("password_digest") ? :password_changed : :email_changed
19
+ Runtime.security_events.issue(user: self, kind: kind, at: now)
20
+ if saved_change_to_email_address? && email_address_before_last_save != email_address
21
+ Runtime.security_events.issue(user: self, kind: :email_changed, at: now, recipient: email_address_before_last_save)
22
+ end
23
+ end
24
+ if defined?(::AddAuthCeremony) && ::AddAuthCeremony.table_exists?
25
+ ::AddAuthCeremony.where(user_id: id, consumed_at: nil).update_all(consumed_at: now)
26
+ end
27
+ sessions.where(revoked_at: nil).update_all(revoked_at: now)
28
+ if defined?(::AddAuthSignInToken) && ::AddAuthSignInToken.table_exists?
29
+ ::AddAuthSignInToken.where(user_id: id, consumed_at: nil, revoked_at: nil)
30
+ .update_all(revoked_at: now, delivery_payload: nil)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ # Every Core entry point returns one of these instead of raising for an
5
+ # authentication outcome (it still raises for programmer error) or
6
+ # returning a bare boolean. See docs/authentication-gem-plan.md section 2,
7
+ # "Everything returns a Result," for the rationale: a closed set of reason
8
+ # codes is something a host can exhaustively match/translate, and it can't
9
+ # accidentally leak *which* half of a credential check failed.
10
+ class Result
11
+ # Closed set. Adding a value here is a deliberate, documented decision --
12
+ # hosts are expected to `case`/`in` over this list.
13
+ REASONS = %i[
14
+ invalid_credentials unknown_identifier unconfirmed
15
+ locked disabled expired_token
16
+ consumed_token revoked_token challenge_rejected
17
+ challenge_unavailable rate_limited origin_mismatch
18
+ counter_regression elevation_required
19
+ ].freeze
20
+
21
+ attr_reader :user, :strategy, :credential, :reason, :session, :grant
22
+
23
+ def self.success(user:, strategy:, credential: nil, session: nil, grant: nil)
24
+ new(success: true, user:, strategy:, credential:, session:, grant:)
25
+ end
26
+
27
+ def self.failure(reason:)
28
+ raise ArgumentError, "unknown Result reason: #{reason.inspect}" unless REASONS.include?(reason)
29
+
30
+ new(success: false, reason:)
31
+ end
32
+
33
+ def success?
34
+ @success
35
+ end
36
+
37
+ def failure?
38
+ !success?
39
+ end
40
+
41
+ def inspect = "#<AddAuth::Result success=#{success?} strategy=#{strategy.inspect} reason=#{reason.inspect}>"
42
+
43
+ private
44
+
45
+ def initialize(success:, user: nil, strategy: nil, credential: nil, reason: nil, session: nil, grant: nil)
46
+ @success = success
47
+ @user = user
48
+ @strategy = strategy
49
+ @credential = credential
50
+ @reason = reason
51
+ @session, @grant = session, grant
52
+ freeze
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module AddAuth
6
+ module Testing
7
+ module_function
8
+
9
+ def delivered_link(mail, purpose: :sign_in)
10
+ path = {sign_in: "/sign-in/link", reauthentication: "/reauthenticate/link", recovery: "/recover/link"}.fetch(purpose)
11
+ urls = mail.body.decoded.scan(%r{https?://[^\s<>]+}).filter_map do |text|
12
+ uri = URI.parse(text)
13
+ uri if uri.path == path && URI.decode_www_form(uri.query.to_s).to_h["token"]
14
+ rescue URI::InvalidURIError, ArgumentError
15
+ nil
16
+ end
17
+ raise ArgumentError, "expected exactly one #{purpose} link" unless urls.length == 1
18
+ urls.first
19
+ end
20
+
21
+ def with_virtual_authenticator(driver, **options)
22
+ require "selenium-webdriver"
23
+ defaults = {protocol: :ctap2, transport: :internal, resident_key: true, user_verification: true, user_verified: true}
24
+ authenticator = driver.add_virtual_authenticator(Selenium::WebDriver::VirtualAuthenticatorOptions.new(**defaults, **options))
25
+ yield authenticator
26
+ ensure
27
+ authenticator.remove! if authenticator&.valid?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ VERSION = "0.2.1"
5
+ end
data/lib/add_auth.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/version"
4
+ require "add_auth/result"
5
+ require "add_auth/configuration"
6
+
7
+ # Layer 1 (plain Ruby, no Rails dependency). See
8
+ # docs/authentication-gem-plan.md section 2 in the workspace repo for the
9
+ # two-layer architecture this module is built around: AddAuth::Core holds
10
+ # every security decision and knows nothing about controllers, views, or the
11
+ # session hash. AddAuth::Rails (loaded below, only inside a Rails app) is the
12
+ # thin engine that wires Core into a host application and generates
13
+ # ejectable, disposable UI on top of it.
14
+ require "add_auth/core/browser_binding"
15
+ require "add_auth/core/access_policy"
16
+ require "add_auth/core/sessions"
17
+ require "add_auth/core/step_up"
18
+ require "add_auth/core/intake"
19
+ require "add_auth/core/rate_limit"
20
+ require "add_auth/core/delivery"
21
+ require "add_auth/core/maintenance"
22
+ require "add_auth/core/security_events"
23
+ require "add_auth/core/strategies/email_link"
24
+ require "add_auth/core/strategies/passkey"
25
+ require "add_auth/core/challenge/base"
26
+ require "add_auth/core/challenge/http"
27
+ require "add_auth/core/challenge/null"
28
+ require "add_auth/core/challenge/test"
29
+ require "add_auth/core/challenge/turnstile"
30
+ require "add_auth/core/challenge/recaptcha"
31
+ require "add_auth/core/digest/base"
32
+ require "add_auth/core/digest/hmac"
33
+
34
+ module AddAuth
35
+ class Error < StandardError; end
36
+
37
+ class << self
38
+ def configuration
39
+ @configuration ||= Configuration.new
40
+ end
41
+
42
+ def configure
43
+ yield configuration
44
+ end
45
+ end
46
+ end
47
+
48
+ if defined?(::Rails::Engine)
49
+ require "add_auth/rails/engine"
50
+ require "add_auth/rails/delivery_cipher"
51
+ require "add_auth/rails/stores/email_tokens"
52
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module AddAuth
6
+ module Generators
7
+ # `bin/rails g add_auth:challenge turnstile` (or `recaptcha`) writes an
8
+ # opt-in initializer without touching the host's existing initializer.
9
+ class ChallengeGenerator < ::Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+ argument :provider, type: :string, required: true, banner: "turnstile|recaptcha"
12
+ class_option :version, type: :string, default: "v3", desc: "reCAPTCHA version (v2 or v3)"
13
+
14
+ def validate_provider
15
+ return if %w[turnstile recaptcha].include?(provider.to_s.downcase)
16
+
17
+ raise Thor::Error, "Choose a challenge provider: turnstile or recaptcha."
18
+ end
19
+
20
+ def validate_version
21
+ return if provider.to_s.downcase == "turnstile"
22
+ return if %w[v2 v3].include?(options[:version].to_s.downcase)
23
+
24
+ raise Thor::Error, "reCAPTCHA version must be v2 or v3."
25
+ end
26
+
27
+ def create_initializer
28
+ path = File.join(destination_root, "config/initializers/add_auth_challenge.rb")
29
+ if File.exist?(path)
30
+ say "Skipping #{path}; review the existing challenge configuration.", :yellow
31
+ return
32
+ end
33
+
34
+ empty_directory "config/initializers"
35
+ template "#{provider.to_s.downcase}.rb.tt", path
36
+ say "Challenge server verification configured for #{provider}. Review environment keys and allowed hostnames."
37
+ end
38
+
39
+ def add_route
40
+ path = File.join(destination_root, "config/routes.rb")
41
+ return unless File.file?(path)
42
+ return if File.read(path).include?("add_auth/challenge.js")
43
+
44
+ route 'get "add_auth/challenge.js", to: "add_auth/assets#challenge"'
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by add_auth:challenge. Set both keys before enabling this in a
4
+ # deployed environment. v2 uses a checkbox; v3 additionally checks action and
5
+ # score. The adapter always validates the token server-side.
6
+ site_key = ENV["RECAPTCHA_SITE_KEY"]
7
+ secret_key = ENV["RECAPTCHA_SECRET_KEY"]
8
+
9
+ if site_key.to_s.empty? || secret_key.to_s.empty?
10
+ raise AddAuth::Error, "Configured captcha requires both provider keys in production" if Rails.env.production?
11
+ Rails.logger.warn("AddAuth reCAPTCHA is disabled: set RECAPTCHA_SITE_KEY and RECAPTCHA_SECRET_KEY")
12
+ else
13
+ if Rails.env.production? && ENV.fetch("RECAPTCHA_ALLOWED_HOSTNAMES", "").split(",").all? { |host| host.strip.empty? }
14
+ raise AddAuth::Error, "Configured captcha requires allowed hostnames in production"
15
+ end
16
+ AddAuth.configure do |config|
17
+ config.challenge = AddAuth::Core::Challenge::Recaptcha.new(
18
+ site_key: site_key,
19
+ secret_key: secret_key,
20
+ version: :<%= options[:version].to_s.downcase %>,
21
+ minimum_score: ENV.fetch("RECAPTCHA_MINIMUM_SCORE", "0.5"),
22
+ expected_action: ENV["RECAPTCHA_EXPECTED_ACTION"].presence,
23
+ allowed_hostnames: ENV.fetch("RECAPTCHA_ALLOWED_HOSTNAMES", "").split(",")
24
+ )
25
+ config.challenge_on = %i[sign_in email_link]
26
+ config.challenge_when_unavailable = :closed
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by add_auth:challenge. Set both keys before enabling this in a
4
+ # deployed environment. The adapter always validates the token server-side.
5
+ site_key = ENV["TURNSTILE_SITE_KEY"]
6
+ secret_key = ENV["TURNSTILE_SECRET_KEY"]
7
+
8
+ if site_key.to_s.empty? || secret_key.to_s.empty?
9
+ raise AddAuth::Error, "Configured captcha requires both provider keys in production" if Rails.env.production?
10
+ Rails.logger.warn("AddAuth Turnstile is disabled: set TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY")
11
+ else
12
+ if Rails.env.production? && ENV.fetch("TURNSTILE_ALLOWED_HOSTNAMES", "").split(",").all? { |host| host.strip.empty? }
13
+ raise AddAuth::Error, "Configured captcha requires allowed hostnames in production"
14
+ end
15
+ AddAuth.configure do |config|
16
+ config.challenge = AddAuth::Core::Challenge::Turnstile.new(
17
+ site_key: site_key,
18
+ secret_key: secret_key,
19
+ allowed_hostnames: ENV.fetch("TURNSTILE_ALLOWED_HOSTNAMES", "").split(",")
20
+ )
21
+ config.challenge_on = %i[sign_in email_link]
22
+ config.challenge_when_unavailable = :closed
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "generators/add_auth/ejection"
5
+
6
+ module AddAuth
7
+ module Generators
8
+ class ControllersGenerator < ::Rails::Generators::Base
9
+ include Ejection
10
+
11
+ def generate = eject(:controllers)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/ejection"
4
+
5
+ module AddAuth
6
+ module Generators
7
+ module Ejection
8
+ private
9
+
10
+ def eject(kind, only: "all")
11
+ Rails::Ejection.new(host_root: destination_root).install(kind: kind, only: only).each do |result|
12
+ say "#{result[:preserved] ? "Preserved" : "Created"} #{result[:path]}"
13
+ end
14
+ say "Run bin/rails add_auth:doctor after gem upgrades to review upstream changes."
15
+ end
16
+ end
17
+ end
18
+ end