standard_id 0.7.1 → 0.8.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: 79293d8af473e409c125e4412da31b1438fc0099efea935116d9036978251395
4
- data.tar.gz: 568d1a2c768b0bbf17385c02781ec8193ef6a6b94da5118d1b4383755718ccbb
3
+ metadata.gz: 112cdb26b4fc96d4be1830bde78fada616b87a79f6d7adff9657df72d8942a57
4
+ data.tar.gz: 4efeaf6a5f3f5bfdaf3747bebc0f5f9c3ccf22b02642c69e7b59c8a3d4aec74a
5
5
  SHA512:
6
- metadata.gz: 855acbd34604cd3a626d041daf889f3329259cdd23aab12e5f0055e1e75ddd55b728be1103b9b286602d045b1f37f34ef2be6251d96a77609e7e1f69414aa557
7
- data.tar.gz: 3c99a235eb70e8713093d8bc4231677c888eae0f392aed5ccd72bcc09f65147d74c3e6355b877c5928d27a5c40622df6c4e450030da2b2d9affdc5264258d4dd
6
+ metadata.gz: eea26565c62749d409acbdf38a282f63edf16ed907872d9673fea58057ede62ea679adb1765cd1e55855a48ca67cdabc5baeb7fa52277529828366c560462e7e
7
+ data.tar.gz: c250360c48f34dc9d52b6f1391e7816735dd1d3e9be63d7d80a0e8ae9b87d7ee178f8c78867eead4b7c792ac62b6a753ea90be73caf9e1f60e4c94415c57f1fd
@@ -42,8 +42,23 @@ module StandardId
42
42
  social_providers: {
43
43
  google_enabled: StandardId.config.google_client_id.present?,
44
44
  apple_enabled: StandardId.config.apple_client_id.present?
45
- }
45
+ },
46
+ enabled_mechanisms: web_enabled_mechanisms
46
47
  }.deep_merge(additional_props)
47
48
  end
49
+
50
+ def web_enabled_mechanisms
51
+ web = StandardId.config.web
52
+ {
53
+ password_login: web.password_login,
54
+ signup: web.signup,
55
+ passwordless_login: web.passwordless_login,
56
+ social_login: web.social_login,
57
+ password_reset: web.password_reset,
58
+ email_verification: web.email_verification,
59
+ phone_verification: web.phone_verification,
60
+ sessions_management: web.sessions_management
61
+ }
62
+ end
48
63
  end
49
64
  end
@@ -0,0 +1,72 @@
1
+ module StandardId
2
+ module LifecycleHooks
3
+ extend ActiveSupport::Concern
4
+
5
+ private
6
+
7
+ # Invoke the after_sign_in hook if configured.
8
+ #
9
+ # @param account [Object] the authenticated account
10
+ # @param context [Hash] context about the sign-in
11
+ # - :connection [String] "email", "password", or "social"
12
+ # - :provider [String, nil] e.g. "google", "apple", or nil
13
+ # - :first_sign_in [Boolean] whether this is the account's first browser session
14
+ # @return [String, nil] redirect path override, or nil for default
15
+ # @raise [StandardId::AuthenticationDenied] to reject the sign-in
16
+ def invoke_after_sign_in(account, context)
17
+ hook = StandardId.config.after_sign_in
18
+ return nil unless hook.respond_to?(:call)
19
+
20
+ context = context.merge(first_sign_in: first_sign_in?(account))
21
+ hook.call(account, request, context)
22
+ end
23
+
24
+ # Invoke the after_account_created hook if configured.
25
+ #
26
+ # @param account [Object] the newly created account
27
+ # @param context [Hash] context about the creation
28
+ # - :mechanism [String] "passwordless", "social", or "signup"
29
+ # - :provider [String, nil] e.g. "google", "apple", or nil
30
+ # @return [void]
31
+ def invoke_after_account_created(account, context)
32
+ hook = StandardId.config.after_account_created
33
+ return unless hook.respond_to?(:call)
34
+
35
+ hook.call(account, request, context)
36
+ end
37
+
38
+ # Determine if this is the account's first browser session.
39
+ # A count of 1 means the session just created is the only one.
40
+ def first_sign_in?(account)
41
+ account.sessions.where(type: "StandardId::BrowserSession").active.count <= 1
42
+ end
43
+
44
+ # Handle AuthenticationDenied by revoking the session and redirecting to login.
45
+ # If the account was just created, clean it up to avoid orphaned records.
46
+ #
47
+ # @param error [StandardId::AuthenticationDenied] the denial error
48
+ # @param account [Object, nil] the account to clean up if newly created
49
+ # @param newly_created [Boolean] whether the account was created during this request
50
+ def handle_authentication_denied(error, account: nil, newly_created: false)
51
+ session_manager.revoke_current_session!
52
+ destroy_newly_created_account(account) if newly_created
53
+ message = error.message
54
+ # When raised without arguments, StandardError#message returns the class name
55
+ message = "Sign-in was denied" if message.blank? || message == error.class.name
56
+ redirect_to StandardId::WebEngine.routes.url_helpers.login_path, alert: message
57
+ end
58
+
59
+ # Destroy a newly created account and all its dependents.
60
+ # Used when after_sign_in rejects a just-created account to avoid orphans.
61
+ def destroy_newly_created_account(account)
62
+ return unless account&.persisted?
63
+
64
+ ActiveRecord::Base.transaction do
65
+ account.sessions.destroy_all
66
+ account.identifiers.each { |i| i.credentials.destroy_all }
67
+ account.identifiers.destroy_all
68
+ account.destroy
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,28 @@
1
+ module StandardId
2
+ module WebMechanismGate
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ # Declares which web mechanism this controller requires.
7
+ # If the mechanism is disabled via config, requests return 404.
8
+ #
9
+ # class SignupController < BaseController
10
+ # requires_web_mechanism :signup
11
+ # end
12
+ def requires_web_mechanism(mechanism_name)
13
+ before_action -> { enforce_web_mechanism!(mechanism_name) }
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def enforce_web_mechanism!(mechanism_name)
20
+ unless StandardId.config.web.respond_to?(mechanism_name)
21
+ raise ArgumentError, "Unknown web mechanism: #{mechanism_name.inspect}. " \
22
+ "Valid mechanisms: #{StandardId.config.web.class.instance_methods(false).grep_v(/=/).sort.join(', ')}"
23
+ end
24
+
25
+ head :not_found unless StandardId.config.web.public_send(mechanism_name)
26
+ end
27
+ end
28
+ end
@@ -4,10 +4,12 @@ module StandardId
4
4
  module Callback
5
5
  class ProvidersController < StandardId::Web::BaseController
6
6
  public_controller
7
+ requires_web_mechanism :social_login
7
8
 
8
9
  include StandardId::WebAuthentication
9
10
  include StandardId::SocialAuthentication
10
11
  include StandardId::Web::SocialLoginParams
12
+ include StandardId::LifecycleHooks
11
13
 
12
14
  # Social callbacks must be accessible without an existing browser session
13
15
  # because they create/sign-in the session upon successful callback.
@@ -28,21 +30,35 @@ module StandardId
28
30
  provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
29
31
  social_info = provider_response[:user_info]
30
32
  provider_tokens = provider_response[:tokens]
31
- account = find_or_create_account_from_social(social_info)
33
+ begin
34
+ account = find_or_create_account_from_social(social_info)
35
+ rescue ActiveRecord::RecordNotUnique
36
+ # Race condition: concurrent request created the account first — retry to find it
37
+ account = find_or_create_account_from_social(social_info)
38
+ end
39
+ newly_created = account.previously_new_record?
32
40
  session_manager.sign_in_account(account)
33
41
 
42
+ provider_name = provider.provider_name
43
+ invoke_after_account_created(account, { mechanism: "social", provider: provider_name }) if newly_created
44
+
34
45
  run_social_callback(
35
- provider: provider.provider_name,
46
+ provider: provider_name,
36
47
  social_info: social_info,
37
48
  provider_tokens: provider_tokens,
38
49
  account: account,
39
50
  original_request_params: state_data
40
51
  )
41
52
 
42
- destination = state_data["redirect_uri"]
43
- redirect_options = { notice: "Successfully signed in with #{provider.provider_name.humanize}" }
53
+ context = { connection: "social", provider: provider_name }
54
+ redirect_override = invoke_after_sign_in(account, context)
55
+
56
+ destination = redirect_override || state_data["redirect_uri"]
57
+ redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
44
58
  redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
45
59
  redirect_to destination, redirect_options
60
+ rescue StandardId::AuthenticationDenied => e
61
+ handle_authentication_denied(e, account: account, newly_created: newly_created)
46
62
  rescue StandardId::OAuthError => e
47
63
  redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
48
64
  end
@@ -4,6 +4,7 @@ module StandardId
4
4
  include StandardId::ControllerPolicy
5
5
  include StandardId::WebAuthentication
6
6
  include StandardId::SetCurrentRequestDetails
7
+ include StandardId::WebMechanismGate
7
8
 
8
9
  include StandardId::WebEngine.routes.url_helpers
9
10
  helper StandardId::WebEngine.routes.url_helpers
@@ -6,6 +6,7 @@ module StandardId
6
6
  include StandardId::InertiaRendering
7
7
  include StandardId::Web::SocialLoginParams
8
8
  include StandardId::PasswordlessStrategy
9
+ include StandardId::LifecycleHooks
9
10
 
10
11
  layout "public"
11
12
 
@@ -32,16 +33,23 @@ module StandardId
32
33
  private
33
34
 
34
35
  def passwordless_enabled?
35
- StandardId.config.passwordless.enabled
36
+ StandardId.config.web.passwordless_login
36
37
  end
37
38
 
38
39
  def handle_password_login
40
+ return head(:not_found) unless StandardId.config.web.password_login
41
+
39
42
  if sign_in_account(login_params)
40
- redirect_to params[:redirect_uri] || after_authentication_url, status: :see_other, notice: "Successfully signed in"
43
+ context = { connection: "password", provider: nil }
44
+ redirect_override = invoke_after_sign_in(current_account, context)
45
+ destination = redirect_override || params[:redirect_uri] || after_authentication_url
46
+ redirect_to destination, status: :see_other, notice: "Successfully signed in"
41
47
  else
42
48
  flash.now[:alert] = "Invalid email or password"
43
49
  render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
44
50
  end
51
+ rescue StandardId::AuthenticationDenied => e
52
+ handle_authentication_denied(e)
45
53
  end
46
54
 
47
55
  def handle_passwordless_login
@@ -2,14 +2,14 @@ module StandardId
2
2
  module Web
3
3
  class LoginVerifyController < BaseController
4
4
  public_controller
5
+ requires_web_mechanism :passwordless_login
5
6
 
6
7
  include StandardId::InertiaRendering
8
+ include StandardId::LifecycleHooks
7
9
 
8
10
  layout "public"
9
11
 
10
12
  skip_before_action :require_browser_session!, only: [:show, :update]
11
-
12
- before_action :ensure_passwordless_enabled!
13
13
  before_action :redirect_if_authenticated, only: [:show]
14
14
  before_action :require_otp_payload!
15
15
 
@@ -39,23 +39,28 @@ module StandardId
39
39
  return
40
40
  end
41
41
 
42
- session_manager.sign_in_account(result.account)
43
- emit_authentication_succeeded(result.account)
42
+ account = result.account
43
+ newly_created = account.previously_new_record?
44
44
 
45
- session.delete(:standard_id_otp_payload)
45
+ session_manager.sign_in_account(account)
46
+ emit_authentication_succeeded(account)
46
47
 
47
- redirect_to after_authentication_url, status: :see_other, notice: "Successfully signed in"
48
- end
48
+ invoke_after_account_created(account, { mechanism: "passwordless", provider: nil }) if newly_created
49
49
 
50
- private
50
+ context = { connection: @otp_data[:connection], provider: nil }
51
+ redirect_override = invoke_after_sign_in(account, context)
51
52
 
52
- def ensure_passwordless_enabled!
53
- return if StandardId.config.passwordless.enabled
53
+ session.delete(:standard_id_otp_payload)
54
54
 
55
+ destination = redirect_override || after_authentication_url
56
+ redirect_to destination, status: :see_other, notice: "Successfully signed in"
57
+ rescue StandardId::AuthenticationDenied => e
55
58
  session.delete(:standard_id_otp_payload)
56
- redirect_to login_path, alert: "Passwordless login is not available"
59
+ handle_authentication_denied(e, account: account, newly_created: newly_created)
57
60
  end
58
61
 
62
+ private
63
+
59
64
  def redirect_if_authenticated
60
65
  redirect_to after_authentication_url, status: :see_other if authenticated?
61
66
  end
@@ -3,6 +3,7 @@ module StandardId
3
3
  module ResetPassword
4
4
  class ConfirmController < BaseController
5
5
  public_controller
6
+ requires_web_mechanism :password_reset
6
7
 
7
8
  layout "public"
8
9
 
@@ -3,6 +3,7 @@ module StandardId
3
3
  module ResetPassword
4
4
  class StartController < BaseController
5
5
  public_controller
6
+ requires_web_mechanism :password_reset
6
7
 
7
8
  layout "public"
8
9
 
@@ -2,6 +2,7 @@ module StandardId
2
2
  module Web
3
3
  class SessionsController < BaseController
4
4
  authenticated_controller
5
+ requires_web_mechanism :sessions_management
5
6
 
6
7
  def index
7
8
  @sessions = current_account.sessions.active.order(created_at: :desc)
@@ -2,8 +2,10 @@ module StandardId
2
2
  module Web
3
3
  class SignupController < BaseController
4
4
  public_controller
5
+ requires_web_mechanism :signup
5
6
 
6
7
  include StandardId::InertiaRendering
8
+ include StandardId::LifecycleHooks
7
9
 
8
10
  layout "public"
9
11
 
@@ -38,14 +40,20 @@ module StandardId
38
40
 
39
41
  if form.submit
40
42
  session_manager.sign_in_account(form.account)
41
- redirect_to params[:redirect_uri] || after_authentication_url,
42
- notice: "Account created successfully"
43
+ invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
44
+
45
+ context = { connection: "password", provider: nil }
46
+ redirect_override = invoke_after_sign_in(form.account, context)
47
+ destination = redirect_override || params[:redirect_uri] || after_authentication_url
48
+ redirect_to destination, notice: "Account created successfully"
43
49
  else
44
50
  @redirect_uri = params[:redirect_uri] || after_authentication_url
45
51
  @connection = params[:connection]
46
52
  flash.now[:alert] = form.errors.full_messages.join(", ")
47
53
  render_with_inertia action: :show, props: auth_page_props(errors: form.errors.to_hash), status: :unprocessable_content
48
54
  end
55
+ rescue StandardId::AuthenticationDenied => e
56
+ handle_authentication_denied(e, account: form.account, newly_created: form.account&.previously_new_record?)
49
57
  end
50
58
 
51
59
  def social_signup_url
@@ -3,6 +3,7 @@ module StandardId
3
3
  module VerifyEmail
4
4
  class BaseController < StandardId::Web::BaseController
5
5
  public_controller
6
+ requires_web_mechanism :email_verification
6
7
 
7
8
  skip_before_action :require_browser_session!
8
9
  end
@@ -3,6 +3,7 @@ module StandardId
3
3
  module VerifyPhone
4
4
  class BaseController < StandardId::Web::BaseController
5
5
  public_controller
6
+ requires_web_mechanism :phone_verification
6
7
 
7
8
  skip_before_action :require_browser_session!
8
9
  end
@@ -1,9 +1,5 @@
1
1
  {
2
2
  "ignored_warnings": [
3
- {
4
- "fingerprint": "24fc02735a2ad863d6bf1171a4a329b208e9e7c41841fa0149d8e6878d4ce299",
5
- "note": "Auth engine intentionally redirects to params[:redirect_uri] after signup for OAuth/post-auth flow"
6
- },
7
3
  {
8
4
  "fingerprint": "6b35e9906d62a9b9cd0dff9cf53924d40e74bc4f96cfccf27e67e93551113243",
9
5
  "note": "Auth engine intentionally redirects to params[:redirect_uri] after logout for OAuth/post-auth flow"
@@ -16,15 +12,19 @@
16
12
  "fingerprint": "bdbc72619da2ba771b1185ccf16acce801066689bf51adf116eab8c8714b39af",
17
13
  "note": "HEAD vs GET distinction is inconsequential here; storing return URL on GET-only is safe"
18
14
  },
19
- {
20
- "fingerprint": "16bd6ec7c3fa130eb80c15fc90c87f9859d89b37258807bfaffe4101366611a6",
21
- "note": "Auth engine intentionally redirects to params[:redirect_uri] after login for OAuth/post-auth flow"
22
- },
23
15
  {
24
16
  "fingerprint": "e4f96cb212c73c3165c3db6eaa6368c29d362b61264f034e80c9fa6705d72e5b",
25
17
  "note": "Auth engine intentionally redirects to params[:redirect_uri] when user is not authenticated"
18
+ },
19
+ {
20
+ "fingerprint": "68be03a57d3ef2cfb68582fc78ac2eb6b96aaa0a9897a9a975c24b889fdbb2aa",
21
+ "note": "after_sign_in hook redirect is controlled by the host app configuration, not user input"
22
+ },
23
+ {
24
+ "fingerprint": "277cf277d1c94f46d0abaeba9c51312d1d17e6f62c2e8d457dda47a6aad422aa",
25
+ "note": "after_sign_in hook redirect is controlled by the host app configuration, not user input"
26
26
  }
27
27
  ],
28
- "updated": "2026-02-27",
29
- "brakeman_version": "8.0.2"
28
+ "updated": "2026-03-21",
29
+ "brakeman_version": "8.0.4"
30
30
  }
@@ -21,6 +21,20 @@ StandardConfig.schema.draw do
21
21
  # Callable (lambda/proc) that returns a Hash of extra Sentry user context fields.
22
22
  # Receives (account, session) where session may be nil. Non-callable values are ignored.
23
23
  field :sentry_context, type: :any, default: nil
24
+
25
+ # Post-authentication lifecycle hooks (synchronous, WebEngine only)
26
+ #
27
+ # after_sign_in: Called after successful sign-in, before redirect.
28
+ # Receives: (account, request, context)
29
+ # Context: { first_sign_in: bool, connection: "email"/"password"/"social", provider: nil/"google"/"apple" }
30
+ # Return: nil (default redirect) or a path string (override redirect)
31
+ # Raise StandardId::AuthenticationDenied.new("message") to reject sign-in.
32
+ field :after_sign_in, type: :any, default: nil
33
+
34
+ # after_account_created: Called after a new account is created via any mechanism.
35
+ # Receives: (account, request, context)
36
+ # Context: { mechanism: "passwordless"/"social"/"signup", provider: nil/"google"/"apple" }
37
+ field :after_account_created, type: :any, default: nil
24
38
  end
25
39
 
26
40
  scope :events do
@@ -28,6 +42,8 @@ StandardConfig.schema.draw do
28
42
  end
29
43
 
30
44
  scope :passwordless do
45
+ # Deprecated: use web.passwordless_login to control WebEngine passwordless login.
46
+ # Retained for backwards compatibility with consuming apps that set this field.
31
47
  field :enabled, type: :boolean, default: false
32
48
  field :connection, type: :string, default: "email"
33
49
  field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
@@ -81,4 +97,15 @@ StandardConfig.schema.draw do
81
97
  field :allowed_redirect_url_prefixes, type: :array, default: []
82
98
  field :available_scopes, type: :array, default: -> { [] }
83
99
  end
100
+
101
+ scope :web do
102
+ field :password_login, type: :boolean, default: true
103
+ field :signup, type: :boolean, default: true
104
+ field :passwordless_login, type: :boolean, default: false
105
+ field :social_login, type: :boolean, default: true
106
+ field :password_reset, type: :boolean, default: true
107
+ field :email_verification, type: :boolean, default: true
108
+ field :phone_verification, type: :boolean, default: true
109
+ field :sessions_management, type: :boolean, default: true
110
+ end
84
111
  end
@@ -70,6 +70,9 @@ module StandardId
70
70
  def oauth_error_code = :unsupported_response_type
71
71
  end
72
72
 
73
+ # Lifecycle hook errors
74
+ class AuthenticationDenied < StandardError; end
75
+
73
76
  # Audience verification errors
74
77
  class InvalidAudienceError < StandardError
75
78
  attr_reader :required, :actual
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.7.1"
2
+ VERSION = "0.8.0"
3
3
  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.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -112,12 +112,14 @@ files:
112
112
  - app/controllers/concerns/standard_id/controller_policy.rb
113
113
  - app/controllers/concerns/standard_id/inertia_rendering.rb
114
114
  - app/controllers/concerns/standard_id/inertia_support.rb
115
+ - app/controllers/concerns/standard_id/lifecycle_hooks.rb
115
116
  - app/controllers/concerns/standard_id/passwordless_strategy.rb
116
117
  - app/controllers/concerns/standard_id/sentry_context.rb
117
118
  - app/controllers/concerns/standard_id/set_current_request_details.rb
118
119
  - app/controllers/concerns/standard_id/social_authentication.rb
119
120
  - app/controllers/concerns/standard_id/web/social_login_params.rb
120
121
  - app/controllers/concerns/standard_id/web_authentication.rb
122
+ - app/controllers/concerns/standard_id/web_mechanism_gate.rb
121
123
  - app/controllers/standard_id/api/authorization_controller.rb
122
124
  - app/controllers/standard_id/api/base_controller.rb
123
125
  - app/controllers/standard_id/api/oauth/base_controller.rb
@@ -265,6 +267,7 @@ metadata:
265
267
  homepage_uri: https://github.com/rarebit-one/standard_id
266
268
  source_code_uri: https://github.com/rarebit-one/standard_id
267
269
  changelog_uri: https://github.com/rarebit-one/standard_id/blob/main/CHANGELOG.md
270
+ bug_tracker_uri: https://github.com/rarebit-one/standard_id/issues
268
271
  rdoc_options: []
269
272
  require_paths:
270
273
  - lib