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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ # This transport never reads/writes browser cookies or invokes the host's
5
+ # browser Authentication concern. Hosts may map the same Core result to a
6
+ # versioned application DTO through Runtime.mobile_authentication.
7
+ class MobileSessionsController < ActionController::API
8
+ before_action :private_response
9
+ before_action :require_mobile_feature
10
+ before_action :require_native_feature, only: %i[apple_challenge apple apple_enroll]
11
+ before_action :require_mobile_session, except: %i[create exchange apple_challenge apple apple_enroll]
12
+ rescue_from AddAuth::Error, ActiveRecord::ActiveRecordError, with: :unavailable
13
+
14
+ def create
15
+ result = Rails::Runtime.mobile_authentication.password(identifier: params[:email_address], password: params[:password],
16
+ client_id: params[:client_id], ip: request.remote_ip, user_agent: request.user_agent, challenge_token: params[:challenge_token])
17
+ return failure(result.reason) unless result.success?
18
+ created(result)
19
+ end
20
+
21
+ def show
22
+ render json: {session_id: @mobile_session.id, user_id: @mobile_session.user_id, expires_at: @mobile_session.expires_at}
23
+ end
24
+
25
+ def exchange
26
+ admitted = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: :mobile_handoff)
27
+ return failure(admitted) if admitted.is_a?(Symbol)
28
+ result = Rails::Runtime.mobile_handoffs.exchange(code: params[:code], state: params[:state],
29
+ code_verifier: params[:code_verifier], client_id: params[:client_id], ip: request.remote_ip, user_agent: request.user_agent)
30
+ return failure(result.reason) unless result.success?
31
+ created(result)
32
+ end
33
+
34
+ def apple_challenge
35
+ result = Rails::Runtime.native_apple.start(client_id: params[:client_id], ip: request.remote_ip, challenge_token: params[:challenge_token],
36
+ intent: params.fetch(:intent, "sign_in"))
37
+ return failure(result.reason) unless result.success?
38
+ challenge = result.credential
39
+ render json: {challenge_id: challenge.id, nonce: challenge.nonce, expires_at: challenge.expires_at}, status: :created
40
+ end
41
+
42
+ def apple
43
+ result = Rails::Runtime.native_apple.complete(client_id: params[:client_id], challenge_id: params[:challenge_id], nonce: params[:nonce],
44
+ identity_token: params[:identity_token], ip: request.remote_ip, user_agent: request.user_agent)
45
+ return failure(result.reason) unless result.success?
46
+ created(result)
47
+ end
48
+
49
+ def apple_enroll
50
+ result = Rails::Runtime.native_apple.enroll(client_id: params[:client_id], challenge_id: params[:challenge_id], nonce: params[:nonce],
51
+ identity_token: params[:identity_token], ip: request.remote_ip, identifier: params[:email_address])
52
+ return failure(result.reason) unless result.success?
53
+ render json: {status: "confirmation_required"}, status: :accepted
54
+ end
55
+
56
+ def index
57
+ page = Rails::Runtime.sessions.list_page(user: @mobile_user, current_session_id: @mobile_session.id, before: params[:before])
58
+ render json: {sessions: page.entries.map { |entry|
59
+ {id: entry.id, current: entry.current, transport: entry.transport, client_id: entry.client_id,
60
+ authenticated_with: entry.authenticated_with, last_seen_at: entry.last_seen_at, expires_at: entry.expires_at}
61
+ }, next_cursor: page.next_cursor}
62
+ end
63
+
64
+ def destroy
65
+ Rails::Runtime.sessions.revoke(session: @mobile_session)
66
+ head :no_content
67
+ end
68
+
69
+ def revoke
70
+ revoked = Rails::Runtime.sessions.revoke_one(user: @mobile_user, session: @mobile_session, session_id: params[:id])
71
+ head(revoked ? :no_content : :not_found)
72
+ end
73
+
74
+ def revoke_all
75
+ result = Rails::Runtime.revoke_all(user: @mobile_user, session: @mobile_session,
76
+ password: params[:password], ip: request.remote_ip, challenge_token: params[:challenge_token])
77
+ result.success? ? head(:no_content) : failure(result.reason)
78
+ end
79
+
80
+ private
81
+
82
+ def created(result)
83
+ render json: {token: result.grant.bearer, token_type: "Bearer", expires_at: result.session.expires_at,
84
+ session_id: result.session.id, user_id: result.user.id}, status: :created
85
+ end
86
+
87
+ def require_mobile_session
88
+ require "add_auth/core/mobile_authentication"
89
+ bearer = Core::MobileAuthentication.bearer(request.authorization)
90
+ grant = Rails::Runtime.sessions.resume_mobile(bearer: bearer)
91
+ return failure(:invalid_credentials) unless grant
92
+ @mobile_session = grant.session
93
+ @mobile_user = grant.session.user
94
+ end
95
+
96
+ def require_mobile_feature
97
+ head :not_found unless Rails::Runtime.config.mobile.enabled
98
+ end
99
+
100
+ def require_native_feature
101
+ return unavailable unless Rails::Runtime.config.mobile.apple_providers.is_a?(Hash)
102
+ head :not_found if Rails::Runtime.config.mobile.apple_providers.empty?
103
+ end
104
+
105
+ def private_response
106
+ response.set_header("Cache-Control", "no-store")
107
+ response.set_header("Referrer-Policy", "no-referrer")
108
+ head :upgrade_required if ::Rails.env.production? && !request.ssl?
109
+ end
110
+
111
+ def failure(reason)
112
+ status = Core::MobileResponse.status(reason)
113
+ response.set_header("Retry-After", "300") if [429, 503].include?(status)
114
+ response.set_header("WWW-Authenticate", "Bearer") if status == 401
115
+ render json: {error: reason}, status: status
116
+ end
117
+
118
+ def unavailable = failure(:challenge_unavailable)
119
+ end
120
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "add_auth/rails/provider_libraries/apple_form_post_correlation"
4
+ require "add_auth/rails/provider_libraries/omniauth"
5
+ require "add_auth/rails/provider_libraries/omniauth_correlation"
6
+ require "add_auth/rails/provider_libraries/request_protection"
7
+
8
+ module AddAuth
9
+ # The protocol library has already verified the callback before this
10
+ # controller sees it. This controller only starts/consumes a Core
11
+ # transaction and maps its typed result to the normal Rails session/UI.
12
+ class ProviderSignInsController < ::ApplicationController
13
+ include Rails::AuthenticationPages
14
+
15
+ skip_before_action :require_authentication, raise: false
16
+ before_action :private_response
17
+ before_action :load_provider
18
+ before_action :require_add_auth_authentication, unless: :anonymous_or_callback?
19
+ protect_from_forgery with: :exception, except: :callback
20
+ helper AddAuth::SignInsHelper
21
+ layout "add_auth/authentication"
22
+ rescue_from AddAuth::Error, with: :provider_unavailable
23
+
24
+ # This deliberately renders an ordinary confirmation form instead of
25
+ # redirecting to /auth/:provider: OmniAuth 2 accepts a CSRF-protected POST,
26
+ # and a 303 would turn that navigation into an unsafe GET. It is equally
27
+ # usable with Turbo, inside a frame, and with JavaScript disabled.
28
+ def enrollment
29
+ return head :not_found unless Rails::Runtime.config.lifecycle.enabled
30
+ page("enrollment")
31
+ end
32
+
33
+ def prepare
34
+ return head :not_found if mobile? && !Rails::Runtime.config.mobile.enabled
35
+ if enrollment?
36
+ return head :not_found unless Rails::Runtime.config.lifecycle.enabled
37
+ @enrollment_identifier = Rails::Runtime.intake.call(identifier: params[:email_address], ip: request.remote_ip,
38
+ action: :register, challenge_token: challenge_token)
39
+ return provider_intake_failure(@enrollment_identifier) if @enrollment_identifier.is_a?(Symbol)
40
+ else
41
+ admitted = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: (params[:flow].to_s == "reauthenticate") ? :reauthenticate : :provider, challenge_token: challenge_token)
42
+ return provider_intake_failure(admitted) if admitted.is_a?(Symbol)
43
+ end
44
+ if params[:flow].to_s == "link"
45
+ elevation = Rails::Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
46
+ purpose: Core::ExternalIdentities::LINK, policy: Rails::Runtime.step_up_policy)
47
+ return redirect_to "/reauthenticate?purpose=link_external_identity", status: :see_other unless elevation.success?
48
+ end
49
+ pending = service.begin_transaction(configuration_id: @provider.configuration.id,
50
+ browser_secret: browser_secret, **transaction_subject)
51
+ return provider_failure(pending.reason) unless pending.success?
52
+
53
+ if mobile?
54
+ accepted = Rails::Runtime.mobile_handoffs.begin_transaction(pending: pending.credential,
55
+ client_id: params[:client_id], callback: params[:callback], state: params[:state],
56
+ code_challenge: params[:code_challenge], code_challenge_method: params[:code_challenge_method])
57
+ unless accepted.success?
58
+ service.cancel(transaction: pending.credential.id, browser_secret: browser_secret)
59
+ return provider_failure(accepted.reason)
60
+ end
61
+ end
62
+
63
+ if enrollment? && !Rails::Runtime.provider_enrollment.write(pending: pending.credential,
64
+ identifier: @enrollment_identifier, profile: registration_profile)
65
+ service.cancel(transaction: pending.credential.id, browser_secret: browser_secret)
66
+ return provider_failure(:invalid_credentials)
67
+ end
68
+ remember_correlation!(pending.credential)
69
+ @provider_path = "/auth/#{@provider.middleware_name}"
70
+ @omniauth_csrf_parameter = Rails::ProviderLibraries::RequestProtection.parameter
71
+ @omniauth_csrf_token = Rails::ProviderLibraries::RequestProtection.token(session: request.env.fetch("rack.session"),
72
+ rails_token: -> { form_authenticity_token })
73
+ @heading = heading_for(pending.credential.purpose)
74
+ # This token-free URL posts to the host's existing OAuth middleware.
75
+ # Preserve its Rails origin check without sending a referrer off-site.
76
+ response.set_header("Referrer-Policy", "same-origin")
77
+ page("prepare")
78
+ end
79
+
80
+ # OmniAuth performs callback state/nonce/code validation. The correlation
81
+ # middleware has attached the opaque Core transaction only after its
82
+ # state check succeeds; no raw params, profile attributes, or tokens are
83
+ # accepted here. Apple's signed callback cookie supplies that same opaque
84
+ # data when its form_post cannot send the normal SameSite=Lax session.
85
+ def callback
86
+ correlation = request.env[correlation_env_key]
87
+ return provider_failure(:invalid_credentials) unless valid_correlation?(correlation)
88
+ return provider_failure(:invalid_credentials) unless correlation.fetch("configuration_id") == @provider.configuration.id
89
+
90
+ pending = service.pending(transaction: correlation.fetch("transaction"), browser_secret: correlation.fetch("browser_secret"))
91
+ return provider_failure(:invalid_credentials) unless pending && pending.configuration.equal?(@provider.configuration) &&
92
+ pending.purpose.to_s == correlation.fetch("purpose")
93
+
94
+ callback = Rails::ProviderLibraries::OmniAuth::CallbackResult.capture(env: request.env, provider: @provider.middleware_name)
95
+ evidence = pending.configuration.verify(server_result: callback, transaction: pending)
96
+ return provider_failure(:invalid_credentials) unless evidence
97
+
98
+ case pending.purpose
99
+ when Core::ExternalIdentities::MOBILE then finish_mobile(evidence)
100
+ when Core::ExternalIdentities::ENROLL then finish_enrollment(pending, evidence)
101
+ when :sign_in then finish_sign_in(evidence)
102
+ when Core::ExternalIdentities::LINK then finish_link(pending, evidence)
103
+ else finish_reauthentication(pending, evidence)
104
+ end
105
+ ensure
106
+ Rails::Runtime.provider_enrollment.erase(pending: pending) if pending&.purpose == Core::ExternalIdentities::ENROLL
107
+ end
108
+
109
+ private
110
+
111
+ def service = Rails::Runtime.external_identities
112
+
113
+ def load_provider
114
+ return head :not_found unless Rails::Runtime.config.external_identities.enabled
115
+
116
+ @provider = Rails::Runtime.config.external_identities.providers.find { |item| item.middleware_name == params[:provider].to_s }
117
+ head :not_found unless @provider
118
+ end
119
+
120
+ def sign_in? = params[:flow].to_s == "sign_in"
121
+ def mobile? = params[:flow].to_s == "mobile"
122
+
123
+ def enrollment? = params[:flow].to_s == "enroll"
124
+ def anonymous_or_callback? = sign_in? || enrollment? || mobile? || action_name == "callback"
125
+ def registration_profile = {}
126
+
127
+ def transaction_subject
128
+ return {purpose: Core::ExternalIdentities::MOBILE} if mobile?
129
+ return {purpose: Core::ExternalIdentities::ENROLL} if enrollment?
130
+ return {purpose: :sign_in} if sign_in?
131
+ return {user: Current.user, session: Current.session, purpose: Core::ExternalIdentities::LINK} if params[:flow].to_s == "link"
132
+
133
+ {user: Current.user, session: Current.session, purpose: params[:purpose].to_s}
134
+ end
135
+
136
+ def browser_secret
137
+ session[:add_auth_browser] ||= Rails::Runtime.browser_binding.generate
138
+ end
139
+
140
+ def remember_correlation!(pending)
141
+ if @provider.apple_form_post?
142
+ Rails::ProviderLibraries::AppleFormPostCorrelation.remember!(session: session, transaction: pending.id,
143
+ browser_secret: browser_secret, configuration_id: @provider.configuration.id, purpose: pending.purpose, remember: remember?)
144
+ else
145
+ Rails::ProviderLibraries::OmniAuthCorrelation.remember!(session: session, provider: @provider.middleware_name,
146
+ transaction: pending.id, browser_secret: browser_secret, configuration_id: @provider.configuration.id, purpose: pending.purpose, remember: remember?)
147
+ end
148
+ end
149
+
150
+ def correlation_env_key
151
+ @provider.apple_form_post? ? "add_auth.apple_correlation" : Rails::ProviderLibraries::OmniAuthCorrelation::ENV_KEY
152
+ end
153
+
154
+ def valid_correlation?(value)
155
+ value.is_a?(Hash) && %w[transaction browser_secret configuration_id purpose].all? do |key|
156
+ value[key].is_a?(String) && value[key].valid_encoding? && value[key].bytesize.between?(1, 2048)
157
+ end
158
+ end
159
+
160
+ def finish_enrollment(pending, evidence)
161
+ intake = Rails::Runtime.provider_enrollment.read(pending: pending)
162
+ return provider_failure(:invalid_credentials) unless intake
163
+ result = Rails::Runtime.accounts.register_external(evidence: evidence, **intake)
164
+ return provider_failure(result.reason) unless result.success?
165
+ redirect_to "/account/check-email", status: :see_other
166
+ end
167
+
168
+ def provider_intake_failure(reason)
169
+ status = {rate_limited: :too_many_requests, challenge_unavailable: :service_unavailable}.fetch(reason, :unprocessable_entity)
170
+ @error = "This request could not be verified. Please try again shortly."
171
+ response.set_header("Retry-After", "300") if %i[rate_limited challenge_unavailable].include?(reason)
172
+ page("failure", status: status)
173
+ end
174
+
175
+ def finish_sign_in(evidence)
176
+ result = service.sign_in(evidence: evidence, replacing: add_auth_replacement_session,
177
+ **add_auth_session_hints.merge(remember: correlation_remember?))
178
+ return provider_failure(result.reason) unless result.success?
179
+
180
+ destination = after_authentication_url
181
+ add_auth_accept(result.grant)
182
+ redirect_to destination, status: :see_other
183
+ end
184
+
185
+ def finish_mobile(evidence)
186
+ result = service.mobile_handoff(evidence: evidence, handoffs: Rails::Runtime.mobile_handoffs)
187
+ return provider_failure(result.reason) unless result.success?
188
+ handoff = result.credential
189
+ redirect_to "#{handoff.callback}?#{URI.encode_www_form(code: handoff.code, state: handoff.state)}", status: :see_other, allow_other_host: true
190
+ end
191
+
192
+ def finish_link(pending, evidence)
193
+ account, current = correlated_session(pending)
194
+ return provider_failure(:elevation_required) unless account && current
195
+
196
+ elevation = Rails::Runtime.sessions.with_elevation(user: account, session: current,
197
+ purpose: Core::ExternalIdentities::LINK, policy: Rails::Runtime.step_up_policy)
198
+ result = service.link(user: account, session: current, evidence: evidence, grant: elevation.credential)
199
+ return provider_failure(result.reason) unless result.success?
200
+
201
+ redirect_to "/account/external-identities", status: :see_other
202
+ end
203
+
204
+ def finish_reauthentication(pending, evidence)
205
+ account, current = correlated_session(pending)
206
+ return provider_failure(:elevation_required) unless account && current
207
+
208
+ result = service.reauthenticate(user: account, session: current, evidence: evidence)
209
+ return provider_failure(result.reason) unless result.success?
210
+
211
+ grant = Rails::Runtime.sessions.rotate_for_step_up(user: account, session: current, grant: result.credential)
212
+ return provider_failure(:elevation_required) unless grant
213
+
214
+ add_auth_accept(grant)
215
+ redirect_to Rails::Runtime.step_up_policy.return_to(pending.purpose), status: :see_other
216
+ end
217
+
218
+ # A form_post callback can correctly correlate an authenticated transaction
219
+ # while lacking the normal app cookie. Rehydrate only the exact session row
220
+ # recorded by Core; Core rechecks that row's digest and policy version under
221
+ # lock before link/reauthentication can mutate authority.
222
+ def correlated_session(pending)
223
+ account = ::User.find_by(id: pending.user_id)
224
+ current = account && ::Session.find_by(id: pending.session_id, user_id: account.id, token_digest: pending.session_digest)
225
+ [account, current]
226
+ end
227
+
228
+ def provider_failure(_reason)
229
+ @error = "We could not verify this provider sign-in. Please try again."
230
+ page("failure", status: :unprocessable_entity)
231
+ end
232
+
233
+ def provider_unavailable
234
+ @error = "Provider sign-in is temporarily unavailable. Please try again shortly."
235
+ page("failure", status: :service_unavailable)
236
+ end
237
+
238
+ def heading_for(purpose)
239
+ [:sign_in, Core::ExternalIdentities::ENROLL, Core::ExternalIdentities::MOBILE].include?(purpose) ? "Continue to #{@provider.label}" : "Confirm with #{@provider.label}"
240
+ end
241
+
242
+ def correlation_remember? = request.env[correlation_env_key].fetch("remember") == true
243
+
244
+ def remember? = sign_in? && params[:remember].to_s == "1"
245
+
246
+ def authentication_partial(partial) = "add_auth/provider_sign_ins/#{partial}"
247
+ end
248
+ end
@@ -14,6 +14,7 @@ module AddAuth
14
14
 
15
15
  def add_auth_challenge_form_data(action)
16
16
  data = {turbo_frame: "_top"}
17
+ data[:turbo] = false unless AddAuth.configuration.turbo_enabled
17
18
  return data unless AddAuth.configuration.challenge_on.include?(action)
18
19
  provider = AddAuth.configuration.challenge
19
20
  return data unless provider.site_key
@@ -23,7 +24,7 @@ module AddAuth
23
24
  else return data
24
25
  end
25
26
  data.merge(controller: "add-auth-challenge",
26
- action: "submit->add-auth-challenge#submit add_auth:proof-used->add-auth-challenge#reset turbo:submit-end->add-auth-challenge#reset turbo:before-cache@document->add-auth-challenge#beforeCache",
27
+ action: "submit->add-auth-challenge#submit add_auth:proof-used->add-auth-challenge#reset turbo:submit-end->add-auth-challenge#reset turbo:before-cache@document->add-auth-challenge#beforeCache pagehide@window->add-auth-challenge#beforeCache pageshow@window->add-auth-challenge#restore",
27
28
  add_auth_challenge_provider_value: kind, add_auth_challenge_site_key_value: provider.site_key,
28
29
  add_auth_challenge_action_value: (provider.respond_to?(:expected_action) && provider.expected_action) || action,
29
30
  add_auth_challenge_script_url_value: provider.script_url)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class AccountDeliveryJob < DeliveryJob
5
+ def perform(id)
6
+ record = ::AddAuthAccountToken.find_by(id: id)
7
+ return unless record
8
+ deliver(service: Rails::Runtime.accounts, record: record, mailer: AccountMailer) do |delivery|
9
+ AccountMailer.link(recipient: delivery.fetch(:recipient), purpose: delivery.fetch(:purpose),
10
+ url: Rails::Runtime.account_proof_url(delivery.fetch(:token), purpose: delivery.fetch(:purpose)))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class AccountRequestJob < ActiveJob::Base
5
+ self.log_arguments = false
6
+ retry_on StandardError, wait: :polynomially_longer, attempts: 8
7
+
8
+ def perform(encrypted_identifier)
9
+ payload = Rails::Runtime.decrypt_intake(encrypted_identifier)
10
+ if payload.is_a?(Hash)
11
+ Rails::Runtime.accounts.issue(identifier: payload["identifier"], purpose: payload["purpose"], request_id: job_id)
12
+ end
13
+ record = ::AddAuthAccountToken.find_by(request_id: job_id)
14
+ AccountDeliveryJob.perform_later(record.id) if record
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ class AccountMailer < SignInMailer
5
+ def link(recipient:, url:, purpose:)
6
+ @instruction = {"confirm" => "Confirm your email address", "reset_password" => "Choose a new password", "unlock" => "Unlock your account"}.fetch(purpose)
7
+ @url = url
8
+ mail(to: recipient, from: Rails::Runtime.config.mail_from, subject: @instruction,
9
+ content_type: "text/plain").extend(SignInMailer::DeliveryReceipt)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ <%= @instruction %>
2
+
3
+ Open this link, then use the button on the page to confirm your request:
4
+ <%= @url %>
5
+
6
+ This link expires in <%= AddAuth.configuration.lifecycle.proof_lifetime.to_i / 60 %> minutes and can be used once.
7
+ If you did not request this message, you can ignore it.
@@ -0,0 +1,5 @@
1
+ <% content_for :title, "Check your email" %>
2
+ <h1 id="add_auth-title">Check your email</h1>
3
+ <p role="status">If this request is available for your account, we’ll send a link with the next step.</p>
4
+ <p class="<%= add_auth_class(:muted) %>">Check your spam folder too. Only the latest link for this request will work.</p>
5
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,6 @@
1
+ <% content_for :title, "Delete your account" %>
2
+ <h1 id="add_auth-title">Delete your account</h1>
3
+ <p>Your account will be deleted and all sessions signed out. Verify it’s you, then return here to confirm.</p>
4
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
+ <%= button_to "Delete my account", "/account", method: :delete, class: add_auth_class(:button), form: {data: {turbo_frame: "_top"}} %>
6
+ <p><%= link_to "Keep my account", "/sessions" %></p>
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Change your email" %>
2
+ <h1 id="add_auth-title">Change your email</h1>
3
+ <p class="<%= add_auth_class(:muted) %>">Your current email stays active until you confirm the new address. Confirmation signs out your existing sessions.</p>
4
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
+ <%= form_with url: "/account/email", data: {turbo_frame: "_top"} do |form| %>
6
+ <div class="<%= add_auth_class(:field) %>">
7
+ <%= form.label :email_address, "New email address" %>
8
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email", value: (params[:email_address] if params[:email_address].is_a?(String)), class: add_auth_class(:input) %>
9
+ </div>
10
+ <%= form.submit "Send confirmation link", class: add_auth_class(:button) %>
11
+ <% end %>
12
+ <p><%= link_to "Back to your sessions", "/sessions" %></p>
@@ -0,0 +1,5 @@
1
+ <% content_for :title, "Request a new link" %>
2
+ <h1 id="add_auth-title">Request a new link</h1>
3
+ <p>This link is unavailable, expired, or already used. Request a new link to finish your account update.</p>
4
+ <p><%= link_to "Request another link", "/account/requests/#{@purpose}" %></p>
5
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Change your password" %>
2
+ <h1 id="add_auth-title">Change your password</h1>
3
+ <p class="<%= add_auth_class(:muted) %>">Verify it’s you before changing your password. This signs out your existing sessions.</p>
4
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
+ <%= form_with url: "/account/password", data: {turbo_frame: "_top"} do |form| %>
6
+ <div class="<%= add_auth_class(:field) %>">
7
+ <%= form.label :password, "New password" %>
8
+ <%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
9
+ </div>
10
+ <%= form.submit "Change password", class: add_auth_class(:button) %>
11
+ <% end %>
12
+ <p><%= link_to "Back to your sessions", "/sessions" %></p>
@@ -0,0 +1,16 @@
1
+ <% title = {"confirm" => "Confirm this email address", "reset_password" => "Choose a new password", "unlock" => "Unlock this account"}.fetch(@purpose) %>
2
+ <% content_for :title, title %>
3
+ <h1 id="add_auth-title"><%= title %></h1>
4
+ <p class="<%= add_auth_class(:muted) %>">Use the button below to complete your request. You’ll sign in afterward.</p>
5
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
6
+ <%= form_with url: "/account/proofs/#{@purpose}", data: {turbo_frame: "_top"} do |form| %>
7
+ <%= form.hidden_field :token, value: (params[:token] if params[:token].is_a?(String)) %>
8
+ <% if @purpose == "reset_password" %>
9
+ <div class="<%= add_auth_class(:field) %>">
10
+ <%= form.label :password, "New password" %>
11
+ <%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
12
+ </div>
13
+ <% end %>
14
+ <%= form.submit title, class: add_auth_class(:button) %>
15
+ <% end %>
16
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,17 @@
1
+ <% content_for :title, "Create an account" %>
2
+ <h1 id="add_auth-title">Create an account</h1>
3
+ <p class="<%= add_auth_class(:muted) %>">We’ll email you a link to confirm your address before you sign in.</p>
4
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
5
+ <%= form_with url: "/account/sign-up", data: add_auth_challenge_form_data(:register) do |form| %>
6
+ <div class="<%= add_auth_class(:field) %>">
7
+ <%= form.label :email_address, "Email address" %>
8
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email", value: (params[:email_address] if params[:email_address].is_a?(String)), class: add_auth_class(:input) %>
9
+ </div>
10
+ <div class="<%= add_auth_class(:field) %>">
11
+ <%= form.label :password, "New password" %>
12
+ <%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
13
+ </div>
14
+ <%= add_auth_challenge_tag :register %>
15
+ <%= form.submit "Create account", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
16
+ <% end %>
17
+ <p><%= link_to "Already have an account? Sign in", "/sign-in" %></p>
@@ -0,0 +1,14 @@
1
+ <% title = {"confirm" => "Confirm your email", "reset_password" => "Reset your password", "unlock" => "Unlock your account"}.fetch(@purpose) %>
2
+ <% content_for :title, title %>
3
+ <h1 id="add_auth-title"><%= title %></h1>
4
+ <p class="<%= add_auth_class(:muted) %>">Enter your account email address to request a secure link.</p>
5
+ <% if @error %><p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p><% end %>
6
+ <%= form_with url: "/account/requests/#{@purpose}", data: add_auth_challenge_form_data(:account_request) do |form| %>
7
+ <div class="<%= add_auth_class(:field) %>">
8
+ <%= form.label :email_address, "Email address" %>
9
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email", value: (params[:email_address] if params[:email_address].is_a?(String)), class: add_auth_class(:input) %>
10
+ </div>
11
+ <%= add_auth_challenge_tag :account_request %>
12
+ <%= form.submit "Send account link", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
13
+ <% end %>
14
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Try your request again</h1>
2
+ <p role="alert" class="<%= add_auth_class(:notice) %>"><%= @error %></p>
3
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,22 @@
1
+ <h1 id="add_auth-title">External sign-in methods</h1>
2
+ <% if @error %><p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p><% end %>
3
+ <p>Add another way to sign in. You’ll verify your account before linking or removing a provider.</p>
4
+ <% @providers.each do |provider| %>
5
+ <%= form_with url: "/account/external-identities/providers/#{provider.middleware_name}", method: :post, data: add_auth_challenge_form_data(:provider).merge(turbo: false) do |form| %>
6
+ <%= add_auth_challenge_tag :provider, id: "link_#{provider.middleware_name}" %>
7
+ <%= form.submit "Link #{provider.label}", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
8
+ <% end %>
9
+ <% end %>
10
+ <% @identities.each do |identity| %>
11
+ <% provider = @providers.find { |item| item.configuration.id == identity.provider_id } %>
12
+ <section id="external_identity_<%= identity.id %>">
13
+ <h2><%= provider&.label || "External provider" %></h2>
14
+ <p>Linked <%= identity.linked_at.strftime("%Y-%m-%d") %>.</p>
15
+ <details>
16
+ <summary>Remove this sign-in method</summary>
17
+ <p>Keep another usable sign-in method before removing this one.</p>
18
+ <%= button_to "Confirm removal", "/account/external-identities/#{identity.id}", method: :delete, form: {data: {turbo: false}} %>
19
+ </details>
20
+ </section>
21
+ <% end %>
22
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Try again shortly</h1>
2
+ <p role="alert"><%= @error %></p>
3
+ <p><%= link_to "Back to sign in", "/sign-in" %></p>
@@ -1,15 +1,17 @@
1
1
  <div data-controller="add-auth-passkey"
2
+ data-add-auth-passkey-turbo-value="<%= AddAuth.configuration.turbo_enabled %>"
2
3
  data-add-auth-passkey-options-url-value="<%= options_url %>"
3
4
  data-add-auth-passkey-finish-url-value="<%= finish_url %>"
4
5
  data-add-auth-passkey-mode-value="<%= mode %>"
5
6
  data-add-auth-passkey-purpose-value="<%= local_assigns[:purpose] %>"
6
7
  data-add-auth-passkey-conditional-value="<%= local_assigns.fetch(:conditional, false) && !AddAuth.configuration.challenge_on.include?(action) %>"
7
8
  data-add-auth-passkey-csrf-value="<%= form_authenticity_token %>"
8
- data-action="turbo:before-cache@document->add-auth-passkey#beforeCache submit@document->add-auth-passkey#otherSubmission">
9
+ data-action="turbo:before-cache@document->add-auth-passkey#beforeCache pagehide@window->add-auth-passkey#beforeCache pageshow@window->add-auth-passkey#restore submit@document->add-auth-passkey#otherSubmission">
9
10
  <p data-add-auth-passkey-target="unavailable">Passkeys need JavaScript and a compatible browser or device. Use another method allowed by your account policy if available.</p>
10
11
  <%= form_with url: options_url, data: add_auth_challenge_form_data(action).merge(add_auth_passkey_target: "form", action: [add_auth_challenge_form_data(action)[:action], "submit->add-auth-passkey#submit"].compact.join(" ")) do |form| %>
11
12
  <%= add_auth_challenge_tag action, id: "passkey_#{action}" %>
12
13
  <div hidden data-add-auth-passkey-target="controls">
14
+ <%= render "add_auth/sign_ins/remember", form: form, id: "passkey" if action == :sign_in %>
13
15
  <%= form.submit label, class: add_auth_class(:button), data: {add_auth_challenge_target: "submit", add_auth_passkey_target: "button"} %>
14
16
  </div>
15
17
  <% end %>
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Create an account" %>
2
+ <h1 id="add_auth-title">Create an account with <%= @provider.label %></h1>
3
+ <p>Enter your email, then continue to your provider. We’ll email you a separate confirmation link before you can sign in.</p>
4
+ <%= form_with url: "/account/sign-up/providers/#{@provider.middleware_name}", method: :post, data: add_auth_challenge_form_data(:register).merge(turbo: false) do |form| %>
5
+ <div class="<%= add_auth_class(:field) %>">
6
+ <%= form.label :email_address, "Email address" %>
7
+ <%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email", class: add_auth_class(:input) %>
8
+ </div>
9
+ <%= add_auth_challenge_tag :register %>
10
+ <%= form.submit "Continue with #{@provider.label}", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
11
+ <% end %>
12
+ <p><%= link_to "Already have an account? Sign in", "/sign-in" %></p>
@@ -0,0 +1,3 @@
1
+ <h1 id="add_auth-title">Provider sign-in could not be completed</h1>
2
+ <p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p>
3
+ <p><%= link_to "Return to sign in", "/sign-in" %></p>
@@ -0,0 +1,7 @@
1
+ <h1 id="add_auth-title"><%= @heading %></h1>
2
+ <p class="<%= add_auth_class(:muted) %>">You will continue to <%= @provider.label %> to verify this request.</p>
3
+ <%= form_with url: @provider_path, method: :post, authenticity_token: false, data: {turbo: false} do |form| %>
4
+ <%= hidden_field_tag @omniauth_csrf_parameter, @omniauth_csrf_token %>
5
+ <%= form.submit "Continue with #{@provider.label}", class: add_auth_class(:button) %>
6
+ <% end %>
7
+ <p><%= link_to "Cancel", "/sign-in" %></p>