add_auth 0.2.2 → 0.3.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +38 -14
  4. data/ROADMAP.md +87 -38
  5. data/SECURITY.md +6 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +138 -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/helpers/add_auth/sign_ins_helper.rb +2 -1
  12. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  13. data/app/jobs/add_auth/account_request_job.rb +17 -0
  14. data/app/mailers/add_auth/account_mailer.rb +12 -0
  15. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  16. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  17. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  18. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  19. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  20. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  21. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  22. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  23. data/app/views/add_auth/accounts/_request.html.erb +14 -0
  24. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  25. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  26. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  27. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  28. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  29. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  30. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  31. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  32. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  33. data/app/views/add_auth/sessions/index.html.erb +7 -0
  34. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  35. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  36. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  37. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  38. data/exe/add_auth-preflight +26 -0
  39. data/lib/add_auth/configuration.rb +78 -4
  40. data/lib/add_auth/core/access_policy.rb +11 -1
  41. data/lib/add_auth/core/account_lifecycle.rb +251 -0
  42. data/lib/add_auth/core/account_policy.rb +50 -0
  43. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  44. data/lib/add_auth/core/external_identities.rb +278 -0
  45. data/lib/add_auth/core/intake.rb +6 -1
  46. data/lib/add_auth/core/maintenance.rb +5 -3
  47. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  48. data/lib/add_auth/core/migration/readiness.rb +41 -0
  49. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  50. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  51. data/lib/add_auth/core/mobile_profile.rb +46 -0
  52. data/lib/add_auth/core/mobile_response.rb +12 -0
  53. data/lib/add_auth/core/native_authentication.rb +77 -0
  54. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  55. data/lib/add_auth/core/passwords/credential.rb +49 -0
  56. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  57. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  58. data/lib/add_auth/core/remaining_factors.rb +24 -0
  59. data/lib/add_auth/core/sessions.rb +102 -17
  60. data/lib/add_auth/core/step_up.rb +13 -1
  61. data/lib/add_auth/migration/static_inventory.rb +116 -0
  62. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  63. data/lib/add_auth/rails/authentication.rb +1 -1
  64. data/lib/add_auth/rails/authentication_pages.rb +5 -0
  65. data/lib/add_auth/rails/doctor.rb +70 -3
  66. data/lib/add_auth/rails/ejection.rb +1 -1
  67. data/lib/add_auth/rails/engine.rb +6 -1
  68. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  69. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  70. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  71. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  72. data/lib/add_auth/rails/password_adoption.rb +37 -0
  73. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  74. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  77. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  78. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  80. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  81. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  82. data/lib/add_auth/rails/runtime.rb +170 -8
  83. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  84. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  85. data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
  86. data/lib/add_auth/rails/stores/authority.rb +28 -0
  87. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  88. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  89. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  90. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  91. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  92. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  93. data/lib/add_auth/result.rb +1 -0
  94. data/lib/add_auth/version.rb +1 -1
  95. data/lib/add_auth.rb +4 -0
  96. data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
  97. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  98. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  99. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  100. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  101. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  102. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  103. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  104. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  105. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  106. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  107. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  109. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  110. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  111. data/lib/generators/add_auth/install/templates/initializer.rb +4 -0
  112. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  113. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  114. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  115. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  116. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  117. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  118. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  119. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  120. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  121. data/lib/tasks/add_auth.rake +18 -1
  122. metadata +85 -2
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require "add_auth/core/mobile_profile"
4
5
 
5
6
  module AddAuth
6
7
  module Core
@@ -10,14 +11,15 @@ module AddAuth
10
11
  end
11
12
  class Entry
12
13
  attr_reader :id, :current, :authenticated_with, :authenticated_at, :created_at,
13
- :last_seen_at, :expires_at, :ip_address, :user_agent
14
+ :last_seen_at, :expires_at, :ip_address, :user_agent, :transport, :client_id
14
15
 
15
16
  def initialize(id:, current:, authenticated_with:, authenticated_at:, created_at:,
16
- last_seen_at:, expires_at:, ip_address:, user_agent:)
17
+ last_seen_at:, expires_at:, ip_address:, user_agent:, transport: "browser", client_id: nil)
17
18
  @id, @current, @authenticated_with, @authenticated_at, @created_at = id, current,
18
19
  authenticated_with, authenticated_at, created_at
19
20
  @last_seen_at, @expires_at, @ip_address, @user_agent = last_seen_at, expires_at,
20
21
  ip_address, user_agent
22
+ @transport, @client_id = transport, client_id
21
23
  end
22
24
 
23
25
  def inspect = "#<AddAuth::Core::Sessions::Entry id=#{id} current=#{current.inspect}>"
@@ -27,7 +29,7 @@ module AddAuth
27
29
  Page = Struct.new(:entries, :next_cursor)
28
30
 
29
31
  def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
30
- legacy_bridge_until: nil, clock: Time, access_policy: nil)
32
+ legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil)
31
33
  unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
32
34
  raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
33
35
  end
@@ -36,7 +38,19 @@ module AddAuth
36
38
  end
37
39
  @store, @digest, @eligible, @clock = store, digest, eligible, clock
38
40
  @access_policy = access_policy
41
+ @password_lifecycle = password_lifecycle
42
+ @on_sign_in = on_sign_in
43
+ @verified_denial = verified_denial
44
+ raise ArgumentError, "invalid mobile profile" unless mobile_profile.nil? || mobile_profile.is_a?(MobileProfile)
45
+ @mobile = mobile_profile
39
46
  @lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
47
+ if remembered_profile
48
+ values = remembered_profile.values_at(:lifetime, :idle_timeout)
49
+ unless values.all? { |n| n.is_a?(Numeric) && n.finite? && n.between?(60, 90 * 86_400) } && values.last <= values.first
50
+ raise ArgumentError, "remembered session timeouts must be bounded and idle_timeout <= lifetime"
51
+ end
52
+ @remembered = remembered_profile.slice(:lifetime, :idle_timeout).freeze
53
+ end
40
54
  end
41
55
 
42
56
  def start(user:, method:, replacing: nil, **hints)
@@ -50,36 +64,72 @@ module AddAuth
50
64
  end
51
65
  end
52
66
 
53
- def authenticate(identifier:, password:, replacing: nil, **hints)
67
+ def authenticate(**arguments, &verify)
68
+ result = authenticate_result(**arguments, &verify)
69
+ result.grant if result.success?
70
+ end
71
+
72
+ def authenticate_result(identifier:, password:, replacing: nil, disclose_policy: false, **hints)
54
73
  @store.with_identifier(identifier: identifier, replacing: replacing) do |user|
74
+ @password_lifecycle&.prepare(user: user)
55
75
  verified = yield
56
- create_in_transaction(user: user, method: :password, replacing: replacing, **hints) if user && verified && verified.id == user.id
76
+ valid = user && verified && verified.id == user.id && verified.password_digest == user.password_digest
77
+ denial = @verified_denial&.call(user) if valid && disclose_policy
78
+ valid = @password_lifecycle.verified(user: user, password: password, valid: !!valid) if @password_lifecycle
79
+ grant = create_in_transaction(user: user, method: :password, replacing: replacing, **hints) if valid
80
+ if grant
81
+ Result.success(user: user, strategy: :password, session: grant.session, grant: grant)
82
+ else
83
+ Result.failure(reason: %i[unconfirmed locked disabled].include?(denial) ? denial : :invalid_credentials)
84
+ end
57
85
  end
58
86
  end
59
87
 
60
88
  # Runs inside the store's account transaction. When replacing a browser,
61
89
  # that session's account must also be locked on the same connection.
62
- def create_in_transaction(user:, method:, persist: nil, replacing: nil, proof: nil, **hints)
90
+ def create_in_transaction(user:, method:, persist: nil, replacing: nil, proof: nil, remember: false, transport: :browser, client_id: nil, **hints)
63
91
  return unless user && @eligible.call(user) == true
64
- raise ArgumentError, "unsupported proof" unless %w[password email_link passkey].include?(method.to_s)
92
+ supported_transport = transport == :browser || (transport == :mobile && @mobile&.client?(client_id))
93
+ return unless supported_transport
94
+ return if transport == :mobile && replacing
95
+ raise ArgumentError, "unsupported proof" unless %w[password email_link passkey external_identity].include?(method.to_s)
65
96
  return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
66
97
  if method.to_s == "passkey"
67
98
  return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
68
99
  proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
69
100
  end
101
+ if method.to_s == "external_identity"
102
+ return unless @access_policy && proof.is_a?(ExternalIdentities::SessionProof) && proof.user_id == user.id &&
103
+ proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300 &&
104
+ @access_policy.external_credential_current?(user: user, id: proof.credential_id, version: proof.credential_version)
105
+ hints = hints.merge(authentication_external_id: proof.credential_id,
106
+ authentication_external_version: proof.credential_version)
107
+ end
70
108
  if user.respond_to?(:add_auth_policy_version)
71
109
  hints = hints.merge(authentication_policy_version: @access_policy ? @access_policy.version(user) : user.add_auth_policy_version,
72
110
  authentication_credential_id: proof&.credential_id, authentication_uv: proof&.user_verification || false)
73
111
  end
74
- raw = bearer
112
+ raw = (transport == :mobile) ? @mobile.bearer : bearer
75
113
  now = @clock.now
76
114
  if replacing
77
115
  prior = @store.find_for_user_in_transaction(user_id: replacing.user_id, session_id: replacing.id)
78
116
  @store.update(prior, revoked_at: now) if prior && !prior.revoked_at && prior.token_digest == replacing.token_digest
79
117
  end
80
118
  writer = persist || ->(**attributes) { @store.create(user: user, **attributes) }
119
+ duration = @lifetime
120
+ if @remembered
121
+ remembered = remember == true
122
+ duration = @remembered.fetch(:lifetime) if remembered
123
+ hints = hints.merge(remembered: remembered, idle_timeout: remembered ? @remembered.fetch(:idle_timeout) : @idle)
124
+ end
125
+ if transport == :mobile
126
+ duration = @mobile.lifetime
127
+ hints = hints.merge(transport: "mobile", client_id: client_id, mobile_idle_timeout: @mobile.idle_timeout)
128
+ hints[:remembered] = false if @remembered
129
+ end
81
130
  row = writer.call(token_digest: @digest.digest(raw), authenticated_with: method.to_s,
82
- authenticated_at: now, expires_at: now + @lifetime, last_seen_at: now, **hints)
131
+ authenticated_at: now, expires_at: now + duration, last_seen_at: now, **hints)
132
+ @on_sign_in&.call(user: user, session: row, at: now)
83
133
  Grant.new(session: row, bearer: raw)
84
134
  end
85
135
 
@@ -90,7 +140,7 @@ module AddAuth
90
140
  legacy = lookup.key?(:id)
91
141
  @store.with_session(**lookup) do |user, row|
92
142
  now = @clock.now
93
- next unless live?(user, row, now: now)
143
+ next unless transport_for(row) == "browser" && live?(user, row, now: now)
94
144
  if legacy
95
145
  next if row.token_digest || now >= @deadline
96
146
  raw = bearer
@@ -104,13 +154,25 @@ module AddAuth
104
154
  end
105
155
  end
106
156
 
157
+ # Only the exact Authorization bearer value enters this parser. Cookies,
158
+ # query parameters and legacy host API tokens are not alternate inputs.
159
+ def resume_mobile(bearer:)
160
+ return unless @mobile && bearer.is_a?(String) && MobileProfile::PATTERN.match?(bearer)
161
+ @store.with_session(digest: @digest.digest(bearer)) do |user, row|
162
+ now = @clock.now
163
+ next unless transport_for(row) == "mobile" && live?(user, row, now: now)
164
+ @store.update(row, last_seen_at: now) if row.last_seen_at <= now - 60
165
+ Grant.new(session: row)
166
+ end
167
+ end
168
+
107
169
  # A trusted cookie identifies what to retire, even if that account is now
108
170
  # ineligible. This snapshot is replacement input, never authentication.
109
171
  def replacement_for(signed_value:)
110
172
  lookup = cookie_lookup(signed_value)
111
173
  return unless lookup
112
174
  @store.with_session(**lookup) do |_user, row|
113
- row unless lookup.key?(:id) && row&.token_digest
175
+ row if transport_for(row) == "browser" && !(lookup.key?(:id) && row&.token_digest)
114
176
  end
115
177
  end
116
178
 
@@ -134,7 +196,7 @@ module AddAuth
134
196
  now = @clock.now
135
197
  candidates = @store.list_for_user(user_id: user.id, before: before&.to_i,
136
198
  excluding: current_session_id, limit: PAGE_SIZE + 1, now: now,
137
- active_after: now - @idle, legacy: !!(@deadline && now < @deadline))
199
+ active_after: now - [@idle, @remembered&.fetch(:idle_timeout) || @idle, @mobile&.idle_timeout || @idle].max, legacy: !!(@deadline && now < @deadline))
138
200
  cursor = candidates[PAGE_SIZE - 1].id if candidates.length > PAGE_SIZE
139
201
  rows = candidates.first(PAGE_SIZE)
140
202
  if before.nil? && current_session_id
@@ -147,7 +209,8 @@ module AddAuth
147
209
  Entry.new(id: row.id, current: row.id == current_session_id,
148
210
  authenticated_with: row.authenticated_with, authenticated_at: row.authenticated_at,
149
211
  created_at: row.created_at, last_seen_at: row.last_seen_at, expires_at: row.expires_at,
150
- ip_address: row.ip_address, user_agent: row.user_agent)
212
+ ip_address: row.ip_address, user_agent: row.user_agent,
213
+ transport: transport_for(row), client_id: row.respond_to?(:client_id) ? row.client_id : nil)
151
214
  end
152
215
  Page.new(entries: entries, next_cursor: cursor)
153
216
  end
@@ -169,8 +232,11 @@ module AddAuth
169
232
  def reauthenticate(user:, session:, purpose:, policy:)
170
233
  with_live_session(user: user, session: session) do |account, row, _now|
171
234
  next Result.failure(reason: :elevation_required) if @access_policy && !@access_policy.sign_in_allowed?(account, :password)
235
+ @password_lifecycle&.prepare(user: account)
172
236
  verified = yield account
173
- next Result.failure(reason: :invalid_credentials) unless verified && verified.id == account.id
237
+ valid = verified && verified.id == account.id && verified.password_digest == account.password_digest
238
+ valid = @password_lifecycle.verified(user: account, password: nil, valid: !!valid, rehash: false) if @password_lifecycle
239
+ next Result.failure(reason: :invalid_credentials) unless valid
174
240
  evidence = StepUp::Evidence.new(user_id: account.id, session_id: row.id,
175
241
  method: :password, verified_at: @clock.now, session_digest: row.token_digest,
176
242
  credential_version: policy.password_version(account))
@@ -202,7 +268,7 @@ module AddAuth
202
268
  row = current_in_transaction(user: user, session: session)
203
269
  return unless row && grant.is_a?(StepUp::Grant) && grant.valid_for?(user: user,
204
270
  user_id: user.id, session_id: row.id, session_digest: row.token_digest, purpose: grant.purpose, now: @clock.now)
205
- raw = bearer
271
+ raw = (transport_for(row) == "mobile") ? @mobile.bearer : bearer
206
272
  attributes = {token_digest: @digest.digest(raw), elevated_at: grant.verified_at,
207
273
  elevated_with: grant.method.to_s, elevation_purpose: grant.purpose.to_s,
208
274
  elevation_credential_id: grant.credential_id, elevation_uv: grant.user_verification,
@@ -232,7 +298,9 @@ module AddAuth
232
298
  now = @clock.now
233
299
  return Result.failure(reason: :elevation_required) unless row&.respond_to?(:elevation_version) && row.elevated_at &&
234
300
  row.elevation_purpose == purpose.to_s && row.elevation_expires_at && now < row.elevation_expires_at
235
- evidence = StepUp::Evidence.new(user_id: user.id, session_id: row.id,
301
+ evidence_class = (row.elevated_with == "external_identity") ? StepUp::ExternalEvidence : StepUp::Evidence
302
+ extra = (row.elevated_with == "external_identity") ? {purpose: purpose} : {}
303
+ evidence = evidence_class.send(:new, **extra, user_id: user.id, session_id: row.id,
236
304
  method: row.elevated_with, verified_at: row.elevated_at, session_digest: row.token_digest,
237
305
  credential_id: row.elevation_credential_id, user_verification: row.elevation_uv,
238
306
  credential_version: row.elevation_version)
@@ -277,10 +345,27 @@ module AddAuth
277
345
  return false unless user && row && @eligible.call(user) == true && !row.revoked_at
278
346
  return false if @access_policy && !@access_policy.session_allowed?(user, row)
279
347
  legacy = !row.token_digest && @deadline && now < @deadline
348
+ idle = @idle
349
+ if transport_for(row) == "mobile"
350
+ return false unless @mobile&.client?(row.client_id) && row.token_digest
351
+ stored_idle = row.mobile_idle_timeout
352
+ return false unless stored_idle.is_a?(Integer) && stored_idle.between?(60, 90 * 86_400)
353
+ idle = [stored_idle, @mobile.idle_timeout].min
354
+ return false unless row.authenticated_at && (row.authenticated_at + @mobile.lifetime > now)
355
+ elsif transport_for(row) != "browser"
356
+ return false
357
+ elsif @remembered && row.respond_to?(:remembered) && row.remembered == true
358
+ stored_idle = row.idle_timeout
359
+ return false unless stored_idle.is_a?(Numeric) && stored_idle.finite? && stored_idle.positive?
360
+ idle = [stored_idle, @remembered.fetch(:idle_timeout)].min
361
+ return false unless row.authenticated_at && row.authenticated_at + @remembered.fetch(:lifetime) > now
362
+ end
280
363
  (legacy || (row.expires_at && row.last_seen_at)) &&
281
- (!row.expires_at || row.expires_at > now) && (!row.last_seen_at || row.last_seen_at > now - @idle)
364
+ (!row.expires_at || row.expires_at > now) && (!row.last_seen_at || row.last_seen_at > now - idle)
282
365
  end
283
366
 
367
+ def transport_for(row) = row.respond_to?(:transport) ? row.transport : "browser"
368
+
284
369
  def bearer = "lk1:#{SecureRandom.urlsafe_base64(32)}"
285
370
  end
286
371
  end
@@ -5,7 +5,7 @@ module AddAuth
5
5
  # Evidence is issued by a trusted verifier, never deserialized from a client.
6
6
  # A grant retains that verifier's current policy and the proof's original age.
7
7
  class StepUp
8
- METHODS = %i[password email_link passkey].freeze
8
+ METHODS = %i[password email_link passkey external_identity].freeze
9
9
 
10
10
  class Evidence
11
11
  attr_reader :user_id, :session_id, :method, :verified_at, :credential_id,
@@ -28,6 +28,15 @@ module AddAuth
28
28
  def inspect = "#<AddAuth::Core::StepUp::Evidence [FILTERED]>"
29
29
  end
30
30
 
31
+ class ExternalEvidence < Evidence
32
+ attr_reader :purpose
33
+ def initialize(purpose:, **attributes)
34
+ @purpose = purpose.to_sym
35
+ super(**attributes)
36
+ end
37
+ private_class_method :new
38
+ end
39
+
31
40
  class Grant
32
41
  attr_reader :purpose, :expires_at
33
42
  def initialize(evidence:, purpose:, expires_at:, policy:)
@@ -117,6 +126,9 @@ module AddAuth
117
126
  return false if rule.require_passkey && !evidence.strong?
118
127
  return false unless evidence.session_digest.is_a?(String) && !evidence.session_digest.empty?
119
128
  return false if evidence.method == :passkey && !evidence.strong?
129
+ if evidence.method == :external_identity
130
+ return false unless evidence.is_a?(ExternalEvidence) && evidence.purpose == purpose.to_sym
131
+ end
120
132
  if evidence.method == :password
121
133
  return false unless evidence.credential_version.is_a?(String) && !evidence.credential_version.empty?
122
134
  return false unless user.respond_to?(:password_digest) && password_version(user) == evidence.credential_version