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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class Configuration
5
+ attr_accessor :challenge, :challenge_on,
6
+ :digest_secret, :eligible, :stylesheet, :css_classes, :mail_from, :base_url, :rate_limit_store, :trusted_recovery_address, :support_url
7
+ attr_writer :session_token_digest, :sign_in_token_digest
8
+
9
+ SessionOptions = Struct.new(:enabled, :lifetime, :idle_timeout, :legacy_bridge_until)
10
+ EmailOptions = Struct.new(:enabled, :token_lifetime, :same_browser)
11
+ StepUpOptions = Struct.new(:enabled, :purposes, :fresh_for, :strong_for)
12
+ PasskeyOptions = Struct.new(:enabled, :rp_id, :origins, :name, :anonymous_limit)
13
+ NotificationOptions = Struct.new(:enabled)
14
+ MaintenanceOptions = Struct.new(:batch_size, :session_retention, :email_retention, :notification_retention)
15
+ attr_accessor :passwords_enabled
16
+ attr_reader :maintenance, :notifications, :passkeys, :session, :email_link, :step_up, :challenge_when_unavailable
17
+
18
+ def initialize
19
+ @passwords_enabled = true
20
+ @session = SessionOptions.new(enabled: false, lifetime: 43_200, idle_timeout: 1800)
21
+ @email_link = EmailOptions.new(enabled: false, token_lifetime: 1200, same_browser: false)
22
+ @notifications = NotificationOptions.new(enabled: false)
23
+ @maintenance = MaintenanceOptions.new(batch_size: 100)
24
+ @passkeys = PasskeyOptions.new(enabled: false, origins: [], name: "Your account", anonymous_limit: 1000)
25
+ @trusted_recovery_address = ->(_user) {}
26
+ @step_up = StepUpOptions.new(enabled: false, purposes: {}, fresh_for: 600, strong_for: 300)
27
+ @eligible = ->(_user) { true } # Hosts supply their confirmed/locked/disabled policy here.
28
+ @stylesheet = "/add_auth.css"
29
+ @css_classes = {}
30
+ @challenge = Core::Challenge::Null.new
31
+ @challenge_on = []
32
+ @challenge_when_unavailable = :closed
33
+ end
34
+
35
+ def session_token_digest
36
+ @session_token_digest ||= default_digest("add_auth.session-token-digest.v1")
37
+ end
38
+
39
+ def sign_in_token_digest
40
+ @sign_in_token_digest ||= default_digest("add_auth.sign-in-token-digest.v1")
41
+ end
42
+
43
+ def challenge_when_unavailable=(policy)
44
+ value = policy.to_sym
45
+ raise ArgumentError, "challenge_when_unavailable must be :closed or :open" unless %i[closed open].include?(value)
46
+
47
+ @challenge_when_unavailable = value
48
+ rescue NoMethodError
49
+ raise ArgumentError, "challenge_when_unavailable must be :closed or :open"
50
+ end
51
+
52
+ private
53
+
54
+ def default_digest(salt)
55
+ unless digest_secret.respond_to?(:call)
56
+ raise AddAuth::Error, "configure digest_secret or inject a digest adapter outside a Rails app"
57
+ end
58
+ Core::Digest::Hmac.new(salt: salt, secret: digest_secret.call)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ # Account policy survives feature toggles; disabling passkeys cannot restore
6
+ # weaker access to an account that explicitly adopted strict policy.
7
+ class AccessPolicy
8
+ def initialize(credentials:, passkeys_enabled:, email_enabled:, trusted_recovery_address:, password_enabled: true)
9
+ @credentials, @passkeys_enabled, @email_enabled = credentials, passkeys_enabled, email_enabled
10
+ @trusted_recovery_address = trusted_recovery_address
11
+ @password_enabled = password_enabled
12
+ end
13
+
14
+ def strict?(user) = user.respond_to?(:add_auth_strict) && user.add_auth_strict == true
15
+ def version(user) = user.respond_to?(:add_auth_policy_version) ? user.add_auth_policy_version : 0
16
+
17
+ def sign_in_allowed?(user, method)
18
+ return false if strict?(user) && method.to_sym != :passkey
19
+ case method.to_sym
20
+ when :password then @password_enabled && user.respond_to?(:password_digest) && user.password_digest.is_a?(String) && !user.password_digest.empty?
21
+ when :email_link then @email_enabled
22
+ when :passkey then @passkeys_enabled
23
+ else false
24
+ end
25
+ end
26
+
27
+ def methods_for(user)
28
+ StepUp::METHODS.select { |method| sign_in_allowed?(user, method) }
29
+ end
30
+
31
+ def recovery_address(user)
32
+ address = @trusted_recovery_address.call(user)
33
+ address if address.is_a?(String) && address.valid_encoding? && address.bytesize.between?(3, 254) && address == user.email_address
34
+ end
35
+
36
+ def recoverable?(user) = !strict?(user) && @email_enabled && !recovery_address(user).nil?
37
+ def fallback?(user) = !strict?(user) && (sign_in_allowed?(user, :password) || recoverable?(user))
38
+
39
+ def credential_current?(user:, id:)
40
+ credential = @credentials.call(id)
41
+ credential && credential.user_id == user.id && !credential.revoked_at
42
+ end
43
+
44
+ def session_allowed?(user, row)
45
+ return false if row.respond_to?(:authentication_policy_version) && row.authentication_policy_version != version(user)
46
+ if row.authenticated_with == "passkey"
47
+ return false unless row.authentication_uv && credential_current?(user: user, id: row.authentication_credential_id)
48
+ end
49
+ return true unless strict?(user)
50
+ (row.authenticated_with == "passkey" && row.authentication_uv) ||
51
+ (row.elevated_with == "passkey" && row.elevation_uv && credential_current?(user: user, id: row.elevation_credential_id))
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module AddAuth
6
+ module Core
7
+ # A browser secret is transport state, never identity or stronger proof.
8
+ class BrowserBinding
9
+ def initialize(digest:)
10
+ @digest = digest
11
+ end
12
+
13
+ def generate = SecureRandom.urlsafe_base64(32)
14
+
15
+ def digest(secret)
16
+ @digest.digest("browser:#{secret}") if valid?(secret)
17
+ end
18
+
19
+ def matches?(stored, secret)
20
+ valid?(secret) && stored.is_a?(String) && @digest.matches?(stored, "browser:#{secret}")
21
+ end
22
+
23
+ def valid?(secret)
24
+ secret.is_a?(String) && secret.bytesize == 43 && secret.ascii_only? && /\A[A-Za-z0-9_-]{43}\z/.match?(secret)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ module Challenge
6
+ # The adapter contract every challenge provider implements. See
7
+ # docs/authentication-gem-plan.md section 9 for the full design,
8
+ # including the decision that matters most here: #verify returns a
9
+ # result with a real `unavailable?` state, distinct from `rejected?`.
10
+ # A provider outage and a failed human challenge are different events
11
+ # and most integrations in the wild conflate them -- either locking out
12
+ # every user during an outage, or waving everyone through. The host
13
+ # chooses which way to fail via `Configuration#challenge_when_unavailable`.
14
+ #
15
+ # Concrete adapters (Turnstile, reCAPTCHA) use only Ruby's standard
16
+ # library and are loaded with Core. A host opts in with
17
+ # `bin/rails g add_auth:challenge turnstile`, which generates the
18
+ # initializer stanza and route; no provider call happens until the host
19
+ # supplies keys and includes the action in `challenge_on`.
20
+ class Base
21
+ Verification = Data.define(:status, :reason) do
22
+ def initialize(status:, reason: nil)
23
+ unless %i[success rejected unavailable].include?(status)
24
+ raise ArgumentError, "unknown challenge status"
25
+ end
26
+ super
27
+ end
28
+
29
+ def success? = status == :success
30
+ def rejected? = status == :rejected
31
+ def unavailable? = status == :unavailable
32
+ end
33
+
34
+ def site_key
35
+ raise NotImplementedError, "#{self.class} must implement #site_key"
36
+ end
37
+
38
+ def script_url
39
+ raise NotImplementedError, "#{self.class} must implement #script_url"
40
+ end
41
+
42
+ def stimulus_controller
43
+ raise NotImplementedError, "#{self.class} must implement #stimulus_controller"
44
+ end
45
+
46
+ # @param token [String, nil] the provider's response token from the form.
47
+ # @param remote_ip [String, nil]
48
+ # @param action [Symbol] one of Configuration#challenge_on's entries;
49
+ # used to bind the token to the form it was minted for.
50
+ # @return [Verification]
51
+ def verify(token:, remote_ip:, action:)
52
+ raise NotImplementedError, "#{self.class} must implement #verify"
53
+ end
54
+
55
+ def inspect = "#<#{self.class} [FILTERED]>"
56
+ attr_reader :allowed_hostnames
57
+
58
+ private
59
+
60
+ def require_value(value, name, max_bytes:)
61
+ unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, max_bytes)
62
+ raise ArgumentError, "challenge #{name} must be present"
63
+ end
64
+ value.dup.freeze
65
+ end
66
+
67
+ def normalize_hostnames(hostnames)
68
+ values = hostnames.is_a?(String) ? hostnames.split(",") : Array(hostnames)
69
+ values.map { |hostname| hostname.to_s.strip.downcase }.reject(&:empty?).uniq.freeze
70
+ end
71
+
72
+ def normalize_action(action)
73
+ value = action.to_s
74
+ raise ArgumentError, "challenge action must be present" unless value.match?(/\A[a-zA-Z0-9._:-]{1,64}\z/)
75
+ value
76
+ end
77
+
78
+ def hostname_allowed?(hostname)
79
+ hostname.is_a?(String) && !hostname.empty? &&
80
+ (@allowed_hostnames.empty? || @allowed_hostnames.include?(hostname.downcase))
81
+ end
82
+
83
+ def response_failure(payload)
84
+ return unavailable(:verification_service) unless [true, false].include?(payload["success"])
85
+ return if payload["success"] == true
86
+ errors = payload["error-codes"]
87
+ valid_errors = errors.nil? || (errors.is_a?(Array) && errors.all? { |value| value.is_a?(String) })
88
+ return unavailable(:verification_service) unless valid_errors
89
+ service_errors = %w[internal-error missing-input-secret invalid-input-secret bad-request]
90
+ return unavailable(:verification_service) if (Array(errors) & service_errors).any?
91
+ rejected(:challenge_rejected)
92
+ end
93
+
94
+ def success = Verification.new(status: :success)
95
+ def rejected(reason) = Verification.new(status: :rejected, reason:)
96
+ def unavailable(reason) = Verification.new(status: :unavailable, reason:)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "timeout"
7
+
8
+ module AddAuth
9
+ module Core
10
+ module Challenge
11
+ # Small, dependency-free HTTP boundary shared by provider adapters. It
12
+ # deliberately returns protocol states instead of raising provider errors
13
+ # into an authentication request. A provider outage and a malformed
14
+ # response are both unavailable; an invalid user token is rejected.
15
+ class Http
16
+ Response = Data.define(:status, :payload)
17
+
18
+ DEFAULT_OPEN_TIMEOUT = 3
19
+ DEFAULT_READ_TIMEOUT = 3
20
+ MAX_TOKEN_BYTES = 2048
21
+ MAX_RESPONSE_BYTES = 16_384
22
+ TOTAL_TIMEOUT = 10
23
+
24
+ def initialize(endpoint:, secret_key:, transport: nil, allowed_hosts: nil,
25
+ open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
26
+ @allowed_hosts = allowed_hosts&.map { |host| host.to_s.downcase }&.freeze
27
+ @endpoint = validate_endpoint(endpoint)
28
+ @secret_key = validate_secret(secret_key)
29
+ @transport = transport || method(:request)
30
+ @open_timeout = bounded_timeout(open_timeout, "open_timeout")
31
+ @read_timeout = bounded_timeout(read_timeout, "read_timeout")
32
+ end
33
+
34
+ def call(token:, remote_ip: nil)
35
+ return Response.new(status: :rejected, payload: nil) unless valid_token?(token)
36
+
37
+ parameters = {"secret" => @secret_key, "response" => token}
38
+ parameters["remoteip"] = remote_ip if remote_ip.is_a?(String) && !remote_ip.empty?
39
+ raw = @transport.call(uri: @endpoint, params: parameters)
40
+ status, body = extract(raw)
41
+ return Response.new(status: :unavailable, payload: nil) unless status.between?(200, 299)
42
+
43
+ return Response.new(status: :unavailable, payload: nil) if body.bytesize > MAX_RESPONSE_BYTES
44
+ payload = JSON.parse(body, max_nesting: 10)
45
+ return Response.new(status: :unavailable, payload: nil) unless payload.is_a?(Hash)
46
+
47
+ Response.new(status: :ok, payload: payload)
48
+ rescue JSON::ParserError, TypeError, ArgumentError
49
+ Response.new(status: :unavailable, payload: nil)
50
+ rescue
51
+ # Network, TLS and provider client failures must not disclose
52
+ # endpoint details or secret material to the authentication caller.
53
+ Response.new(status: :unavailable, payload: nil)
54
+ end
55
+
56
+ def inspect = "#<AddAuth::Core::Challenge::Http [FILTERED]>"
57
+
58
+ private
59
+
60
+ def validate_endpoint(endpoint)
61
+ uri = URI.parse(endpoint.to_s)
62
+ unless uri.is_a?(URI::HTTPS) && uri.host && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
63
+ raise ArgumentError, "challenge endpoint must be HTTPS"
64
+ end
65
+ if @allowed_hosts && !@allowed_hosts.include?(uri.host.downcase)
66
+ raise ArgumentError, "challenge endpoint host is not allowed"
67
+ end
68
+ uri
69
+ rescue URI::InvalidURIError
70
+ raise ArgumentError, "challenge endpoint must be a valid HTTPS URL"
71
+ end
72
+
73
+ def validate_secret(secret)
74
+ value = secret.to_s
75
+ raise ArgumentError, "challenge secret_key must be present" if value.empty? || value.bytesize > 512
76
+
77
+ value
78
+ end
79
+
80
+ def bounded_timeout(value, name)
81
+ number = Float(value)
82
+ raise ArgumentError, "#{name} must be between 0.1 and 30 seconds" unless number.between?(0.1, 30)
83
+
84
+ number
85
+ rescue ArgumentError, TypeError
86
+ raise ArgumentError, "#{name} must be between 0.1 and 30 seconds"
87
+ end
88
+
89
+ def valid_token?(token)
90
+ token.is_a?(String) && token.valid_encoding? && token.bytesize.between?(1, MAX_TOKEN_BYTES)
91
+ end
92
+
93
+ def extract(raw)
94
+ if raw.respond_to?(:code) && raw.respond_to?(:body)
95
+ [Integer(raw.code), raw.body.to_s]
96
+ elsif raw.is_a?(Array) && raw.size == 2
97
+ [Integer(raw.fetch(0)), raw.fetch(1).to_s]
98
+ else
99
+ raise TypeError, "invalid challenge transport response"
100
+ end
101
+ end
102
+
103
+ def request(uri:, params:)
104
+ client = Net::HTTP.new(uri.host, uri.port)
105
+ client.use_ssl = true
106
+ client.open_timeout = @open_timeout
107
+ client.read_timeout = @read_timeout
108
+ request = Net::HTTP::Post.new(uri.request_uri)
109
+ request.set_form_data(params)
110
+ client.write_timeout = @read_timeout
111
+ client.max_retries = 0
112
+ Timeout.timeout(TOTAL_TIMEOUT) do
113
+ result = nil
114
+ client.start do |http|
115
+ http.request(request) do |response|
116
+ body = +""
117
+ response.read_body do |chunk|
118
+ raise IOError, "challenge response too large" if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
119
+ body << chunk
120
+ end
121
+ result = [response.code.to_i, body]
122
+ end
123
+ end
124
+ result
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ module Challenge
6
+ # The default adapter: no challenge configured, every verification
7
+ # succeeds, and the view helper renders nothing. This is what makes
8
+ # `add_auth_challenge_tag` safe to leave in a generated view whether or
9
+ # not the host has configured Turnstile/reCAPTCHA (section 9).
10
+ class Null < Base
11
+ def site_key = nil
12
+ def script_url = nil
13
+ def stimulus_controller = nil
14
+
15
+ def verify(token:, remote_ip:, action:)
16
+ success
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/challenge/http"
4
+
5
+ module AddAuth
6
+ module Core
7
+ module Challenge
8
+ # Google reCAPTCHA Siteverify adapter for both v2 checkbox and v3 score
9
+ # keys. v2 deliberately ignores action/score fields because Google does
10
+ # not make the v3 contract available for that mode.
11
+ class Recaptcha < Base
12
+ ENDPOINT = "https://www.google.com/recaptcha/api/siteverify"
13
+ SCRIPT_URL = "https://www.google.com/recaptcha/api.js"
14
+
15
+ def initialize(site_key:, secret_key:, version: :v3, allowed_hostnames: [], minimum_score: 0.5,
16
+ expected_action: nil, transport: nil, endpoint: ENDPOINT,
17
+ open_timeout: Http::DEFAULT_OPEN_TIMEOUT, read_timeout: Http::DEFAULT_READ_TIMEOUT)
18
+ @site_key = require_value(site_key, "site_key", max_bytes: 512)
19
+ @version = normalize_version(version)
20
+ @allowed_hostnames = normalize_hostnames(allowed_hostnames)
21
+ @minimum_score = normalize_score(minimum_score)
22
+ @expected_action = expected_action.nil? ? nil : normalize_action(expected_action)
23
+ @http = Http.new(endpoint: endpoint, secret_key: secret_key, allowed_hosts: ["www.google.com"], transport: transport,
24
+ open_timeout: open_timeout, read_timeout: read_timeout)
25
+ end
26
+
27
+ attr_reader :site_key, :version, :minimum_score, :expected_action
28
+
29
+ def script_url = "#{SCRIPT_URL}?render=#{(@version == :v3) ? URI.encode_www_form_component(site_key) : "explicit"}"
30
+ def stimulus_controller = "add_auth--challenge-recaptcha"
31
+
32
+ def verify(token:, remote_ip:, action:)
33
+ response = @http.call(token: token, remote_ip: remote_ip)
34
+ return rejected(:invalid_token) if response.status == :rejected
35
+ return unavailable(:verification_service) unless response.status == :ok
36
+
37
+ payload = response.payload
38
+ failure = response_failure(payload)
39
+ return failure if failure
40
+ return rejected(:challenge_rejected) unless hostname_allowed?(payload["hostname"])
41
+ return success if @version == :v2
42
+
43
+ expected = @expected_action || normalize_action(action)
44
+ return rejected(:challenge_rejected) unless payload["action"] == expected
45
+ return rejected(:challenge_rejected) unless score_at_least?(payload["score"])
46
+
47
+ success
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_version(version)
53
+ value = version.to_sym
54
+ raise ArgumentError, "reCAPTCHA version must be :v2 or :v3" unless %i[v2 v3].include?(value)
55
+
56
+ value
57
+ rescue NoMethodError
58
+ raise ArgumentError, "reCAPTCHA version must be :v2 or :v3"
59
+ end
60
+
61
+ def normalize_score(score)
62
+ value = Float(score)
63
+ raise ArgumentError, "minimum_score must be between 0 and 1" unless value.between?(0, 1)
64
+
65
+ value
66
+ rescue ArgumentError, TypeError
67
+ raise ArgumentError, "minimum_score must be between 0 and 1"
68
+ end
69
+
70
+ def score_at_least?(score)
71
+ score.is_a?(Numeric) && score.finite? && score.between?(@minimum_score, 1)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ module Challenge
6
+ # Deterministic adapter for specs. Install the desired mode in test setup
7
+ # and restore the previous configuration after each example. This exercises
8
+ # Core outcomes without coupling host tests to provider HTTP stubs.
9
+ class Test < Base
10
+ def initialize(mode: :success)
11
+ @mode = mode
12
+ end
13
+
14
+ attr_accessor :mode
15
+
16
+ def site_key = "test-site-key"
17
+ def script_url = nil
18
+ def stimulus_controller = "add_auth--challenge-test"
19
+
20
+ def verify(token:, remote_ip:, action:)
21
+ case @mode
22
+ when :success then success
23
+ when :rejected then rejected(:challenge_rejected)
24
+ when :unavailable then unavailable(:verification_service)
25
+ else
26
+ raise ArgumentError, "unknown Test challenge mode: #{@mode.inspect}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/challenge/http"
4
+
5
+ module AddAuth
6
+ module Core
7
+ module Challenge
8
+ # Cloudflare Turnstile's server-side Siteverify adapter. The browser
9
+ # widget is optional and host-rendered; this class owns the security
10
+ # decision made from Cloudflare's response.
11
+ class Turnstile < Base
12
+ ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
13
+ SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js"
14
+
15
+ def initialize(site_key:, secret_key:, allowed_hostnames: [], transport: nil,
16
+ endpoint: ENDPOINT, open_timeout: Http::DEFAULT_OPEN_TIMEOUT,
17
+ read_timeout: Http::DEFAULT_READ_TIMEOUT)
18
+ @site_key = require_value(site_key, "site_key", max_bytes: 512)
19
+ @allowed_hostnames = normalize_hostnames(allowed_hostnames)
20
+ @http = Http.new(endpoint: endpoint, secret_key: secret_key, allowed_hosts: ["challenges.cloudflare.com"], transport: transport,
21
+ open_timeout: open_timeout, read_timeout: read_timeout)
22
+ end
23
+
24
+ attr_reader :site_key
25
+
26
+ def script_url = "#{SCRIPT_URL}?render=explicit"
27
+ def stimulus_controller = "add_auth--challenge-turnstile"
28
+
29
+ def verify(token:, remote_ip:, action:)
30
+ expected_action = normalize_action(action)
31
+ response = @http.call(token: token, remote_ip: remote_ip)
32
+ return rejected(:invalid_token) if response.status == :rejected
33
+ return unavailable(:verification_service) unless response.status == :ok
34
+
35
+ payload = response.payload
36
+ failure = response_failure(payload)
37
+ return failure if failure
38
+ return rejected(:challenge_rejected) unless payload["action"] == expected_action
39
+ return rejected(:challenge_rejected) unless hostname_allowed?(payload["hostname"])
40
+
41
+ success
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ # Shared leased-delivery state machine. The including strategy supplies
6
+ # rejection(user, record) and delivery_details(user, record); the store owns
7
+ # locking and persistence. Transport always runs after this lock returns.
8
+ module Delivery
9
+ def claim_delivery(digest:)
10
+ @store.with_token(digest: digest) do |user, record|
11
+ next if rejection(user, record) || record.delivery_payload.nil? || record.delivered_at ||
12
+ (record.delivery_lease_until && record.delivery_lease_until > @clock.now)
13
+ details = delivery_details(user, record)
14
+ next unless details
15
+ key = @random.hex(16)
16
+ @store.lease(record: record, key: key, until_time: @clock.now + 300)
17
+ details.merge(lease: key)
18
+ end
19
+ end
20
+
21
+ def delivery_failed(digest:, lease: nil)
22
+ @store.with_token(digest: digest) do |user, record|
23
+ next if lease && record && record.delivery_lease_key != lease
24
+ @store.revoke(record: record, at: @clock.now) unless rejection(user, record)
25
+ end
26
+ nil
27
+ end
28
+
29
+ def delivery_succeeded(digest:, lease:)
30
+ finish_delivery(digest: digest, lease: lease, outcome: :delivered)
31
+ end
32
+
33
+ def finish_delivery(digest:, lease:, outcome:)
34
+ raise ArgumentError, "unsupported delivery outcome" unless %i[delivered cancelled].include?(outcome)
35
+ return false unless lease.is_a?(String) && !lease.empty?
36
+ @store.with_token(digest: digest) do |_user, record|
37
+ next false unless record && record.delivery_lease_key == lease
38
+ if outcome == :delivered
39
+ @store.delivered(record: record, at: @clock.now)
40
+ else
41
+ @store.revoke(record: record, at: @clock.now)
42
+ @store.lease(record: record, key: nil, until_time: nil)
43
+ end
44
+ true
45
+ end
46
+ end
47
+
48
+ def delivery_retry(digest:, lease:)
49
+ @store.with_token(digest: digest) do |_user, record|
50
+ @store.lease(record: record, key: nil, until_time: nil) if record && record.delivery_lease_key == lease
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ module Digest
6
+ # The adapter contract every token/session digest implementation
7
+ # satisfies. See docs/authentication-gem-plan.md section 2's
8
+ # "Pluggable cryptography" subsection: unlike the Challenge adapter,
9
+ # there is no credible reason to swap this for a *security* property --
10
+ # the reason to override it is organizational (a host mandated to run
11
+ # OpenSSL in FIPS mode, or to route HMAC operations through an HSM).
12
+ # Any object responding to #digest and #matches? satisfies this
13
+ # contract; Base exists to document it and to fail loudly if a
14
+ # concrete adapter forgets a method, the same role Challenge::Base
15
+ # plays for challenge providers.
16
+ class Base
17
+ # @param token [String] the raw, high-entropy secret (a session
18
+ # token, a sign-in token) as presented by the client.
19
+ # @return [String] an opaque digest safe to store at rest.
20
+ def digest(token)
21
+ raise NotImplementedError, "#{self.class} must implement #digest"
22
+ end
23
+
24
+ # @param digest [String, nil] the stored digest to compare against.
25
+ # @param token [String, nil] the raw token presented by the client.
26
+ # @return [Boolean] true only for a byte-exact match of the digest
27
+ # the same token would produce, compared in constant time.
28
+ def matches?(digest, token)
29
+ raise NotImplementedError, "#{self.class} must implement #matches?"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end