standard_id 0.1.6 → 0.1.7

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +368 -22
  3. data/app/controllers/concerns/standard_id/set_current_request_details.rb +19 -0
  4. data/app/controllers/concerns/standard_id/social_authentication.rb +86 -37
  5. data/app/controllers/concerns/standard_id/web_authentication.rb +29 -1
  6. data/app/controllers/standard_id/api/base_controller.rb +1 -0
  7. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +7 -18
  8. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +33 -37
  9. data/app/controllers/standard_id/web/base_controller.rb +1 -0
  10. data/app/controllers/standard_id/web/login_controller.rb +6 -19
  11. data/app/controllers/standard_id/web/signup_controller.rb +3 -6
  12. data/app/forms/standard_id/web/signup_form.rb +32 -1
  13. data/app/models/standard_id/browser_session.rb +8 -0
  14. data/app/models/standard_id/client_secret_credential.rb +11 -0
  15. data/app/models/standard_id/device_session.rb +4 -0
  16. data/app/models/standard_id/identifier.rb +28 -0
  17. data/app/models/standard_id/service_session.rb +1 -1
  18. data/app/models/standard_id/session.rb +16 -2
  19. data/app/views/standard_id/web/auth/callback/providers/{apple_mobile.html.erb → mobile_callback.html.erb} +1 -1
  20. data/config/routes/api.rb +1 -2
  21. data/config/routes/web.rb +4 -3
  22. data/lib/generators/standard_id/install/templates/standard_id.rb +11 -8
  23. data/lib/standard_config/config.rb +3 -12
  24. data/lib/standard_config/config_provider.rb +6 -6
  25. data/lib/standard_config/schema.rb +2 -2
  26. data/lib/standard_id/account_locking.rb +86 -0
  27. data/lib/standard_id/account_status.rb +45 -0
  28. data/lib/standard_id/api/authentication_guard.rb +40 -1
  29. data/lib/standard_id/api/token_manager.rb +1 -1
  30. data/lib/standard_id/config/schema.rb +11 -9
  31. data/lib/standard_id/current_attributes.rb +9 -0
  32. data/lib/standard_id/engine.rb +9 -0
  33. data/lib/standard_id/errors.rb +12 -0
  34. data/lib/standard_id/events/definitions.rb +157 -0
  35. data/lib/standard_id/events/event.rb +123 -0
  36. data/lib/standard_id/events/subscribers/account_locking_subscriber.rb +17 -0
  37. data/lib/standard_id/events/subscribers/account_status_subscriber.rb +17 -0
  38. data/lib/standard_id/events/subscribers/base.rb +165 -0
  39. data/lib/standard_id/events/subscribers/logging_subscriber.rb +122 -0
  40. data/lib/standard_id/events.rb +137 -0
  41. data/lib/standard_id/oauth/authorization_code_flow.rb +10 -0
  42. data/lib/standard_id/oauth/client_credentials_flow.rb +31 -0
  43. data/lib/standard_id/oauth/password_flow.rb +36 -4
  44. data/lib/standard_id/oauth/passwordless_otp_flow.rb +38 -2
  45. data/lib/standard_id/oauth/subflows/social_login_grant.rb +11 -22
  46. data/lib/standard_id/oauth/token_grant_flow.rb +22 -1
  47. data/lib/standard_id/passwordless/base_strategy.rb +32 -0
  48. data/lib/standard_id/provider_registry.rb +73 -0
  49. data/lib/standard_id/{social_providers → providers}/apple.rb +46 -7
  50. data/lib/standard_id/providers/base.rb +242 -0
  51. data/lib/standard_id/{social_providers → providers}/google.rb +26 -7
  52. data/lib/standard_id/version.rb +1 -1
  53. data/lib/standard_id/web/authentication_guard.rb +29 -0
  54. data/lib/standard_id/web/session_manager.rb +39 -1
  55. data/lib/standard_id/web/token_manager.rb +2 -2
  56. data/lib/standard_id.rb +13 -2
  57. metadata +18 -6
  58. data/lib/standard_id/social_providers/response_builder.rb +0 -18
@@ -6,37 +6,24 @@ module StandardId
6
6
 
7
7
  skip_before_action :validate_content_type!
8
8
 
9
- def google
10
- expect_and_permit!([], [:id_token, :code])
11
- handle_social_callback("google")
12
- end
13
-
14
- def apple
15
- expect_and_permit!([], [:id_token, :code, :state, :flow])
16
- handle_social_callback("apple")
17
- end
18
-
19
- private
20
-
21
- def handle_social_callback(connection)
9
+ def callback
22
10
  original_params = decode_state_params
23
- flow = resolve_flow_for(connection)
24
- provider_response = get_user_info_from_provider(connection, flow: flow)
11
+ provider_response = get_user_info_from_provider(flow: resolve_flow_for(provider.provider_name))
25
12
  social_info = provider_response[:user_info]
26
13
  provider_tokens = provider_response[:tokens]
27
- account = find_or_create_account_from_social(social_info, connection)
14
+ account = find_or_create_account_from_social(social_info)
28
15
 
29
16
  flow = StandardId::Oauth::SocialFlow.new(
30
17
  params,
31
18
  request,
32
19
  account: account,
33
- connection: connection,
20
+ connection: provider.provider_name,
34
21
  original_params: original_params
35
22
  )
36
23
 
37
24
  token_response = flow.execute
38
25
  run_social_callback(
39
- provider: connection,
26
+ provider: provider.provider_name,
40
27
  social_info: social_info,
41
28
  provider_tokens: provider_tokens,
42
29
  account: account,
@@ -44,6 +31,8 @@ module StandardId
44
31
  render json: token_response, status: :ok
45
32
  end
46
33
 
34
+ private
35
+
47
36
  def decode_state_params
48
37
  encoded_state = params[:state]
49
38
 
@@ -8,35 +8,10 @@ module StandardId
8
8
 
9
9
  # Social callbacks must be accessible without an existing browser session
10
10
  # because they create/sign-in the session upon successful callback.
11
- skip_before_action :require_browser_session!, only: [:google, :apple, :apple_mobile]
12
- skip_before_action :verify_authenticity_token, only: [:apple, :apple_mobile]
11
+ skip_before_action :require_browser_session!, only: [:callback, :mobile_callback]
12
+ skip_before_action :verify_authenticity_token, only: [:callback, :mobile_callback], if: :skip_csrf_verification?
13
13
 
14
- def google
15
- handle_social_callback("google")
16
- end
17
-
18
- def apple
19
- handle_social_callback("apple")
20
- end
21
-
22
- def apple_mobile
23
- state_data = decode_state_params
24
- destination = state_data["redirect_uri"]
25
-
26
- unless allow_other_host_redirect?(destination)
27
- raise StandardId::InvalidRequestError, "Redirect URI is not allowed"
28
- end
29
-
30
- relay_params = mobile_relay_params
31
- @mobile_redirect_url = build_mobile_redirect(destination, relay_params)
32
- render :apple_mobile, layout: false
33
- rescue StandardId::InvalidRequestError => e
34
- render plain: e.message, status: :unprocessable_entity
35
- end
36
-
37
- private
38
-
39
- def handle_social_callback(connection)
14
+ def callback
40
15
  if params[:error].present?
41
16
  handle_callback_error
42
17
  return
@@ -46,22 +21,22 @@ module StandardId
46
21
 
47
22
  begin
48
23
  state_data = decode_state_params
49
- redirect_uri = connection == "apple" ? apple_callback_url : google_callback_url
50
- provider_response = get_user_info_from_provider(connection, redirect_uri: redirect_uri)
24
+ redirect_uri = callback_url_for
25
+ provider_response = get_user_info_from_provider(redirect_uri: redirect_uri)
51
26
  social_info = provider_response[:user_info]
52
27
  provider_tokens = provider_response[:tokens]
53
- account = find_or_create_account_from_social(social_info, connection)
28
+ account = find_or_create_account_from_social(social_info)
54
29
  session_manager.sign_in_account(account)
55
30
 
56
31
  run_social_callback(
57
- provider: connection,
32
+ provider: provider.provider_name,
58
33
  social_info: social_info,
59
34
  provider_tokens: provider_tokens,
60
35
  account: account,
61
36
  )
62
37
 
63
38
  destination = state_data["redirect_uri"]
64
- redirect_options = { notice: "Successfully signed in with #{connection.humanize}" }
39
+ redirect_options = { notice: "Successfully signed in with #{provider.provider_name.humanize}" }
65
40
  redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
66
41
  redirect_to destination, redirect_options
67
42
  rescue StandardId::OAuthError => e
@@ -69,12 +44,33 @@ module StandardId
69
44
  end
70
45
  end
71
46
 
72
- def google_callback_url
73
- auth_callback_google_url
47
+ def mobile_callback
48
+ unless provider.supports_mobile_callback?
49
+ raise StandardId::InvalidRequestError, "Provider #{provider.provider_name} does not support mobile callback"
50
+ end
51
+
52
+ state_data = decode_state_params
53
+ destination = state_data["redirect_uri"]
54
+
55
+ unless allow_other_host_redirect?(destination)
56
+ raise StandardId::InvalidRequestError, "Redirect URI is not allowed"
57
+ end
58
+
59
+ relay_params = mobile_relay_params
60
+ @mobile_redirect_url = build_mobile_redirect(destination, relay_params)
61
+ render :mobile_callback, layout: false
62
+ rescue StandardId::InvalidRequestError => e
63
+ render plain: e.message, status: :unprocessable_entity
64
+ end
65
+
66
+ private
67
+
68
+ def callback_url_for
69
+ "#{request.base_url}#{provider.callback_path}"
74
70
  end
75
71
 
76
- def apple_callback_url
77
- auth_callback_apple_url
72
+ def skip_csrf_verification?
73
+ provider.skip_csrf?
78
74
  end
79
75
 
80
76
  def decode_state_params
@@ -2,6 +2,7 @@ module StandardId
2
2
  module Web
3
3
  class BaseController < ApplicationController
4
4
  include StandardId::WebAuthentication
5
+ include StandardId::SetCurrentRequestDetails
5
6
 
6
7
  include StandardId::WebEngine.routes.url_helpers
7
8
  helper StandardId::WebEngine.routes.url_helpers
@@ -37,28 +37,15 @@ module StandardId
37
37
  end
38
38
 
39
39
  def social_login_url
40
- case params[:connection]
41
- when "google"
42
- google_authorization_url
43
- when "apple"
44
- apple_authorization_url
45
- else
46
- raise StandardId::InvalidRequestError, "Unsupported social connection: #{connection}"
47
- end
48
- end
49
-
50
- def google_authorization_url
51
- StandardId::SocialProviders::Google.authorization_url(
52
- state: encode_state,
53
- redirect_uri: auth_callback_google_url
54
- )
55
- end
40
+ connection = params[:connection]
41
+ provider = StandardId::ProviderRegistry.get(connection)
56
42
 
57
- def apple_authorization_url
58
- StandardId::SocialProviders::Apple.authorization_url(
43
+ provider.authorization_url(
59
44
  state: encode_state,
60
- redirect_uri: auth_callback_apple_url
45
+ redirect_uri: "#{request.base_url}#{provider.callback_path}"
61
46
  )
47
+ rescue StandardId::ProviderRegistry::ProviderNotFoundError => e
48
+ raise StandardId::InvalidRequestError, e.message
62
49
  end
63
50
 
64
51
  def encode_state
@@ -61,12 +61,9 @@ module StandardId
61
61
  end
62
62
 
63
63
  def callback_url
64
- case params[:connection]
65
- when "google"
66
- auth_callback_google_url
67
- when "apple"
68
- auth_callback_apple_url
69
- end
64
+ connection = params[:connection]
65
+ provider = StandardId::ProviderRegistry.get(connection)
66
+ "#{request.base_url}#{provider.callback_path}"
70
67
  end
71
68
 
72
69
  def encode_state
@@ -16,15 +16,21 @@ module StandardId
16
16
  def submit
17
17
  return false unless valid?
18
18
 
19
+ emit_account_creating
20
+
19
21
  ActiveRecord::Base.transaction do
20
22
  @account = Account.create!(account_params)
21
- StandardId::PasswordCredential.create!(
23
+
24
+ password_credential = StandardId::PasswordCredential.create!(
22
25
  password_credential_params.merge(
23
26
  credential_attributes: {
24
27
  identifier_attributes: email_identifier_params.merge(account: @account)
25
28
  }
26
29
  )
27
30
  )
31
+
32
+ emit_account_created
33
+ emit_credential_created(password_credential)
28
34
  end
29
35
 
30
36
  true
@@ -38,6 +44,31 @@ module StandardId
38
44
 
39
45
  private
40
46
 
47
+ def emit_account_creating
48
+ StandardId::Events.publish(
49
+ StandardId::Events::ACCOUNT_CREATING,
50
+ account_params: account_params,
51
+ auth_method: "password"
52
+ )
53
+ end
54
+
55
+ def emit_account_created
56
+ StandardId::Events.publish(
57
+ StandardId::Events::ACCOUNT_CREATED,
58
+ account: @account,
59
+ auth_method: "password",
60
+ source: "signup"
61
+ )
62
+ end
63
+
64
+ def emit_credential_created(password_credential)
65
+ StandardId::Events.publish(
66
+ StandardId::Events::CREDENTIAL_PASSWORD_CREATED,
67
+ credential: password_credential,
68
+ account: @account
69
+ )
70
+ end
71
+
41
72
  def account_params
42
73
  { name: (email.to_s.split("@").first.presence || "User"), email: }
43
74
  end
@@ -2,6 +2,14 @@ module StandardId
2
2
  class BrowserSession < Session
3
3
  validates :user_agent, presence: true
4
4
 
5
+ def self.expiry
6
+ StandardId.config.session.browser_session_lifetime.seconds.from_now
7
+ end
8
+
9
+ def self.remember_me_expiry
10
+ StandardId.config.session.browser_session_remember_me_lifetime.seconds.from_now
11
+ end
12
+
5
13
  def browser_info
6
14
  return {} if user_agent.blank?
7
15
 
@@ -18,6 +18,7 @@ module StandardId
18
18
 
19
19
  def revoke!
20
20
  update!(active: false, revoked_at: Time.current)
21
+ emit_revoked_event
21
22
  end
22
23
 
23
24
  def active?
@@ -57,6 +58,16 @@ module StandardId
57
58
  self.client_secret ||= SecureRandom.hex(32)
58
59
  end
59
60
 
61
+ def emit_revoked_event
62
+ StandardId::Events.publish(
63
+ StandardId::Events::CREDENTIAL_CLIENT_SECRET_REVOKED,
64
+ credential: self,
65
+ client_application: client_application,
66
+ client_id: client_id,
67
+ revoked_at: revoked_at
68
+ )
69
+ end
70
+
60
71
  # Note: We intentionally do not enforce subset validation for per-secret overrides here.
61
72
  # If needed later, we can introduce a configuration flag to enable enforcement.
62
73
  end
@@ -3,6 +3,10 @@ module StandardId
3
3
  validates :device_id, presence: true
4
4
  validates :device_agent, presence: true
5
5
 
6
+ def self.expiry
7
+ StandardId.config.session.device_session_lifetime.seconds.from_now
8
+ end
9
+
6
10
  def device_info
7
11
  return {} if device_agent.blank?
8
12
 
@@ -11,6 +11,7 @@ module StandardId
11
11
  validates :value, presence: true, uniqueness: { scope: [:account_id, :type] }
12
12
 
13
13
  after_commit :mark_account_verified!, on: :update, if: :just_verified?
14
+ after_commit :emit_identifier_created_event, on: :create
14
15
 
15
16
  def verified?
16
17
  verified_at.present?
@@ -18,6 +19,7 @@ module StandardId
18
19
 
19
20
  def verify!
20
21
  update!(verified_at: Time.current)
22
+ emit_verification_succeeded
21
23
  end
22
24
 
23
25
  def unverify!
@@ -37,6 +39,32 @@ module StandardId
37
39
  return unless account.has_attribute?(:verified_at)
38
40
 
39
41
  account.update!(verified: true, verified_at: Time.current)
42
+ emit_account_verified
43
+ end
44
+
45
+ def emit_identifier_created_event
46
+ StandardId::Events.publish(
47
+ StandardId::Events::IDENTIFIER_CREATED,
48
+ identifier: self,
49
+ account: account
50
+ )
51
+ end
52
+
53
+ def emit_verification_succeeded
54
+ StandardId::Events.publish(
55
+ StandardId::Events::IDENTIFIER_VERIFICATION_SUCCEEDED,
56
+ identifier: self,
57
+ account: account,
58
+ verified_at: verified_at
59
+ )
60
+ end
61
+
62
+ def emit_account_verified
63
+ StandardId::Events.publish(
64
+ StandardId::Events::ACCOUNT_VERIFIED,
65
+ account: account,
66
+ verified_via: type.demodulize.underscore.gsub("_identifier", "")
67
+ )
40
68
  end
41
69
  end
42
70
  end
@@ -21,7 +21,7 @@ module StandardId
21
21
  end
22
22
 
23
23
  def self.default_expiry
24
- 90.days.from_now # TODO: make this configurable
24
+ StandardId.config.session.service_session_lifetime.seconds.from_now
25
25
  end
26
26
 
27
27
  def refresh!
@@ -19,7 +19,7 @@ module StandardId
19
19
  attr_reader :token
20
20
 
21
21
  before_validation :generate_token, :generate_token_digest, :generate_lookup_hash, on: :create
22
-
22
+ after_commit :emit_session_revoked_event, on: :update, if: :just_revoked?
23
23
 
24
24
  def active?
25
25
  !revoked? && !expired?
@@ -33,7 +33,8 @@ module StandardId
33
33
  revoked_at.present?
34
34
  end
35
35
 
36
- def revoke!
36
+ def revoke!(reason: nil)
37
+ @reason = reason
37
38
  update!(revoked_at: Time.current)
38
39
  end
39
40
 
@@ -50,5 +51,18 @@ module StandardId
50
51
  def generate_lookup_hash
51
52
  self.lookup_hash = Digest::SHA256.hexdigest("#{token}:#{Rails.configuration.secret_key_base}")
52
53
  end
54
+
55
+ def just_revoked?
56
+ saved_change_to_revoked_at? && revoked?
57
+ end
58
+
59
+ def emit_session_revoked_event
60
+ StandardId::Events.publish(
61
+ StandardId::Events::SESSION_REVOKED,
62
+ session: self,
63
+ account:,
64
+ reason: @reason
65
+ )
66
+ end
53
67
  end
54
68
  end
@@ -2,7 +2,7 @@
2
2
  <html>
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <title>Continuing Sign in with Apple…</title>
5
+ <title>Continuing Sign in with <% @provider.provider_name.humanize %> …</title>
6
6
  <style>
7
7
  body {
8
8
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
data/config/routes/api.rb CHANGED
@@ -16,8 +16,7 @@ StandardId::ApiEngine.routes.draw do
16
16
  resource :token, only: [:create]
17
17
 
18
18
  namespace :callback do
19
- post :google, to: "providers#google"
20
- post :apple, to: "providers#apple"
19
+ post ":provider", to: "providers#callback", as: :provider
21
20
  end
22
21
  end
23
22
  end
data/config/routes/web.rb CHANGED
@@ -7,10 +7,11 @@ StandardId::WebEngine.routes.draw do
7
7
 
8
8
  # Social authentication callbacks (web flow)
9
9
  namespace :auth do
10
+ post "callback_mobile/:provider", to: "callback/providers#mobile_callback", as: :callback_mobile
11
+
10
12
  namespace :callback do
11
- get :google, to: "providers#google"
12
- post :apple, to: "providers#apple"
13
- post :apple_mobile, to: "providers#apple_mobile"
13
+ get ":provider", to: "providers#callback", as: :provider
14
+ post ":provider", to: "providers#callback"
14
15
  end
15
16
  end
16
17
 
@@ -15,6 +15,13 @@ StandardId.configure do |c|
15
15
  # c.use_inertia = true
16
16
  # c.inertia_component_namespace = "auth" # Component path prefix (e.g., "auth/login/show")
17
17
 
18
+ # Session lifetimes (in seconds)
19
+ # c.session.browser_session_lifetime = 86400 # 24 hours (web sessions)
20
+ # c.session.browser_session_remember_me_lifetime = 2_592_000 # 30 days (remember me cookies)
21
+ # c.session.device_session_lifetime = 2_592_000 # 30 days (API device sessions)
22
+ # c.session.service_session_lifetime = 7_776_000 # 90 days (service-to-service sessions)
23
+
24
+ # Passwordless authentication delivery (DEPRECATED - use event subscriptions instead)
18
25
  # c.passwordless_email_sender = ->(email, code) { PasswordlessMailer.with(code: code, to: email).deliver_later }
19
26
  # c.passwordless_sms_sender = ->(phone, code) { SmsProvider.send_code(phone: phone, code: code) }
20
27
 
@@ -40,6 +47,10 @@ StandardId.configure do |c|
40
47
  # }
41
48
  # }
42
49
 
50
+ # Events
51
+ # Enable or disable logging emitted via the internal event system
52
+ # c.events.enable_logging = false
53
+
43
54
  # Social login credentials (if enabled in your app)
44
55
  # c.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
45
56
  # c.social.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
@@ -55,14 +66,6 @@ StandardId.configure do |c|
55
66
  # name: social_info[:name] || social_info[:given_name]
56
67
  # }
57
68
  # }
58
- # c.social.social_callback = ->(social_info:, provider:, tokens:, account:) {
59
- # Analytics.track_social_login(
60
- # provider: provider,
61
- # email: social_info[:email],
62
- # tokens: tokens,
63
- # account_id: account.id
64
- # )
65
- # }
66
69
 
67
70
  # OIDC Logout allow list
68
71
  # c.allowed_post_logout_redirect_uris = [
@@ -23,13 +23,10 @@ module StandardConfig
23
23
  # If set, Authorization endpoints can redirect to this path with a redirect_uri param
24
24
  attr_accessor :login_url
25
25
 
26
- # Social login provider credentials and hooks
27
- attr_accessor :google_client_id, :google_client_secret
28
- attr_accessor :apple_client_id, :apple_client_secret, :apple_private_key, :apple_key_id, :apple_team_id
29
- attr_accessor :social_account_attributes, :social_callback
26
+ # Social login hooks
27
+ attr_accessor :social_account_attributes
30
28
 
31
- # Passwordless authentication callbacks
32
- # These should be callable objects (procs/lambdas) that accept (recipient, code) parameters
29
+ # Passwordless authentication delivery callbacks (deprecated - use events instead)
33
30
  attr_accessor :passwordless_email_sender, :passwordless_sms_sender
34
31
 
35
32
  # Allowed post-logout redirect URIs for OIDC logout endpoint
@@ -56,15 +53,9 @@ module StandardConfig
56
53
  @logger = nil
57
54
  @issuer = nil
58
55
  @login_url = nil
59
- @google_client_id = nil
60
- @google_client_secret = nil
61
- @apple_client_id = nil
62
- @apple_client_secret = nil
63
- @apple_private_key = nil
64
56
  @apple_key_id = nil
65
57
  @apple_team_id = nil
66
58
  @social_account_attributes = nil
67
- @social_callback = nil
68
59
  @passwordless_email_sender = nil
69
60
  @passwordless_sms_sender = nil
70
61
  @allowed_post_logout_redirect_uris = []
@@ -9,9 +9,9 @@ module StandardConfig
9
9
  end
10
10
 
11
11
  def method_missing(method_name, *args)
12
- if method_name.to_s.end_with?('=')
12
+ if method_name.to_s.end_with?("=")
13
13
  # Setter - only works for static configs (OpenStruct objects)
14
- field_name = method_name.to_s.chomp('=').to_sym
14
+ field_name = method_name.to_s.chomp("=").to_sym
15
15
  validate_field!(field_name)
16
16
 
17
17
  config_object = @resolver_proc.call
@@ -42,11 +42,11 @@ module StandardConfig
42
42
  config_object = @resolver_proc.call
43
43
  raw_value = if config_object.respond_to?(field_name)
44
44
  config_object.send(field_name)
45
- elsif config_object.respond_to?(:[])
45
+ elsif config_object.respond_to?(:[])
46
46
  config_object[field_name] || config_object[field_name.to_s]
47
- else
47
+ else
48
48
  nil
49
- end
49
+ end
50
50
 
51
51
  # Cast the value according to schema
52
52
  field_def = @schema&.field_definition(@scope_name, field_name)
@@ -64,7 +64,7 @@ module StandardConfig
64
64
  end
65
65
 
66
66
  def respond_to_missing?(method_name, include_private = false)
67
- field_name = method_name.to_s.end_with?('=') ? method_name.to_s.chomp('=').to_sym : method_name.to_sym
67
+ field_name = method_name.to_s.end_with?("=") ? method_name.to_s.chomp("=").to_sym : method_name.to_sym
68
68
  @schema&.valid_field?(@scope_name, field_name) || super
69
69
  end
70
70
 
@@ -55,8 +55,8 @@ module StandardConfig
55
55
  when :boolean
56
56
  case value
57
57
  when true, false then value
58
- when 'true', '1', 1 then true
59
- when 'false', '0', 0 then false
58
+ when "true", "1", 1 then true
59
+ when "false", "0", 0 then false
60
60
  else !!value
61
61
  end
62
62
  when :array
@@ -0,0 +1,86 @@
1
+ module StandardId
2
+ module AccountLocking
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ belongs_to :locked_by, polymorphic: true, optional: true
7
+ belongs_to :unlocked_by, polymorphic: true, optional: true
8
+
9
+ scope :locked, -> { where(locked: true) }
10
+ scope :unlocked, -> { where(locked: false) }
11
+
12
+ after_commit :emit_account_locked_event, on: :update, if: :just_locked?
13
+ after_commit :emit_account_unlocked_event, on: :update, if: :just_unlocked?
14
+
15
+ # Subscribe to events to enforce lock status
16
+ # Lock check runs BEFORE status check (more restrictive first)
17
+ StandardId::Events.subscribe(
18
+ StandardId::Events::OAUTH_TOKEN_ISSUING,
19
+ StandardId::Events::SESSION_CREATING,
20
+ StandardId::Events::SESSION_VALIDATING
21
+ ) do |event|
22
+ account = event[:account]
23
+ if account&.locked?
24
+ raise StandardId::AccountLockedError.new(account)
25
+ end
26
+ end
27
+ end
28
+
29
+ def locked?
30
+ locked == true
31
+ end
32
+
33
+ def unlocked?
34
+ !locked?
35
+ end
36
+
37
+ def lock!(reason:, locked_by: nil)
38
+ return true if locked?
39
+
40
+ update!(
41
+ locked: true,
42
+ locked_at: Time.current,
43
+ lock_reason: reason,
44
+ locked_by: locked_by
45
+ )
46
+ end
47
+
48
+ def unlock!(unlocked_by: nil)
49
+ return true if unlocked?
50
+
51
+ update!(
52
+ locked: false,
53
+ unlocked_at: Time.current,
54
+ unlocked_by: unlocked_by,
55
+ lock_reason: nil
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def just_locked?
62
+ locked_previously_changed? && locked?
63
+ end
64
+
65
+ def just_unlocked?
66
+ locked_previously_changed? && unlocked?
67
+ end
68
+
69
+ def emit_account_locked_event
70
+ StandardId::Events.publish(
71
+ StandardId::Events::ACCOUNT_LOCKED,
72
+ account: self,
73
+ reason: lock_reason,
74
+ locked_by:
75
+ )
76
+ end
77
+
78
+ def emit_account_unlocked_event
79
+ StandardId::Events.publish(
80
+ StandardId::Events::ACCOUNT_UNLOCKED,
81
+ account: self,
82
+ unlocked_by:
83
+ )
84
+ end
85
+ end
86
+ end