standard_id 0.12.0 → 0.13.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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +115 -6
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +84 -0
- data/app/controllers/concerns/standard_id/web_authentication.rb +65 -3
- data/app/controllers/standard_id/api/passwordless_controller.rb +2 -2
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +1 -1
- data/app/controllers/standard_id/web/login_controller.rb +10 -8
- data/app/controllers/standard_id/web/login_verify_controller.rb +3 -3
- data/app/controllers/standard_id/web/signup_controller.rb +1 -1
- data/lib/standard_id/config/schema.rb +11 -0
- data/lib/standard_id/scope_config.rb +20 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +17 -2
- data/lib/standard_id.rb +8 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90019539f1e0cc2c3fee8e641ff0b18a337bd1b263aa78441b3c886bde6b72d4
|
|
4
|
+
data.tar.gz: a98746c6be195bf842304c07bcb6276f8f95031be8c83b349ce902185874e248
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85558657315f76bfbdb3d05638cb7280805e2f329dca42dc99f0c0a2e2fd99bcd0ce6d0ffa4289138d13ac89207276fd9c0d75dd52336e8538c12dcfcbcd8164
|
|
7
|
+
data.tar.gz: 707fb170fee01dae20abd78a62ba67c9f9d6a2135ed6ddad02610f1194b89c41e9cef62f12a8c2358931b66c21aa66952cfaaccb2b0d36bd60f7269fbf6b7493
|
|
@@ -1,24 +1,80 @@
|
|
|
1
1
|
module StandardId
|
|
2
|
+
# Public concern providing authentication lifecycle hook invocations.
|
|
3
|
+
#
|
|
4
|
+
# Include this in host app controllers that implement custom authentication
|
|
5
|
+
# flows but want to participate in StandardId's hook system. The hooks are
|
|
6
|
+
# configured via `StandardId.config.before_sign_in`, `after_sign_in`, and
|
|
7
|
+
# `after_account_created` callbacks.
|
|
8
|
+
#
|
|
9
|
+
# This is the same concern used internally by the WebEngine's built-in
|
|
10
|
+
# controllers -- there is no separate "internal" version.
|
|
11
|
+
#
|
|
12
|
+
# Requires the including controller to include `StandardId::WebAuthentication`
|
|
13
|
+
# (for `session_manager` and `request` access).
|
|
14
|
+
#
|
|
15
|
+
# @example Usage in a host app controller
|
|
16
|
+
# class Auth::SessionsController < ApplicationController
|
|
17
|
+
# include StandardId::WebAuthentication
|
|
18
|
+
# include StandardId::LifecycleHooks
|
|
19
|
+
#
|
|
20
|
+
# def create
|
|
21
|
+
# account = authenticate_somehow(params)
|
|
22
|
+
# invoke_before_sign_in(account, { mechanism: "custom", provider: nil })
|
|
23
|
+
# session_manager.sign_in_account(account)
|
|
24
|
+
# redirect_override = invoke_after_sign_in(account, { mechanism: "custom", provider: nil })
|
|
25
|
+
# redirect_to redirect_override || root_path
|
|
26
|
+
# rescue StandardId::AuthenticationDenied => e
|
|
27
|
+
# handle_authentication_denied(e)
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
2
30
|
module LifecycleHooks
|
|
3
31
|
extend ActiveSupport::Concern
|
|
4
32
|
|
|
33
|
+
# Default profile resolver when StandardId.config.profile_resolver is nil.
|
|
34
|
+
DEFAULT_PROFILE_RESOLVER = ->(acct, pt) { acct.profiles.exists?(profileable_type: pt) }
|
|
35
|
+
|
|
5
36
|
private
|
|
6
37
|
|
|
7
38
|
# Invoke the before_sign_in hook if configured.
|
|
8
39
|
# Called after credential verification, BEFORE session creation.
|
|
9
40
|
#
|
|
41
|
+
# When a scope is active (via route defaults), the built-in profile
|
|
42
|
+
# validation runs BEFORE the app's custom hook. If the account lacks
|
|
43
|
+
# the required profile, AuthenticationDenied is raised immediately.
|
|
44
|
+
#
|
|
10
45
|
# @param account [Object] the authenticated account
|
|
11
46
|
# @param context [Hash] context about the sign-in
|
|
12
47
|
# - :mechanism [String] "password", "passwordless", or "social"
|
|
13
48
|
# - :provider [String, nil] e.g. "google", "apple", or nil
|
|
14
49
|
# - :first_sign_in [Boolean] whether this is the account's first browser session
|
|
50
|
+
# - :scope [Symbol, nil] scope name when scoped authentication is active
|
|
51
|
+
# - :profile_type [String, nil] required profile type for the scope
|
|
52
|
+
# - :after_sign_in_path [String, nil] default redirect path for the scope
|
|
15
53
|
# @return [void]
|
|
16
|
-
# @raise [StandardId::AuthenticationDenied] when hook returns { error: "..." }
|
|
54
|
+
# @raise [StandardId::AuthenticationDenied] when profile check fails or hook returns { error: "..." }
|
|
17
55
|
def invoke_before_sign_in(account, context)
|
|
56
|
+
scope_config = current_scope_config
|
|
57
|
+
if scope_config
|
|
58
|
+
context = context.merge(
|
|
59
|
+
scope: scope_config.name,
|
|
60
|
+
profile_type: scope_config.profile_type,
|
|
61
|
+
after_sign_in_path: scope_config.after_sign_in_path
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Built-in profile check — runs before the app's custom hook
|
|
65
|
+
if scope_config.requires_profile?
|
|
66
|
+
resolver = StandardId.config.profile_resolver || DEFAULT_PROFILE_RESOLVER
|
|
67
|
+
unless resolver.call(account, scope_config.profile_type)
|
|
68
|
+
raise StandardId::AuthenticationDenied, scope_config.no_profile_message
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
context = context.merge(first_sign_in: first_sign_in?(account, session_created: false))
|
|
74
|
+
|
|
18
75
|
hook = StandardId.config.before_sign_in
|
|
19
76
|
return unless hook.respond_to?(:call)
|
|
20
77
|
|
|
21
|
-
context = context.merge(first_sign_in: first_sign_in?(account, session_created: false))
|
|
22
78
|
result = hook.call(account, request, context)
|
|
23
79
|
|
|
24
80
|
if result.is_a?(Hash) && result[:error].present?
|
|
@@ -28,23 +84,45 @@ module StandardId
|
|
|
28
84
|
|
|
29
85
|
# Invoke the after_sign_in hook if configured.
|
|
30
86
|
#
|
|
87
|
+
# When a scope is active, scope context is merged into the context hash.
|
|
88
|
+
# If the hook does not return a custom redirect path, the scope's
|
|
89
|
+
# after_sign_in_path is used as the default redirect.
|
|
90
|
+
#
|
|
31
91
|
# @param account [Object] the authenticated account
|
|
32
92
|
# @param context [Hash] context about the sign-in
|
|
33
93
|
# - :mechanism [String] "password", "passwordless", or "social"
|
|
34
94
|
# - :provider [String, nil] e.g. "google", "apple", or nil
|
|
35
95
|
# - :first_sign_in [Boolean] whether this is the account's first browser session
|
|
36
96
|
# - :session [StandardId::Session] the session that was just created
|
|
97
|
+
# - :scope [Symbol, nil] scope name when scoped authentication is active
|
|
98
|
+
# - :profile_type [String, nil] required profile type for the scope
|
|
99
|
+
# - :after_sign_in_path [String, nil] default redirect path for the scope
|
|
37
100
|
# @return [String, nil] redirect path override, or nil for default
|
|
38
101
|
# @raise [StandardId::AuthenticationDenied] to reject the sign-in
|
|
39
102
|
def invoke_after_sign_in(account, context)
|
|
40
|
-
|
|
41
|
-
|
|
103
|
+
scope_config = current_scope_config
|
|
104
|
+
if scope_config
|
|
105
|
+
context = context.merge(
|
|
106
|
+
scope: scope_config.name,
|
|
107
|
+
profile_type: scope_config.profile_type,
|
|
108
|
+
after_sign_in_path: scope_config.after_sign_in_path
|
|
109
|
+
)
|
|
110
|
+
end
|
|
42
111
|
|
|
112
|
+
hook = StandardId.config.after_sign_in
|
|
43
113
|
context = context.merge(
|
|
44
114
|
first_sign_in: first_sign_in?(account, session_created: true),
|
|
45
115
|
session: session_manager.current_session
|
|
46
116
|
)
|
|
47
|
-
|
|
117
|
+
|
|
118
|
+
if hook.respond_to?(:call)
|
|
119
|
+
result = hook.call(account, request, context)
|
|
120
|
+
# If hook returned a redirect path, use it; otherwise fall back to scope path
|
|
121
|
+
return result if result.present?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# When no hook override, use the scope's after_sign_in_path if present
|
|
125
|
+
scope_config&.after_sign_in_path
|
|
48
126
|
end
|
|
49
127
|
|
|
50
128
|
# Invoke the after_account_created hook if configured.
|
|
@@ -53,8 +131,20 @@ module StandardId
|
|
|
53
131
|
# @param context [Hash] context about the creation
|
|
54
132
|
# - :mechanism [String] "passwordless", "social", or "signup"
|
|
55
133
|
# - :provider [String, nil] e.g. "google", "apple", or nil
|
|
134
|
+
# - :scope [Symbol, nil] scope name when scoped authentication is active
|
|
135
|
+
# - :profile_type [String, nil] required profile type for the scope
|
|
136
|
+
# - :after_sign_in_path [String, nil] default redirect path for the scope
|
|
56
137
|
# @return [void]
|
|
57
138
|
def invoke_after_account_created(account, context)
|
|
139
|
+
scope_config = current_scope_config
|
|
140
|
+
if scope_config
|
|
141
|
+
context = context.merge(
|
|
142
|
+
scope: scope_config.name,
|
|
143
|
+
profile_type: scope_config.profile_type,
|
|
144
|
+
after_sign_in_path: scope_config.after_sign_in_path
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
58
148
|
hook = StandardId.config.after_account_created
|
|
59
149
|
return unless hook.respond_to?(:call)
|
|
60
150
|
|
|
@@ -73,6 +163,11 @@ module StandardId
|
|
|
73
163
|
# Handle AuthenticationDenied by revoking the session and redirecting to login.
|
|
74
164
|
# If the account was just created, clean it up to avoid orphaned records.
|
|
75
165
|
#
|
|
166
|
+
# @note By default this redirects to the WebEngine's login_path. Host app
|
|
167
|
+
# controllers that include LifecycleHooks without mounting the WebEngine
|
|
168
|
+
# should override this method to redirect to their own login page. When the
|
|
169
|
+
# WebEngine route is unavailable, falls back to `StandardId.config.login_url`
|
|
170
|
+
# or `"/"`.
|
|
76
171
|
# @param error [StandardId::AuthenticationDenied] the denial error
|
|
77
172
|
# @param account [Object, nil] the account to clean up if newly created
|
|
78
173
|
# @param newly_created [Boolean] whether the account was created during this request
|
|
@@ -82,7 +177,12 @@ module StandardId
|
|
|
82
177
|
message = error.message
|
|
83
178
|
# When raised without arguments, StandardError#message returns the class name
|
|
84
179
|
message = "Sign-in was denied" if message.blank? || message == error.class.name
|
|
85
|
-
|
|
180
|
+
login_path = begin
|
|
181
|
+
StandardId::WebEngine.routes.url_helpers.login_path
|
|
182
|
+
rescue NameError, NoMethodError, ActionController::UrlGenerationError
|
|
183
|
+
StandardId.config.login_url || "/"
|
|
184
|
+
end
|
|
185
|
+
redirect_to login_path, alert: message
|
|
86
186
|
end
|
|
87
187
|
|
|
88
188
|
# Destroy a newly created account and all its dependents.
|
|
@@ -97,5 +197,14 @@ module StandardId
|
|
|
97
197
|
account.destroy
|
|
98
198
|
end
|
|
99
199
|
end
|
|
200
|
+
|
|
201
|
+
# Look up the scope config for the current request.
|
|
202
|
+
# Reads :scope from route defaults (set by scoped route constraints).
|
|
203
|
+
# Returns nil when no scope is active, preserving backward compatibility.
|
|
204
|
+
# Memoized per request to avoid redundant ScopeConfig allocations.
|
|
205
|
+
def current_scope_config
|
|
206
|
+
return @current_scope_config if defined?(@current_scope_config)
|
|
207
|
+
@current_scope_config = StandardId.scope_for(request.path_parameters[:scope])
|
|
208
|
+
end
|
|
100
209
|
end
|
|
101
210
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Public concern for host app controllers that need passwordless OTP capabilities.
|
|
3
|
+
#
|
|
4
|
+
# Include this in your custom controllers to generate and verify OTP codes
|
|
5
|
+
# without mounting the WebEngine's built-in login controllers.
|
|
6
|
+
#
|
|
7
|
+
# Requires the including controller to have access to `request` (standard in
|
|
8
|
+
# all Rails controllers). No other dependencies are needed -- both
|
|
9
|
+
# `generate_passwordless_otp` and `verify_passwordless_otp` only use `request`.
|
|
10
|
+
#
|
|
11
|
+
# @example Usage in a host app controller
|
|
12
|
+
# class Auth::LoginController < ApplicationController
|
|
13
|
+
# include StandardId::PasswordlessFlow
|
|
14
|
+
# include StandardId::WebAuthentication # needed for session_manager
|
|
15
|
+
# include StandardId::LifecycleHooks
|
|
16
|
+
#
|
|
17
|
+
# def create
|
|
18
|
+
# generate_passwordless_otp(username: params[:email])
|
|
19
|
+
# redirect_to verify_path
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# def verify
|
|
23
|
+
# result = verify_passwordless_otp(username: params[:email], code: params[:code])
|
|
24
|
+
# if result.success?
|
|
25
|
+
# session_manager.sign_in_account(result.account)
|
|
26
|
+
# redirect_to root_path
|
|
27
|
+
# else
|
|
28
|
+
# render :verify, status: :unprocessable_content
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
module PasswordlessFlow
|
|
33
|
+
extend ActiveSupport::Concern
|
|
34
|
+
|
|
35
|
+
include StandardId::PasswordlessStrategy
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Generate a passwordless OTP code and send it to the user.
|
|
40
|
+
#
|
|
41
|
+
# Delegates to the appropriate strategy (EmailStrategy or SmsStrategy)
|
|
42
|
+
# based on the connection type. The strategy validates the username,
|
|
43
|
+
# creates a CodeChallenge, and triggers the configured sender callback.
|
|
44
|
+
#
|
|
45
|
+
# @param username [String] the recipient's email address or phone number
|
|
46
|
+
# @param connection [String] the delivery channel ("email" or "sms"), defaults to "email"
|
|
47
|
+
# @return [StandardId::CodeChallenge] the created code challenge
|
|
48
|
+
# @raise [StandardId::InvalidRequestError] when the username format is invalid
|
|
49
|
+
# or the connection type is unsupported
|
|
50
|
+
def generate_passwordless_otp(username:, connection: "email")
|
|
51
|
+
strategy = strategy_for(connection)
|
|
52
|
+
strategy.start!(username: username, connection: connection)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Verify a passwordless OTP code and resolve the account.
|
|
56
|
+
#
|
|
57
|
+
# Delegates to `StandardId::Passwordless.verify`, which handles code
|
|
58
|
+
# validation, constant-time comparison, attempt tracking, and account
|
|
59
|
+
# resolution (find or create).
|
|
60
|
+
#
|
|
61
|
+
# @param username [String] the identifier value (email or phone number)
|
|
62
|
+
# @param code [String] the OTP code to verify
|
|
63
|
+
# @param connection [String] the delivery channel ("email" or "sms"), defaults to "email"
|
|
64
|
+
# @param allow_registration [Boolean] whether to create a new account if none exists (default: true)
|
|
65
|
+
# @return [StandardId::Passwordless::VerificationService::Result] a result with:
|
|
66
|
+
# - success? -- true when verification succeeded
|
|
67
|
+
# - account -- the authenticated/created account (nil on failure)
|
|
68
|
+
# - challenge -- the consumed CodeChallenge (nil on failure)
|
|
69
|
+
# - error -- human-readable message (nil on success)
|
|
70
|
+
# - error_code -- machine-readable symbol (nil on success):
|
|
71
|
+
# :invalid_code, :expired, :max_attempts, :not_found, :blank_code,
|
|
72
|
+
# :account_not_found, :server_error
|
|
73
|
+
# - attempts -- failed attempt count (nil on success)
|
|
74
|
+
def verify_passwordless_otp(username:, code:, connection: "email", allow_registration: true)
|
|
75
|
+
StandardId::Passwordless.verify(
|
|
76
|
+
username: username,
|
|
77
|
+
code: code,
|
|
78
|
+
connection: connection,
|
|
79
|
+
request: request,
|
|
80
|
+
allow_registration: allow_registration
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
module StandardId
|
|
2
|
+
# Public concern providing cookie-based session management for web controllers.
|
|
3
|
+
#
|
|
4
|
+
# Include this in host app controllers to access StandardId's session
|
|
5
|
+
# management capabilities. This is the same concern used internally by
|
|
6
|
+
# the WebEngine's built-in controllers.
|
|
7
|
+
#
|
|
8
|
+
# ## Public helpers (available in controllers and views)
|
|
9
|
+
#
|
|
10
|
+
# - `current_account` -- Returns the currently authenticated account, or nil.
|
|
11
|
+
# Loads from session token or remember-me cookie. Delegated to SessionManager.
|
|
12
|
+
#
|
|
13
|
+
# - `authenticated?` -- Returns true if a user is currently signed in.
|
|
14
|
+
#
|
|
15
|
+
# - `current_session` -- Returns the current StandardId::BrowserSession, or nil.
|
|
16
|
+
# Delegated to SessionManager.
|
|
17
|
+
#
|
|
18
|
+
# - `revoke_current_session!` -- Revokes the current browser session and clears
|
|
19
|
+
# all session/cookie tokens. Use for sign-out flows. Delegated to SessionManager.
|
|
20
|
+
#
|
|
21
|
+
# - `sign_in_account(login_params, &before_session)` -- Authenticates via password
|
|
22
|
+
# credentials. Accepts a block called after credential verification but before
|
|
23
|
+
# session creation (for lifecycle hooks). Returns the PasswordCredential on
|
|
24
|
+
# success, nil on failure.
|
|
25
|
+
#
|
|
26
|
+
# - `session_manager` -- Returns the StandardId::Web::SessionManager instance
|
|
27
|
+
# for the current request. Useful for direct session operations like
|
|
28
|
+
# `session_manager.sign_in_account(account)` in passwordless flows.
|
|
29
|
+
#
|
|
30
|
+
# @example Usage in a host app controller
|
|
31
|
+
# class ApplicationController < ActionController::Base
|
|
32
|
+
# include StandardId::WebAuthentication
|
|
33
|
+
#
|
|
34
|
+
# before_action :authenticate_account!
|
|
35
|
+
# end
|
|
2
36
|
module WebAuthentication
|
|
3
37
|
extend ActiveSupport::Concern
|
|
4
38
|
|
|
5
39
|
included do
|
|
6
40
|
include StandardId::InertiaSupport
|
|
7
|
-
helper_method :current_account, :authenticated
|
|
41
|
+
helper_method :current_account, :authenticated?, :current_scope_names
|
|
8
42
|
|
|
9
43
|
if StandardId.config.alias_current_user
|
|
10
44
|
define_method(:current_user) { current_account }
|
|
@@ -12,10 +46,22 @@ module StandardId
|
|
|
12
46
|
end
|
|
13
47
|
end
|
|
14
48
|
|
|
15
|
-
|
|
49
|
+
# @!method current_session
|
|
50
|
+
# Returns the current StandardId::BrowserSession, or nil.
|
|
51
|
+
# @!method current_account
|
|
52
|
+
# Returns the currently authenticated account, or nil.
|
|
53
|
+
# Loads from session token or remember-me cookie.
|
|
54
|
+
# @!method current_scope_names
|
|
55
|
+
# Returns an array of scope names the user has authenticated into.
|
|
56
|
+
# @!method revoke_current_session!
|
|
57
|
+
# Revokes the current browser session and clears all session/cookie tokens.
|
|
58
|
+
delegate :current_session, :current_account, :current_scope_names, :revoke_current_session!, to: :session_manager
|
|
16
59
|
|
|
17
60
|
private
|
|
18
61
|
|
|
62
|
+
# Returns true if a user is currently signed in.
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean]
|
|
19
65
|
def authenticated?
|
|
20
66
|
current_account.present?
|
|
21
67
|
end
|
|
@@ -59,6 +105,16 @@ module StandardId
|
|
|
59
105
|
session.delete(:return_to_after_authenticating) || "/"
|
|
60
106
|
end
|
|
61
107
|
|
|
108
|
+
# Authenticate a user via password credentials and create a browser session.
|
|
109
|
+
#
|
|
110
|
+
# Accepts a block called after credential verification but before session
|
|
111
|
+
# creation, allowing lifecycle hooks (e.g. invoke_before_sign_in) to reject
|
|
112
|
+
# the sign-in by raising StandardId::AuthenticationDenied.
|
|
113
|
+
#
|
|
114
|
+
# @param login_params [Hash] must include :email (or :login) and :password;
|
|
115
|
+
# optionally :remember_me
|
|
116
|
+
# @yield [account] called after password verification, before session creation
|
|
117
|
+
# @return [StandardId::PasswordCredential, nil] the credential on success, nil on failure
|
|
62
118
|
def sign_in_account(login_params, &before_session)
|
|
63
119
|
login = login_params[:email] || login_params[:login] # support both :email and :login keys
|
|
64
120
|
password = login_params[:password]
|
|
@@ -98,7 +154,7 @@ module StandardId
|
|
|
98
154
|
# but before session creation. The block may raise AuthenticationDenied.
|
|
99
155
|
before_session&.call(password_credential.account)
|
|
100
156
|
|
|
101
|
-
session_manager.sign_in_account(password_credential.account)
|
|
157
|
+
session_manager.sign_in_account(password_credential.account, scope_name: request.path_parameters[:scope])
|
|
102
158
|
session_manager.set_remember_cookie(password_credential) if remember_me
|
|
103
159
|
|
|
104
160
|
StandardId::Events.publish(
|
|
@@ -110,6 +166,12 @@ module StandardId
|
|
|
110
166
|
end
|
|
111
167
|
end
|
|
112
168
|
|
|
169
|
+
# Returns the StandardId::Web::SessionManager for the current request.
|
|
170
|
+
#
|
|
171
|
+
# Use this for direct session operations in custom flows, e.g.:
|
|
172
|
+
# session_manager.sign_in_account(account)
|
|
173
|
+
#
|
|
174
|
+
# @return [StandardId::Web::SessionManager]
|
|
113
175
|
def session_manager
|
|
114
176
|
@session_manager ||= StandardId::Web::SessionManager.new(
|
|
115
177
|
token_manager,
|
|
@@ -3,7 +3,7 @@ module StandardId
|
|
|
3
3
|
class PasswordlessController < BaseController
|
|
4
4
|
public_controller
|
|
5
5
|
|
|
6
|
-
include StandardId::
|
|
6
|
+
include StandardId::PasswordlessFlow
|
|
7
7
|
|
|
8
8
|
# RAR-60: Rate limit OTP initiation by IP (10 per hour)
|
|
9
9
|
rate_limit to: StandardId.config.rate_limits.api_passwordless_start_per_ip,
|
|
@@ -23,7 +23,7 @@ module StandardId
|
|
|
23
23
|
def start
|
|
24
24
|
raise StandardId::InvalidRequestError, "username, email, or phone_number parameter is required" if start_params[:username].blank?
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
generate_passwordless_otp(username: start_params[:username], connection: start_params[:connection])
|
|
27
27
|
|
|
28
28
|
render json: { message: "Code sent successfully" }, status: :ok
|
|
29
29
|
end
|
|
@@ -39,7 +39,7 @@ module StandardId
|
|
|
39
39
|
newly_created = account.previously_new_record?
|
|
40
40
|
|
|
41
41
|
invoke_before_sign_in(account, { mechanism: "social", provider: provider.provider_name })
|
|
42
|
-
session_manager.sign_in_account(account)
|
|
42
|
+
session_manager.sign_in_account(account, scope_name: state_data&.dig("scope"))
|
|
43
43
|
|
|
44
44
|
provider_name = provider.provider_name
|
|
45
45
|
invoke_after_account_created(account, { mechanism: "social", provider: provider_name }) if newly_created
|
|
@@ -5,7 +5,7 @@ module StandardId
|
|
|
5
5
|
|
|
6
6
|
include StandardId::InertiaRendering
|
|
7
7
|
include StandardId::Web::SocialLoginParams
|
|
8
|
-
include StandardId::
|
|
8
|
+
include StandardId::PasswordlessFlow
|
|
9
9
|
include StandardId::LifecycleHooks
|
|
10
10
|
|
|
11
11
|
layout "public"
|
|
@@ -72,19 +72,17 @@ module StandardId
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
def handle_passwordless_login
|
|
75
|
-
|
|
75
|
+
username = login_params[:email].to_s.strip.downcase
|
|
76
76
|
connection = StandardId.config.passwordless.connection
|
|
77
77
|
|
|
78
|
-
if
|
|
78
|
+
if username.blank?
|
|
79
79
|
flash.now[:alert] = "Please enter your email address"
|
|
80
80
|
render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
|
|
81
81
|
return
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
strategy = strategy_for(connection)
|
|
85
|
-
|
|
86
84
|
begin
|
|
87
|
-
|
|
85
|
+
generate_passwordless_otp(username: username, connection: connection)
|
|
88
86
|
rescue StandardId::InvalidRequestError => e
|
|
89
87
|
flash.now[:alert] = e.message
|
|
90
88
|
render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
|
|
@@ -93,7 +91,7 @@ module StandardId
|
|
|
93
91
|
|
|
94
92
|
code_ttl = StandardId.config.passwordless.code_ttl
|
|
95
93
|
signed_payload = Rails.application.message_verifier(:otp).generate(
|
|
96
|
-
{ username:
|
|
94
|
+
{ username: username, connection: connection },
|
|
97
95
|
expires_in: code_ttl.seconds
|
|
98
96
|
)
|
|
99
97
|
session[:standard_id_otp_payload] = signed_payload
|
|
@@ -138,7 +136,11 @@ module StandardId
|
|
|
138
136
|
end
|
|
139
137
|
|
|
140
138
|
def extract_social_login_params
|
|
141
|
-
request.parameters.except("controller", "action", "format", "authenticity_token", "commit", "login").to_h.deep_dup
|
|
139
|
+
social_params = request.parameters.except("controller", "action", "format", "authenticity_token", "commit", "login").to_h.deep_dup
|
|
140
|
+
# Include scope from route defaults so it survives the OAuth redirect/callback round trip
|
|
141
|
+
scope = request.path_parameters[:scope]
|
|
142
|
+
social_params["scope"] = scope.to_s if scope.present?
|
|
143
|
+
social_params
|
|
142
144
|
end
|
|
143
145
|
|
|
144
146
|
def extract_oauth_params(provider)
|
|
@@ -5,6 +5,7 @@ module StandardId
|
|
|
5
5
|
requires_web_mechanism :passwordless_login
|
|
6
6
|
|
|
7
7
|
include StandardId::InertiaRendering
|
|
8
|
+
include StandardId::PasswordlessFlow
|
|
8
9
|
include StandardId::LifecycleHooks
|
|
9
10
|
|
|
10
11
|
layout "public"
|
|
@@ -33,11 +34,10 @@ module StandardId
|
|
|
33
34
|
return
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
result =
|
|
37
|
+
result = verify_passwordless_otp(
|
|
37
38
|
username: @otp_data[:username],
|
|
38
39
|
code: code,
|
|
39
40
|
connection: @otp_data[:connection],
|
|
40
|
-
request: request,
|
|
41
41
|
allow_registration: passwordless_registration_enabled?
|
|
42
42
|
)
|
|
43
43
|
|
|
@@ -52,7 +52,7 @@ module StandardId
|
|
|
52
52
|
|
|
53
53
|
invoke_before_sign_in(account, { mechanism: "passwordless", provider: nil })
|
|
54
54
|
|
|
55
|
-
session_manager.sign_in_account(account)
|
|
55
|
+
session_manager.sign_in_account(account, scope_name: request.path_parameters[:scope])
|
|
56
56
|
emit_authentication_succeeded(account)
|
|
57
57
|
|
|
58
58
|
if newly_created
|
|
@@ -40,7 +40,7 @@ module StandardId
|
|
|
40
40
|
|
|
41
41
|
if form.submit
|
|
42
42
|
invoke_before_sign_in(form.account, { mechanism: "password", provider: nil })
|
|
43
|
-
session_manager.sign_in_account(form.account)
|
|
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
|
context = { mechanism: "password", provider: nil }
|
|
@@ -18,6 +18,17 @@ StandardConfig.schema.draw do
|
|
|
18
18
|
field :use_inertia, type: :boolean, default: false
|
|
19
19
|
field :inertia_component_namespace, type: :string, default: "standard_id"
|
|
20
20
|
field :alias_current_user, type: :boolean, default: false
|
|
21
|
+
|
|
22
|
+
# Scope-aware authentication: maps scope names to profile-based access config.
|
|
23
|
+
# Each scope is a hash with keys: :profile_type, :after_sign_in_path,
|
|
24
|
+
# :no_profile_message, :label, :allow_registration.
|
|
25
|
+
field :scopes, type: :any, default: {}
|
|
26
|
+
|
|
27
|
+
# Callable that resolves whether an account has a profile for a given scope.
|
|
28
|
+
# Receives (account, profile_type) and returns true/false.
|
|
29
|
+
# Override to customise profile lookup logic.
|
|
30
|
+
# Default (nil) uses: account.profiles.exists?(profileable_type: profile_type)
|
|
31
|
+
field :profile_resolver, type: :any, default: nil
|
|
21
32
|
# Callable (lambda/proc) that returns a Hash of extra Sentry user context fields.
|
|
22
33
|
# Receives (account, session) where session may be nil. Non-callable values are ignored.
|
|
23
34
|
field :sentry_context, type: :any, default: nil
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class ScopeConfig
|
|
3
|
+
# @!attribute [r] allow_registration
|
|
4
|
+
# Reserved for future use — controls whether new accounts can register under this scope.
|
|
5
|
+
attr_reader :name, :profile_type, :after_sign_in_path, :no_profile_message, :label, :allow_registration
|
|
6
|
+
|
|
7
|
+
def initialize(name, config = {})
|
|
8
|
+
@name = name.to_sym
|
|
9
|
+
@profile_type = config[:profile_type]
|
|
10
|
+
@after_sign_in_path = config[:after_sign_in_path]
|
|
11
|
+
@no_profile_message = config[:no_profile_message] || "Access denied. No matching profile found."
|
|
12
|
+
@label = config[:label] || name.to_s.humanize
|
|
13
|
+
@allow_registration = config.fetch(:allow_registration, true)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def requires_profile?
|
|
17
|
+
profile_type.present?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/standard_id/version.rb
CHANGED
|
@@ -19,26 +19,37 @@ module StandardId
|
|
|
19
19
|
Current.account ||= load_current_account
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def sign_in_account(account)
|
|
22
|
+
def sign_in_account(account, scope_name: nil)
|
|
23
23
|
emit_session_creating(account, "browser")
|
|
24
24
|
|
|
25
25
|
# Prevent session fixation by resetting the Rails session before
|
|
26
26
|
# creating an authenticated session (Rails Security Guide §2.5).
|
|
27
27
|
# Preserve return_to URL across the reset so post-login redirect works.
|
|
28
28
|
return_to = session[:return_to_after_authenticating]
|
|
29
|
+
existing_scopes = session[:standard_id_scopes]
|
|
29
30
|
@reset_session&.call
|
|
30
31
|
session[:return_to_after_authenticating] = return_to if return_to
|
|
32
|
+
session[:standard_id_scopes] = existing_scopes if existing_scopes
|
|
31
33
|
|
|
32
34
|
token_manager.create_browser_session(account).tap do |browser_session|
|
|
33
35
|
# Store in both session and encrypted cookie for backward compatibility
|
|
34
36
|
# Action Cable will use the encrypted cookie
|
|
35
37
|
session[:session_token] = browser_session.token
|
|
36
38
|
cookies.encrypted[:session_token] = browser_session.token
|
|
39
|
+
if scope_name
|
|
40
|
+
scopes = Array(session[:standard_id_scopes])
|
|
41
|
+
scopes << scope_name.to_s unless scopes.include?(scope_name.to_s)
|
|
42
|
+
session[:standard_id_scopes] = scopes
|
|
43
|
+
end
|
|
37
44
|
Current.session = browser_session
|
|
38
45
|
emit_session_created(browser_session, account, "browser")
|
|
39
46
|
end
|
|
40
47
|
end
|
|
41
48
|
|
|
49
|
+
def current_scope_names
|
|
50
|
+
Array(session[:standard_id_scopes])
|
|
51
|
+
end
|
|
52
|
+
|
|
42
53
|
def revoke_current_session!
|
|
43
54
|
current_session&.revoke!
|
|
44
55
|
clear_session!
|
|
@@ -51,6 +62,7 @@ module StandardId
|
|
|
51
62
|
def clear_session!
|
|
52
63
|
# TODO: make token key names configurable
|
|
53
64
|
session.delete(:session_token)
|
|
65
|
+
session.delete(:standard_id_scopes)
|
|
54
66
|
cookies.encrypted[:session_token] = nil
|
|
55
67
|
cookies.delete(:remember_token)
|
|
56
68
|
|
|
@@ -100,7 +112,10 @@ module StandardId
|
|
|
100
112
|
password_credential = StandardId::PasswordCredential.find_by_token_for(:remember_me, cookies[:remember_token])
|
|
101
113
|
return if password_credential.blank?
|
|
102
114
|
|
|
103
|
-
# Prevent session fixation on returning-user remember-me flow
|
|
115
|
+
# Prevent session fixation on returning-user remember-me flow.
|
|
116
|
+
# Note: standard_id_scopes are intentionally NOT preserved here —
|
|
117
|
+
# remember-me re-auth is a fresh session context where scopes
|
|
118
|
+
# must be re-acquired through explicit scoped sign-in.
|
|
104
119
|
@reset_session&.call
|
|
105
120
|
|
|
106
121
|
token_manager.create_browser_session(password_credential.account, remember_me: true).tap do |browser_session|
|
data/lib/standard_id.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "standard_id/engine"
|
|
|
4
4
|
require "standard_id/web_engine"
|
|
5
5
|
require "standard_id/api_engine"
|
|
6
6
|
require "standard_id/config/schema"
|
|
7
|
+
require "standard_id/scope_config"
|
|
7
8
|
require "standard_id/errors"
|
|
8
9
|
require "standard_id/events"
|
|
9
10
|
require "standard_id/events/subscribers/base"
|
|
@@ -81,6 +82,13 @@ module StandardId
|
|
|
81
82
|
config.account_class_name.constantize
|
|
82
83
|
end
|
|
83
84
|
|
|
85
|
+
def scope_for(name)
|
|
86
|
+
return nil if config.scopes.blank? || name.blank?
|
|
87
|
+
scope_hash = config.scopes[name.to_sym]
|
|
88
|
+
return nil unless scope_hash
|
|
89
|
+
ScopeConfig.new(name, scope_hash)
|
|
90
|
+
end
|
|
91
|
+
|
|
84
92
|
def skip_host_authorization(framework: nil, callback: nil)
|
|
85
93
|
AuthorizationBypass.apply(framework: framework, callback: callback)
|
|
86
94
|
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.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -113,6 +113,7 @@ files:
|
|
|
113
113
|
- app/controllers/concerns/standard_id/inertia_rendering.rb
|
|
114
114
|
- app/controllers/concerns/standard_id/inertia_support.rb
|
|
115
115
|
- app/controllers/concerns/standard_id/lifecycle_hooks.rb
|
|
116
|
+
- app/controllers/concerns/standard_id/passwordless_flow.rb
|
|
116
117
|
- app/controllers/concerns/standard_id/passwordless_strategy.rb
|
|
117
118
|
- app/controllers/concerns/standard_id/rate_limit_handling.rb
|
|
118
119
|
- app/controllers/concerns/standard_id/sentry_context.rb
|
|
@@ -257,6 +258,7 @@ files:
|
|
|
257
258
|
- lib/standard_id/provider_registry.rb
|
|
258
259
|
- lib/standard_id/providers/base.rb
|
|
259
260
|
- lib/standard_id/rate_limit_store.rb
|
|
261
|
+
- lib/standard_id/scope_config.rb
|
|
260
262
|
- lib/standard_id/testing.rb
|
|
261
263
|
- lib/standard_id/testing/authentication_helpers.rb
|
|
262
264
|
- lib/standard_id/testing/factories/credentials.rb
|