standard_id 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/standard_id/controller_policy.rb +99 -0
  3. data/app/controllers/standard_id/api/authorization_controller.rb +2 -0
  4. data/app/controllers/standard_id/api/base_controller.rb +2 -0
  5. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +2 -0
  6. data/app/controllers/standard_id/api/oauth/tokens_controller.rb +2 -0
  7. data/app/controllers/standard_id/api/oidc/logout_controller.rb +2 -0
  8. data/app/controllers/standard_id/api/passwordless_controller.rb +2 -0
  9. data/app/controllers/standard_id/api/userinfo_controller.rb +2 -0
  10. data/app/controllers/standard_id/api/well_known/jwks_controller.rb +6 -0
  11. data/app/controllers/standard_id/web/account_controller.rb +2 -0
  12. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +2 -0
  13. data/app/controllers/standard_id/web/base_controller.rb +2 -0
  14. data/app/controllers/standard_id/web/login_controller.rb +2 -0
  15. data/app/controllers/standard_id/web/login_verify_controller.rb +12 -84
  16. data/app/controllers/standard_id/web/logout_controller.rb +7 -0
  17. data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +2 -0
  18. data/app/controllers/standard_id/web/reset_password/start_controller.rb +2 -0
  19. data/app/controllers/standard_id/web/sessions_controller.rb +2 -0
  20. data/app/controllers/standard_id/web/signup_controller.rb +2 -0
  21. data/app/controllers/standard_id/web/verify_email/base_controller.rb +2 -0
  22. data/app/controllers/standard_id/web/verify_phone/base_controller.rb +2 -0
  23. data/app/models/standard_id/authorization_code.rb +2 -1
  24. data/lib/standard_id/authorization_bypass.rb +121 -0
  25. data/lib/standard_id/current_attributes.rb +1 -1
  26. data/lib/standard_id/events.rb +1 -0
  27. data/lib/standard_id/jwt_service.rb +41 -15
  28. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +3 -2
  29. data/lib/standard_id/oauth/authorization_code_flow.rb +4 -0
  30. data/lib/standard_id/oauth/password_flow.rb +5 -1
  31. data/lib/standard_id/oauth/passwordless_otp_flow.rb +10 -61
  32. data/lib/standard_id/oauth/subflows/social_login_grant.rb +2 -1
  33. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +1 -0
  34. data/lib/standard_id/passwordless/verification_service.rb +227 -0
  35. data/lib/standard_id/testing/authentication_helpers.rb +75 -0
  36. data/lib/standard_id/testing/factories/credentials.rb +24 -0
  37. data/lib/standard_id/testing/factories/identifiers.rb +37 -0
  38. data/lib/standard_id/testing/factories/oauth.rb +89 -0
  39. data/lib/standard_id/testing/factories/sessions.rb +112 -0
  40. data/lib/standard_id/testing/factory_bot.rb +7 -0
  41. data/lib/standard_id/testing/request_helpers.rb +60 -0
  42. data/lib/standard_id/testing.rb +26 -0
  43. data/lib/standard_id/version.rb +1 -1
  44. data/lib/standard_id.rb +6 -0
  45. metadata +40 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f0a6bc5ee9c7ea15e8f9ea7bee06f7cb080da52fade31a85bb0d0ead5207552
4
- data.tar.gz: 8dffbedfccd7f2434fbf77befe544ebb0c2dc55e4bbbbfb105c11254543e8103
3
+ metadata.gz: 956fc621df693ec184f7d65482d96c4f25d99ae55274b68681c9c6b892bde095
4
+ data.tar.gz: 0d5c39a196c8c9e8c99adb24fa61552698672f3df4a5bcf3d06d03b2b61e6a46
5
5
  SHA512:
6
- metadata.gz: dd96b17327a9468bd2128dea33826450009949e73b6579e36f7c422cd5f9e8aa36a1201de4019e523f79f709539a2cc2e4f8c92dabb15fdef0505488fb3c9cb1
7
- data.tar.gz: 74e0c84b638ecb2ab7dbc7f7303617e8b2614fc62f9c8218f33da27136f267f41f6ca676afdff2197395eff5410b538fc186f3c9c9f1c7148cd43bc653b22b57
6
+ metadata.gz: 584c37aa6aa46abe4a576297d6d754e5632c4b710c59dd1bd2b6f8d7c5c17ac86a8aef109df15fe3de720a7d1be405af31441f833a914b3bc8bfea264b296da5
7
+ data.tar.gz: 1733a94c80aba6290cc5eabbe7df67f4ff145c5af18e8cb9202cd3d598b5e5569039fc729cdfed3eacfee55908fe36bf4801e706da7c0e8b544666383b7b6863
@@ -0,0 +1,99 @@
1
+ require "monitor"
2
+ require "set"
3
+
4
+ module StandardId
5
+ module ControllerPolicy
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :_standard_id_auth_policy, instance_writer: false
10
+ end
11
+
12
+ class_methods do
13
+ # Declares this controller as public (no host-app auth required).
14
+ def public_controller
15
+ self._standard_id_auth_policy = :public
16
+ ControllerPolicy.register(self, :public)
17
+ end
18
+
19
+ # Declares this controller as authenticated (requires host-app
20
+ # authentication but not authorization).
21
+ def authenticated_controller
22
+ self._standard_id_auth_policy = :authenticated
23
+ ControllerPolicy.register(self, :authenticated)
24
+ end
25
+
26
+ # Auto-register subclasses that inherit a policy declaration.
27
+ # This ensures controllers like VerifyEmail::ConfirmController
28
+ # (which inherits from VerifyEmail::BaseController) appear in
29
+ # the registry and receive AuthorizationBypass skips.
30
+ def inherited(subclass)
31
+ super
32
+ policy = subclass._standard_id_auth_policy
33
+ ControllerPolicy.register(subclass, policy) if policy
34
+ end
35
+ end
36
+
37
+ # Monitor instead of Mutex: registry is called from within synchronized
38
+ # blocks (e.g. register, public_controllers), so we need reentrant locking.
39
+ LOCK = Monitor.new
40
+ private_constant :LOCK
41
+
42
+ class << self
43
+ def public_controllers
44
+ LOCK.synchronize { registry[:public].dup }
45
+ end
46
+
47
+ def authenticated_controllers
48
+ LOCK.synchronize { registry[:authenticated].dup }
49
+ end
50
+
51
+ def all_controllers
52
+ LOCK.synchronize { registry[:public] + registry[:authenticated] }
53
+ end
54
+
55
+ # Registers a controller under the given policy. Raises if the
56
+ # controller is already registered under the opposite policy.
57
+ # Re-registering under the same policy is a safe no-op (Set semantics).
58
+ def register(controller, policy)
59
+ LOCK.synchronize do
60
+ other = policy == :public ? :authenticated : :public
61
+ if registry[other].include?(controller)
62
+ raise ArgumentError, "#{controller} is already registered as #{other}"
63
+ end
64
+
65
+ registry[policy] << controller
66
+ end
67
+
68
+ # If AuthorizationBypass has already been applied, apply skips to this
69
+ # newly registered controller immediately. This handles dev-mode lazy
70
+ # loading where controllers register after the initial apply_skips! call.
71
+ AuthorizationBypass.apply_to_controller(controller, policy) if AuthorizationBypass.applied?
72
+ end
73
+
74
+ # Returns a point-in-time copy of the registry, safe to iterate
75
+ # without holding the lock.
76
+ def registry_snapshot
77
+ LOCK.synchronize do
78
+ (@registry ||= { public: Set.new, authenticated: Set.new })
79
+ .transform_values(&:dup)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def registry
86
+ LOCK.synchronize { @registry ||= { public: Set.new, authenticated: Set.new } }
87
+ end
88
+
89
+ public
90
+
91
+ # @api private — intended for test isolation only.
92
+ def reset_registry!
93
+ LOCK.synchronize do
94
+ @registry = { public: Set.new, authenticated: Set.new }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class AuthorizationController < BaseController
4
+ public_controller
5
+
4
6
  include ActionController::Cookies
5
7
 
6
8
  skip_before_action :validate_content_type!
@@ -1,9 +1,11 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class BaseController < ActionController::API
4
+ include StandardId::ControllerPolicy
4
5
  include StandardId::ApiAuthentication
5
6
  include StandardId::SetCurrentRequestDetails
6
7
 
8
+ before_action -> { Current.scope = :api if defined?(::Current) }
7
9
  before_action :validate_content_type!
8
10
 
9
11
  after_action :set_no_store_headers
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Api::Oauth
3
3
  module Callback
4
4
  class ProvidersController < BaseController
5
+ public_controller
6
+
5
7
  include StandardId::SocialAuthentication
6
8
 
7
9
  skip_before_action :validate_content_type!
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Api
3
3
  module Oauth
4
4
  class TokensController < BaseController
5
+ public_controller
6
+
5
7
  skip_before_action :validate_content_type!
6
8
 
7
9
  FLOW_STRATEGIES = {
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Api
3
3
  module Oidc
4
4
  class LogoutController < ::StandardId::Api::BaseController
5
+ public_controller
6
+
5
7
  include ActionController::Cookies
6
8
 
7
9
  skip_before_action :validate_content_type!
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class PasswordlessController < BaseController
4
+ public_controller
5
+
4
6
  include StandardId::PasswordlessStrategy
5
7
 
6
8
  def start
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class UserinfoController < BaseController
4
+ authenticated_controller
5
+
4
6
  skip_before_action :validate_content_type!
5
7
 
6
8
  def show
@@ -3,7 +3,13 @@
3
3
  module StandardId
4
4
  module Api
5
5
  module WellKnown
6
+ # Inherits from ActionController::API (not Api::BaseController) to avoid
7
+ # content-type validation and no-store cache headers — JWKS is a public,
8
+ # cacheable endpoint. Includes ControllerPolicy directly as a result.
6
9
  class JwksController < ActionController::API
10
+ include StandardId::ControllerPolicy
11
+ public_controller
12
+
7
13
  def show
8
14
  jwks = StandardId::JwtService.jwks
9
15
 
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class AccountController < BaseController
4
+ authenticated_controller
5
+
4
6
  def show
5
7
  @account = current_account
6
8
  @sessions = current_account.sessions.active.order(created_at: :desc)
@@ -3,6 +3,8 @@ module StandardId
3
3
  module Auth
4
4
  module Callback
5
5
  class ProvidersController < StandardId::Web::BaseController
6
+ public_controller
7
+
6
8
  include StandardId::WebAuthentication
7
9
  include StandardId::SocialAuthentication
8
10
  include StandardId::Web::SocialLoginParams
@@ -1,6 +1,7 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class BaseController < ApplicationController
4
+ include StandardId::ControllerPolicy
4
5
  include StandardId::WebAuthentication
5
6
  include StandardId::SetCurrentRequestDetails
6
7
 
@@ -9,6 +10,7 @@ module StandardId
9
10
 
10
11
  layout -> { StandardId.config.web_layout.presence || "application" }
11
12
 
13
+ before_action -> { Current.scope = :web if defined?(::Current) }
12
14
  before_action :require_browser_session!
13
15
  end
14
16
  end
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class LoginController < BaseController
4
+ public_controller
5
+
4
6
  include StandardId::InertiaRendering
5
7
  include StandardId::Web::SocialLoginParams
6
8
  include StandardId::PasswordlessStrategy
@@ -1,10 +1,9 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class LoginVerifyController < BaseController
4
- include StandardId::InertiaRendering
5
- include StandardId::PasswordlessStrategy
4
+ public_controller
6
5
 
7
- class OtpVerificationFailed < StandardError; end
6
+ include StandardId::InertiaRendering
8
7
 
9
8
  layout "public"
10
9
 
@@ -27,42 +26,21 @@ module StandardId
27
26
  return
28
27
  end
29
28
 
30
- # Record failed attempts outside the main transaction so they persist
31
- challenge = find_active_challenge
32
- attempts = record_attempt(challenge, code)
33
-
34
- if challenge.blank? || !ActiveSupport::SecurityUtils.secure_compare(challenge.code, code)
35
- emit_otp_validation_failed(attempts) if challenge.present?
29
+ result = StandardId::Passwordless::VerificationService.verify(
30
+ connection: @otp_data[:connection],
31
+ username: @otp_data[:username],
32
+ code: code,
33
+ request: request
34
+ )
36
35
 
37
- flash.now[:alert] = "Invalid or expired verification code"
36
+ unless result.success?
37
+ flash.now[:alert] = result.error
38
38
  render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
39
39
  return
40
40
  end
41
41
 
42
- strategy = strategy_for(@otp_data[:connection])
43
-
44
- begin
45
- ActiveRecord::Base.transaction do
46
- # Re-fetch with lock inside transaction to prevent concurrent use
47
- locked_challenge = StandardId::CodeChallenge.lock.find(challenge.id)
48
- raise OtpVerificationFailed unless locked_challenge.active?
49
-
50
- account = strategy.find_or_create_account(@otp_data[:username])
51
- locked_challenge.use!
52
-
53
- emit_otp_validated(account, locked_challenge)
54
- session_manager.sign_in_account(account)
55
- emit_authentication_succeeded(account)
56
- end
57
- rescue OtpVerificationFailed
58
- flash.now[:alert] = "Invalid or expired verification code"
59
- render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
60
- return
61
- rescue ActiveRecord::RecordInvalid => e
62
- flash.now[:alert] = "Unable to complete sign in: #{e.record.errors.full_messages.to_sentence}"
63
- render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
64
- return
65
- end
42
+ session_manager.sign_in_account(result.account)
43
+ emit_authentication_succeeded(result.account)
66
44
 
67
45
  session.delete(:standard_id_otp_payload)
68
46
 
@@ -98,56 +76,6 @@ module StandardId
98
76
  end
99
77
  end
100
78
 
101
- def find_active_challenge
102
- StandardId::CodeChallenge.active.find_by(
103
- realm: "authentication",
104
- channel: @otp_data[:connection],
105
- target: @otp_data[:username]
106
- )
107
- end
108
-
109
- def record_attempt(challenge, code)
110
- return 0 if challenge.blank?
111
- return 0 if ActiveSupport::SecurityUtils.secure_compare(challenge.code, code)
112
-
113
- attempts = (challenge.metadata["attempts"] || 0) + 1
114
- challenge.update!(metadata: challenge.metadata.merge("attempts" => attempts))
115
-
116
- max_attempts = StandardId.config.passwordless.max_attempts
117
- challenge.use! if attempts >= max_attempts
118
-
119
- attempts
120
- end
121
-
122
- def emit_otp_validated(account, challenge)
123
- StandardId::Events.publish(
124
- StandardId::Events::OTP_VALIDATED,
125
- account: account,
126
- channel: @otp_data[:connection]
127
- )
128
- StandardId::Events.publish(
129
- StandardId::Events::PASSWORDLESS_CODE_VERIFIED,
130
- code_challenge: challenge,
131
- account: account,
132
- channel: @otp_data[:connection]
133
- )
134
- end
135
-
136
- def emit_otp_validation_failed(attempts)
137
- StandardId::Events.publish(
138
- StandardId::Events::OTP_VALIDATION_FAILED,
139
- identifier: @otp_data[:username],
140
- channel: @otp_data[:connection],
141
- attempts: attempts
142
- )
143
- StandardId::Events.publish(
144
- StandardId::Events::PASSWORDLESS_CODE_FAILED,
145
- identifier: @otp_data[:username],
146
- channel: @otp_data[:connection],
147
- attempts: attempts
148
- )
149
- end
150
-
151
79
  def emit_authentication_succeeded(account)
152
80
  StandardId::Events.publish(
153
81
  StandardId::Events::AUTHENTICATION_SUCCEEDED,
@@ -1,6 +1,13 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class LogoutController < BaseController
4
+ # Classified as authenticated (not public) because logout requires
5
+ # host-app authentication. The controller handles unauthenticated
6
+ # users gracefully via redirect_if_not_authenticated rather than
7
+ # raising, and skips require_browser_session! to allow session
8
+ # revocation even with an expired browser session.
9
+ authenticated_controller
10
+
4
11
  skip_before_action :require_browser_session!, only: [:create]
5
12
 
6
13
  before_action :redirect_if_not_authenticated
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  module ResetPassword
4
4
  class ConfirmController < BaseController
5
+ public_controller
6
+
5
7
  layout "public"
6
8
 
7
9
  skip_before_action :require_browser_session!, only: [:show, :update]
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  module ResetPassword
4
4
  class StartController < BaseController
5
+ public_controller
6
+
5
7
  layout "public"
6
8
 
7
9
  skip_before_action :require_browser_session!, only: [:show, :create]
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class SessionsController < BaseController
4
+ authenticated_controller
5
+
4
6
  def index
5
7
  @sessions = current_account.sessions.active.order(created_at: :desc)
6
8
  @current_session = current_session
@@ -1,6 +1,8 @@
1
1
  module StandardId
2
2
  module Web
3
3
  class SignupController < BaseController
4
+ public_controller
5
+
4
6
  include StandardId::InertiaRendering
5
7
 
6
8
  layout "public"
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  module VerifyEmail
4
4
  class BaseController < StandardId::Web::BaseController
5
+ public_controller
6
+
5
7
  skip_before_action :require_browser_session!
6
8
  end
7
9
  end
@@ -2,6 +2,8 @@ module StandardId
2
2
  module Web
3
3
  module VerifyPhone
4
4
  class BaseController < StandardId::Web::BaseController
5
+ public_controller
6
+
5
7
  skip_before_action :require_browser_session!
6
8
  end
7
9
  end
@@ -16,7 +16,7 @@ module StandardId
16
16
 
17
17
  before_validation :set_issued_and_expiry, on: :create
18
18
 
19
- def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, metadata: {})
19
+ def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {})
20
20
  create!(
21
21
  account: account,
22
22
  code_hash: hash_for(plaintext_code),
@@ -26,6 +26,7 @@ module StandardId
26
26
  audience: audience,
27
27
  code_challenge: code_challenge,
28
28
  code_challenge_method: code_challenge_method,
29
+ nonce: nonce,
29
30
  issued_at: Time.current,
30
31
  expires_at: Time.current + default_ttl,
31
32
  metadata: metadata || {}
@@ -0,0 +1,121 @@
1
+ module StandardId
2
+ module AuthorizationBypass
3
+ FRAMEWORK_CALLBACKS = {
4
+ action_policy: :verify_authorized,
5
+ pundit: :verify_authorized,
6
+ cancancan: :check_authorization
7
+ }.freeze
8
+
9
+ MUTEX = Mutex.new
10
+ private_constant :MUTEX
11
+
12
+ class << self
13
+ # Skips the host app's authorization callback on all engine controllers,
14
+ # and also skips authenticate_account! on public controllers (login,
15
+ # signup, callbacks, etc.) since those must be accessible without a session.
16
+ #
17
+ # In production (eager_load=true), controllers are already loaded when
18
+ # this runs so the registry is populated. In development (eager_load=false),
19
+ # controllers are loaded lazily on first request; newly registered
20
+ # controllers receive skips immediately via apply_to_controller (called
21
+ # from ControllerPolicy.register). The to_prepare block handles class
22
+ # reloading — after Zeitwerk unloads/reloads classes, the freshly loaded
23
+ # controllers re-register and receive skips again.
24
+ def apply(framework: nil, callback: nil)
25
+ if framework && callback
26
+ raise ArgumentError, "Provide framework: or callback:, not both"
27
+ end
28
+
29
+ register_prepare = false
30
+
31
+ MUTEX.synchronize do
32
+ # Guard against duplicate to_prepare registrations if called more than
33
+ # once (e.g. in tests or misconfigured initializers). skip_before_action
34
+ # is idempotent so duplicates are harmless, but this keeps things tidy.
35
+ return if @callback_name
36
+
37
+ @callback_name = resolve_callback(framework, callback)
38
+ # @prepared is intentionally NOT cleared by reset!. This ensures
39
+ # at most one to_prepare block is registered per process lifetime.
40
+ # Trade-off: after reset! + apply (e.g. in tests switching
41
+ # frameworks), the to_prepare code path is not re-registered, so
42
+ # it can only be verified by the first test that calls apply.
43
+ register_prepare = !@prepared
44
+ @prepared = true
45
+ end
46
+
47
+ apply_skips!
48
+
49
+ # Re-apply after class reloading in development. In dev (eager_load=false),
50
+ # reset_registry! + apply_skips! is effectively a no-op because the
51
+ # registry is empty at this point — lazy-loaded controllers haven't
52
+ # registered yet. The real work for lazy-loaded controllers is done by
53
+ # apply_to_controller (called from ControllerPolicy.register). This
54
+ # block is still needed because after a Zeitwerk reload, controllers
55
+ # re-register and apply_to_controller fires again for each one, but the
56
+ # reset_registry! here clears stale references to the old class objects
57
+ # to prevent memory leaks in long dev sessions.
58
+ return unless register_prepare
59
+
60
+ Rails.application.config.to_prepare do
61
+ StandardId::ControllerPolicy.reset_registry!
62
+ StandardId::AuthorizationBypass.apply_skips!
63
+ end
64
+ end
65
+
66
+ # Whether apply has been called. Used by ControllerPolicy.register to
67
+ # decide if newly loaded controllers need immediate skip_before_action.
68
+ def applied?
69
+ MUTEX.synchronize { !@callback_name.nil? }
70
+ end
71
+
72
+ # Apply skips to a single controller. Called by ControllerPolicy.register
73
+ # when a controller is lazily loaded after apply has already been called.
74
+ def apply_to_controller(controller, policy)
75
+ callback = MUTEX.synchronize { @callback_name }
76
+ return unless callback
77
+
78
+ controller.skip_before_action callback, raise: false
79
+ if policy == :public
80
+ # authenticate_account! is defined in WebAuthentication, not on API
81
+ # controllers. raise: false ensures this is a safe no-op for API
82
+ # controllers that don't have the callback.
83
+ controller.skip_before_action :authenticate_account!, raise: false
84
+ end
85
+ end
86
+
87
+ # @api private — called internally by apply and the to_prepare block.
88
+ # Must remain public because it is invoked from a to_prepare lambda
89
+ # registered in apply, which executes outside this module's scope.
90
+ def apply_skips!
91
+ StandardId::ControllerPolicy.registry_snapshot.each do |policy, controllers|
92
+ controllers.each { |controller| apply_to_controller(controller, policy) }
93
+ end
94
+ end
95
+
96
+ # @api private — intended for test isolation only.
97
+ # NOTE: This clears @callback_name (so applied? returns false and apply
98
+ # can be called again with a different framework) but intentionally does
99
+ # NOT clear @prepared, so no additional to_prepare block is registered.
100
+ def reset!
101
+ MUTEX.synchronize { @callback_name = nil }
102
+ end
103
+
104
+ private
105
+
106
+ def resolve_callback(framework, callback)
107
+ if callback
108
+ callback.to_sym
109
+ elsif framework
110
+ FRAMEWORK_CALLBACKS.fetch(framework.to_sym) do
111
+ raise ArgumentError, "Unknown framework: #{framework}. " \
112
+ "Supported: #{FRAMEWORK_CALLBACKS.keys.join(', ')}. " \
113
+ "Or pass callback: :your_callback_name instead."
114
+ end
115
+ else
116
+ raise ArgumentError, "Provide either framework: or callback:"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -3,7 +3,7 @@ module StandardId
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- attribute :session, :account, :request_id, :ip_address, :user_agent
6
+ attribute :session, :account, :request_id, :ip_address, :user_agent, :scope
7
7
  end
8
8
  end
9
9
  end
@@ -129,6 +129,7 @@ module StandardId
129
129
  enriched[:ip_address] ||= ::Current.ip_address if ::Current.respond_to?(:ip_address) && ::Current.ip_address.present?
130
130
  enriched[:user_agent] ||= ::Current.user_agent if ::Current.respond_to?(:user_agent) && ::Current.user_agent.present?
131
131
  enriched[:current_account] ||= ::Current.account if ::Current.respond_to?(:account) && ::Current.account.present?
132
+ enriched[:scope] ||= ::Current.scope if ::Current.respond_to?(:scope) && ::Current.scope.present?
132
133
  end
133
134
 
134
135
  enriched.merge(payload)