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
@@ -0,0 +1,45 @@
1
+ module StandardId
2
+ module AccountStatus
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ enum :status, { active: "active", inactive: "inactive" }, default: :active
7
+
8
+ after_commit :emit_account_status_changed_event, on: :update, if: :status_previously_changed?
9
+
10
+ StandardId::Events.subscribe(
11
+ StandardId::Events::OAUTH_TOKEN_ISSUING,
12
+ StandardId::Events::SESSION_CREATING,
13
+ StandardId::Events::SESSION_VALIDATING
14
+ ) do |event|
15
+ account = event[:account]
16
+ if account&.inactive?
17
+ raise StandardId::AccountDeactivatedError, "Account is deactivated"
18
+ end
19
+ end
20
+ end
21
+
22
+ def activate!
23
+ return true if active?
24
+
25
+ update!(status: :active, activated_at: Time.current)
26
+ end
27
+
28
+ def deactivate!
29
+ return true if inactive?
30
+
31
+ update!(status: :inactive, deactivated_at: Time.current)
32
+ end
33
+
34
+ private
35
+
36
+ def emit_account_status_changed_event
37
+ event = inactive? ? StandardId::Events::ACCOUNT_DEACTIVATED : StandardId::Events::ACCOUNT_ACTIVATED
38
+ StandardId::Events.publish(
39
+ event,
40
+ account: self,
41
+ previous_status: status_previously_was
42
+ )
43
+ end
44
+ end
45
+ end
@@ -1,18 +1,21 @@
1
1
  module StandardId
2
2
  module Api
3
3
  class AuthenticationGuard
4
- def require_session!(session_manager)
4
+ def require_session!(session_manager, request: nil)
5
5
  api_session = session_manager.current_session
6
+ emit_session_validating(api_session, request)
6
7
 
7
8
  if api_session.blank?
8
9
  raise StandardId::NotAuthenticatedError, "Invalid or missing access token"
9
10
  elsif api_session.respond_to?(:expired?) && api_session.expired?
11
+ emit_session_expired(api_session)
10
12
  raise StandardId::ExpiredSessionError, "Session has expired"
11
13
  elsif api_session.respond_to?(:revoked?) && api_session.revoked?
12
14
  session_manager.clear_session!
13
15
  raise StandardId::RevokedSessionError, "Session has been revoked"
14
16
  end
15
17
 
18
+ emit_session_validated(api_session)
16
19
  api_session
17
20
  end
18
21
 
@@ -51,6 +54,42 @@ module StandardId
51
54
  raise ArgumentError, "Scopes must be provided as a String, Symbol, or Array"
52
55
  end
53
56
  end
57
+
58
+ def emit_session_validating(api_session, request)
59
+ StandardId::Events.publish(
60
+ StandardId::Events::SESSION_VALIDATING,
61
+ session: api_session
62
+ )
63
+ end
64
+
65
+ def emit_session_validated(api_session)
66
+ account = if api_session.respond_to?(:account)
67
+ api_session.account
68
+ elsif api_session.respond_to?(:account_id)
69
+ StandardId.account_class.find_by(id: api_session.account_id)
70
+ end
71
+
72
+ StandardId::Events.publish(
73
+ StandardId::Events::SESSION_VALIDATED,
74
+ session: api_session,
75
+ account: account
76
+ )
77
+ end
78
+
79
+ def emit_session_expired(api_session)
80
+ account = if api_session.respond_to?(:account)
81
+ api_session.account
82
+ elsif api_session.respond_to?(:account_id)
83
+ StandardId.account_class.find_by(id: api_session.account_id)
84
+ end
85
+
86
+ StandardId::Events.publish(
87
+ StandardId::Events::SESSION_EXPIRED,
88
+ session: api_session,
89
+ account: account,
90
+ expired_at: api_session.respond_to?(:expires_at) ? api_session.expires_at : nil
91
+ )
92
+ end
54
93
  end
55
94
  end
56
95
  end
@@ -13,7 +13,7 @@ module StandardId
13
13
  ip_address: @request.remote_ip,
14
14
  device_id: device_id || SecureRandom.uuid,
15
15
  device_agent: device_agent || @request.user_agent,
16
- expires_at: 30.days.from_now # TODO: make this configurable
16
+ expires_at: StandardId::DeviceSession.expiry
17
17
  )
18
18
  end
19
19
 
@@ -18,6 +18,10 @@ StandardConfig.schema.draw do
18
18
  field :inertia_component_namespace, type: :string, default: "standard_id"
19
19
  end
20
20
 
21
+ scope :events do
22
+ field :enable_logging, type: :boolean, default: false
23
+ end
24
+
21
25
  scope :passwordless do
22
26
  field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
23
27
  field :max_attempts, type: :integer, default: 3
@@ -31,6 +35,13 @@ StandardConfig.schema.draw do
31
35
  field :require_numbers, type: :boolean, default: false
32
36
  end
33
37
 
38
+ scope :session do
39
+ field :browser_session_lifetime, type: :integer, default: 86400 # 24 hours in seconds
40
+ field :browser_session_remember_me_lifetime, type: :integer, default: 2592000 # 30 days in seconds
41
+ field :device_session_lifetime, type: :integer, default: 2592000 # 30 days in seconds
42
+ field :service_session_lifetime, type: :integer, default: 7776000 # 90 days in seconds
43
+ end
44
+
34
45
  scope :oauth do
35
46
  field :default_token_lifetime, type: :integer, default: 3600 # 1 hour in seconds
36
47
  field :refresh_token_lifetime, type: :integer, default: 2592000 # 30 days in seconds
@@ -42,16 +53,7 @@ StandardConfig.schema.draw do
42
53
  end
43
54
 
44
55
  scope :social do
45
- field :google_client_id, type: :string, default: nil
46
- field :google_client_secret, type: :string, default: nil
47
- field :apple_client_id, type: :string, default: nil
48
- field :apple_client_secret, type: :string, default: nil
49
- field :apple_private_key, type: :string, default: nil
50
- field :apple_key_id, type: :string, default: nil
51
- field :apple_team_id, type: :string, default: nil
52
- field :apple_mobile_client_id, type: :string, default: nil
53
56
  field :social_account_attributes, type: :any, default: nil
54
57
  field :allowed_redirect_url_prefixes, type: :array, default: []
55
- field :social_callback, type: :any, default: nil
56
58
  end
57
59
  end
@@ -0,0 +1,9 @@
1
+ module StandardId
2
+ module CurrentAttributes
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ attribute :session, :account, :request_id, :ip_address, :user_agent
7
+ end
8
+ end
9
+ end
@@ -1,5 +1,14 @@
1
1
  module StandardId
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace StandardId
4
+
5
+ config.after_initialize do
6
+ if StandardId.config.events.enable_logging
7
+ StandardId::Events::Subscribers::LoggingSubscriber.attach
8
+ end
9
+
10
+ StandardId::Events::Subscribers::AccountStatusSubscriber.attach
11
+ StandardId::Events::Subscribers::AccountLockingSubscriber.attach
12
+ end
4
13
  end
5
14
  end
@@ -4,6 +4,18 @@ module StandardId
4
4
  class InvalidSessionError < StandardError; end
5
5
  class ExpiredSessionError < InvalidSessionError; end
6
6
  class RevokedSessionError < InvalidSessionError; end
7
+ class AccountDeactivatedError < StandardError; end
8
+
9
+ class AccountLockedError < StandardError
10
+ attr_reader :account, :lock_reason, :locked_at
11
+
12
+ def initialize(account)
13
+ @account = account
14
+ @lock_reason = account.lock_reason
15
+ @locked_at = account.locked_at
16
+ super("Account has been locked#{lock_reason ? ": #{lock_reason}" : ""}")
17
+ end
18
+ end
7
19
 
8
20
  class OAuthError < StandardError
9
21
  def oauth_error_code
@@ -0,0 +1,157 @@
1
+ module StandardId
2
+ module Events
3
+ module Definitions
4
+ AUTHENTICATION_ATTEMPT_STARTED = "authentication.attempt.started"
5
+ AUTHENTICATION_SUCCEEDED = "authentication.attempt.succeeded"
6
+ AUTHENTICATION_FAILED = "authentication.attempt.failed"
7
+ PASSWORD_VALIDATED = "authentication.password.validated"
8
+ PASSWORD_VALIDATION_FAILED = "authentication.password.failed"
9
+ OTP_VALIDATED = "authentication.otp.validated"
10
+ OTP_VALIDATION_FAILED = "authentication.otp.failed"
11
+
12
+ SESSION_CREATING = "session.creating"
13
+ SESSION_CREATED = "session.created"
14
+ SESSION_VALIDATING = "session.validating"
15
+ SESSION_VALIDATED = "session.validated"
16
+ SESSION_EXPIRED = "session.expired"
17
+ SESSION_REVOKED = "session.revoked"
18
+ SESSION_REFRESHED = "session.refreshed"
19
+
20
+ ACCOUNT_CREATING = "account.creating"
21
+ ACCOUNT_CREATED = "account.created"
22
+ ACCOUNT_VERIFIED = "account.verified"
23
+ ACCOUNT_STATUS_CHANGED = "account.status_changed"
24
+ ACCOUNT_ACTIVATED = "account.activated"
25
+ ACCOUNT_DEACTIVATED = "account.deactivated"
26
+ ACCOUNT_LOCKED = "account.locked"
27
+ ACCOUNT_UNLOCKED = "account.unlocked"
28
+
29
+ IDENTIFIER_CREATED = "identifier.created"
30
+ IDENTIFIER_VERIFICATION_STARTED = "identifier.verification.started"
31
+ IDENTIFIER_VERIFICATION_SUCCEEDED = "identifier.verification.succeeded"
32
+ IDENTIFIER_VERIFICATION_FAILED = "identifier.verification.failed"
33
+ IDENTIFIER_LINKED = "identifier.linked"
34
+
35
+ OAUTH_AUTHORIZATION_REQUESTED = "oauth.authorization.requested"
36
+ OAUTH_AUTHORIZATION_GRANTED = "oauth.authorization.granted"
37
+ OAUTH_AUTHORIZATION_DENIED = "oauth.authorization.denied"
38
+ OAUTH_TOKEN_ISSUING = "oauth.token.issuing"
39
+ OAUTH_TOKEN_ISSUED = "oauth.token.issued"
40
+ OAUTH_TOKEN_REFRESHED = "oauth.token.refreshed"
41
+ OAUTH_CODE_CONSUMED = "oauth.code.consumed"
42
+
43
+ PASSWORDLESS_CODE_REQUESTED = "passwordless.code.requested"
44
+ PASSWORDLESS_CODE_GENERATED = "passwordless.code.generated"
45
+ PASSWORDLESS_CODE_SENT = "passwordless.code.sent"
46
+ PASSWORDLESS_CODE_VERIFIED = "passwordless.code.verified"
47
+ PASSWORDLESS_CODE_FAILED = "passwordless.code.failed"
48
+ PASSWORDLESS_ACCOUNT_CREATED = "passwordless.account.created"
49
+
50
+ SOCIAL_AUTH_STARTED = "social.auth.started"
51
+ SOCIAL_CALLBACK_RECEIVED = "social.auth.callback_received"
52
+ SOCIAL_USER_INFO_FETCHED = "social.user_info.fetched"
53
+ SOCIAL_ACCOUNT_CREATED = "social.account.created"
54
+ SOCIAL_ACCOUNT_LINKED = "social.account.linked"
55
+ SOCIAL_AUTH_COMPLETED = "social.auth.completed"
56
+
57
+ CREDENTIAL_PASSWORD_CREATED = "credential.password.created"
58
+ CREDENTIAL_PASSWORD_RESET_INITIATED = "credential.password.reset_initiated"
59
+ CREDENTIAL_PASSWORD_RESET_COMPLETED = "credential.password.reset_completed"
60
+ CREDENTIAL_PASSWORD_CHANGED = "credential.password.changed"
61
+ CREDENTIAL_CLIENT_SECRET_CREATED = "credential.client_secret.created"
62
+ CREDENTIAL_CLIENT_SECRET_ROTATED = "credential.client_secret.rotated"
63
+ CREDENTIAL_CLIENT_SECRET_REVOKED = "credential.client_secret.revoked"
64
+
65
+ AUTHENTICATION_EVENTS = [
66
+ AUTHENTICATION_ATTEMPT_STARTED,
67
+ AUTHENTICATION_SUCCEEDED,
68
+ AUTHENTICATION_FAILED,
69
+ PASSWORD_VALIDATED,
70
+ PASSWORD_VALIDATION_FAILED,
71
+ OTP_VALIDATED,
72
+ OTP_VALIDATION_FAILED
73
+ ].freeze
74
+
75
+ SESSION_EVENTS = [
76
+ SESSION_CREATING,
77
+ SESSION_CREATED,
78
+ SESSION_VALIDATING,
79
+ SESSION_VALIDATED,
80
+ SESSION_EXPIRED,
81
+ SESSION_REVOKED,
82
+ SESSION_REFRESHED
83
+ ].freeze
84
+
85
+ ACCOUNT_EVENTS = [
86
+ ACCOUNT_CREATING,
87
+ ACCOUNT_CREATED,
88
+ ACCOUNT_VERIFIED,
89
+ ACCOUNT_STATUS_CHANGED,
90
+ ACCOUNT_ACTIVATED,
91
+ ACCOUNT_DEACTIVATED,
92
+ ACCOUNT_LOCKED,
93
+ ACCOUNT_UNLOCKED
94
+ ].freeze
95
+
96
+ IDENTIFIER_EVENTS = [
97
+ IDENTIFIER_CREATED,
98
+ IDENTIFIER_VERIFICATION_STARTED,
99
+ IDENTIFIER_VERIFICATION_SUCCEEDED,
100
+ IDENTIFIER_VERIFICATION_FAILED,
101
+ IDENTIFIER_LINKED
102
+ ].freeze
103
+
104
+ OAUTH_EVENTS = [
105
+ OAUTH_AUTHORIZATION_REQUESTED,
106
+ OAUTH_AUTHORIZATION_GRANTED,
107
+ OAUTH_AUTHORIZATION_DENIED,
108
+ OAUTH_TOKEN_ISSUING,
109
+ OAUTH_TOKEN_ISSUED,
110
+ OAUTH_TOKEN_REFRESHED,
111
+ OAUTH_CODE_CONSUMED
112
+ ].freeze
113
+
114
+ PASSWORDLESS_EVENTS = [
115
+ PASSWORDLESS_CODE_REQUESTED,
116
+ PASSWORDLESS_CODE_GENERATED,
117
+ PASSWORDLESS_CODE_SENT,
118
+ PASSWORDLESS_CODE_VERIFIED,
119
+ PASSWORDLESS_CODE_FAILED,
120
+ PASSWORDLESS_ACCOUNT_CREATED
121
+ ].freeze
122
+
123
+ SOCIAL_EVENTS = [
124
+ SOCIAL_AUTH_STARTED,
125
+ SOCIAL_CALLBACK_RECEIVED,
126
+ SOCIAL_USER_INFO_FETCHED,
127
+ SOCIAL_ACCOUNT_CREATED,
128
+ SOCIAL_ACCOUNT_LINKED,
129
+ SOCIAL_AUTH_COMPLETED
130
+ ].freeze
131
+
132
+ CREDENTIAL_EVENTS = [
133
+ CREDENTIAL_PASSWORD_CREATED,
134
+ CREDENTIAL_PASSWORD_RESET_INITIATED,
135
+ CREDENTIAL_PASSWORD_RESET_COMPLETED,
136
+ CREDENTIAL_PASSWORD_CHANGED,
137
+ CREDENTIAL_CLIENT_SECRET_CREATED,
138
+ CREDENTIAL_CLIENT_SECRET_ROTATED,
139
+ CREDENTIAL_CLIENT_SECRET_REVOKED
140
+ ].freeze
141
+
142
+ ALL_EVENTS = (
143
+ AUTHENTICATION_EVENTS +
144
+ SESSION_EVENTS +
145
+ ACCOUNT_EVENTS +
146
+ IDENTIFIER_EVENTS +
147
+ OAUTH_EVENTS +
148
+ PASSWORDLESS_EVENTS +
149
+ SOCIAL_EVENTS +
150
+ CREDENTIAL_EVENTS
151
+ ).freeze
152
+ end
153
+
154
+ # Include definitions at module level for convenience
155
+ include Definitions
156
+ end
157
+ end
@@ -0,0 +1,123 @@
1
+ module StandardId
2
+ module Events
3
+ # Event object that wraps ActiveSupport::Notifications event data
4
+ #
5
+ # Provides a clean interface for accessing event information in subscribers.
6
+ #
7
+ # @attr_reader [String] name The full namespaced event name
8
+ # @attr_reader [Hash] payload The event payload data
9
+ # @attr_reader [Time] started_at When the event started
10
+ # @attr_reader [Time] finished_at When the event finished
11
+ # @attr_reader [String] transaction_id Unique identifier for this event instance
12
+ #
13
+ class Event
14
+ attr_reader :name, :payload, :started_at, :finished_at, :transaction_id
15
+
16
+ # @param name [String] The full namespaced event name
17
+ # @param payload [Hash] The event payload
18
+ # @param started_at [Time] When the event started
19
+ # @param finished_at [Time] When the event finished
20
+ # @param transaction_id [String] Unique identifier for the event
21
+ def initialize(name:, payload:, started_at: nil, finished_at: nil, transaction_id: nil)
22
+ @name = name
23
+ @payload = payload.with_indifferent_access
24
+ @started_at = started_at
25
+ @finished_at = finished_at
26
+ @transaction_id = transaction_id
27
+ end
28
+
29
+ # Get the event name without the namespace prefix
30
+ #
31
+ # @return [String] The short event name
32
+ # @example
33
+ # event.short_name # => "authentication.attempt.succeeded"
34
+ #
35
+ def short_name
36
+ name.to_s.delete_prefix("#{Events::NAMESPACE}.")
37
+ end
38
+
39
+ # Get the event type from the payload
40
+ #
41
+ # @return [String, nil] The event type
42
+ #
43
+ def event_type
44
+ payload[:event_type]
45
+ end
46
+
47
+ # Get the unique event ID
48
+ #
49
+ # @return [String, nil] The event UUID
50
+ #
51
+ def event_id
52
+ payload[:event_id]
53
+ end
54
+
55
+ # Get the event timestamp
56
+ #
57
+ # @return [String, nil] ISO8601 formatted timestamp
58
+ #
59
+ def timestamp
60
+ payload[:timestamp]
61
+ end
62
+
63
+ # Calculate the duration of the event in milliseconds
64
+ #
65
+ # @return [Float, nil] Duration in milliseconds, or nil if timing not available
66
+ #
67
+ def duration_ms
68
+ return nil unless started_at && finished_at
69
+ (finished_at - started_at) * 1000
70
+ end
71
+
72
+ # Convenience method to access payload values
73
+ #
74
+ # @param key [Symbol, String] The payload key
75
+ # @return [Object] The value from the payload
76
+ #
77
+ def [](key)
78
+ payload[key]
79
+ end
80
+
81
+ # Check if the payload contains a key
82
+ #
83
+ # @param key [Symbol, String] The payload key
84
+ # @return [Boolean]
85
+ #
86
+ def key?(key)
87
+ payload.key?(key)
88
+ end
89
+
90
+ # Convert event to a hash representation
91
+ #
92
+ # @return [Hash] The event as a hash
93
+ #
94
+ def to_h
95
+ {
96
+ name: name,
97
+ short_name: short_name,
98
+ transaction_id: transaction_id,
99
+ started_at: started_at&.iso8601,
100
+ finished_at: finished_at&.iso8601,
101
+ duration_ms: duration_ms,
102
+ payload: payload.to_h
103
+ }
104
+ end
105
+
106
+ # Convert event to JSON
107
+ #
108
+ # @return [String] JSON representation
109
+ #
110
+ def to_json(*args)
111
+ to_h.to_json(*args)
112
+ end
113
+
114
+ # String representation for debugging
115
+ #
116
+ # @return [String]
117
+ #
118
+ def inspect
119
+ "#<#{self.class.name} name=#{name} event_id=#{event_id} duration_ms=#{duration_ms}>"
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,17 @@
1
+ module StandardId
2
+ module Events
3
+ module Subscribers
4
+ class AccountLockingSubscriber < Base
5
+ subscribe_to StandardId::Events::ACCOUNT_LOCKED
6
+
7
+ def call(event)
8
+ account = event[:account]
9
+ active_sessions = account.sessions.active
10
+ active_sessions.find_each do |session|
11
+ session.revoke!(reason: "account_locked")
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ module StandardId
2
+ module Events
3
+ module Subscribers
4
+ class AccountStatusSubscriber < Base
5
+ subscribe_to StandardId::Events::ACCOUNT_DEACTIVATED
6
+
7
+ def call(event)
8
+ account = event[:account]
9
+ active_sessions = account.sessions.active
10
+ active_sessions.find_each do |session|
11
+ session.revoke!(reason: "account_deactivated")
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,165 @@
1
+ module StandardId
2
+ module Events
3
+ module Subscribers
4
+ # Base class for event subscribers
5
+ #
6
+ # @example Single event subscription
7
+ # class AuditSubscriber < StandardId::Events::Subscribers::Base
8
+ # subscribe_to StandardId::Events::AUTHENTICATION_SUCCEEDED
9
+ #
10
+ # def call(event)
11
+ # AuditLog.create!(
12
+ # event_type: event.short_name,
13
+ # account_id: event[:account]&.id,
14
+ # ip_address: event[:ip_address],
15
+ # metadata: event.payload
16
+ # )
17
+ # end
18
+ # end
19
+ #
20
+ # @example Multiple event subscription
21
+ # class SecurityAlertSubscriber < StandardId::Events::Subscribers::Base
22
+ # subscribe_to StandardId::Events::AUTHENTICATION_FAILED,
23
+ # StandardId::Events::SESSION_REVOKED,
24
+ # StandardId::Events::ACCOUNT_LOCKED
25
+ #
26
+ # def call(event)
27
+ # SecurityMailer.alert(event.to_h).deliver_later
28
+ # end
29
+ # end
30
+ #
31
+ # @example Pattern subscription
32
+ # class MetricsSubscriber < StandardId::Events::Subscribers::Base
33
+ # subscribe_to_pattern(/authentication/)
34
+ #
35
+ # def call(event)
36
+ # StatsD.increment("standard_id.#{event.short_name}")
37
+ # end
38
+ # end
39
+ #
40
+ class Base
41
+ class << self
42
+ # Subscribe to specific event(s)
43
+ #
44
+ # @param event_names [Array<String>] Event name constants
45
+ # @return [void]
46
+ #
47
+ def subscribe_to(*event_names)
48
+ @subscribed_events = event_names.flatten
49
+ end
50
+
51
+ # Subscribe to events matching a pattern
52
+ #
53
+ # @param pattern [Regexp] Pattern to match event names
54
+ # @return [void]
55
+ #
56
+ def subscribe_to_pattern(pattern)
57
+ @subscription_pattern = pattern
58
+ end
59
+
60
+ # Get the subscribed events
61
+ #
62
+ # @return [Array<String>]
63
+ #
64
+ def subscribed_events
65
+ @subscribed_events || []
66
+ end
67
+
68
+ # Get the subscription pattern
69
+ #
70
+ # @return [Regexp, nil]
71
+ #
72
+ def subscription_pattern
73
+ @subscription_pattern
74
+ end
75
+
76
+ # Register this subscriber with the event system
77
+ #
78
+ # @return [Array<Object>] The subscription handles
79
+ #
80
+ def attach
81
+ instance = new
82
+ @subscriptions ||= []
83
+
84
+ if subscription_pattern
85
+ @subscriptions << Events.subscribe(subscription_pattern) do |event|
86
+ instance.handle(event)
87
+ end
88
+ end
89
+
90
+ subscribed_events.each do |event_name|
91
+ @subscriptions << Events.subscribe(event_name) do |event|
92
+ instance.handle(event)
93
+ end
94
+ end
95
+
96
+ @subscriptions
97
+ end
98
+
99
+ # Unregister this subscriber from the event system
100
+ #
101
+ # @return [void]
102
+ #
103
+ def detach
104
+ return unless @subscriptions
105
+
106
+ @subscriptions.each do |subscription|
107
+ Events.unsubscribe(subscription)
108
+ end
109
+ @subscriptions = []
110
+ end
111
+
112
+ # Check if the subscriber is currently attached
113
+ #
114
+ # @return [Boolean]
115
+ #
116
+ def attached?
117
+ @subscriptions&.any?
118
+ end
119
+ end
120
+
121
+ # Handle an event
122
+ #
123
+ # Override this method to add custom handling logic like
124
+ # async processing or error handling. By default, it calls
125
+ # the `call` method.
126
+ #
127
+ # @param event [StandardId::Events::Event] The event to handle
128
+ # @return [void]
129
+ #
130
+ def handle(event)
131
+ call(event)
132
+ rescue StandardError => e
133
+ handle_error(e, event)
134
+ end
135
+
136
+ # Process the event
137
+ #
138
+ # Subclasses must implement this method.
139
+ #
140
+ # @param event [StandardId::Events::Event] The event to process
141
+ # @raise [NotImplementedError] If not implemented by subclass
142
+ #
143
+ def call(event)
144
+ raise NotImplementedError, "#{self.class.name} must implement #call"
145
+ end
146
+
147
+ # Handle errors during event processing
148
+ #
149
+ # Override this method to customize error handling.
150
+ # By default, it logs the error and re-raises.
151
+ #
152
+ # @param error [StandardError] The error that occurred
153
+ # @param event [StandardId::Events::Event] The event being processed
154
+ # @raise [StandardError] Re-raises the error by default
155
+ #
156
+ def handle_error(error, event)
157
+ StandardId.logger.error(
158
+ "[StandardId::Events] Error in #{self.class.name} handling #{event.short_name}: #{error.message}"
159
+ )
160
+ raise error
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end