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 +4 -4
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -0
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +23 -3
- data/app/controllers/standard_id/web/base_controller.rb +45 -0
- data/app/controllers/standard_id/web/login_controller.rb +11 -4
- data/app/controllers/standard_id/web/login_verify_controller.rb +14 -2
- data/app/controllers/standard_id/web/logout_controller.rb +7 -2
- data/app/controllers/standard_id/web/signup_controller.rb +8 -5
- data/lib/standard_id/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 664c61bf0dc0dbcf758fe4d114fad3776d0327777b64dde60f6d9d9f11db14cc
|
|
4
|
+
data.tar.gz: c87f620480f13c64051a0d915b37e2fd295f224f0a41fe0c4820bb8131321c70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
85
|
+
redirect_uri: string_param(:redirect_uri) || after_authentication_url,
|
|
83
86
|
timestamp: Time.current.to_i
|
|
84
87
|
}.to_json)
|
|
85
88
|
end
|
data/lib/standard_id/version.rb
CHANGED