searls-auth 1.0.2 → 2.0.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -1
  3. data/README.md +22 -1
  4. data/TODO.md +21 -0
  5. data/app/controllers/searls/auth/base_controller.rb +4 -4
  6. data/app/controllers/searls/auth/email_verifications_controller.rb +1 -1
  7. data/app/controllers/searls/auth/logins_controller.rb +6 -2
  8. data/app/controllers/searls/auth/registrations_controller.rb +11 -11
  9. data/app/controllers/searls/auth/requests_password_resets_controller.rb +3 -3
  10. data/app/controllers/searls/auth/resets_passwords_controller.rb +3 -3
  11. data/app/controllers/searls/auth/settings_controller.rb +2 -2
  12. data/app/controllers/searls/auth/verifications_controller.rb +1 -1
  13. data/app/helpers/searls/auth/application_helper.rb +11 -1
  14. data/app/mailers/searls/auth/email_verification_mailer.rb +1 -1
  15. data/app/mailers/searls/auth/login_link_mailer.rb +1 -1
  16. data/app/mailers/searls/auth/password_reset_mailer.rb +1 -1
  17. data/app/views/searls/auth/email_verification_mailer/verification_email.html.erb +1 -1
  18. data/app/views/searls/auth/email_verification_mailer/verification_email.text.erb +1 -1
  19. data/app/views/searls/auth/login_link_mailer/login_link.html.erb +1 -1
  20. data/app/views/searls/auth/login_link_mailer/login_link.text.erb +1 -1
  21. data/app/views/searls/auth/logins/show.html.erb +1 -1
  22. data/app/views/searls/auth/password_reset_mailer/password_reset.html.erb +1 -1
  23. data/app/views/searls/auth/password_reset_mailer/password_reset.text.erb +1 -1
  24. data/app/views/searls/auth/registrations/show.html.erb +1 -1
  25. data/app/views/searls/auth/requests_password_resets/show.html.erb +1 -1
  26. data/app/views/searls/auth/resets_passwords/show.html.erb +1 -1
  27. data/app/views/searls/auth/settings/edit.html.erb +1 -1
  28. data/app/views/searls/auth/verifications/show.html.erb +1 -1
  29. data/lib/searls/auth/builds_target_redirect_url.rb +39 -30
  30. data/lib/searls/auth/config.rb +28 -0
  31. data/lib/searls/auth/creates_user.rb +5 -5
  32. data/lib/searls/auth/delivers_password_reset.rb +2 -2
  33. data/lib/searls/auth/emails_link.rb +2 -2
  34. data/lib/searls/auth/emails_verification.rb +2 -2
  35. data/lib/searls/auth/sanitizes_flash_html.rb +18 -0
  36. data/lib/searls/auth/version.rb +1 -1
  37. data/lib/searls/auth.rb +4 -0
  38. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4314fcd2c4eee5b0f32ace811ceeefaa44728895592fdebd5aa6a696303133e7
4
- data.tar.gz: 8d4963fb8be2ecbf4ad13762e8697c2d9d992f9d382dcc0c73b2a38d6daf6041
3
+ metadata.gz: b89495d0211c7f68d45f32b331fa2aba422d39a829b3ab7569bbe66901c17de3
4
+ data.tar.gz: 89eb9084ad27d89d1bb4ca328a157ca89578a0bfe57c53359f92b30421a2ec33
5
5
  SHA512:
6
- metadata.gz: 79c05ebe302bc1846f72ac29ae21a16ee629050a52b31cb4bd9ff4d5ec70bd1737e4cfe7451546cf74dd0aa82781d0952855218f4f47286419049f59265e6982
7
- data.tar.gz: a309ca56baf772d1c3eb46adf1897d33af4bb0077d14768e9646b03b402311cb9793082f9f16fdc9ac27286a001561843e338858eda1a99a960e7b331268d85c
6
+ metadata.gz: a06c5572753eb9718b4da43e297b8fcc19b20d7b3e19bdb8bd13ab400b289179e3b3f1a2e92fca5ce1203064b5445f2a7868cde2a8c3ee42c7c560d301659aaa
7
+ data.tar.gz: 2b9a204452996e4d25df5abbc1608667f59ab6f791138918ca99c8dd0c24cf09173617fa1d2e3839bc3263b491970a627ac72336d90eca653f18c8b5acc770c0
data/CHANGELOG.md CHANGED
@@ -1,4 +1,9 @@
1
- ## [Unreleased]
1
+ ## [2.0.0] - 2026-01-22
2
+
3
+ * **BREAKING:** Remove `redirect_subdomain` support in favor of `redirect_host`
4
+ * **BREAKING:** Replace `redirect_host_allowed_predicate`, `cross_domain_sso_token_generator`, and `cross_cookie_domain_predicate` with `sso_token_for_cross_domain_redirects`
5
+ * Security: Sanitize user-controlled `notice` and `alert` query params rendered on the login page to mitigate reflected XSS, while preserving safe formatting tags and links (`<a href=...>` and Turbo data attributes)
6
+ * Behavior: Disallowed tags/attributes/protocols are stripped from login `notice`/`alert` params (e.g. `<script>`, event handler attributes, `javascript:` links)
2
7
 
3
8
  ## [1.0.2] - 2025-10-13
4
9
 
data/README.md CHANGED
@@ -84,6 +84,15 @@ end
84
84
  ```
85
85
  As stated in the comment above, you can find each configuration and its default value in the code.
86
86
 
87
+ ### Customize user lookup
88
+
89
+ searls-auth uses separate hooks to find users by email for login vs registration:
90
+
91
+ - `config.user_finder_by_email` is used by login.
92
+ - `config.user_finder_by_email_for_registration` is used by registration to find an existing row to update (e.g., “upgrade” an existing user record).
93
+
94
+ If `user_finder_by_email_for_registration` returns a persisted user, searls-auth will block the registration attempt unless `config.existing_user_registration_blocked_predicate.call(user, params)` returns `false`.
95
+
87
96
  ### Choose your login methods
88
97
 
89
98
  By default, users can log in either by clicking a magic link or by entering a 6‑digit code they receive via email. This is controlled by the `auth_methods` configuration:
@@ -264,7 +273,19 @@ end
264
273
  | `[:password, :email_link, :email_otp]` | `:optional` | Users can log in with either password or email. Registration logs the user in immediately and also emails a verification link. |
265
274
  | `[:password, :email_link]` | `:required` | Registration emails a link and blocks password login until verified. Resend verification is exposed at `searls_auth.resend_email_verification_path`. |
266
275
 
267
- In every case, `redirect_path` values are normalized to on-site URLs, so forwarding someone to login with `redirect_path: some_path` keeps the eventual redirect on your domain (cross-subdomain redirects still work via `redirect_subdomain`).
276
+ In every case, `redirect_path` values are normalized to on-site URLs, so forwarding someone to login with `redirect_path: some_path` keeps the eventual redirect on your domain.
277
+
278
+ You can optionally pass `redirect_host` to redirect to another host (e.g., an admin subdomain or another app).
279
+
280
+ When redirecting to another host, you can append an SSO token by setting `config.sso_token_for_cross_domain_redirects` (defaults to `nil`). It is called with `(user, request, target_host)` and should return a token string or `nil`.
281
+
282
+ ### Multi-domain logout bounce
283
+
284
+ The engine provides `searls_auth.logout_path`, which clears the current session on the current host.
285
+
286
+ If your app uses multiple cookie domains, the engine cannot clear the other domain’s session cookie. The simplest approach is to implement logout in your host app and “bounce” through the other domain(s), clearing each cookie in turn.
287
+
288
+ If you mount the engine at `/`, you can shadow its `/logout` route by defining your own `get "/logout"` route before the `mount` line. Your controller can call `Searls::Auth::ResetsSession.new.reset(self, except_for: [...])` and then redirect to your other domain’s logout endpoint with a `return_to` back to your primary domain.
268
289
 
269
290
  ## Use it
270
291
 
data/TODO.md ADDED
@@ -0,0 +1,21 @@
1
+ # TODO (bwb-adoption)
2
+
3
+ This is the remaining `searls-auth` gem work needed for grog/BWB adoption.
4
+
5
+ - [x] Add **cross-host** redirect support (not just path-only redirects)
6
+ - [x] Accept a `redirect_host` param (optional), and reject/ignore it by default
7
+ - [x] Add a config hook to validate hosts (e.g. `redirect_host_allowed_predicate`)
8
+ - [x] Extend `Searls::Auth::BuildsTargetRedirectUrl` to build absolute URLs using `redirect_host` when allowed
9
+ - [x] Preserve existing path normalization and avoid open redirects
10
+ - [x] Add **cross-cookie-domain SSO token forwarding** hook
11
+ - [x] Provide a config hook (e.g. `cross_domain_sso_token_generator`) that can append `sso_token=...` when redirecting to another cookie domain
12
+ - [x] Make token param name configurable (default `sso_token`)
13
+ - [x] Provide an overridable way to decide “cross-cookie-domain” (default: registrable domain differs)
14
+ - [x] Add (or document) **logout bounce** support for multi-domain apps
15
+ - [x] Either: first-class config for “also clear session on another domain”, or explicit docs showing how host apps can implement it
16
+ - [x] Add example app coverage
17
+ - [x] Add integration/system tests proving `redirect_host` is rejected by default and allowed when configured
18
+ - [x] Add a test proving SSO token appending works when enabled
19
+ - [x] Update docs
20
+ - [x] Document `user_finder_by_email_for_registration` and the “upgrade existing non-registered user row” pattern
21
+ - [x] Document the new cross-domain hooks and security expectations
@@ -8,7 +8,7 @@ module Searls
8
8
  helper_method :forwardable_params
9
9
 
10
10
  def forwardable_params
11
- {redirect_path: params[:redirect_path], redirect_subdomain: params[:redirect_subdomain]}.compact_blank
11
+ {redirect_path: params[:redirect_path], redirect_host: params[:redirect_host]}.compact_blank
12
12
  end
13
13
 
14
14
  protected
@@ -40,8 +40,8 @@ module Searls
40
40
  session[:searls_auth_email_otp_verification_attempts] += 1
41
41
  end
42
42
 
43
- def target_redirect_url
44
- Searls::Auth::BuildsTargetRedirectUrl.new.build(request, params)
43
+ def target_redirect_url(user: nil)
44
+ Searls::Auth::BuildsTargetRedirectUrl.new.build(request, params, user: user)
45
45
  end
46
46
 
47
47
  def redirect_with_host_awareness(target)
@@ -49,7 +49,7 @@ module Searls
49
49
  end
50
50
 
51
51
  def redirect_after_login(user)
52
- if (target = target_redirect_url)
52
+ if (target = target_redirect_url(user: user))
53
53
  redirect_with_host_awareness(target)
54
54
  else
55
55
  redirect_to Searls::Auth.config.resolve(
@@ -22,7 +22,7 @@ module Searls
22
22
  searls_auth.pending_email_verification_path({
23
23
  email: session[:searls_auth_pending_email],
24
24
  redirect_path: session[:searls_auth_pending_redirect_path],
25
- redirect_subdomain: session[:searls_auth_pending_redirect_subdomain]
25
+ redirect_host: session[:searls_auth_pending_redirect_host]
26
26
  }.compact_blank)
27
27
  else
28
28
  searls_auth.login_path
@@ -4,6 +4,9 @@ module Searls
4
4
  before_action :reset_expired_email_otp
5
5
 
6
6
  def show
7
+ sanitizes_flash_html = SanitizesFlashHtml.new
8
+ flash.now[:notice] ||= sanitizes_flash_html.sanitize(params[:notice]) if params[:notice].present?
9
+ flash.now[:alert] ||= sanitizes_flash_html.sanitize(params[:alert]) if params[:alert].present?
7
10
  render Searls::Auth.config.login_view, layout: Searls::Auth.config.layout
8
11
  end
9
12
 
@@ -16,9 +19,10 @@ module Searls
16
19
  end
17
20
 
18
21
  def destroy
19
- ResetsSession.new.reset(self, except_for: [:has_logged_in_before])
22
+ ResetsSession.new.reset(self, except_for: Searls::Auth.config.preserve_session_keys_after_logout)
20
23
  flash[:notice] = Searls::Auth.config.resolve(:flash_notice_after_logout, params)
21
- redirect_to searls_auth.login_path
24
+ target = Searls::Auth.config.resolve(:redirect_url_after_logout, flash[:notice], params, request, main_app)
25
+ redirect_to(target.presence || searls_auth.login_path, allow_other_host: true)
22
26
  end
23
27
 
24
28
  private
@@ -36,22 +36,22 @@ module Searls
36
36
  end
37
37
 
38
38
  def handle_password_registration(user)
39
- target_path, target_subdomain = registration_redirect_destination(user)
39
+ target_path, target_host = registration_redirect_destination(user)
40
40
  if Searls::Auth.config.email_verification_mode != :none
41
- EmailsVerification.new.email(user: user, redirect_path: target_path, redirect_subdomain: target_subdomain)
41
+ EmailsVerification.new.email(user: user, redirect_path: target_path, redirect_host: target_host)
42
42
  session[:searls_auth_pending_email] = user.email
43
43
  session[:searls_auth_pending_redirect_path] = target_path
44
- session[:searls_auth_pending_redirect_subdomain] = target_subdomain
44
+ session[:searls_auth_pending_redirect_host] = target_host
45
45
  end
46
46
 
47
47
  if Searls::Auth.config.email_verification_mode == :required
48
48
  flash[:notice] = Searls::Auth.config.resolve(:flash_notice_after_registration, user, params)
49
- redirect_to searls_auth.pending_email_verification_path({email: user.email, redirect_path: target_path, redirect_subdomain: target_subdomain}.compact_blank)
49
+ redirect_to searls_auth.pending_email_verification_path({email: user.email, redirect_path: target_path, redirect_host: target_host}.compact_blank)
50
50
  else
51
51
  session[:user_id] = user.id
52
52
  session[:has_logged_in_before] = true
53
53
  flash[:notice] = Searls::Auth.config.resolve(:flash_notice_after_login, user, params)
54
- if (target = target_redirect_url)
54
+ if (target = target_redirect_url(user: user))
55
55
  redirect_with_host_awareness(target)
56
56
  else
57
57
  fallback = Searls::Auth.config.resolve(:redirect_path_after_login, user, params, request, main_app)
@@ -62,25 +62,25 @@ module Searls
62
62
 
63
63
  def handle_email_registration(user)
64
64
  return unless email_methods_enabled?
65
- target_path, target_subdomain = registration_redirect_destination(user)
66
- enqueue_login_verification_email(user, target_path:, target_subdomain:)
65
+ target_path, target_host = registration_redirect_destination(user)
66
+ enqueue_login_verification_email(user, target_path:, target_host:)
67
67
  flash[:notice] = Searls::Auth.config.resolve(:flash_notice_after_registration, user, params)
68
68
  redirect_to searls_auth.verify_path(**forwardable_params)
69
69
  end
70
70
 
71
71
  def registration_redirect_destination(user)
72
72
  if redirect_params_supplied?
73
- [params[:redirect_path], params[:redirect_subdomain]]
73
+ [params[:redirect_path], params[:redirect_host]]
74
74
  else
75
75
  [Searls::Auth.config.resolve(:redirect_path_after_register, user, params, request, main_app), nil]
76
76
  end
77
77
  end
78
78
 
79
79
  def redirect_params_supplied?
80
- params[:redirect_path].present? || params[:redirect_subdomain].present?
80
+ params[:redirect_path].present? || params[:redirect_host].present?
81
81
  end
82
82
 
83
- def enqueue_login_verification_email(user, target_path:, target_subdomain:)
83
+ def enqueue_login_verification_email(user, target_path:, target_host:)
84
84
  email_otp = nil
85
85
  if Searls::Auth.config.auth_methods.include?(:email_otp)
86
86
  attach_email_otp_to_session!(user)
@@ -92,7 +92,7 @@ module Searls
92
92
  user: user,
93
93
  email_otp: email_otp,
94
94
  redirect_path: target_path,
95
- redirect_subdomain: target_subdomain
95
+ redirect_host: target_host
96
96
  )
97
97
  end
98
98
  end
@@ -16,7 +16,7 @@ module Searls
16
16
  Searls::Auth::DeliversPasswordReset.new.deliver(
17
17
  user:,
18
18
  redirect_path: params[:redirect_path],
19
- redirect_subdomain: params[:redirect_subdomain]
19
+ redirect_host: params[:redirect_host]
20
20
  )
21
21
  end
22
22
 
@@ -24,7 +24,7 @@ module Searls
24
24
  redirect_to searls_auth.password_reset_request_path(
25
25
  email: email,
26
26
  redirect_path: params[:redirect_path],
27
- redirect_subdomain: params[:redirect_subdomain]
27
+ redirect_host: params[:redirect_host]
28
28
  )
29
29
  end
30
30
 
@@ -36,7 +36,7 @@ module Searls
36
36
  flash[:alert] = Searls::Auth.config.resolve(:flash_error_after_password_reset_not_enabled, params)
37
37
  redirect_to searls_auth.login_path(
38
38
  redirect_path: params[:redirect_path],
39
- redirect_subdomain: params[:redirect_subdomain]
39
+ redirect_host: params[:redirect_host]
40
40
  )
41
41
  nil
42
42
  end
@@ -36,7 +36,7 @@ module Searls
36
36
  flash[:alert] = Searls::Auth.config.resolve(:flash_error_after_password_reset_not_enabled, params)
37
37
  redirect_to searls_auth.login_path(
38
38
  redirect_path: params[:redirect_path],
39
- redirect_subdomain: params[:redirect_subdomain]
39
+ redirect_host: params[:redirect_host]
40
40
  )
41
41
  nil
42
42
  end
@@ -49,7 +49,7 @@ module Searls
49
49
  flash[:alert] = Searls::Auth.config.resolve(:flash_error_after_password_reset_token_invalid, params)
50
50
  redirect_to searls_auth.password_reset_request_path(
51
51
  redirect_path: params[:redirect_path],
52
- redirect_subdomain: params[:redirect_subdomain]
52
+ redirect_host: params[:redirect_host]
53
53
  )
54
54
  nil
55
55
  end
@@ -64,7 +64,7 @@ module Searls
64
64
  else
65
65
  redirect_to searls_auth.login_path(
66
66
  redirect_path: params[:redirect_path],
67
- redirect_subdomain: params[:redirect_subdomain]
67
+ redirect_host: params[:redirect_host]
68
68
  )
69
69
  end
70
70
  end
@@ -50,7 +50,7 @@ module Searls
50
50
 
51
51
  redirect_to searls_auth.login_path(
52
52
  redirect_path: request.original_fullpath,
53
- redirect_subdomain: request.subdomain
53
+ redirect_host: request.host
54
54
  )
55
55
  nil
56
56
  end
@@ -62,7 +62,7 @@ module Searls
62
62
  session.delete(:user_id)
63
63
  redirect_to searls_auth.login_path(
64
64
  redirect_path: request.original_fullpath,
65
- redirect_subdomain: request.subdomain
65
+ redirect_host: request.host
66
66
  )
67
67
  nil
68
68
  end
@@ -39,7 +39,7 @@ module Searls
39
39
  session[:user_id] = result.user.id
40
40
  session[:has_logged_in_before] = true
41
41
  flash[:notice] = Searls::Auth.config.resolve(:flash_notice_after_login, result.user, params)
42
- if (target = target_redirect_url)
42
+ if (target = target_redirect_url(user: result.user))
43
43
  redirect_with_host_awareness(target)
44
44
  else
45
45
  redirect_to Searls::Auth.config.resolve(:redirect_path_after_login, result.user, params, request, main_app)
@@ -55,7 +55,7 @@ module Searls
55
55
  end
56
56
 
57
57
  def enable_turbo?
58
- params[:redirect_subdomain].blank? || params[:redirect_subdomain] == request.subdomain
58
+ !redirect_host_crosses_host?
59
59
  end
60
60
 
61
61
  def attr_for(model, field_name)
@@ -78,6 +78,16 @@ module Searls
78
78
  def params
79
79
  @view_context.params
80
80
  end
81
+
82
+ def request
83
+ @view_context.request
84
+ end
85
+
86
+ def redirect_host_crosses_host?
87
+ host = params[:redirect_host].to_s.strip.downcase
88
+ return false if host.blank?
89
+ host != request.host
90
+ end
81
91
  end
82
92
 
83
93
  module ApplicationHelper
@@ -6,7 +6,7 @@ module Searls
6
6
  @user = params[:user]
7
7
  @token = params[:token]
8
8
  @redirect_path = params[:redirect_path]
9
- @redirect_subdomain = params[:redirect_subdomain]
9
+ @redirect_host = params[:redirect_host]
10
10
 
11
11
  mail(
12
12
  to: format_to(@user),
@@ -6,7 +6,7 @@ module Searls
6
6
  @user = params[:user]
7
7
  @token = params[:token]
8
8
  @redirect_path = params[:redirect_path]
9
- @redirect_subdomain = params[:redirect_subdomain]
9
+ @redirect_host = params[:redirect_host]
10
10
  @email_otp = params[:email_otp]
11
11
 
12
12
  mail(
@@ -6,7 +6,7 @@ module Searls
6
6
  @user = params[:user]
7
7
  @token = params[:token]
8
8
  @redirect_path = params[:redirect_path]
9
- @redirect_subdomain = params[:redirect_subdomain]
9
+ @redirect_host = params[:redirect_host]
10
10
 
11
11
  mail(
12
12
  to: format_to(@user),
@@ -12,7 +12,7 @@
12
12
  <%= link_to searls_auth.verify_email_url({
13
13
  token: @token,
14
14
  redirect_path: @redirect_path,
15
- redirect_subdomain: @redirect_subdomain
15
+ redirect_host: @redirect_host
16
16
  }.compact_blank) do %>
17
17
  <span style="width: 70%; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); display: inline-block; padding-left: 2rem; padding-right: 2rem; padding-top: 1rem; padding-bottom: 1rem; font-size: 1.5rem; font-weight: 700; border-radius: 0.75rem; background-color: <%= @config.email_button_color %>; color: white;">Verify email address</span>
18
18
  <% end %>
@@ -1,6 +1,6 @@
1
1
  Hello<% if (user_name = searls_auth_helper.attr_for(@user, @config.user_name_method)).present? %>, <%= user_name %><% end %>!
2
2
 
3
3
  Verify your email using this link:
4
- <%= searls_auth.verify_email_url({ token: @token, redirect_path: @redirect_path, redirect_subdomain: @redirect_subdomain }.compact_blank) %>
4
+ <%= searls_auth.verify_email_url({ token: @token, redirect_path: @redirect_path, redirect_host: @redirect_host }.compact_blank) %>
5
5
 
6
6
  If you didn't ask for this, you can ignore this email.
@@ -13,7 +13,7 @@
13
13
  <%= link_to verify_token_url({
14
14
  token: @token,
15
15
  redirect_path: @redirect_path,
16
- redirect_subdomain: @redirect_subdomain
16
+ redirect_host: @redirect_host
17
17
  }.compact_blank) do %>
18
18
  <span style="width: 70%; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); display: inline-block; padding-left: 2rem; padding-right: 2rem; padding-top: 1rem; padding-bottom: 1rem; font-size: 1.5rem; font-weight: 700; border-radius: 0.75rem; background-color: <%= @config.email_button_color %>; color: white;">Log in</span>
19
19
  <% end %>
@@ -10,7 +10,7 @@ You can log in by visiting this URL for your <%= searls_auth_helper.rpad(@config
10
10
  <%= searls_auth.verify_token_url({
11
11
  token: @token,
12
12
  redirect_path: @redirect_path,
13
- redirect_subdomain: @redirect_subdomain
13
+ redirect_host: @redirect_host
14
14
  }.compact_blank) %>
15
15
  <% end %>
16
16
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  <%= form_with url: searls_auth.login_path, method: :post, data: {controller: searls_auth_helper.login_stimulus_controller} do |f| %>
4
4
  <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
5
- <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
5
+ <%= f.hidden_field :redirect_host, value: params[:redirect_host] %>
6
6
  <%= render "searls/auth/shared/login_fields", f: f %>
7
7
  <div>
8
8
  <%= f.submit "Log in" %>
@@ -12,7 +12,7 @@
12
12
  <%= link_to password_reset_edit_url({
13
13
  token: @token,
14
14
  redirect_path: @redirect_path,
15
- redirect_subdomain: @redirect_subdomain
15
+ redirect_host: @redirect_host
16
16
  }.compact_blank) do %>
17
17
  <span style="width: 70%; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); display: inline-block; padding-left: 2rem; padding-right: 2rem; padding-top: 1rem; padding-bottom: 1rem; font-size: 1.5rem; font-weight: 700; border-radius: 0.75rem; background-color: <%= @config.email_button_color %>; color: white;">Reset password</span>
18
18
  <% end %>
@@ -1,6 +1,6 @@
1
1
  Hello<% if (user_name = searls_auth_helper.attr_for(@user, @config.user_name_method)).present? %>, <%= user_name %><% end %>!
2
2
 
3
3
  Reset your password using this link:
4
- <%= password_reset_edit_url({ token: @token, redirect_path: @redirect_path, redirect_subdomain: @redirect_subdomain }.compact_blank) %>
4
+ <%= password_reset_edit_url({ token: @token, redirect_path: @redirect_path, redirect_host: @redirect_host }.compact_blank) %>
5
5
 
6
6
  If you didn't ask for this, you can ignore this email.
@@ -2,7 +2,7 @@
2
2
 
3
3
  <%= form_with url: searls_auth.register_path, data: {controller: searls_auth_helper.login_stimulus_controller} do |f| %>
4
4
  <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
5
- <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
5
+ <%= f.hidden_field :redirect_host, value: params[:redirect_host] %>
6
6
  <%= render "searls/auth/shared/register_fields", f: f %>
7
7
  <%= f.submit "Register" %>
8
8
  <% end %>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <%= form_with url: searls_auth.password_reset_request_path, method: :post, data: {controller: searls_auth_helper.login_stimulus_controller} do |f| %>
4
4
  <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
5
- <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
5
+ <%= f.hidden_field :redirect_host, value: params[:redirect_host] %>
6
6
  <div>
7
7
  <%= f.label :email, "Email address" %>
8
8
  <%= f.email_field :email, required: true, value: params[:email], autocomplete: "email" %>
@@ -3,7 +3,7 @@
3
3
  <%= form_with url: searls_auth.password_reset_update_path, method: :patch do |f| %>
4
4
  <%= f.hidden_field :token, value: @token %>
5
5
  <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
6
- <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
6
+ <%= f.hidden_field :redirect_host, value: params[:redirect_host] %>
7
7
  <div>
8
8
  <%= label_tag :email, "Email address" %>
9
9
  <%= email_field_tag :email, @user_email, disabled: true %>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <%= form_with scope: :settings, url: searls_auth.settings_path, method: :patch do |f| %>
4
4
  <%= hidden_field_tag :redirect_path, params[:redirect_path] %>
5
- <%= hidden_field_tag :redirect_subdomain, params[:redirect_subdomain] %>
5
+ <%= hidden_field_tag :redirect_host, params[:redirect_host] %>
6
6
 
7
7
  <fieldset>
8
8
  <legend>Password</legend>
@@ -7,7 +7,7 @@
7
7
  <% if Searls::Auth.config.auth_methods.include?(:email_otp) %>
8
8
  <%= form_with(url: searls_auth.verify_path, method: :post, data: { turbo: searls_auth_helper.enable_turbo? }) do |f| %>
9
9
  <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
10
- <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
10
+ <%= f.hidden_field :redirect_host, value: params[:redirect_host] %>
11
11
  <div data-controller="<%= searls_auth_helper.otp_stimulus_controller %>">
12
12
  <%= f.label :short_code, "Code" %>
13
13
  <%= f.text_field :short_code,
@@ -1,16 +1,22 @@
1
1
  require "uri"
2
+ require "rack/utils"
2
3
 
3
4
  module Searls
4
5
  module Auth
5
6
  class BuildsTargetRedirectUrl
6
- def build(request, params)
7
+ def build(request, params, user: nil)
7
8
  path = normalize_path(params[:redirect_path])
8
- host = resolve_host(request, params[:redirect_subdomain])
9
+ host = normalize_redirect_host(params[:redirect_host])
9
10
 
10
- if host == request.host && path.present?
11
+ if (host.blank? || host == request.host) && path.present?
11
12
  path
12
- elsif host != request.host
13
- absolute_url(request, host, path)
13
+ elsif host.present? && host != request.host
14
+ url = absolute_url(request, host, path)
15
+ if same_cookie_domain?(request, host)
16
+ url
17
+ else
18
+ append_cross_domain_sso_token(url, request, user, host) || path
19
+ end
14
20
  end
15
21
  end
16
22
 
@@ -23,32 +29,12 @@ module Searls
23
29
  end
24
30
  end
25
31
 
26
- def resolve_host(request, subdomain)
27
- s = normalize_subdomain(subdomain)
28
- cur = request.subdomain.presence
29
- return request.host if s.nil? || s == cur || (s == "" && cur.nil?)
30
- return root_host(request) || request.host if s == "" && cur
31
- base = root_host(request) || request.host
32
- "#{s}.#{base}"
33
- end
32
+ def normalize_redirect_host(raw)
33
+ v = raw.to_s.strip.downcase
34
+ return if v.blank?
34
35
 
35
- def normalize_subdomain(raw)
36
- return "" if raw.is_a?(String) && raw.strip.empty?
37
- if (v = raw.to_s.downcase).present?
38
- v if /\A[a-z0-9-]+\z/.match?(v)
39
- end
40
- end
41
-
42
- def root_host(request)
43
- request.domain.presence || begin
44
- host = URI.parse(request.base_url).host
45
- sub = request.subdomain.to_s
46
- if sub.empty?
47
- host
48
- else
49
- pref = "#{sub}."
50
- host.start_with?(pref) ? host.delete_prefix(pref) : host
51
- end
36
+ if /\A[a-z0-9.-]+\z/.match?(v)
37
+ v
52
38
  end
53
39
  end
54
40
 
@@ -67,6 +53,29 @@ module Searls
67
53
  end
68
54
  uri.to_s
69
55
  end
56
+
57
+ def append_cross_domain_sso_token(url, request, user, host)
58
+ return if user.blank?
59
+
60
+ token = begin
61
+ provider = Searls::Auth.config.sso_token_for_cross_domain_redirects
62
+ provider&.call(user, request, host)
63
+ end
64
+ return if token.blank?
65
+
66
+ uri = URI.parse(url)
67
+ query = Rack::Utils.parse_nested_query(uri.query)
68
+ query["sso_token"] = token
69
+ uri.query = Rack::Utils.build_nested_query(query)
70
+ uri.to_s
71
+ end
72
+
73
+ def same_cookie_domain?(request, host)
74
+ domain = request.domain
75
+ return false if domain.blank?
76
+
77
+ host == domain || host.end_with?(".#{domain}")
78
+ end
70
79
  end
71
80
  end
72
81
  end
@@ -9,9 +9,11 @@ module Searls
9
9
  # Core hooks that must always be callable
10
10
  HOOK_FIELDS = [
11
11
  :user_finder_by_email,
12
+ :user_finder_by_email_for_registration,
12
13
  :user_finder_by_id,
13
14
  :user_finder_by_token,
14
15
  :user_initializer,
16
+ :existing_user_registration_blocked_predicate,
15
17
  :token_generator,
16
18
  :email_verified_predicate,
17
19
  :email_verified_setter,
@@ -23,9 +25,11 @@ module Searls
23
25
  :email_verification_mode, # :none, :optional, :required
24
26
  # Data setup
25
27
  :user_finder_by_email, # proc(email)
28
+ :user_finder_by_email_for_registration, # proc(email)
26
29
  :user_finder_by_id, # proc(id)
27
30
  :user_finder_by_token, # proc(token)
28
31
  :user_initializer, # proc(params)
32
+ :existing_user_registration_blocked_predicate, # proc(user, params)
29
33
  :user_name_method, # string
30
34
  :token_generator, # proc()
31
35
  :password_verifier, # proc(user, password)
@@ -60,12 +64,15 @@ module Searls
60
64
  :redirect_path_after_register, # string or proc(user, params, request, routes), all new registrations redirect here
61
65
  :redirect_path_after_login, # string or proc(user, params, request, routes), only redirected here if redirect_path param not set
62
66
  :redirect_path_after_settings_change, # string or proc(user, params, request, routes), post-settings updates redirect here
67
+ :redirect_url_after_logout, # string or proc(notice, params, request, routes)
68
+ :sso_token_for_cross_domain_redirects, # proc(user, request, target_host)
63
69
  # Hook setup
64
70
  :validate_registration, # proc(user, params, errors = []), must return an array of error messages where empty means valid
65
71
  :after_login_success, # proc(user)
66
72
  # Branding setup
67
73
  :app_name, # string
68
74
  :app_url, # string
75
+ :support_email_address, # string
69
76
  :email_banner_image_path, # string
70
77
  :email_background_color, # string
71
78
  :email_button_color, # string
@@ -108,6 +115,14 @@ module Searls
108
115
  auth_methods.include?(:password) && password_reset_enabled
109
116
  end
110
117
 
118
+ def default_redirect_path_after_login
119
+ redirect_path_after_login
120
+ end
121
+
122
+ def default_redirect_path_after_login=(value)
123
+ self.redirect_path_after_login = value
124
+ end
125
+
111
126
  def password_present?(user)
112
127
  predicate = password_present_predicate
113
128
  return false if predicate.nil?
@@ -120,6 +135,7 @@ module Searls
120
135
  validate_email_verification_mode!
121
136
  validate_numeric_options!
122
137
  validate_core_hooks!
138
+ ensure_callable_optional!(:sso_token_for_cross_domain_redirects)
123
139
  validate_password_settings!
124
140
  validate_default_user_hooks!
125
141
  rescue => e
@@ -224,6 +240,7 @@ module Searls
224
240
  def validate_default_user_hooks!
225
241
  hooks_pointing_at_user = [
226
242
  :user_finder_by_email,
243
+ :user_finder_by_email_for_registration,
227
244
  :user_finder_by_id,
228
245
  :user_finder_by_token,
229
246
  :user_initializer,
@@ -263,6 +280,17 @@ module Searls
263
280
  end
264
281
  end
265
282
 
283
+ if public_send(:user_finder_by_email_for_registration).equal?(Searls::Auth::DEFAULT_CONFIG[:user_finder_by_email_for_registration])
284
+ unless ::User.respond_to?(:find_by)
285
+ raise Searls::Auth::Error, "Default :user_finder_by_email_for_registration expects User.find_by(email: ...) to exist."
286
+ end
287
+ has_email_method = ::User.method_defined?(:email)
288
+ has_email_column = ::User.respond_to?(:column_names) && ::User.column_names.include?("email")
289
+ unless schema_checks_blocked? || has_email_method || has_email_column
290
+ raise Searls::Auth::Error, "Default :user_finder_by_email_for_registration expects a `users.email` attribute."
291
+ end
292
+ end
293
+
266
294
  if public_send(:user_finder_by_token).equal?(Searls::Auth::DEFAULT_CONFIG[:user_finder_by_token])
267
295
  unless ::User.respond_to?(:find_by_token_for)
268
296
  raise Searls::Auth::Error, "Default :user_finder_by_token expects User.find_by_token_for(:email_auth, token) (Rails signed_id API)."
@@ -7,10 +7,10 @@ module Searls
7
7
  Result = Struct.new(:user, :success?, :error_messages)
8
8
 
9
9
  def call(params)
10
- user = Searls::Auth.config.user_finder_by_email.call(params[:email]) ||
11
- Searls::Auth.config.user_initializer.call(params)
10
+ found = Searls::Auth.config.user_finder_by_email_for_registration.call(params[:email])
11
+ user = found || Searls::Auth.config.user_initializer.call(params)
12
12
 
13
- if user.persisted?
13
+ if user.persisted? && Searls::Auth.config.existing_user_registration_blocked_predicate.call(user, params)
14
14
  Result.new(nil, false, ["An account already exists for that email address. <a href=\"#{login_path(**forwardable_params(params))}\">Log in</a> instead?"])
15
15
  elsif (errors = Searls::Auth.config.validate_registration.call(user, params, [])).any?
16
16
  Result.new(nil, false, errors)
@@ -32,13 +32,13 @@ module Searls
32
32
  private
33
33
 
34
34
  def forwardable_params(params)
35
- params.permit(:redirect_path, :redirect_subdomain, :email)
35
+ params.permit(:redirect_path, :redirect_host, :email)
36
36
  end
37
37
 
38
38
  def simplified_error_messages(model)
39
39
  model.errors.details.keys.map { |attr|
40
40
  model.errors.full_messages_for(attr).first
41
- }.join
41
+ }.compact
42
42
  end
43
43
  end
44
44
  end
@@ -3,13 +3,13 @@ module Searls
3
3
  class DeliversPasswordReset
4
4
  Result = Struct.new(:success?, keyword_init: true)
5
5
 
6
- def deliver(user:, redirect_path: nil, redirect_subdomain: nil)
6
+ def deliver(user:, redirect_path: nil, redirect_host: nil)
7
7
  token = Searls::Auth.config.password_reset_token_generator.call(user)
8
8
  PasswordResetMailer.with(
9
9
  user:,
10
10
  token:,
11
11
  redirect_path:,
12
- redirect_subdomain:
12
+ redirect_host:
13
13
  ).password_reset.deliver_later
14
14
  Result.new(success?: true)
15
15
  end
@@ -1,13 +1,13 @@
1
1
  module Searls
2
2
  module Auth
3
3
  class EmailsLink
4
- def email(user:, email_otp:, redirect_path: nil, redirect_subdomain: nil)
4
+ def email(user:, email_otp:, redirect_path: nil, redirect_host: nil)
5
5
  LoginLinkMailer.with(
6
6
  user:,
7
7
  token: (Searls::Auth.config.auth_methods.include?(:email_link) ? generate_token!(user) : nil),
8
8
  email_otp: (Searls::Auth.config.auth_methods.include?(:email_otp) ? email_otp : nil),
9
9
  redirect_path:,
10
- redirect_subdomain:
10
+ redirect_host:
11
11
  ).login_link.deliver_later
12
12
  end
13
13
 
@@ -1,12 +1,12 @@
1
1
  module Searls
2
2
  module Auth
3
3
  class EmailsVerification
4
- def email(user:, redirect_path: nil, redirect_subdomain: nil)
4
+ def email(user:, redirect_path: nil, redirect_host: nil)
5
5
  EmailVerificationMailer.with(
6
6
  user:,
7
7
  token: generate_token!(user),
8
8
  redirect_path:,
9
- redirect_subdomain:
9
+ redirect_host:
10
10
  ).verification_email.deliver_later
11
11
  end
12
12
 
@@ -0,0 +1,18 @@
1
+ module Searls
2
+ module Auth
3
+ class SanitizesFlashHtml
4
+ def initialize
5
+ require "rails-html-sanitizer"
6
+ @safe_list_sanitizer = Rails::HTML::SafeListSanitizer.new
7
+ end
8
+
9
+ def sanitize(html)
10
+ @safe_list_sanitizer.sanitize(
11
+ html,
12
+ tags: %w[a b br code em i li ol p strong u ul],
13
+ attributes: %w[data-turbo-confirm data-turbo-method href rel target title]
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,5 +1,5 @@
1
1
  module Searls
2
2
  module Auth
3
- VERSION = "1.0.2"
3
+ VERSION = "2.0.0"
4
4
  end
5
5
  end
data/lib/searls/auth.rb CHANGED
@@ -3,6 +3,7 @@ require_relative "auth/parses_time_safely"
3
3
  require_relative "auth/config"
4
4
  require_relative "auth/builds_target_redirect_url" if defined?(Rails)
5
5
  require_relative "auth/creates_user" if defined?(Rails)
6
+ require_relative "auth/sanitizes_flash_html" if defined?(Rails)
6
7
  require_relative "auth/emails_link"
7
8
  require_relative "auth/emails_verification"
8
9
  require_relative "auth/engine" if defined?(Rails)
@@ -22,9 +23,11 @@ module Searls
22
23
  email_verification_mode: :none,
23
24
  # Data setup
24
25
  user_finder_by_email: ->(email) { User.find_by(email:) },
26
+ user_finder_by_email_for_registration: ->(email) { User.find_by(email:) },
25
27
  user_finder_by_id: ->(id) { User.find_by(id:) },
26
28
  user_finder_by_token: ->(token) { User.find_by_token_for(:email_auth, token) },
27
29
  user_initializer: ->(params) { User.new(email: params[:email]) },
30
+ existing_user_registration_blocked_predicate: ->(_user, _params) { true },
28
31
  user_name_method: "name",
29
32
  token_generator: ->(user) { user.generate_token_for(:email_auth) },
30
33
  email_otp_expiry_minutes: 30,
@@ -67,6 +70,7 @@ module Searls
67
70
  redirect_path_after_settings_change: ->(user, params, request, routes) {
68
71
  routes.respond_to?(:edit_settings_path) ? routes.edit_settings_path : "/settings"
69
72
  },
73
+ sso_token_for_cross_domain_redirects: nil,
70
74
  # Hook setup
71
75
  validate_registration: ->(user, params, errors) { errors },
72
76
  after_login_success: ->(user) {},
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: searls-auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Searls
@@ -34,6 +34,7 @@ files:
34
34
  - LICENSE.txt
35
35
  - README.md
36
36
  - Rakefile
37
+ - TODO.md
37
38
  - app/controllers/searls/auth/base_controller.rb
38
39
  - app/controllers/searls/auth/email_verifications_controller.rb
39
40
  - app/controllers/searls/auth/logins_controller.rb
@@ -80,6 +81,7 @@ files:
80
81
  - lib/searls/auth/railtie.rb
81
82
  - lib/searls/auth/resets_password.rb
82
83
  - lib/searls/auth/resets_session.rb
84
+ - lib/searls/auth/sanitizes_flash_html.rb
83
85
  - lib/searls/auth/updates_settings.rb
84
86
  - lib/searls/auth/version.rb
85
87
  - script/setup