add_auth 0.2.2 → 0.4.0

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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +64 -17
  4. data/ROADMAP.md +98 -43
  5. data/SECURITY.md +8 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +146 -0
  7. data/app/controllers/add_auth/assets_controller.rb +2 -1
  8. data/app/controllers/add_auth/external_identities_controller.rb +56 -0
  9. data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
  10. data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
  11. data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
  12. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  13. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  14. data/app/jobs/add_auth/account_request_job.rb +17 -0
  15. data/app/mailers/add_auth/account_mailer.rb +12 -0
  16. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  17. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  18. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  19. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  20. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  21. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  22. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  23. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  24. data/app/views/add_auth/accounts/_request.html.erb +17 -0
  25. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  26. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  27. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  28. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  29. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  30. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  31. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  32. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  33. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  34. data/app/views/add_auth/sessions/index.html.erb +7 -0
  35. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  36. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  37. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  38. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  39. data/exe/add_auth-preflight +26 -0
  40. data/lib/add_auth/configuration.rb +78 -4
  41. data/lib/add_auth/core/access_policy.rb +11 -1
  42. data/lib/add_auth/core/account_lifecycle.rb +295 -0
  43. data/lib/add_auth/core/account_policy.rb +68 -0
  44. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  45. data/lib/add_auth/core/external_identities.rb +278 -0
  46. data/lib/add_auth/core/intake.rb +6 -1
  47. data/lib/add_auth/core/maintenance.rb +5 -3
  48. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  49. data/lib/add_auth/core/migration/readiness.rb +41 -0
  50. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  51. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  52. data/lib/add_auth/core/mobile_profile.rb +46 -0
  53. data/lib/add_auth/core/mobile_response.rb +12 -0
  54. data/lib/add_auth/core/native_authentication.rb +77 -0
  55. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  56. data/lib/add_auth/core/passwords/credential.rb +49 -0
  57. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  58. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  59. data/lib/add_auth/core/remaining_factors.rb +24 -0
  60. data/lib/add_auth/core/sessions.rb +102 -17
  61. data/lib/add_auth/core/step_up.rb +13 -1
  62. data/lib/add_auth/migration/static_inventory.rb +116 -0
  63. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  64. data/lib/add_auth/rails/authentication.rb +1 -1
  65. data/lib/add_auth/rails/authentication_pages.rb +6 -1
  66. data/lib/add_auth/rails/doctor.rb +80 -6
  67. data/lib/add_auth/rails/ejection.rb +1 -1
  68. data/lib/add_auth/rails/engine.rb +6 -1
  69. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  70. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  71. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  72. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  73. data/lib/add_auth/rails/password_adoption.rb +37 -0
  74. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  77. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  78. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  80. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  81. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  82. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  83. data/lib/add_auth/rails/runtime.rb +179 -8
  84. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  85. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  86. data/lib/add_auth/rails/stores/account_tokens.rb +104 -0
  87. data/lib/add_auth/rails/stores/authority.rb +28 -0
  88. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  89. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  90. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  91. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  92. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  93. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  94. data/lib/add_auth/result.rb +1 -0
  95. data/lib/add_auth/version.rb +1 -1
  96. data/lib/add_auth.rb +4 -0
  97. data/lib/generators/add_auth/accounts/accounts_generator.rb +56 -0
  98. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  99. data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  101. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  102. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  103. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  104. data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
  105. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  106. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  107. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  110. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  111. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  112. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  113. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  114. data/lib/generators/add_auth/feature_configuration.rb +21 -0
  115. data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
  116. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  117. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  119. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  120. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  121. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  122. data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
  123. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  124. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
  125. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  126. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
  127. data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
  128. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  129. data/lib/tasks/add_auth.rake +18 -1
  130. metadata +101 -2
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "base64"
5
+
6
+ module AddAuth
7
+ module Core
8
+ # The native verifier protects this application handoff. It does not replace
9
+ # upstream provider state/nonce/PKCE, which remain the library's responsibility.
10
+ class MobileHandoffs
11
+ PURPOSE = ExternalIdentities::MOBILE
12
+ CODE = /\Aah1:[A-Za-z0-9_-]{43}\z/
13
+ STATE = /\A[A-Za-z0-9_-]{43,128}\z/
14
+ VERIFIER = /\A[A-Za-z0-9._~-]{43,128}\z/
15
+ CHALLENGE = /\A[A-Za-z0-9_-]{43}\z/
16
+ Handoff = Struct.new(:code, :state, :callback) do
17
+ def inspect = "#<AddAuth::Core::MobileHandoffs::Handoff [FILTERED]>"
18
+ end
19
+
20
+ def initialize(store:, profile:, sessions:, access_policy:, digest:, clock: Time, lifetime: 60)
21
+ raise ArgumentError, "handoff lifetime must be between 1 and 120 seconds" unless lifetime.is_a?(Integer) && lifetime.between?(1, 120)
22
+ @store, @profile, @sessions, @access, @digest, @clock, @lifetime = store, profile, sessions, access_policy, digest, clock, lifetime
23
+ end
24
+
25
+ def begin_transaction(pending:, client_id:, callback:, state:, code_challenge:, code_challenge_method:)
26
+ return failure unless pending.is_a?(ExternalIdentities::Pending) && pending.purpose == PURPOSE &&
27
+ @profile&.client?(client_id) && @profile.callback(client_id) == callback && callback &&
28
+ text?(state, STATE) && text?(code_challenge, CHALLENGE) && code_challenge_method == "S256" &&
29
+ pending.issued_at <= @clock.now && pending.expires_at > @clock.now
30
+ row = @store.create_pending(external_digest: @digest.digest(pending.id), client_id: client_id, callback: callback,
31
+ state: state, challenge_digest: @digest.digest(code_challenge), issued_at: pending.issued_at, expires_at: pending.expires_at)
32
+ row ? Result.success(user: nil, strategy: :external_identity) : failure
33
+ end
34
+
35
+ # Called only from ExternalIdentities' verified binding finalizer, inside
36
+ # the owning account transaction. No Session or bearer exists at this point.
37
+ def issue_in_transaction(user:, proof:, pending:)
38
+ return failure unless proof.is_a?(ExternalIdentities::SessionProof) && proof.user_id == user.id && pending.purpose == PURPOSE
39
+ row = @store.by_external_digest(digest: @digest.digest(pending.id))
40
+ return failure unless row && current_profile?(row) && !row.digest && !row.consumed_at && row.expires_at > @clock.now
41
+ code = "ah1:#{SecureRandom.urlsafe_base64(32)}"
42
+ issued = @store.issue(row: row, user_id: user.id, digest: @digest.digest(code),
43
+ credential_id: proof.credential_id, credential_version: proof.credential_version,
44
+ policy_version: @access.version(user), authenticated_at: proof.verified_at,
45
+ expires_at: [row.expires_at, @clock.now + @lifetime].min)
46
+ return failure unless issued
47
+ Result.success(user: user, strategy: :external_identity, credential: Handoff.new(code: code, state: row.state, callback: row.callback))
48
+ end
49
+
50
+ def exchange(code:, state:, code_verifier:, client_id:, ip: nil, user_agent: nil)
51
+ return failure unless @profile&.client?(client_id) && text?(code, CODE) && text?(state, STATE) && text?(code_verifier, VERIFIER)
52
+ challenge = Base64.urlsafe_encode64(::Digest::SHA256.digest(code_verifier), padding: false)
53
+ @store.with_code(digest: @digest.digest(code)) do |user, row|
54
+ now = @clock.now
55
+ next failure unless user && row && current_profile?(row) && row.client_id == client_id &&
56
+ @digest.matches?(row.challenge_digest, challenge) && @digest.matches?(@digest.digest(row.state), state)
57
+ next failure(:consumed_token) if row.consumed_at
58
+ next failure(:expired_token) unless row.issued_at <= now && row.expires_at > now
59
+ next failure unless row.policy_version == @access.version(user)
60
+ proof = ExternalIdentities::SessionProof.send(:new, user_id: user.id, credential_id: row.credential_id,
61
+ credential_version: row.credential_version, verified_at: row.authenticated_at)
62
+ grant = @sessions.create_in_transaction(user: user, method: :external_identity, proof: proof,
63
+ transport: :mobile, client_id: row.client_id, ip_address: hint(ip, 128), user_agent: hint(user_agent, 512))
64
+ next failure unless grant
65
+ # Session creation and consume commit together. An unexpected lost CAS
66
+ # rolls back issuance; it must never return a credential for this request.
67
+ raise AddAuth::Error, "mobile handoff could not finalize" unless @store.consume(row: row, at: now)
68
+ Result.success(user: user, strategy: :external_identity, session: grant.session, grant: grant)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def current_profile?(row) = @profile&.client?(row.client_id) && @profile.callback(row.client_id) == row.callback
75
+ def text?(value, pattern) = value.is_a?(String) && value.ascii_only? && pattern.match?(value)
76
+ def failure(reason = :invalid_credentials) = Result.failure(reason: reason)
77
+
78
+ def hint(value, limit)
79
+ value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= limit && !value.match?(/[\x00-\x1f\x7f]/)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module AddAuth
6
+ module Core
7
+ # Host-selected finite API sessions. Client identifiers select configuration,
8
+ # never prove application identity or authorize an account.
9
+ class MobileProfile
10
+ PATTERN = /\Aam1:[A-Za-z0-9_-]{43}\z/
11
+ attr_reader :lifetime, :idle_timeout, :clients
12
+
13
+ def initialize(lifetime:, idle_timeout:, clients:, callbacks: {})
14
+ unless [lifetime, idle_timeout].all? { |value| value.is_a?(Integer) && value.between?(60, 90 * 86_400) } && idle_timeout <= lifetime
15
+ raise ArgumentError, "mobile timeouts must be explicit, finite and at most 90 days"
16
+ end
17
+ unless clients.is_a?(Array) && clients.size.between?(1, 32) && clients.uniq == clients &&
18
+ clients.all? { |value| value.is_a?(String) && value.match?(/\A[a-z][a-z0-9_-]{0,63}\z/) }
19
+ raise ArgumentError, "register bounded mobile client identifiers"
20
+ end
21
+ @lifetime, @idle_timeout = lifetime, idle_timeout
22
+ @clients = clients.map { |client| client.dup.freeze }.freeze
23
+ unless callbacks.is_a?(Hash) && callbacks.all? { |client, callback| client?(client) && valid_callback?(callback) }
24
+ raise ArgumentError, "register exact mobile callbacks without query, fragment or credentials"
25
+ end
26
+ @callbacks = callbacks.to_h { |client, callback| [client.dup.freeze, callback.dup.freeze] }.freeze
27
+ freeze
28
+ end
29
+
30
+ def client?(id) = id.is_a?(String) && clients.include?(id)
31
+ def callback(client_id) = @callbacks[client_id]
32
+ def bearer = "am1:#{SecureRandom.urlsafe_base64(32)}"
33
+
34
+ private
35
+
36
+ def valid_callback?(value)
37
+ return false unless value.is_a?(String) && value.ascii_only? && value.bytesize.between?(10, 2048) && !value.match?(/[\x00-\x20\x7f\\%]/)
38
+ uri = URI.parse(value)
39
+ uri.scheme && !%w[http javascript data file].include?(uri.scheme.downcase) &&
40
+ uri.host && !uri.host.empty? && uri.path && !uri.path.empty? && !uri.userinfo && !uri.query && !uri.fragment
41
+ rescue URI::InvalidURIError
42
+ false
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ module MobileResponse
6
+ def self.status(reason)
7
+ {rate_limited: 429, challenge_unavailable: 503, challenge_rejected: 422, unconfirmed: 403, locked: 423, disabled: 403,
8
+ elevation_required: 403}.fetch(reason, 401)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module AddAuth
6
+ module Core
7
+ class NativeAuthentication
8
+ Challenge = Struct.new(:id, :nonce, :expires_at) do
9
+ def inspect = "#<AddAuth::Core::NativeAuthentication::Challenge [FILTERED]>"
10
+ end
11
+
12
+ def initialize(external_identities:, profile:, providers:, intake:, verify:, accounts: nil)
13
+ @external, @profile, @providers, @intake, @verify = external_identities, profile, providers, intake, verify
14
+ unless providers.is_a?(Hash) && providers.all? { |client_id, provider| profile&.client?(client_id) && provider.is_a?(ExternalIdentities::Configuration) }
15
+ raise ArgumentError, "register a native provider configuration for each supported client"
16
+ end
17
+ @providers = providers.dup.freeze
18
+ @accounts = accounts
19
+ end
20
+
21
+ def start(client_id:, ip:, challenge_token: nil, intent: "sign_in")
22
+ admitted = @intake.anonymous(ip: ip, action: (intent == "enroll") ? :register : :provider, challenge_token: challenge_token)
23
+ return failure(admitted) if admitted.is_a?(Symbol)
24
+ provider = @providers[client_id]
25
+ return failure unless @profile&.client?(client_id) && provider
26
+ purpose = {"sign_in" => ExternalIdentities::NATIVE, "enroll" => ExternalIdentities::NATIVE_ENROLL}[intent]
27
+ return failure unless purpose
28
+ return failure if intent == "enroll" && !@accounts
29
+ nonce = SecureRandom.urlsafe_base64(32)
30
+ result = @external.begin_transaction(configuration_id: provider.id, browser_secret: nonce, purpose: purpose,
31
+ binding_context: context(client_id))
32
+ return result unless result.success?
33
+ Result.success(user: nil, strategy: :external_identity,
34
+ credential: Challenge.new(id: result.credential.id, nonce: nonce, expires_at: result.credential.expires_at))
35
+ end
36
+
37
+ def complete(client_id:, challenge_id:, nonce:, identity_token:, ip:, user_agent: nil)
38
+ checked = verified(client_id: client_id, challenge_id: challenge_id, nonce: nonce, identity_token: identity_token,
39
+ ip: ip, purpose: ExternalIdentities::NATIVE)
40
+ return checked unless checked.success?
41
+ @external.native_sign_in(evidence: checked.credential, client_id: client_id, ip_address: hint(ip, 128), user_agent: hint(user_agent, 512))
42
+ end
43
+
44
+ def enroll(client_id:, challenge_id:, nonce:, identity_token:, ip:, identifier:, profile: {})
45
+ return failure(:disabled) unless @accounts
46
+ checked = verified(client_id: client_id, challenge_id: challenge_id, nonce: nonce, identity_token: identity_token,
47
+ ip: ip, purpose: ExternalIdentities::NATIVE_ENROLL)
48
+ return checked unless checked.success?
49
+ @accounts.call.register_external(identifier: identifier, evidence: checked.credential, profile: profile)
50
+ end
51
+
52
+ private
53
+
54
+ def verified(client_id:, challenge_id:, nonce:, identity_token:, ip:, purpose:)
55
+ admitted = @intake.anonymous(ip: ip, action: :native_exchange)
56
+ return failure(admitted) if admitted.is_a?(Symbol)
57
+ provider = @providers[client_id]
58
+ return failure unless @profile&.client?(client_id) && provider && identity_token.is_a?(String) &&
59
+ identity_token.ascii_only? && identity_token.bytesize.between?(1, 16_384)
60
+ pending = @external.pending(transaction: challenge_id, browser_secret: nonce, binding_context: context(client_id))
61
+ return failure unless pending && pending.purpose == purpose && pending.configuration.equal?(provider)
62
+ result = @verify.call(identity_token: identity_token, pending: pending, nonce: nonce)
63
+ evidence = provider.verify(server_result: result, transaction: pending)
64
+ return failure unless evidence
65
+ Result.success(user: nil, strategy: :external_identity, credential: evidence)
66
+ end
67
+
68
+ def context(client_id) = "native:#{client_id}"
69
+
70
+ def failure(reason = :invalid_credentials) = Result.failure(reason: reason)
71
+
72
+ def hint(value, limit)
73
+ value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= limit && !value.match?(/[\x00-\x1f\x7f]/)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bcrypt"
4
+
5
+ module AddAuth
6
+ module Core
7
+ module Passwords
8
+ # Explicit metadata support for the stock Rails bcrypt verifier, not an
9
+ # algorithm detector or authentication proof. Override verifiers supply
10
+ # their own available?(digest:) contract instead of using this adapter.
11
+ class BcryptSupport
12
+ PATTERN = /\A\$2[aby]\$(\d{2})\$[.\/A-Za-z0-9]{53}\z/
13
+
14
+ def initialize(maximum_cost: BCrypt::Engine::MAX_COST)
15
+ @maximum_cost = maximum_cost
16
+ end
17
+
18
+ def available?(digest:)
19
+ return false unless digest.is_a?(String) && digest.valid_encoding? && digest.bytesize == 60
20
+ match = PATTERN.match(digest)
21
+ !!(match && match[1].to_i.between?(BCrypt::Engine::MIN_COST, @maximum_cost))
22
+ rescue ArgumentError, EncodingError
23
+ false
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bcrypt"
4
+
5
+ module AddAuth
6
+ module Core
7
+ module Passwords
8
+ class Credential
9
+ def self.scheme_after_assignment(scheme:, password:, digest_changed:)
10
+ (digest_changed && password.is_a?(String) && !password.empty?) ? "rails" : scheme
11
+ end
12
+
13
+ def initialize(legacy_verifier:)
14
+ @legacy = legacy_verifier
15
+ end
16
+
17
+ def authenticate(user:, password:, current:)
18
+ return Result.failure(reason: :invalid_credentials) unless password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 1024)
19
+ verified = case scheme(user)
20
+ when nil, "rails" then current.call(password)
21
+ when "devise_bcrypt" then @legacy&.verify(digest: user.password_digest, password: password) == true
22
+ else false
23
+ end
24
+ verified ? Result.success(user: user, strategy: :password) : Result.failure(reason: :invalid_credentials)
25
+ rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSecret, ArgumentError, EncodingError
26
+ Result.failure(reason: :invalid_credentials)
27
+ end
28
+
29
+ # Availability is not authentication. It asks the explicitly selected
30
+ # verifier whether it supports this persisted credential with its current
31
+ # configuration. Older/custom adapters without that contract fail closed.
32
+ def available?(user:, current:)
33
+ return false unless user.respond_to?(:password_digest)
34
+ digest = user.password_digest
35
+ return false unless digest.is_a?(String) && digest.valid_encoding? && !digest.empty?
36
+ verifier = case scheme(user)
37
+ when nil, "rails" then current
38
+ when "devise_bcrypt" then @legacy
39
+ end
40
+ verifier.respond_to?(:available?) && verifier.available?(digest: digest) == true
41
+ end
42
+
43
+ private
44
+
45
+ def scheme(user) = user.respond_to?(:add_auth_password_scheme) ? user.add_auth_password_scheme : nil
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bcrypt"
4
+ require "add_auth/core/passwords/bcrypt_support"
5
+
6
+ module AddAuth
7
+ module Core
8
+ module Passwords
9
+ # Verification only. New password assignment remains the Rails host's job.
10
+ # The source profile chooses this adapter explicitly; no digest guessing.
11
+ class LegacyBcrypt
12
+ PATTERN = BcryptSupport::PATTERN
13
+
14
+ def initialize(pepper: -> {}, maximum_cost: 14)
15
+ raise ArgumentError, "pepper must be callable" unless pepper.respond_to?(:call)
16
+ raise ArgumentError, "maximum_cost must be between 4 and 20" unless maximum_cost.is_a?(Integer) && maximum_cost.between?(4, 20)
17
+ @pepper, @maximum_cost = pepper, maximum_cost
18
+ @support = BcryptSupport.new(maximum_cost: maximum_cost)
19
+ end
20
+
21
+ def available?(digest:)
22
+ @support.available?(digest: digest) && valid_pepper?(@pepper.call)
23
+ end
24
+
25
+ def verify(digest:, password:)
26
+ return false unless password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 1024)
27
+ return false unless @support.available?(digest: digest)
28
+ pepper = @pepper.call
29
+ return false unless valid_pepper?(pepper)
30
+ BCrypt::Password.new(digest).is_password?((pepper.nil? || pepper.empty?) ? password : "#{password}#{pepper}")
31
+ rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSecret, ArgumentError, EncodingError
32
+ false
33
+ end
34
+
35
+ def inspect = "#<AddAuth::Core::Passwords::LegacyBcrypt [FILTERED]>"
36
+
37
+ private
38
+
39
+ def valid_pepper?(pepper) = pepper.nil? || (pepper.is_a?(String) && pepper.valid_encoding? && pepper.bytesize <= 1024)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ module Passwords
6
+ # Called only inside the owning account transaction. The adapter supplies
7
+ # Rails hashing and persistence; these decisions are shared by entry paths.
8
+ class Lifecycle
9
+ def initialize(store:, policy:, enabled:, maximum_attempts: 20, unlock_in: 3600, clock: Time, issue_unlock: ->(_user) {})
10
+ unless maximum_attempts.is_a?(Integer) && maximum_attempts.between?(1, 100) &&
11
+ unlock_in.is_a?(Numeric) && unlock_in.finite? && unlock_in.between?(60, 86_400)
12
+ raise ArgumentError, "configure bounded password attempts and unlock duration"
13
+ end
14
+ @store, @policy, @enabled, @maximum, @unlock_in, @clock, @issue_unlock = store, policy, enabled, maximum_attempts, unlock_in, clock, issue_unlock
15
+ end
16
+
17
+ def prepare(user:)
18
+ return unless @enabled && user && !user.add_auth_manual_lock && user.locked_at &&
19
+ user.add_auth_locked_until && user.add_auth_locked_until <= @clock.now
20
+ @store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
21
+ end
22
+
23
+ def verified(user:, password:, valid:, rehash: true)
24
+ return false unless user && @policy.call(user) == true
25
+ if valid
26
+ @store.update_account(user: user, failed_attempts: 0) if @enabled && user.failed_attempts != 0
27
+ if rehash && user.respond_to?(:add_auth_password_scheme) && user.add_auth_password_scheme == "devise_bcrypt" && assignable?(password)
28
+ @store.rehash_password(user: user, password: password)
29
+ @store.revoke_authority(user: user, at: @clock.now)
30
+ end
31
+ true
32
+ else
33
+ record_failure(user) if @enabled
34
+ false
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def assignable?(password)
41
+ password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 72) && !password.include?("\0")
42
+ end
43
+
44
+ def record_failure(user)
45
+ attempts = [user.failed_attempts.to_i + 1, @maximum].min
46
+ changes = {failed_attempts: attempts}
47
+ if attempts >= @maximum
48
+ changes[:locked_at] = @clock.now
49
+ changes[:add_auth_locked_until] = @clock.now + @unlock_in
50
+ end
51
+ @store.update_account(user: user, **changes)
52
+ if attempts >= @maximum
53
+ @store.revoke_authority(user: user, at: @clock.now)
54
+ @issue_unlock.call(user)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ # Evaluate against a reloaded account under the credential mutation lock.
6
+ # Counts must include only this account's live credentials. Feature flags
7
+ # and method labels alone do not establish a usable remaining factor.
8
+ class RemainingFactors
9
+ def initialize(access_policy:, passkey_count:, password_available:)
10
+ @access, @passkey_count, @password_available = access_policy, passkey_count, password_available
11
+ end
12
+
13
+ def call(user)
14
+ return true if @access.sign_in_allowed?(user, :passkey) && positive_count?(@passkey_count.call(user))
15
+ return true if @access.recoverable?(user)
16
+ @access.sign_in_allowed?(user, :password) && @password_available.call(user) == true
17
+ end
18
+
19
+ private
20
+
21
+ def positive_count?(value) = value.is_a?(Integer) && value.positive?
22
+ end
23
+ end
24
+ end