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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module ProviderLibraries
6
+ # Mint the form token with the actual maintained OmniAuth validator. A
7
+ # Rails adopter may already configure its session key; using a separate
8
+ # default Rack validator would generate a token that cannot be accepted.
9
+ module RequestProtection
10
+ module_function
11
+
12
+ def validator
13
+ return unless defined?(::OmniAuth::AuthenticityTokenProtection)
14
+ require "rack/protection"
15
+ phase = ::OmniAuth.config.request_validation_phase
16
+ candidate = (phase.is_a?(Class) && phase <= ::Rack::Protection::AuthenticityToken) ? phase.new : phase
17
+ candidate if candidate.is_a?(::Rack::Protection::AuthenticityToken) || rails_validator?(candidate)
18
+ end
19
+
20
+ def token(session:, rails_token: nil)
21
+ configured = validator
22
+ raise AddAuth::Error, "configure a supported OmniAuth request validator" unless configured
23
+ if rails_validator?(configured)
24
+ raise AddAuth::Error, "Rails request token is unavailable" unless rails_token.respond_to?(:call)
25
+ return rails_token.call
26
+ end
27
+ configured.mask_authenticity_token(session)
28
+ end
29
+
30
+ def parameter
31
+ configured = validator
32
+ raise AddAuth::Error, "configure a supported OmniAuth request validator" unless configured
33
+ rails_validator?(configured) ? ::ActionController::Base.request_forgery_protection_token : configured.options.fetch(:authenticity_param)
34
+ end
35
+
36
+ def rails_validator?(candidate)
37
+ defined?(::OmniAuth::RailsCsrfProtection::TokenVerifier) && candidate.is_a?(::OmniAuth::RailsCsrfProtection::TokenVerifier)
38
+ end
39
+ private_class_method :rails_validator?
40
+ end
41
+ end
42
+ end
43
+ end
@@ -14,10 +14,147 @@ module AddAuth
14
14
 
15
15
  def config = AddAuth.configuration
16
16
 
17
+ def account_policy
18
+ Core::AccountPolicy.new(enabled: config.lifecycle.enabled, eligible: config.lifecycle.eligible)
19
+ end
20
+
21
+ def eligible(user)
22
+ config.eligible.call(user) == true && account_policy.allowed?(user)
23
+ end
24
+
25
+ def authority
26
+ require "add_auth/rails/stores/authority"
27
+ tokens = []
28
+ tokens << ::AddAuthSignInToken if defined?(::AddAuthSignInToken) && ::AddAuthSignInToken.table_exists?
29
+ tokens << ::AddAuthAccountToken if defined?(::AddAuthAccountToken) && ::AddAuthAccountToken.table_exists?
30
+ ceremonies = []
31
+ ceremonies << ::AddAuthCeremony if defined?(::AddAuthCeremony) && ::AddAuthCeremony.table_exists?
32
+ ceremonies << ::AddAuthExternalTransaction if defined?(::AddAuthExternalTransaction) && ::AddAuthExternalTransaction.table_exists?
33
+ ceremonies << ::AddAuthMobileHandoff if defined?(::AddAuthMobileHandoff) && ::AddAuthMobileHandoff.table_exists?
34
+ invalidator = if defined?(::AddAuthExternalIdentity) && ::AddAuthExternalIdentity.table_exists?
35
+ ->(user_id:, at:) { Core::ExternalIdentities.invalidate_credentials_in_transaction(store: external_identity_store, user_id: user_id, at: at) }
36
+ end
37
+ Stores::Authority.new(user_model: ::User, session_model: ::Session, token_models: tokens, ceremony_models: ceremonies, external_invalidator: invalidator)
38
+ end
39
+
40
+ def external_identity_store
41
+ require "add_auth/rails/stores/external_identities"
42
+ Stores::ExternalIdentities.new(user_model: ::User, identity_model: ::AddAuthExternalIdentity, transaction_model: ::AddAuthExternalTransaction)
43
+ end
44
+
45
+ def external_provider(id)
46
+ config.external_identities.provider(id) if config.external_identities.enabled
47
+ end
48
+
49
+ def external_reauthentication? = config.external_identities.enabled && config.external_identities.providers.any?(&:reauthentication?)
50
+
51
+ def external_identities
52
+ options = config.external_identities
53
+ raise AddAuth::Error, "enable external identities and register a provider first" unless options.enabled && options.configurations.any?
54
+ Core::ExternalIdentities.new(store: external_identity_store, configurations: options.configurations,
55
+ sessions: sessions, access_policy: access_policy, policy: step_up_policy, eligible: method(:eligible),
56
+ digest: config.sign_in_token_digest, revoke_authority: authority.method(:revoke),
57
+ remaining_method: remaining_factors,
58
+ enrollment_eligible: ->(user) { account_policy.allowed?(user, purpose: :confirm) }, enabled: true, mobile_enabled: config.mobile.enabled)
59
+ end
60
+
61
+ def remaining_factors
62
+ require "add_auth/core/remaining_factors"
63
+ require "add_auth/core/passwords/credential"
64
+ credential = Core::Passwords::Credential.new(legacy_verifier: config.legacy_password_verifier)
65
+ Core::RemainingFactors.new(access_policy: access_policy,
66
+ passkey_count: ->(user) { (defined?(::AddAuthCredential) && ::AddAuthCredential.table_exists?) ? ::AddAuthCredential.where(user_id: user.id, revoked_at: nil).count : 0 },
67
+ password_available: ->(user) { credential.available?(user: user, current: config.current_password_support) })
68
+ end
69
+
70
+ def provider_enrollment
71
+ require "add_auth/rails/provider_enrollment"
72
+ ProviderEnrollment.new(model: ::AddAuthExternalTransaction, digest: config.sign_in_token_digest, key: key("external-enrollment"))
73
+ end
74
+
75
+ def accounts
76
+ raise AddAuth::Error, "enable account lifecycle first" unless config.lifecycle.enabled
77
+ require "add_auth/rails/stores/account_tokens"
78
+ Core::AccountLifecycle.new(store: Stores::AccountTokens.new(user_model: ::User, token_model: ::AddAuthAccountToken,
79
+ session_model: ::Session, address_model: ::AddAuthAddressClaim, authority: authority, provision: config.lifecycle.provision, delete_account: config.lifecycle.delete_account),
80
+ digest: config.sign_in_token_digest, delivery_cipher: DeliveryCipher.new(key: key("account-proofs")),
81
+ policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: config.trusted_recovery_address,
82
+ lifetime: config.lifecycle.proof_lifetime, notify: ->(**event) { security_events.issue(**event) if config.notifications.enabled },
83
+ sessions: sessions, step_up_policy: step_up_policy, profile_attributes: config.lifecycle.profile_attributes, deletion_allowed: config.lifecycle.deletion_allowed,
84
+ external_identities: -> { external_identities })
85
+ end
86
+
87
+ def enqueue_account_request(identifier:, purpose:)
88
+ job = ::AddAuth::AccountRequestJob.perform_later(encrypt_intake(identifier: identifier, purpose: purpose))
89
+ raise AddAuth::Error, "account request queue unavailable" unless job
90
+ job
91
+ rescue
92
+ raise AddAuth::Error, "account request queue unavailable", cause: nil
93
+ end
94
+
95
+ def account_proof_url(token, purpose:)
96
+ raise AddAuth::Error, "unsupported account proof" unless Core::AccountLifecycle::PURPOSES.include?(purpose)
97
+ authentication_url("/account/proofs/#{purpose}", token: token)
98
+ end
99
+
17
100
  def sessions
18
101
  Core::Sessions.new(store: Stores::Sessions.new(user_model: ::User, session_model: ::Session),
19
- digest: config.session_token_digest, eligible: config.eligible, lifetime: config.session.lifetime,
20
- idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy)
102
+ digest: config.session_token_digest, eligible: method(:eligible), lifetime: config.session.lifetime,
103
+ idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy,
104
+ password_lifecycle: password_lifecycle,
105
+ mobile_profile: mobile_profile,
106
+ verified_denial: ->(user) { (config.eligible.call(user) == true) ? account_policy.denial(user) : :disabled },
107
+ remembered_profile: config.lifecycle.enabled ? {lifetime: config.lifecycle.remember_lifetime, idle_timeout: config.lifecycle.remember_idle_timeout} : nil,
108
+ on_sign_in: method(:record_sign_in))
109
+ end
110
+
111
+ def mobile_profile
112
+ options = config.mobile
113
+ return unless options.enabled
114
+ raise AddAuth::Error, "enable session_upgrade before mobile sessions" unless config.session.enabled
115
+ Core::MobileProfile.new(lifetime: options.lifetime, idle_timeout: options.idle_timeout, clients: options.clients, callbacks: options.callbacks)
116
+ end
117
+
118
+ def mobile_authentication
119
+ require "add_auth/core/mobile_authentication"
120
+ Core::MobileAuthentication.new(sessions: sessions, profile: mobile_profile,
121
+ intake: intake, verify_password: method(:authenticate_password))
122
+ end
123
+
124
+ def mobile_handoffs
125
+ require "add_auth/core/mobile_handoffs"
126
+ require "add_auth/rails/stores/mobile_handoffs"
127
+ Core::MobileHandoffs.new(store: Stores::MobileHandoffs.new(user_model: ::User, handoff_model: ::AddAuthMobileHandoff, session_model: ::Session),
128
+ profile: mobile_profile, sessions: sessions, access_policy: access_policy, digest: config.sign_in_token_digest)
129
+ end
130
+
131
+ def native_apple
132
+ require "add_auth/core/native_authentication"
133
+ require "add_auth/rails/provider_libraries/apple_native"
134
+ require "add_auth/rails/provider_libraries/apple_jwks"
135
+ raise AddAuth::Error, "install and require ruby-jwt for native Apple authentication" unless defined?(::JWT)
136
+ raise AddAuth::Error, "configure native Apple providers as an explicit client map" unless config.mobile.apple_providers.is_a?(Hash)
137
+ providers = config.mobile.apple_providers.transform_values { |id| config.external_identities.native_provider(id) }
138
+ Core::NativeAuthentication.new(external_identities: external_identities, profile: mobile_profile,
139
+ providers: providers, intake: intake, accounts: (method(:accounts) if config.lifecycle.enabled), verify: ->(**arguments) {
140
+ ProviderLibraries::AppleNative::CallbackResult.capture(**arguments, jwks: ProviderLibraries::AppleJwks.new(cache: ::Rails.cache))
141
+ })
142
+ end
143
+
144
+ def record_sign_in(user:, session:, at:)
145
+ require "add_auth/rails/stores/commit_dispatch"
146
+ Stores::CommitDispatch.event(transaction: ::User.current_transaction, event: "session_created.add_auth",
147
+ payload: {user_id: user.id, session_id: session.id, method: session.authenticated_with, occurred_at: at})
148
+ end
149
+
150
+ def password_lifecycle
151
+ return unless config.lifecycle.enabled || ::User.column_names.include?("add_auth_password_scheme")
152
+ require "add_auth/core/passwords/lifecycle"
153
+ require "add_auth/rails/stores/account_credentials"
154
+ Core::Passwords::Lifecycle.new(store: Stores::AccountCredentials.new(user_model: ::User, authority: authority),
155
+ policy: method(:eligible), enabled: config.lifecycle.enabled,
156
+ maximum_attempts: config.lifecycle.maximum_attempts, unlock_in: config.lifecycle.unlock_in,
157
+ issue_unlock: ->(user) { accounts.issue_unlock_in_transaction(user: user) })
21
158
  end
22
159
 
23
160
  def security_events
@@ -31,7 +168,9 @@ module AddAuth
31
168
  Core::AccessPolicy.new(credentials: ->(id) {
32
169
  ::AddAuthCredential.find_by(external_id: id) if defined?(::AddAuthCredential) && ::AddAuthCredential.table_exists?
33
170
  }, passkeys_enabled: config.passkeys.enabled, email_enabled: Core::Intake.email_available?(config),
34
- trusted_recovery_address: config.trusted_recovery_address, password_enabled: config.passwords_enabled)
171
+ trusted_recovery_address: config.trusted_recovery_address, password_enabled: config.passwords_enabled,
172
+ external_enabled: config.external_identities.enabled,
173
+ external_current: ->(**arguments) { external_identities.credential_current?(**arguments) })
35
174
  end
36
175
 
37
176
  def email(purpose: :sign_in)
@@ -41,7 +180,7 @@ module AddAuth
41
180
  raise AddAuth::Error, "enable session_upgrade and email_link first" unless Core::Intake.email_available?(config)
42
181
  Core::Strategies::EmailLink.new(store: Stores::EmailTokens.new(user_model: ::User,
43
182
  token_model: ::AddAuthSignInToken, session_model: ::Session), digest: config.sign_in_token_digest,
44
- delivery_cipher: DeliveryCipher.new(key: key("delivery")), eligible: config.eligible,
183
+ delivery_cipher: DeliveryCipher.new(key: key("delivery")), eligible: method(:eligible),
45
184
  normalize_identifier: method(:normalize), identifier_for: ->(user) { user.email_address },
46
185
  token_lifetime: {"reauthentication" => 300, "recovery" => 1200}.fetch(purpose.to_s, config.email_link.token_lifetime),
47
186
  same_browser: (purpose.to_s == "recovery") ? false : config.email_link.same_browser, purpose: purpose,
@@ -82,7 +221,23 @@ module AddAuth
82
221
  else
83
222
  {}
84
223
  end
85
- {sign_out_everywhere: {methods: [:password, :email_link, :passkey], return_to: "/sessions/revoke-all", label: "sign out everywhere"}}.merge(defaults).merge(config.step_up.purposes)
224
+ if config.lifecycle.enabled
225
+ defaults = defaults.merge(
226
+ change_email: {methods: [:password, :email_link, :passkey], return_to: "/account/email", label: "change your email address"},
227
+ change_password: {methods: [:password, :email_link, :passkey], return_to: "/account/password", label: "change your password"},
228
+ delete_account: {methods: [:password, :email_link, :passkey], return_to: "/account/delete", label: "delete your account"}
229
+ )
230
+ end
231
+ if config.external_identities.enabled
232
+ defaults = defaults.merge(
233
+ link_external_identity: {methods: [:password, :email_link, :passkey], return_to: "/account/external-identities", label: "link a sign-in provider"},
234
+ unlink_external_identity: {methods: [:password, :email_link, :passkey], return_to: "/account/external-identities", label: "remove a sign-in provider"}
235
+ )
236
+ defaults = defaults.transform_values do |rule|
237
+ (!external_reauthentication? || rule[:require_passkey] || rule[:reauthentication] == false) ? rule : rule.merge(methods: (rule[:methods] + [:external_identity]).uniq)
238
+ end
239
+ end
240
+ {sign_out_everywhere: {methods: [:password, :email_link, :passkey, *(:external_identity if external_reauthentication?)], return_to: "/sessions/revoke-all", label: "sign out everywhere"}}.merge(defaults).merge(config.step_up.purposes)
86
241
  end
87
242
 
88
243
  def passkeys
@@ -91,7 +246,7 @@ module AddAuth
91
246
  Core::Strategies::Passkey.new(store: Stores::Passkeys.new(user_model: ::User, session_model: ::Session,
92
247
  credential_model: ::AddAuthCredential, ceremony_model: ::AddAuthCeremony, token_model: ::AddAuthSignInToken), sessions: sessions,
93
248
  policy: step_up_policy, access_policy: access_policy, digest: config.sign_in_token_digest,
94
- eligible: config.eligible, rp_id: config.passkeys.rp_id, origins: config.passkeys.origins, name: config.passkeys.name,
249
+ eligible: method(:eligible), rp_id: config.passkeys.rp_id, origins: config.passkeys.origins, name: config.passkeys.name,
95
250
  limiter: method(:limit), anonymous_limit: config.passkeys.anonymous_limit,
96
251
  notify: ->(**event) { security_events.issue(**event) }, allow_localhost: !::Rails.env.production?, support_url: config.support_url,
97
252
  on_failure: ->(reason) { ::ActiveSupport::Notifications.instrument("passkey_failure.add_auth", reason: reason) })
@@ -103,6 +258,7 @@ module AddAuth
103
258
  password_version: ->(user) { config.sign_in_token_digest.digest("password:#{user.password_digest}") },
104
259
  credential_current: ->(user:, evidence:) {
105
260
  evidence.method == :password ||
261
+ (evidence.method == :external_identity && access_policy.external_credential_current?(user: user, id: evidence.credential_id, version: evidence.credential_version)) ||
106
262
  (evidence.method == :passkey && access_policy.credential_current?(user: user, id: evidence.credential_id) &&
107
263
  ::AddAuthCredential.find_by(external_id: evidence.credential_id)&.public_key == evidence.credential_version) ||
108
264
  (evidence.method == :email_link && Core::Intake.email_available?(config) &&
@@ -204,14 +360,20 @@ module AddAuth
204
360
  end
205
361
 
206
362
  def sign_in_url(token, purpose: "sign_in")
363
+ path = {"sign_in" => "/sign-in/link", "reauthentication" => "/reauthenticate/link", "recovery" => "/recover/link"}.fetch(purpose.to_s)
364
+ authentication_url(path, token: token)
365
+ end
366
+
367
+ def authentication_url(path, **query)
207
368
  uri = URI.parse(config.base_url.to_s)
208
369
  valid = uri.host && !uri.userinfo && !uri.query && !uri.fragment && ["", "/"].include?(uri.path) &&
209
370
  (uri.scheme == "https" || (!::Rails.env.production? && uri.scheme == "http"))
210
371
  unless valid
211
372
  raise AddAuth::Error, "configure base_url as a fixed HTTPS origin (HTTP allowed only outside production)"
212
373
  end
213
- path = {"sign_in" => "/sign-in/link", "reauthentication" => "/reauthenticate/link", "recovery" => "/recover/link"}.fetch(purpose.to_s)
214
- "#{uri.to_s.delete_suffix("/")}#{path}?token=#{URI.encode_www_form_component(token)}"
374
+ "#{uri.to_s.delete_suffix("/")}#{path}?#{URI.encode_www_form(query)}"
375
+ rescue URI::InvalidURIError
376
+ raise AddAuth::Error, "configure a fixed HTTPS origin", cause: nil
215
377
  end
216
378
  end
217
379
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Stores
6
+ class AccountCredentials
7
+ def initialize(user_model:, authority:)
8
+ @users, @authority = user_model, authority
9
+ end
10
+
11
+ def update_account(user:, **attributes)
12
+ raise AddAuth::Error, "credential changes require an account transaction" unless @users.connection.transaction_open?
13
+ user.update_columns(attributes)
14
+ user.reload
15
+ rescue ActiveRecord::RecordNotUnique
16
+ raise Core::AccountLifecycle::Conflict
17
+ end
18
+
19
+ def replace_password(user:, password:)
20
+ raise AddAuth::Error, "credential changes require an account transaction" unless @users.connection.transaction_open?
21
+ user.password = password
22
+ unless user.valid?
23
+ user.reload
24
+ raise Core::AccountLifecycle::InvalidPassword
25
+ end
26
+ persist_password(user: user, digest: user.password_digest)
27
+ end
28
+
29
+ def rehash_password(user:, password:)
30
+ fresh = @users.new(password: password)
31
+ persist_password(user: user, digest: fresh.password_digest)
32
+ end
33
+
34
+ def revoke_authority(user:, at:) = @authority.revoke(user_id: user.id, at: at)
35
+
36
+ private
37
+
38
+ def persist_password(user:, digest:)
39
+ update_account(user: user, password_digest: digest, add_auth_password_scheme: "rails")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -20,13 +20,7 @@ module AddAuth
20
20
  transaction.after_commit { completed = true }
21
21
  # Opposing account switches acquire their locks in the same order.
22
22
  accounts = [id, additional_id].compact.uniq.sort_by(&:to_s).map do |account_id|
23
- if @users.connection.adapter_name == "SQLite"
24
- pk = @users.connection.quote_column_name(@users.primary_key)
25
- @users.where(@users.primary_key => account_id).update_all("#{pk} = #{pk}")
26
- @users.find_by(@users.primary_key => account_id)
27
- else
28
- @users.lock.find_by(@users.primary_key => account_id)
29
- end
23
+ current_in_transaction(id: account_id)
30
24
  end
31
25
  result = yield accounts.compact.find { |account| account.id == id }
32
26
  end
@@ -34,6 +28,21 @@ module AddAuth
34
28
  raise AddAuth::Error, "authentication transaction rolled back" unless completed
35
29
  result
36
30
  end
31
+
32
+ # For model save callbacks, whose Active Record transaction already owns
33
+ # the write. All actual row-lock acquisition lives in this adapter.
34
+ def current_in_transaction(id:)
35
+ raise AddAuth::Error, "account row locking requires an owning transaction" unless @users.connection.transaction_open?
36
+ @users.uncached do
37
+ if @users.connection.adapter_name == "SQLite"
38
+ pk = @users.connection.quote_column_name(@users.primary_key)
39
+ @users.unscoped.where(@users.primary_key => id).update_all("#{pk} = #{pk}")
40
+ @users.unscoped.find_by(@users.primary_key => id)
41
+ else
42
+ @users.unscoped.lock.find_by(@users.primary_key => id)
43
+ end
44
+ end
45
+ end
37
46
  end
38
47
  end
39
48
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/stores/email_tokens"
4
+ require "add_auth/rails/stores/commit_dispatch"
5
+ require "add_auth/rails/stores/account_credentials"
6
+
7
+ module AddAuth
8
+ module Rails
9
+ module Stores
10
+ class AccountTokens < EmailTokens
11
+ def initialize(user_model:, token_model:, session_model:, address_model:, authority:, provision:, delete_account: ->(user) { user.destroy! })
12
+ super(user_model: user_model, token_model: token_model, session_model: session_model)
13
+ raise ArgumentError, "address claims must share the account connection pool" unless address_model.connection_pool.equal?(user_model.connection_pool)
14
+ @addresses, @authority, @provision = address_model, authority, provision
15
+ @delete_account = delete_account
16
+ @account_credentials = AccountCredentials.new(user_model: user_model, authority: authority)
17
+ end
18
+
19
+ def create_account(email:, password:, profile: {})
20
+ raise AddAuth::Error, "registration must own its transaction" if @users.connection.transaction_open?
21
+ completed = false
22
+ @users.transaction do |transaction|
23
+ transaction.after_commit { completed = true }
24
+ user = @users.new(profile)
25
+ user.email_address = email
26
+ user.password = password
27
+ user.add_auth_authority = "add_auth"
28
+ user.save!
29
+ yield user
30
+ end
31
+ raise AddAuth::Error, "registration transaction rolled back" unless completed
32
+ :created
33
+ rescue ActiveRecord::RecordNotUnique, Core::AccountLifecycle::Conflict
34
+ :duplicate
35
+ rescue ActiveRecord::RecordInvalid => error
36
+ (error.record.errors.attribute_names - [:email_address]).empty? ? :duplicate : :invalid
37
+ end
38
+
39
+ def replace_pending(**attributes)
40
+ raise AddAuth::Error, "account proof must join its owning transaction" unless @users.current_transaction.open?
41
+ record = super
42
+ CommitDispatch.enqueue(transaction: @users.current_transaction, job: ::AddAuth::AccountDeliveryJob,
43
+ id: record.id, failure_event: "account_enqueue_failed.add_auth", payload: {issuance_id: record.id})
44
+ record
45
+ end
46
+
47
+ def claim_address(user:, digest:, address:, state:)
48
+ raise Core::AccountLifecycle::Conflict if @users.where(email_address: address).where.not(id: user.id).exists?
49
+ @addresses.where(user_id: user.id, state: state).where.not(digest: digest).delete_all
50
+ existing = @addresses.find_by(digest: digest)
51
+ if existing
52
+ raise Core::AccountLifecycle::Conflict unless existing.user_id == user.id && existing.state == state
53
+ else
54
+ @addresses.create!(user_id: user.id, digest: digest, state: state)
55
+ end
56
+ rescue ActiveRecord::RecordNotUnique
57
+ raise Core::AccountLifecycle::Conflict
58
+ end
59
+
60
+ def promote_address(user:, digest:)
61
+ pending = @addresses.find_by!(user_id: user.id, digest: digest, state: "pending")
62
+ @addresses.where(user_id: user.id, state: "current").delete_all
63
+ pending.update!(state: "current")
64
+ end
65
+
66
+ def inspect_token(digest:)
67
+ @tokens.uncached do
68
+ record = @tokens.find_by(digest: digest)
69
+ [record && @users.find_by(id: record.user_id), record]
70
+ end
71
+ end
72
+
73
+ def update_account(user:, **attributes)
74
+ # Core owns notices and invalidation for this transition. Avoid firing
75
+ # a second independent model callback for the same committed change.
76
+ @account_credentials.update_account(user: user, **attributes)
77
+ end
78
+
79
+ def replace_password(user:, password:)
80
+ @account_credentials.replace_password(user: user, password: password)
81
+ end
82
+
83
+ def revoke_authority(user:, at:) = @authority.revoke(user_id: user.id, at: at)
84
+ def provision(user:) = @provision.call(user)
85
+
86
+ def delete_account(user:)
87
+ raise AddAuth::Error, "account deletion requires its owning transaction" unless @users.connection.transaction_open?
88
+ @delete_account.call(user)
89
+ raise Core::AccountLifecycle::DeletionRejected if @users.unscoped.exists?(id: user.id)
90
+ rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::InvalidForeignKey
91
+ raise Core::AccountLifecycle::DeletionRejected, cause: nil
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Stores
6
+ # One persistence implementation for retiring outstanding account authority.
7
+ # Core chooses the event, account and time; callers own the account lock.
8
+ class Authority
9
+ def initialize(user_model:, session_model:, token_models: [], ceremony_models: [], credential_models: [], external_invalidator: nil)
10
+ @external_invalidator = external_invalidator
11
+ @users, @sessions, @tokens, @ceremonies, @credentials = user_model, session_model, token_models, ceremony_models, credential_models
12
+ unless [session_model, *token_models, *ceremony_models, *credential_models].all? { |model| model.connection_pool.equal?(user_model.connection_pool) }
13
+ raise ArgumentError, "authentication models must share one connection pool"
14
+ end
15
+ end
16
+
17
+ def revoke(user_id:, at:)
18
+ raise AddAuth::Error, "authority revocation requires an account transaction" unless @users.connection.transaction_open?
19
+ @external_invalidator&.call(user_id: user_id, at: at)
20
+ @sessions.where(user_id: user_id, revoked_at: nil).update_all(revoked_at: at)
21
+ @tokens.each { |model| model.where(user_id: user_id, consumed_at: nil, revoked_at: nil).update_all(revoked_at: at, delivery_payload: nil) }
22
+ @ceremonies.each { |model| model.where(user_id: user_id, consumed_at: nil).update_all(consumed_at: at) }
23
+ @credentials.each { |model| model.where(user_id: user_id, revoked_at: nil).update_all(revoked_at: at) }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module Stores
6
+ module CommitDispatch
7
+ module_function
8
+
9
+ def enqueue(transaction:, job:, id:, failure_event:, payload:)
10
+ raise AddAuth::Error, "outbox dispatch must join its owning transaction" unless transaction.open?
11
+ transaction.after_commit do
12
+ raise AddAuth::Error unless job.perform_later(id)
13
+ rescue
14
+ notify(event: failure_event, payload: payload)
15
+ end
16
+ end
17
+
18
+ def event(transaction:, event:, payload:)
19
+ raise AddAuth::Error, "commit events must join their owning transaction" unless transaction.open?
20
+ transaction.after_commit { notify(event: event, payload: payload) }
21
+ end
22
+
23
+ def notify(event:, payload:)
24
+ ActiveSupport::Notifications.instrument(event, payload)
25
+ rescue
26
+ # Optional observers cannot report a committed operation as failed.
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "add_auth/rails/stores/account_lock"
5
+
6
+ module AddAuth
7
+ module Rails
8
+ module Stores
9
+ class ExternalIdentities
10
+ def initialize(user_model:, identity_model:, transaction_model:)
11
+ @users, @identities, @transactions = user_model, identity_model, transaction_model
12
+ unless [identity_model, transaction_model].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
13
+ raise ArgumentError, "authentication models must share one connection pool"
14
+ end
15
+ @lock = AccountLock.new(@users)
16
+ end
17
+
18
+ def with_user(id:, &block) = @lock.call(id: id, &block)
19
+
20
+ def with_identity(namespace:, replacing: nil)
21
+ @identities.uncached do
22
+ locator = binding(namespace: namespace)
23
+ @lock.call(id: locator&.user_id, additional_id: replacing&.user_id) do |user|
24
+ yield user, user && binding(namespace: namespace)
25
+ end
26
+ end
27
+ end
28
+
29
+ # The G3 capability establishes fresh-account provenance. The adapter
30
+ # additionally enforces the exact user model/pool and owning transaction
31
+ # before a pending transaction can be consumed.
32
+ def registration_account(user:)
33
+ return unless @users.connection.transaction_open? && user.is_a?(@users) && user.persisted? && !user.destroyed?
34
+ @users.uncached { @users.find_by(id: user.id) }
35
+ end
36
+
37
+ def binding(namespace:) = @identities.find_by(namespace: namespace)
38
+ def binding_for_user(user_id:, id:) = @identities.find_by(user_id: user_id, id: id)
39
+ def bindings(user_id:) = @identities.where(user_id: user_id, revoked_at: nil).to_a
40
+
41
+ def bind(user:, evidence:, at:)
42
+ require_transaction!
43
+ # A savepoint contains unique-index failures on PostgreSQL. Account
44
+ # locks serialize same-owner callbacks; the unique namespace index
45
+ # arbitrates different owners without choosing one by email.
46
+ @identities.transaction(requires_new: true) do
47
+ identity = binding(namespace: evidence.namespace)
48
+ if identity
49
+ return unless identity.user_id == user.id
50
+ return identity unless identity.revoked_at
51
+ identity.update!(revoked_at: nil, linked_at: at, credential_version: SecureRandom.hex(16), provenance: evidence.provenance)
52
+ identity
53
+ else
54
+ @identities.create!(user_id: user.id, namespace: evidence.namespace, provider_id: evidence.provider_id,
55
+ issuer: evidence.issuer, audience: evidence.audience, subject: evidence.subject,
56
+ provenance: evidence.provenance, linked_at: at, credential_version: SecureRandom.hex(16))
57
+ end
58
+ end
59
+ rescue ActiveRecord::RecordNotUnique
60
+ nil
61
+ end
62
+
63
+ def revoke(identity:, at:)
64
+ require_transaction!
65
+ identity.update!(revoked_at: at, credential_version: SecureRandom.hex(16))
66
+ end
67
+
68
+ def invalidate_credentials(user_id:, at:, credential_version:)
69
+ require_transaction!
70
+ # Never move the cutoff backwards if a delayed invalidation arrives.
71
+ @identities.where(user_id: user_id, revoked_at: nil).where("invalidated_at IS NULL OR invalidated_at <= ?", at)
72
+ .update_all(invalidated_at: at, credential_version: credential_version, updated_at: at)
73
+ end
74
+
75
+ def create_transaction(**attributes) = @transactions.create!(**attributes)
76
+ def transaction_by_digest(digest:) = @transactions.uncached { @transactions.find_by(digest: digest) }
77
+
78
+ def consume_transaction(digest:, at:)
79
+ @transactions.where(digest: digest, consumed_at: nil).where("issued_at <= ? AND expires_at > ?", at, at)
80
+ .update_all(consumed_at: at) == 1
81
+ end
82
+
83
+ private
84
+
85
+ def require_transaction!
86
+ raise AddAuth::Error, "identity mutation requires an account transaction" unless @users.connection.transaction_open?
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -12,7 +12,7 @@ module AddAuth
12
12
  scope = @model.where("expires_at <= ?", before)
13
13
  if @kind == :session
14
14
  scope = scope.or(@model.where("revoked_at <= ?", before))
15
- elsif @kind != :ceremony
15
+ elsif !Core::Maintenance::EXPIRING.key?(@kind)
16
16
  scope = without_live_lease(scope, now)
17
17
  end
18
18
  bounded(scope, limit).delete_all