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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74492c7c108bb4188cb94b9bd1d35437b6957e92ae8a7066b182bcbda9a697d8
4
- data.tar.gz: 504fab0ff0fbc628c6369743a10729541fb737c8d33c1eabd4fb992453b3e173
3
+ metadata.gz: 90019539f1e0cc2c3fee8e641ff0b18a337bd1b263aa78441b3c886bde6b72d4
4
+ data.tar.gz: a98746c6be195bf842304c07bcb6276f8f95031be8c83b349ce902185874e248
5
5
  SHA512:
6
- metadata.gz: 00ba9d5328bc4811d6b30c2801779a44ded02010f30932854d85aac312f3e7cf78149169a6fab1e1e78d433e3b90e2d0b5ae701d3d2cd6c9f83bd68527a95065
7
- data.tar.gz: 3f5eade22b06b4f8eef090602af59d61ef0e3a7b3ca7dfb5f942b4e65bedd54992569bab0b8ceb181a9319fae76e011721e71d7fe8186be2822cbdeb6e593d1b
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
- hook = StandardId.config.after_sign_in
41
- return nil unless hook.respond_to?(:call)
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
- hook.call(account, request, context)
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
- redirect_to StandardId::WebEngine.routes.url_helpers.login_path, alert: message
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
- delegate :current_session, :current_account, :revoke_current_session!, to: :session_manager
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::PasswordlessStrategy
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
- strategy_for(start_params[:connection]).start!(start_params)
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::PasswordlessStrategy
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
- email = login_params[:email].to_s.strip.downcase
75
+ username = login_params[:email].to_s.strip.downcase
76
76
  connection = StandardId.config.passwordless.connection
77
77
 
78
- if email.blank?
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
- strategy.start!(username: email, connection: connection)
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: email, connection: connection },
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 = StandardId::Passwordless.verify(
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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.12.0"
2
+ VERSION = "0.13.0"
3
3
  end
@@ -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.12.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