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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module AddAuth
7
+ module Core
8
+ class ExternalIdentities
9
+ # Only configured server code may supply this verifier. It must validate a
10
+ # live middleware/library result, including issuer, audience, state/nonce
11
+ # and (for reauthentication) auth_time/max_age. A request auth hash is not
12
+ # such a result. Core deliberately contains no protocol or token decoder.
13
+ class Configuration
14
+ attr_reader :id, :issuer, :audience
15
+
16
+ def initialize(id:, issuer:, audience:, verifier:, clock: Time)
17
+ @id, @issuer, @audience = [id, issuer, audience].map { |value| self.class.text(value) }
18
+ raise ArgumentError, "a trusted server verifier is required" unless verifier.respond_to?(:call)
19
+ @verifier, @clock = verifier, clock
20
+ freeze
21
+ end
22
+
23
+ def self.text(value)
24
+ raise ArgumentError, "invalid external identity field" unless value.is_a?(String) && value.valid_encoding? &&
25
+ value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
26
+ value.dup.freeze
27
+ end
28
+
29
+ def verify(server_result:, transaction:)
30
+ return unless transaction.is_a?(Pending) && transaction.configuration.equal?(self)
31
+ claims = @verifier.call(server_result: server_result, transaction: transaction)
32
+ return unless claims.is_a?(Hash) && claims[:issuer] == issuer && claims[:audience] == audience
33
+ VerifiedIdentity.send(:new, configuration: self, transaction: transaction,
34
+ subject: self.class.text(claims[:subject]), provenance: self.class.text(claims[:provenance]),
35
+ authenticated_at: claims[:authenticated_at], verified_at: @clock.now)
36
+ rescue ArgumentError
37
+ nil
38
+ end
39
+
40
+ def namespace(subject) = ::Digest::SHA256.hexdigest(JSON.generate([id, issuer, audience, subject]))
41
+ def inspect = "#<AddAuth::Core::ExternalIdentities::Configuration [FILTERED]>"
42
+ end
43
+
44
+ class Pending
45
+ attr_reader :configuration, :id, :purpose, :user_id, :session_id, :session_digest,
46
+ :policy_version, :issued_at, :expires_at
47
+
48
+ def initialize(configuration:, id:, purpose:, user_id:, session_id:, session_digest:, policy_version:, issued_at:, expires_at:)
49
+ @configuration = configuration
50
+ @id, @purpose = id.dup.freeze, purpose.to_sym
51
+ @user_id, @session_id = user_id&.dup&.freeze, session_id&.dup&.freeze
52
+ @session_digest = session_digest&.dup&.freeze
53
+ @policy_version = policy_version
54
+ @issued_at, @expires_at = issued_at.dup.freeze, expires_at.dup.freeze
55
+ freeze
56
+ end
57
+ private_class_method :new
58
+
59
+ def inspect = "#<AddAuth::Core::ExternalIdentities::Pending [FILTERED]>"
60
+ end
61
+
62
+ class VerifiedIdentity
63
+ attr_reader :configuration, :transaction, :subject, :provenance, :authenticated_at, :verified_at
64
+
65
+ def initialize(configuration:, transaction:, subject:, provenance:, authenticated_at:, verified_at:)
66
+ raise ArgumentError, "invalid authentication occurrence time" unless authenticated_at.nil? || authenticated_at.is_a?(Time)
67
+ @configuration, @transaction, @subject, @provenance = configuration, transaction, subject, provenance
68
+ @authenticated_at = authenticated_at&.dup&.freeze
69
+ @verified_at = verified_at.dup.freeze
70
+ freeze
71
+ end
72
+ private_class_method :new
73
+
74
+ def provider_id = configuration.id
75
+ def issuer = configuration.issuer
76
+ def audience = configuration.audience
77
+ def transaction_id = transaction.id
78
+ def purpose = transaction.purpose
79
+ def namespace = configuration.namespace(subject)
80
+ def inspect = "#<AddAuth::Core::ExternalIdentities::VerifiedIdentity [FILTERED]>"
81
+ end
82
+
83
+ # Session evidence is issued only after locked binding resolution. It is
84
+ # never a StepUp::Evidence merely because the callback was just received.
85
+ class SessionProof
86
+ attr_reader :user_id, :credential_id, :credential_version, :verified_at
87
+ def initialize(user_id:, credential_id:, credential_version:, verified_at:)
88
+ @user_id, @credential_id = user_id, credential_id.to_s.dup.freeze
89
+ @credential_version, @verified_at = credential_version.dup.freeze, verified_at.dup.freeze
90
+ freeze
91
+ end
92
+ private_class_method :new
93
+ def user_verification = false
94
+ def inspect = "#<AddAuth::Core::ExternalIdentities::SessionProof [FILTERED]>"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/core/external_identities/evidence"
4
+ require "securerandom"
5
+
6
+ module AddAuth
7
+ module Core
8
+ class ExternalIdentities
9
+ LINK = :link_external_identity
10
+ UNLINK = :unlink_external_identity
11
+ ENROLL = :enroll_external_identity
12
+ MOBILE = :mobile_sign_in
13
+ NATIVE = :native_sign_in
14
+ NATIVE_ENROLL = :native_enroll_external_identity
15
+ ENROLLMENT_PURPOSES = [ENROLL, NATIVE_ENROLL].freeze
16
+
17
+ # G3 rescues this outside its fresh-account transaction. Returning a
18
+ # failure Result here would commit a partially registered account.
19
+ class EnrollmentRejected < StandardError
20
+ attr_reader :reason
21
+ def initialize(reason:)
22
+ @reason = reason
23
+ super("external identity enrollment rejected")
24
+ end
25
+ end
26
+
27
+ # remaining_method is a Core policy port evaluated under the account lock;
28
+ # it must check actual enabled/usable password, passkey or trusted recovery
29
+ # credentials. Authority invalidation uses the shared lifecycle transaction.
30
+ def initialize(store:, configurations:, sessions:, access_policy:, policy:, eligible:,
31
+ digest:, revoke_authority:, remaining_method:, enrollment_eligible: ->(_user) { false }, clock: Time, lifetime: 300, enabled: false, mobile_enabled: false)
32
+ raise ArgumentError, "invalid transaction lifetime" unless lifetime.is_a?(Integer) && lifetime.between?(1, 600)
33
+ @store, @sessions, @access, @policy, @eligible = store, sessions, access_policy, policy, eligible
34
+ @configurations = configurations.to_h { |config| [config.id, config] }.freeze
35
+ raise ArgumentError, "duplicate provider configuration" unless @configurations.size == configurations.size
36
+ @digest, @revoke, @remaining, @clock, @lifetime, @enabled = digest, revoke_authority, remaining_method, clock, lifetime, enabled == true
37
+ @enrollment_eligible = enrollment_eligible
38
+ @mobile_enabled = mobile_enabled == true
39
+ @binding = BrowserBinding.new(digest: digest)
40
+ end
41
+
42
+ def begin_transaction(configuration_id:, browser_secret:, user: nil, session: nil, purpose: :sign_in, binding_context: nil)
43
+ return failure(:disabled) unless @enabled
44
+ config = @configurations[configuration_id]
45
+ return failure unless config && @binding.valid?(browser_secret) && valid_context?(binding_context)
46
+ return failure(:disabled) if [MOBILE.to_s, NATIVE.to_s, NATIVE_ENROLL.to_s].include?(purpose.to_s) && !@mobile_enabled
47
+ if ["sign_in", ENROLL.to_s, MOBILE.to_s, NATIVE.to_s, NATIVE_ENROLL.to_s].include?(purpose.to_s)
48
+ return failure if user || session
49
+ return issue(config, browser_secret, purpose, binding_context: binding_context)
50
+ end
51
+ @store.with_user(id: user&.id) do |account|
52
+ row = account && @sessions.current_in_transaction(user: account, session: session)
53
+ allowed = purpose.to_s == LINK.to_s || @policy.reauthentication_rule_for(purpose)
54
+ next failure(:elevation_required) unless row && @eligible.call(account) == true && allowed
55
+ issue(config, browser_secret, purpose, account, row, binding_context: binding_context)
56
+ end
57
+ end
58
+
59
+ def pending(transaction:, browser_secret:, binding_context: nil)
60
+ return unless @enabled && @binding.valid?(transaction) && @binding.valid?(browser_secret) && valid_context?(binding_context)
61
+ row = @store.transaction_by_digest(digest: @digest.digest(transaction))
62
+ return unless row && row.browser_digest == binding_digest(browser_secret, binding_context) && !row.consumed_at &&
63
+ row.expires_at > @clock.now && row.issued_at <= @clock.now
64
+ config = @configurations[row.provider_id]
65
+ return unless config && row.issuer == config.issuer && row.audience == config.audience
66
+ Pending.send(:new, configuration: config, id: transaction, purpose: row.purpose,
67
+ user_id: row.user_id, session_id: row.session_id, session_digest: row.session_digest,
68
+ policy_version: row.policy_version, issued_at: row.issued_at, expires_at: row.expires_at)
69
+ end
70
+
71
+ # Cancellation is correlation-bound, idempotent and grants no authority.
72
+ def cancel(transaction:, browser_secret:, binding_context: nil)
73
+ proof = pending(transaction: transaction, browser_secret: browser_secret, binding_context: binding_context)
74
+ return false unless proof
75
+ @store.consume_transaction(digest: @digest.digest(proof.id), at: @clock.now)
76
+ end
77
+
78
+ def sign_in(evidence:, replacing: nil, **hints)
79
+ finalize_sign_in(evidence: evidence, purpose: :sign_in, replacing: replacing) do |account, identity, proof|
80
+ grant = @sessions.create_in_transaction(user: account, method: :external_identity,
81
+ proof: proof, replacing: replacing, **hints)
82
+ raise AddAuth::Error, "external identity session could not finalize" unless grant
83
+ Result.success(user: account, strategy: :external_identity, credential: identity, session: grant.session, grant: grant)
84
+ end
85
+ end
86
+
87
+ def mobile_handoff(evidence:, handoffs:)
88
+ return failure(:disabled) unless @mobile_enabled
89
+ finalize_sign_in(evidence: evidence, purpose: MOBILE) do |account, _identity, proof|
90
+ handoffs.issue_in_transaction(user: account, proof: proof, pending: evidence.transaction)
91
+ end
92
+ end
93
+
94
+ def native_sign_in(evidence:, client_id:, **hints)
95
+ return failure(:disabled) unless @mobile_enabled
96
+ finalize_sign_in(evidence: evidence, purpose: NATIVE) do |account, identity, proof|
97
+ grant = @sessions.create_in_transaction(user: account, method: :external_identity,
98
+ proof: proof, transport: :mobile, client_id: client_id, **hints)
99
+ raise AddAuth::Error, "native identity session could not finalize" unless grant
100
+ Result.success(user: account, strategy: :external_identity, credential: identity, session: grant.session, grant: grant)
101
+ end
102
+ end
103
+
104
+ def link(user:, session:, evidence:, grant:)
105
+ return failure(:disabled) unless @enabled
106
+ return failure unless trusted?(evidence) && evidence.purpose == LINK
107
+ @store.with_user(id: user&.id) do |account|
108
+ next failure(:elevation_required) unless management?(account, session, grant, LINK) && bound?(evidence, account, session)
109
+ finalize(evidence, account) do
110
+ identity = @store.binding(namespace: evidence.namespace)
111
+ if identity && (identity.user_id != account.id || !matches?(identity, evidence))
112
+ next failure(:identity_conflict)
113
+ end
114
+ identity = @store.bind(user: account, evidence: evidence, at: @clock.now)
115
+ next failure(:identity_conflict) unless identity
116
+ Result.success(user: account, strategy: :external_identity, credential: identity)
117
+ end
118
+ end
119
+ end
120
+
121
+ # Registration capability is minted by G3 only in create_account's fresh
122
+ # user block. This joins that transaction; it never authenticates the user.
123
+ def bind_new_account_in_transaction(registration:, evidence:)
124
+ reject_enrollment(:disabled) unless @enabled
125
+ valid_registration = defined?(AccountLifecycle::NewAccount) && registration.instance_of?(AccountLifecycle::NewAccount)
126
+ reject_enrollment unless valid_registration && trusted?(evidence) && ENROLLMENT_PURPOSES.include?(evidence.purpose)
127
+ reject_enrollment(:disabled) if evidence.purpose == NATIVE_ENROLL && !@mobile_enabled
128
+ tx = evidence.transaction
129
+ reject_enrollment unless tx.user_id.nil? && tx.session_id.nil? && tx.session_digest.nil? && tx.policy_version.nil?
130
+ account = @store.registration_account(user: registration.user)
131
+ reject_enrollment unless account && account.id == registration.user_id && @enrollment_eligible.call(account) == true
132
+ reject_enrollment(:expired_token) unless tx.issued_at <= @clock.now && tx.expires_at > @clock.now
133
+ reject_enrollment(:identity_conflict) if @store.binding(namespace: evidence.namespace)
134
+ reject_enrollment(:consumed_token) unless @store.consume_transaction(digest: @digest.digest(tx.id), at: @clock.now)
135
+ identity = @store.bind(user: account, evidence: evidence, at: @clock.now)
136
+ reject_enrollment(:identity_conflict) unless identity
137
+ Result.success(user: account, strategy: :external_identity, credential: identity)
138
+ end
139
+
140
+ def unlink(user:, session:, identity_id:, grant:)
141
+ return failure(:disabled) unless @enabled
142
+ @store.with_user(id: user&.id) do |account|
143
+ next failure(:elevation_required) unless management?(account, session, grant, UNLINK)
144
+ identity = @store.binding_for_user(user_id: account.id, id: identity_id)
145
+ next failure unless identity && !identity.revoked_at
146
+ remaining = @store.bindings(user_id: account.id).any? { |other| other.id != identity.id && configured?(other) } &&
147
+ @access.sign_in_allowed?(account, :external_identity)
148
+ next failure(:last_credential) unless remaining || @remaining.call(account) == true
149
+ @store.revoke(identity: identity, at: @clock.now)
150
+ @revoke.call(user_id: account.id, at: @clock.now)
151
+ Result.success(user: account, strategy: :external_identity)
152
+ end
153
+ end
154
+
155
+ def reauthenticate(user:, session:, evidence:, policy: @policy)
156
+ return failure(:disabled) unless @enabled
157
+ return failure(:elevation_required) unless trusted?(evidence) && ![:sign_in, LINK, ENROLL].include?(evidence.purpose)
158
+ @store.with_user(id: user&.id) do |account|
159
+ row = account && @sessions.current_in_transaction(user: account, session: session)
160
+ next failure(:elevation_required) unless row && @eligible.call(account) == true && bound?(evidence, account, row) &&
161
+ @access.sign_in_allowed?(account, :external_identity)
162
+ identity = @store.binding(namespace: evidence.namespace)
163
+ next failure unless current_identity?(identity, evidence) && identity.user_id == account.id
164
+ # A callback timestamp is never substituted for provider auth_time.
165
+ time = evidence.authenticated_at
166
+ next failure(:elevation_required) unless time && time >= Time.at(evidence.transaction.issued_at.to_i) && time <= @clock.now
167
+ proof = StepUp::ExternalEvidence.send(:new, user_id: account.id, session_id: row.id,
168
+ method: :external_identity, verified_at: time, credential_id: identity.id.to_s,
169
+ credential_version: identity.credential_version, session_digest: row.token_digest, purpose: evidence.purpose)
170
+ authorized = policy.authorize(user: account, session_id: row.id, purpose: evidence.purpose, evidence: proof)
171
+ next authorized unless authorized.success?
172
+ finalize(evidence, account) { authorized }
173
+ end
174
+ end
175
+
176
+ # Called by the shared authority-revocation transaction even while provider
177
+ # sign-in is disabled. A binding survives the event, but older anonymous
178
+ # transactions and previously issued session/elevation proofs do not.
179
+ def self.invalidate_credentials_in_transaction(store:, user_id:, at:)
180
+ raise ArgumentError, "invalidation requires an occurrence time" unless at.is_a?(Time)
181
+ store.invalidate_credentials(user_id: user_id, at: at, credential_version: SecureRandom.hex(16))
182
+ end
183
+
184
+ def invalidate_credentials_in_transaction(user_id:, at:)
185
+ self.class.invalidate_credentials_in_transaction(store: @store, user_id: user_id, at: at)
186
+ end
187
+
188
+ def credential_current?(user:, id:, version:)
189
+ return false unless @enabled
190
+ identity = @store.binding_for_user(user_id: user.id, id: id)
191
+ !!(identity && !identity.revoked_at && identity.credential_version == version && configured?(identity))
192
+ end
193
+
194
+ private
195
+
196
+ def finalize_sign_in(evidence:, purpose:, replacing: nil)
197
+ return failure(:disabled) unless @enabled
198
+ return failure unless trusted?(evidence) && evidence.purpose == purpose
199
+ @store.with_identity(namespace: evidence.namespace, replacing: replacing) do |account, identity|
200
+ next failure(:identity_unbound) unless bound_identity?(identity, evidence)
201
+ next failure unless current_identity?(identity, evidence)
202
+ next failure unless account && @eligible.call(account) == true && @access.sign_in_allowed?(account, :external_identity)
203
+ finalize(evidence, account) do
204
+ proof = SessionProof.send(:new, user_id: account.id, credential_id: identity.id,
205
+ credential_version: identity.credential_version, verified_at: @clock.now)
206
+ yield account, identity, proof
207
+ end
208
+ end
209
+ end
210
+
211
+ def issue(config, browser_secret, purpose, account = nil, session = nil, binding_context: nil)
212
+ raw = @binding.generate
213
+ now = @clock.now
214
+ @store.create_transaction(digest: @digest.digest(raw), browser_digest: binding_digest(browser_secret, binding_context),
215
+ provider_id: config.id, issuer: config.issuer, audience: config.audience, purpose: purpose.to_s,
216
+ user_id: account&.id, session_id: session&.id, session_digest: session&.token_digest,
217
+ policy_version: account && @access.version(account), issued_at: now, expires_at: now + @lifetime)
218
+ Result.success(user: account, strategy: :external_identity, credential: pending(transaction: raw, browser_secret: browser_secret, binding_context: binding_context))
219
+ end
220
+
221
+ def valid_context?(context)
222
+ context.nil? || (context.is_a?(String) && context.ascii_only? && /\A[a-z][a-z0-9_:-]{0,127}\z/.match?(context))
223
+ end
224
+
225
+ def binding_digest(secret, context)
226
+ @digest.digest(context ? JSON.generate(["context", context, secret]) : secret)
227
+ end
228
+
229
+ def trusted?(evidence)
230
+ evidence.is_a?(VerifiedIdentity) && @configurations[evidence.provider_id].equal?(evidence.configuration)
231
+ end
232
+
233
+ def configured?(identity)
234
+ config = @configurations[identity.provider_id]
235
+ config && config.issuer == identity.issuer && config.audience == identity.audience
236
+ end
237
+
238
+ def matches?(identity, evidence)
239
+ identity.provider_id == evidence.provider_id && identity.issuer == evidence.issuer &&
240
+ identity.audience == evidence.audience && identity.subject == evidence.subject
241
+ end
242
+
243
+ def bound_identity?(identity, evidence) = identity && !identity.revoked_at && matches?(identity, evidence)
244
+
245
+ def current_identity?(identity, evidence)
246
+ bound_identity?(identity, evidence) && identity.linked_at <= evidence.transaction.issued_at &&
247
+ (!identity.invalidated_at || evidence.transaction.issued_at > identity.invalidated_at)
248
+ end
249
+
250
+ def bound?(evidence, account, session)
251
+ tx = evidence.transaction
252
+ tx.user_id == account.id && tx.session_id == session.id && tx.session_digest == session.token_digest &&
253
+ tx.policy_version == @access.version(account)
254
+ end
255
+
256
+ def management?(account, session, grant, purpose)
257
+ row = account && @sessions.current_in_transaction(user: account, session: session)
258
+ row && @eligible.call(account) == true && grant.is_a?(StepUp::Grant) &&
259
+ grant.valid_for?(user: account, user_id: account.id, session_id: row.id,
260
+ session_digest: row.token_digest, purpose: purpose, now: @clock.now)
261
+ end
262
+
263
+ def finalize(evidence, account)
264
+ tx = evidence.transaction
265
+ return failure(:expired_token) unless tx.issued_at <= @clock.now && tx.expires_at > @clock.now
266
+ return failure unless @eligible.call(account) == true
267
+ return failure(:consumed_token) unless @store.consume_transaction(digest: @digest.digest(tx.id), at: @clock.now)
268
+ yield
269
+ end
270
+
271
+ def reject_enrollment(reason = :invalid_credentials)
272
+ raise EnrollmentRejected.new(reason: reason)
273
+ end
274
+
275
+ def failure(reason = :invalid_credentials) = Result.failure(reason: reason)
276
+ end
277
+ end
278
+ end
@@ -52,7 +52,12 @@ module AddAuth
52
52
  end
53
53
 
54
54
  def self.revoke_after_change?(changes)
55
- (changes.keys.map(&:to_s) & %w[password_digest email_address]).any?
55
+ (changes.keys.map(&:to_s) & %w[password_digest email_address confirmed_at locked_at disabled_at deleted_at add_auth_authority add_auth_manual_lock]).any?
56
+ end
57
+
58
+ def self.notification_after_change(changes)
59
+ return :password_changed if changes.key?("password_digest")
60
+ :email_changed if changes.key?("email_address")
56
61
  end
57
62
  end
58
63
  end
@@ -5,12 +5,14 @@ module AddAuth
5
5
  # One bounded, replayable pass. Stores repeat cutoffs at mutation time;
6
6
  # returning counts records committed work, never a claim of an empty backlog.
7
7
  class Maintenance
8
+ EXPIRING = {ceremony: :ceremonies_deleted, external_transaction: :external_transactions_deleted,
9
+ mobile_handoff: :mobile_handoffs_deleted}.freeze
8
10
  def initialize(stores:, options:, enqueue:, clock: Time)
9
11
  @stores, @options, @enqueue, @clock = stores, options, enqueue, clock
10
12
  unless options.batch_size.is_a?(Integer) && (1..1000).cover?(options.batch_size)
11
13
  raise ArgumentError, "maintenance.batch_size must be an integer from 1 to 1000"
12
14
  end
13
- %i[session email notification].each do |kind|
15
+ %i[session email notification account].each do |kind|
14
16
  value = options.public_send(:"#{kind}_retention")
15
17
  valid = value.nil? || (value.is_a?(Numeric) && value.finite? && value >= 0)
16
18
  unless valid
@@ -23,8 +25,8 @@ module AddAuth
23
25
  now, limit = @clock.now, @options.batch_size
24
26
  counts = {}
25
27
  @stores.each do |kind, store|
26
- if kind == :ceremony
27
- counts[:ceremonies_deleted] = store.purge_expired(before: now, now: now, limit: limit)
28
+ if EXPIRING.key?(kind)
29
+ counts[EXPIRING.fetch(kind)] = store.purge_expired(before: now, now: now, limit: limit)
28
30
  next
29
31
  end
30
32
  retention = @options.public_send(:"#{kind}_retention")
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "add_auth/core/passwords/legacy_bcrypt"
5
+
6
+ module AddAuth
7
+ module Core
8
+ module Migration
9
+ class AccountAdoption
10
+ # A retired source may still save harmless profile/tracking fields, but
11
+ # every credential, lifecycle flag and outstanding Devise proof remains
12
+ # owned by its selected authority. The bridge supplies dirty field names;
13
+ # only Core classifies their security meaning.
14
+ SOURCE_AUTHORITY_FIELDS = %w[email encrypted_password email_address password_digest confirmed_at unconfirmed_email
15
+ confirmation_token confirmation_sent_at reset_password_token reset_password_sent_at
16
+ locked_at failed_attempts unlock_token remember_created_at
17
+ add_auth_locked_until add_auth_manual_lock add_auth_password_scheme add_auth_migration_stamp
18
+ add_auth_strict add_auth_policy_version webauthn_id disabled_at deleted_at].freeze
19
+
20
+ def initialize(store:)
21
+ @store = store
22
+ end
23
+
24
+ def call(after: nil, limit: 100)
25
+ raise ArgumentError, "batch size must be between 1 and 1000" unless limit.is_a?(Integer) && limit.between?(1, 1000)
26
+ page = @store.source_page(after: after, limit: limit + 1)
27
+ counts = {inspected: 0, prepared: 0, unchanged: 0, conflicts: 0, changed_source: 0, active_destination: 0}
28
+ page.first(limit).each do |snapshot|
29
+ counts[:inspected] += 1
30
+ outcome = @store.with_account(snapshot.fetch(:id)) do |source|
31
+ next :changed_source unless source && stamp(source) == stamp(snapshot)
32
+ next :active_destination unless source[:authority] == "devise"
33
+ attributes = self.class.projection(email: source[:email], encrypted_password: source[:encrypted_password])
34
+ next :conflicts unless attributes
35
+ next :unchanged if source[:migration_stamp] == attributes[:add_auth_migration_stamp] && source[:email_address] == attributes[:email_address] && source[:password_digest] == attributes[:password_digest] && source[:scheme] == "devise_bcrypt"
36
+ @store.prepare(id: source[:id], **attributes)
37
+ :prepared
38
+ end
39
+ counts[outcome || :conflicts] += 1
40
+ end
41
+ counts.merge(next_cursor: (page.length > limit) ? page[limit - 1][:id] : nil)
42
+ end
43
+
44
+ def self.stamp(email:, encrypted_password:)
45
+ values = [email, encrypted_password].map { |value| value.nil? ? "nil:" : "#{value.bytesize}:#{value}" }
46
+ ::Digest::SHA256.hexdigest(values.join("|"))
47
+ end
48
+
49
+ def self.source_authority?(value) = value == "devise"
50
+
51
+ def self.authorize_source_write!(authority:, changed_fields:, authority_changed: false)
52
+ raise AddAuth::Error, "switch account authority through the reviewed migration procedure" if authority_changed
53
+ return if source_authority?(authority)
54
+ return if authority == "add_auth" && (changed_fields.map(&:to_s) & SOURCE_AUTHORITY_FIELDS).empty?
55
+ raise AddAuth::Error, "source authentication state is no longer authoritative"
56
+ end
57
+
58
+ def self.projection_for_write(authority:, authority_changed:, changed_fields:, **source)
59
+ authorize_source_write!(authority: authority, authority_changed: authority_changed, changed_fields: changed_fields)
60
+ source_authority?(authority) ? projection(**source) || raise(AddAuth::Error, "unsupported source credential profile; reconcile before writing") : {}
61
+ end
62
+
63
+ def self.projection(email:, encrypted_password:)
64
+ return unless email.is_a?(String) && email.valid_encoding? && email.bytesize.between?(1, 254)
65
+ identifier = email.strip.downcase
66
+ return unless identifier.match?(/\A[^\s@]+@[^\s@]+\z/) && identifier.bytesize <= 254
67
+ match = encrypted_password.is_a?(String) && Passwords::LegacyBcrypt::PATTERN.match(encrypted_password)
68
+ return unless match && match[1].to_i.between?(4, 14)
69
+ {email_address: identifier, password_digest: encrypted_password, add_auth_password_scheme: "devise_bcrypt",
70
+ add_auth_migration_stamp: stamp(email: email, encrypted_password: encrypted_password)}
71
+ end
72
+
73
+ private
74
+
75
+ def stamp(source) = self.class.stamp(email: source[:email], encrypted_password: source[:encrypted_password])
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ module Migration
6
+ # A census is evidence for planning, never permission to change authority.
7
+ class Readiness
8
+ MODULES = %w[database_authenticatable registerable recoverable rememberable validatable confirmable lockable trackable omniauthable timeoutable].freeze
9
+ EXTENSIONS = %w[devise-jwt devise_token_auth devise-two-factor devise-security devise-encryptable devise-passkeys devise-webauthn mongoid].freeze
10
+ TARGETS = %w[app/models/user.rb app/models/session.rb app/models/current.rb app/controllers/concerns/authentication.rb].freeze
11
+ ACTIONS = {
12
+ source_version: "Use a separately tested source profile; the initial fixture target is Devise 5.0.4.",
13
+ effective_inventory: "Run the effective inspection in an isolated local copy to verify schema, options and account counts.",
14
+ incomplete_inventory: "Resolve unreadable, oversized, dynamic or truncated input and repeat inspection.",
15
+ unsupported_extension: "Review each extension's credential and policy semantics before conversion.",
16
+ multiple_scopes: "Use an explicitly reviewed identity-scope conversion; do not merge scopes.",
17
+ account_contract: "Prepare additive Rails User/Session/Authentication contracts and review existing files.",
18
+ password_profile: "Prove the effective legacy verifier and a reset route for unsupported credentials.",
19
+ identifier_conflicts: "Resolve missing or normalization-colliding identifiers without merging accounts.",
20
+ policy_mapping: "Map every active account restriction and lifecycle operation before switch.",
21
+ migration_acceptance: "Complete account, lifecycle, provider, client and rollback rehearsals before switching authority."
22
+ }.freeze
23
+
24
+ def call(facts)
25
+ codes = [:migration_acceptance]
26
+ codes << :source_version unless facts.dig(:versions, :devise) == "5.0.4"
27
+ codes << :effective_inventory unless facts[:mode] == "effective"
28
+ codes << :incomplete_inventory unless facts[:complete]
29
+ codes << :unsupported_extension if facts.fetch(:extensions, []).any? || facts.fetch(:unknown_modules, 0).positive?
30
+ codes << :multiple_scopes if facts.fetch(:scope_count, 0) > 1
31
+ codes << :account_contract unless facts[:rails_contract]
32
+ codes << :password_profile if facts[:mode] != "effective" || facts.fetch(:accounts, []).any? { |a| a[:custom_verifier] || a[:pepper_configured] || a.dig(:counts, :unknown_password).to_i.positive? }
33
+ codes << :identifier_conflicts if facts.fetch(:accounts, []).any? { |a| a.dig(:counts, :missing_identifier).to_i.positive? || a.dig(:counts, :identifier_collisions).to_i.positive? }
34
+ codes << :policy_mapping if facts.fetch(:modules, []).any? { |name| name != "database_authenticatable" }
35
+ {schema_version: 1, status: "inventory", migration_ready: false,
36
+ facts: facts, blockers: codes.uniq.map { |code| {code: code.to_s, action: ACTIONS.fetch(code)} }}
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Core
5
+ class MobileAuthentication
6
+ def initialize(sessions:, profile:, intake:, verify_password:)
7
+ @sessions, @profile, @intake, @verify_password = sessions, profile, intake, verify_password
8
+ end
9
+
10
+ def password(identifier:, password:, client_id:, ip:, user_agent: nil, challenge_token: nil)
11
+ admitted = @intake.call(identifier: identifier, ip: ip, action: :sign_in, challenge_token: challenge_token)
12
+ return Result.failure(reason: admitted) if admitted.is_a?(Symbol)
13
+ return Result.failure(reason: :invalid_credentials) unless @profile&.client?(client_id) && password.is_a?(String) &&
14
+ password.valid_encoding? && password.bytesize.between?(1, 1024) && !password.include?("\0")
15
+
16
+ @sessions.authenticate_result(identifier: admitted, password: password, transport: :mobile, client_id: client_id, disclose_policy: true,
17
+ ip_address: hint(ip, 128), user_agent: hint(user_agent, 512)) do
18
+ @verify_password.call(identifier: admitted, password: password)
19
+ end
20
+ end
21
+
22
+ def self.bearer(header)
23
+ return unless header.is_a?(String) && header.bytesize == 54
24
+ match = /\ABearer (am1:[A-Za-z0-9_-]{43})\z/i.match(header)
25
+ match[1] if match && MobileProfile::PATTERN.match?(match[1])
26
+ end
27
+
28
+ private
29
+
30
+ def hint(value, limit)
31
+ value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= limit && !value.match?(/[\x00-\x1f\x7f]/)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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