standard_id 0.20.0 → 0.20.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: faa6cfb238d38d71af682021ba3b6407cf06657c1b12e7ff29474dc97c753277
4
- data.tar.gz: 365d86af12a4fef90b669f52114ae449d07facd5389089b438ae67174f75059e
3
+ metadata.gz: 664c61bf0dc0dbcf758fe4d114fad3776d0327777b64dde60f6d9d9f11db14cc
4
+ data.tar.gz: c87f620480f13c64051a0d915b37e2fd295f224f0a41fe0c4820bb8131321c70
5
5
  SHA512:
6
- metadata.gz: 4ffc829b81cb6315954c5f2b3632889a7afd3c7457c4971297698f04ec9f43682b7eb715f711e2b28ea3c40d33f51519fc7339cd8d0f4996a7adad413677e880
7
- data.tar.gz: 6247cc6fd0deeee072d90711ae1443d01d42bb585a58330358d9596fae8e665e14af4c274a478f7bc43b4e47adc2aca05d4f2c6ab7a7dc1c4caa846f751ab917
6
+ metadata.gz: 4531519bbd926a39fda0180809a305f1e7de319cb278d1b75b178616548b586d9b767db3eb9e58e661045bc513efbc6785b5a63fa858b91fd74a832170f96c50
7
+ data.tar.gz: b7625be363e4f848fdd7c61f51c287ed8153e2671b64e52e0acef48de4e0ff95668529e5099f7408d4713321406728c8a6d1225418bc4ba03444bd0c12916430
@@ -100,6 +100,12 @@ module StandardId
100
100
  # - :profile_type [String, nil] first configured profile type for the scope (back-compat)
101
101
  # - :profile_types [Array<String>, nil] all configured profile types for the scope
102
102
  # - :after_sign_in_path [String, nil] default redirect path for the scope
103
+ # - :redirect_uri [String, nil] caller-supplied destination (from the form
104
+ # param for password/signup, from the OAuth state cookie for social).
105
+ # Hooks that always return a default path should return nil when this is
106
+ # present so the originator's URL wins — otherwise upstream OAuth/SSO
107
+ # flows that send users to /login?redirect_uri=... will land on the
108
+ # hook's default page instead of completing the handshake.
103
109
  # @return [String, nil] redirect path override, or nil for default
104
110
  # @raise [StandardId::AuthenticationDenied] to reject the sign-in
105
111
  def invoke_after_sign_in(account, context)
@@ -118,6 +124,12 @@ module StandardId
118
124
  return result if result.present?
119
125
  end
120
126
 
127
+ # When the caller supplied a :redirect_uri and the hook returned nil, treat that
128
+ # as the documented "defer to originator" signal — do NOT shadow it with the
129
+ # scope's after_sign_in_path (which would silently break OAuth/SSO handshakes
130
+ # for any host that configures a scope default).
131
+ return nil if context[:redirect_uri].present?
132
+
121
133
  # When no hook override, use the scope's after_sign_in_path if present
122
134
  scope_config&.after_sign_in_path
123
135
  end
@@ -26,6 +26,7 @@ module StandardId
26
26
 
27
27
  begin
28
28
  extract_state_and_nonce => { state_data:, nonce: }
29
+ caller_redirect_uri = state_data&.dig("redirect_uri").presence
29
30
  redirect_uri = callback_url_for
30
31
  provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
31
32
  social_info = provider_response[:user_info]
@@ -52,10 +53,19 @@ module StandardId
52
53
  original_request_params: state_data
53
54
  )
54
55
 
55
- context = { mechanism: "social", provider: provider_name }
56
+ context = {
57
+ mechanism: "social",
58
+ provider: provider_name,
59
+ redirect_uri: caller_redirect_uri
60
+ }
56
61
  redirect_override = invoke_after_sign_in(account, context)
57
62
 
58
- destination = redirect_override || state_data["redirect_uri"]
63
+ # When the hook defers (returns nil), the originator-supplied URL becomes the
64
+ # destination. Validate it before redirect_to — without this, an attacker who
65
+ # tricks a victim into clicking /login?connection=google&redirect_uri=<evil>
66
+ # can steer the post-auth landing page. redirect_override is host-internal so
67
+ # we trust it; only the fallthrough needs validation.
68
+ destination = redirect_override || (safe_destination?(caller_redirect_uri) ? caller_redirect_uri : safe_post_signin_default)
59
69
  redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
60
70
  redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
61
71
  redirect_to destination, redirect_options
@@ -124,7 +134,17 @@ module StandardId
124
134
  "Authentication failed"
125
135
  end
126
136
 
127
- redirect_to StandardId::WebEngine.routes.url_helpers.login_path, alert: error_message
137
+ # Preserve redirect_uri across the bounce-back-to-/login so the user can retry
138
+ # the OAuth handshake and complete it back to the originator. Symmetric with
139
+ # the SocialLinkError / OAuthError rescue paths above.
140
+ redirect_uri = begin
141
+ extract_state_and_nonce => { state_data: }
142
+ state_data&.dig("redirect_uri").presence
143
+ rescue StandardId::InvalidRequestError
144
+ nil
145
+ end
146
+
147
+ redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: redirect_uri), alert: error_message
128
148
  end
129
149
 
130
150
  def mobile_relay_params
@@ -14,6 +14,51 @@ module StandardId
14
14
 
15
15
  before_action -> { Current.scope = :web if defined?(::Current) }
16
16
  before_action :require_browser_session!
17
+
18
+ private
19
+
20
+ # Read a top-level query/form param expected to be a scalar String, returning
21
+ # nil for absent/blank values OR if Rails parsed it as an Array/Hash (e.g. from
22
+ # `?redirect_uri[]=a&redirect_uri[]=b`). Without this guard, `redirect_to` is
23
+ # called with a non-String and raises ArgumentError → 500 for any caller that
24
+ # sends a malformed redirect_uri.
25
+ def string_param(key)
26
+ value = params[key]
27
+ value.is_a?(String) ? value.presence : nil
28
+ end
29
+
30
+ # Whether `destination` is safe to redirect a signed-in user to.
31
+ # - Same-origin paths ("/foo") pass; protocol-relative ("//evil") does not.
32
+ # - Same-origin absolute URLs ("https://this-host/...") pass — `store_location_for_redirect`
33
+ # stashes `request.url` in session, so callers wrapping `after_authentication_url`
34
+ # need same-origin URLs accepted.
35
+ # - Cross-host URLs pass only when the host has explicitly allow-listed the prefix
36
+ # via `StandardId.config.allowed_redirect_url_prefixes`.
37
+ # - Anything else (blank, absolute URL not in the allow-list, protocol-relative,
38
+ # opaque scheme) is rejected; callers should fall back to `safe_post_signin_default`.
39
+ def safe_destination?(destination)
40
+ return false if destination.blank?
41
+ return true if destination.start_with?("/") && !destination.start_with?("//")
42
+ return true if same_origin_url?(destination)
43
+
44
+ Array(StandardId.config.allowed_redirect_url_prefixes).any? do |entry|
45
+ case entry
46
+ when Regexp then entry.match?(destination)
47
+ else destination.start_with?(entry.to_s)
48
+ end
49
+ end
50
+ end
51
+
52
+ def same_origin_url?(destination)
53
+ return false unless destination.start_with?("http://", "https://")
54
+ URI.parse(destination).origin == URI.parse(request.base_url).origin
55
+ rescue URI::Error, ArgumentError
56
+ false
57
+ end
58
+
59
+ def safe_post_signin_default
60
+ "/"
61
+ end
17
62
  end
18
63
  end
19
64
  end
@@ -31,7 +31,7 @@ module StandardId
31
31
  before_action :redirect_if_social_login, only: [:create]
32
32
 
33
33
  def show
34
- @redirect_uri = params[:redirect_uri] || after_authentication_url
34
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
35
35
  @connection = params[:connection]
36
36
 
37
37
  render_with_inertia props: auth_page_props(passwordless_enabled: passwordless_enabled?)
@@ -59,9 +59,12 @@ module StandardId
59
59
  }
60
60
 
61
61
  if result
62
- context = { mechanism: "password", provider: nil }
62
+ redirect_uri = string_param(:redirect_uri)
63
+ context = { mechanism: "password", provider: nil, redirect_uri: redirect_uri }
63
64
  redirect_override = invoke_after_sign_in(current_account, context)
64
- destination = redirect_override || params[:redirect_uri] || after_authentication_url
65
+ fallback = after_authentication_url
66
+ fallback = safe_post_signin_default unless safe_destination?(fallback)
67
+ destination = redirect_override || (safe_destination?(redirect_uri) ? redirect_uri : nil) || fallback
65
68
  redirect_to destination, status: :see_other, notice: "Successfully signed in"
66
69
  else
67
70
  flash.now[:alert] = "Invalid email or password"
@@ -95,7 +98,11 @@ module StandardId
95
98
  expires_in: code_ttl.seconds
96
99
  )
97
100
  session[:standard_id_otp_payload] = signed_payload
98
- session[:return_to_after_authenticating] = params[:redirect_uri] if params[:redirect_uri].present?
101
+ # Use string_param to reject Array/Hash-shaped values — login_verify_controller
102
+ # consumes this via after_authentication_url and passes it to redirect_to;
103
+ # storing a non-String here would crash that flow with ArgumentError.
104
+ redirect_uri = string_param(:redirect_uri)
105
+ session[:return_to_after_authenticating] = redirect_uri if redirect_uri
99
106
 
100
107
  redirect_to login_verify_path, status: :see_other
101
108
  end
@@ -60,12 +60,24 @@ module StandardId
60
60
  invoke_after_account_created(account, { mechanism: "passwordless", provider: nil })
61
61
  end
62
62
 
63
- context = { mechanism: "passwordless", provider: nil }
63
+ # Peek (don't pop) session[:return_to_after_authenticating] after_authentication_url
64
+ # consumes it below when redirect_override is nil, so deleting it here would lose the
65
+ # destination for hosts whose after_sign_in hook defers to the originator's redirect_uri.
66
+ context = {
67
+ mechanism: "passwordless",
68
+ provider: nil,
69
+ redirect_uri: session[:return_to_after_authenticating]
70
+ }
64
71
  redirect_override = invoke_after_sign_in(account, context)
65
72
 
66
73
  session.delete(:standard_id_otp_payload)
67
74
 
68
- destination = redirect_override || after_authentication_url
75
+ # after_authentication_url returns whatever was stashed in
76
+ # session[:return_to_after_authenticating] — which could be an attacker-controlled
77
+ # URL set by handle_passwordless_login from params[:redirect_uri]. string_param
78
+ # blocks Array/Hash but not "https://evil.com/phish". Validate before redirect.
79
+ fallback = after_authentication_url
80
+ destination = redirect_override || (safe_destination?(fallback) ? fallback : safe_post_signin_default)
69
81
  redirect_to destination, status: :see_other, notice: "Successfully signed in"
70
82
  rescue StandardId::AuthenticationDenied => e
71
83
  session.delete(:standard_id_otp_payload)
@@ -14,13 +14,18 @@ module StandardId
14
14
 
15
15
  def create
16
16
  revoke_current_session!
17
- redirect_to params[:redirect_uri] || root_path, notice: "Successfully signed out"
17
+ redirect_to logout_destination, notice: "Successfully signed out"
18
18
  end
19
19
 
20
20
  private
21
21
 
22
22
  def redirect_if_not_authenticated
23
- redirect_to params[:redirect_uri] || root_path unless authenticated?
23
+ redirect_to logout_destination unless authenticated?
24
+ end
25
+
26
+ def logout_destination
27
+ redirect_uri = string_param(:redirect_uri)
28
+ safe_destination?(redirect_uri) ? redirect_uri : root_path
24
29
  end
25
30
  end
26
31
  end
@@ -15,7 +15,7 @@ module StandardId
15
15
  before_action :redirect_if_social_login, only: [:create]
16
16
 
17
17
  def show
18
- @redirect_uri = params[:redirect_uri] || after_authentication_url
18
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
19
19
  @connection = params[:connection] # For social login detection
20
20
 
21
21
  render_with_inertia props: auth_page_props
@@ -43,12 +43,15 @@ module StandardId
43
43
  session_manager.sign_in_account(form.account, scope_name: request.path_parameters[:scope])
44
44
  invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
45
45
 
46
- context = { mechanism: "password", provider: nil }
46
+ redirect_uri = string_param(:redirect_uri)
47
+ context = { mechanism: "password", provider: nil, redirect_uri: redirect_uri }
47
48
  redirect_override = invoke_after_sign_in(form.account, context)
48
- destination = redirect_override || params[:redirect_uri] || after_authentication_url
49
+ fallback = after_authentication_url
50
+ fallback = safe_post_signin_default unless safe_destination?(fallback)
51
+ destination = redirect_override || (safe_destination?(redirect_uri) ? redirect_uri : nil) || fallback
49
52
  redirect_to destination, notice: "Account created successfully"
50
53
  else
51
- @redirect_uri = params[:redirect_uri] || after_authentication_url
54
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
52
55
  @connection = params[:connection]
53
56
  flash.now[:alert] = form.errors.full_messages.join(", ")
54
57
  render_with_inertia action: :show, props: auth_page_props(errors: form.errors.to_hash), status: :unprocessable_content
@@ -79,7 +82,7 @@ module StandardId
79
82
 
80
83
  def encode_state
81
84
  Base64.urlsafe_encode64({
82
- redirect_uri: params[:redirect_uri] || after_authentication_url,
85
+ redirect_uri: string_param(:redirect_uri) || after_authentication_url,
83
86
  timestamp: Time.current.to_i
84
87
  }.to_json)
85
88
  end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.20.0"
2
+ VERSION = "0.20.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.20.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim