standard_id 0.53.0 → 0.54.1

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: 1aa1e111cbaf2abc19d4b43e57a7c276054bf0522418c7303f855659cb3c259f
4
- data.tar.gz: 583e0568a53fbf9880c11cd0fbf4e17ae2b98d73b52b2db2725ed5dbe4a5dab0
3
+ metadata.gz: '047082209f6ec1b345a5500438fb424ef343c2e72f922b063ed876124b0ad957'
4
+ data.tar.gz: 00f773c75baff568d12c3caa98f56b49c4630da093808cf7a8afb82e5a66807a
5
5
  SHA512:
6
- metadata.gz: d5410924e96b90aca074e3a322cfa7e59fc54c5412e41927157b19f03c723355724295a67cc9ddb3dec70ca38e548c1bf8062431a46859c6ab153c6416f3b9e6
7
- data.tar.gz: 3216249e86cd957cc0196518374b2b21073ef03f3f134988f97bedc064ecbbbd9e10a7be88a48606ae7502c9b1dcdecbd8fdd606bea9aa81b361269e2cc9cbbc
6
+ metadata.gz: 9a2c6bb133b7eca3d39545b666b0d82b6ef03f59374110618fee49643dceaf8c839871761e188a1aa4f1f3e5c0b3dfebc28a3a61d68f4cc12fa051337f33de8c
7
+ data.tar.gz: 6aebcee090d61b6094f25b724e3a89bb73e7424227f2bef24e4b17606e175a081673b6e6c296a31374ab22661f5d7b0f998d9e4585aa0ba0330d2d684537f3ce
data/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.54.1] - 2026-10-22
11
+
12
+ ### Security
13
+
14
+ - **Web email/phone verification now counts attempts.** `Web::VerifyEmail::ConfirmController`
15
+ and `Web::VerifyPhone::ConfirmController` looked the code up with
16
+ `CodeChallenge.active.find_by(code:)`, so the only brake on guessing was the per-IP
17
+ rate limit. `PATCH` now goes through `StandardId::Otp.verify`, which counts failed
18
+ attempts and burns the challenge at `passwordless.max_attempts_per_challenge`.
19
+ `GET` no longer checks the code at all (it answered valid/invalid without counting an
20
+ attempt); a wrong code is reported on submit. Because `Otp.verify` honours
21
+ `passwordless.bypass_code`, that code now also verifies an email or phone through these
22
+ pages where it is allowed (never in production, which raises).
23
+ - **Web password reset revokes refresh tokens.** `Web::ResetPasswordConfirmForm` ran
24
+ `account.sessions.destroy_all`, and `Session has_many :refresh_tokens, dependent:
25
+ :nullify` left every refresh token active and detached. It now revokes all sessions
26
+ (`Session.revoke_all_for!`, reason `"password_reset"`, one `SESSION_REVOKED` each)
27
+ and every remaining refresh token for the account, and publishes the documented
28
+ `credential.password.reset_completed` event, which the gem never emitted before (a
29
+ raising subscriber is logged, not surfaced, since the reset has already happened).
30
+ Sessions are revoked, no longer deleted.
31
+
32
+ ## [0.54.0] - 2026-10-22
33
+
34
+ ### Added
35
+
36
+ - **`PasswordlessFlow#start_passwordless_login(username:, connection:, return_to:)`.**
37
+ Sends the code and stores the signed hand-off the WebEngine's `login_verify` page
38
+ requires, so a host controller (a sign-up or invite form) can start a passwordless
39
+ login and redirect to `standard_id_web.login_verify_path` without copying the
40
+ gem's `message_verifier(:otp)` / `session[:standard_id_otp_payload]` internals.
41
+ Nothing is stored when the code cannot be sent. The gem's own login controllers
42
+ now use the same code. (rarebit-one/rarebit-sre#479)
43
+ - **`c.mailer_parent_class`** (default `"ActionMailer::Base"`). The class
44
+ StandardId's mailers inherit from; set it to `"ApplicationMailer"` so built-in OTP
45
+ and password-reset mail uses the host's layout, helpers, `default_url_options`
46
+ and locale handling. With a host parent the gem no longer sets its placeholder
47
+ `default from:` or `layout "mailer"`, so the parent's apply. A value that is
48
+ not an `ActionMailer::Base` subclass raises `StandardId::ConfigurationError`
49
+ naming the setting.
50
+ (rarebit-one/rarebit-sre#479)
51
+
10
52
  ## [0.53.0] - 2026-10-22
11
53
 
12
54
  ### Added
data/README.md CHANGED
@@ -817,6 +817,14 @@ locale files, or the templates in `app/views/standard_id/passwordless_mailer/`.
817
817
  An assigned `c.passwordless.mailer_subject` still sets the sign-in subject. The subscriber runs synchronously inside the request, so
818
818
  `I18n.locale`, `Current.*` and the like are still available.
819
819
 
820
+ The built-in mailers inherit from `ActionMailer::Base`. Set
821
+ `c.mailer_parent_class = "ApplicationMailer"` to have them inherit your mailer
822
+ base instead, with its layout, helpers, `default_url_options` and locale
823
+ handling. It is read when the gem's mailers load, so set it in an initializer.
824
+ The sender still comes from `c.passwordless.mailer_from` and
825
+ `c.reset_password.mailer_from`, which every gem message passes explicitly, so a
826
+ parent's `default from:` does not apply to them.
827
+
820
828
  Event payload includes:
821
829
  - `channel` - `"email"` or `"sms"`
822
830
  - `identifier` - The email address or phone number
@@ -828,6 +836,31 @@ Event payload includes:
828
836
 
829
837
  > **Note**: `passwordless_email_sender` / `passwordless_sms_sender` were removed in 0.43; see the [Migration Guide](docs/MIGRATION_GUIDE.md).
830
838
 
839
+ ### Starting a passwordless login from your own controller
840
+
841
+ To send the code from your own controller (a sign-up form, an invite page) and
842
+ let the WebEngine's verify page finish the sign-in, include
843
+ `StandardId::PasswordlessFlow` and call `start_passwordless_login`. It sends the
844
+ code and stores the signed hand-off the verify page requires, then you redirect
845
+ there through the engine's mounted helper, which includes the mount path:
846
+
847
+ ```ruby
848
+ class Onboarding::SignupsController < ApplicationController
849
+ include StandardId::PasswordlessFlow
850
+
851
+ def create
852
+ start_passwordless_login(username: params[:email], return_to: welcome_path)
853
+ redirect_to standard_id_web.login_verify_path, status: :see_other
854
+ rescue StandardId::InvalidRequestError => e
855
+ redirect_to new_signup_path, alert: e.message
856
+ end
857
+ end
858
+ ```
859
+
860
+ `connection:` defaults to `c.passwordless.connection`. `return_to:` becomes the
861
+ post-sign-in destination, still subject to the verify page's safe-redirect check.
862
+ Nothing is stored when the code cannot be sent.
863
+
831
864
  ### Using OTP for non-authentication flows
832
865
 
833
866
  The same hardened OTP machinery (enumeration defense, atomic attempt tracking, pessimistic locking, bypass_code hook) is exposed as a public primitive via `StandardId::Otp`. Use it when you need one-time codes for purposes *other* than authentication — contact verification widgets, step-up challenges, custom confirmation flows, etc.
@@ -6,7 +6,8 @@ module StandardId
6
6
  #
7
7
  # Requires the including controller to have access to `request` (standard in
8
8
  # all Rails controllers). No other dependencies are needed -- both
9
- # `generate_passwordless_otp` and `verify_passwordless_otp` only use `request`.
9
+ # `generate_passwordless_otp` and `verify_passwordless_otp` only use `request`;
10
+ # `start_passwordless_login` also writes to `session`.
10
11
  #
11
12
  # @example Usage in a host app controller
12
13
  # class Auth::LoginController < ApplicationController
@@ -29,13 +30,76 @@ module StandardId
29
30
  # end
30
31
  # end
31
32
  # end
33
+ #
34
+ # @example Hand off to the WebEngine's verify page (no custom verify action)
35
+ # class Onboarding::SignupsController < ApplicationController
36
+ # include StandardId::PasswordlessFlow
37
+ #
38
+ # def create
39
+ # start_passwordless_login(username: params[:email], return_to: welcome_path)
40
+ # redirect_to standard_id_web.login_verify_path, status: :see_other
41
+ # rescue StandardId::InvalidRequestError => e
42
+ # redirect_to new_signup_path, alert: e.message
43
+ # end
44
+ # end
32
45
  module PasswordlessFlow
33
46
  extend ActiveSupport::Concern
34
47
 
35
48
  include StandardId::PasswordlessStrategy
36
49
 
50
+ # Session key for the signed hand-off the WebEngine's login_verify page reads.
51
+ OTP_PAYLOAD_SESSION_KEY = :standard_id_otp_payload
52
+
37
53
  private
38
54
 
55
+ # Start a passwordless login that the WebEngine's login_verify page finishes.
56
+ #
57
+ # Sends the code (as `generate_passwordless_otp`), then stores the signed
58
+ # hand-off that login_verify requires, valid for `passwordless.code_ttl`.
59
+ # The caller redirects to that page itself, through the engine's mounted
60
+ # helper so the mount path is included (e.g. `standard_id_web.login_verify_path`).
61
+ #
62
+ # Nothing is stored when sending fails, so the verify page is never reachable
63
+ # for a code that was not sent.
64
+ #
65
+ # @param username [String] the recipient's email address or phone number
66
+ # @param connection [String] the delivery channel, defaults to `passwordless.connection`
67
+ # @param return_to [String, nil] where to send the account after it verifies;
68
+ # the verify page still checks it is a safe destination. Non-String values are ignored.
69
+ # @return [StandardId::CodeChallenge] the created code challenge
70
+ # @raise [StandardId::InvalidRequestError] when the username format is invalid
71
+ # or the connection type is unsupported
72
+ def start_passwordless_login(username:, connection: StandardId.config.passwordless.connection, return_to: nil)
73
+ challenge = generate_passwordless_otp(username: username, connection: connection)
74
+ store_passwordless_handoff(username: username, connection: connection)
75
+ session[:return_to_after_authenticating] = return_to if return_to.is_a?(String) && return_to.present?
76
+ challenge
77
+ end
78
+
79
+ def store_passwordless_handoff(username:, connection:)
80
+ session[OTP_PAYLOAD_SESSION_KEY] = Rails.application.message_verifier(:otp).generate(
81
+ { username: username, connection: connection },
82
+ expires_in: StandardId.config.passwordless.code_ttl.seconds
83
+ )
84
+ end
85
+
86
+ # The stored hand-off as `{ username:, connection: }`, or nil when there is
87
+ # none. A tampered or expired hand-off is deleted and raises
88
+ # ActiveSupport::MessageVerifier::InvalidSignature.
89
+ def read_passwordless_handoff
90
+ signed_payload = session[OTP_PAYLOAD_SESSION_KEY]
91
+ return if signed_payload.blank?
92
+
93
+ Rails.application.message_verifier(:otp).verify(signed_payload).symbolize_keys
94
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
95
+ clear_passwordless_handoff
96
+ raise
97
+ end
98
+
99
+ def clear_passwordless_handoff
100
+ session.delete(OTP_PAYLOAD_SESSION_KEY)
101
+ end
102
+
39
103
  # Generate a passwordless OTP code and send it to the user.
40
104
  #
41
105
  # Delegates to the appropriate strategy (EmailStrategy or SmsStrategy)
@@ -100,25 +100,16 @@ module StandardId
100
100
  end
101
101
 
102
102
  begin
103
- generate_passwordless_otp(username: username, connection: connection)
103
+ # string_param rejects Array/Hash-shaped redirect_uri values (see
104
+ # BaseController#string_param); start_passwordless_login also ignores
105
+ # non-Strings, since login_verify passes the stored value to redirect_to.
106
+ start_passwordless_login(username: username, connection: connection, return_to: string_param(:redirect_uri))
104
107
  rescue StandardId::InvalidRequestError => e
105
108
  flash.now[:alert] = e.message
106
109
  render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
107
110
  return
108
111
  end
109
112
 
110
- code_ttl = StandardId.config.passwordless.code_ttl
111
- signed_payload = Rails.application.message_verifier(:otp).generate(
112
- { username: username, connection: connection },
113
- expires_in: code_ttl.seconds
114
- )
115
- session[:standard_id_otp_payload] = signed_payload
116
- # Use string_param to reject Array/Hash-shaped values — login_verify_controller
117
- # consumes this via after_authentication_url and passes it to redirect_to;
118
- # storing a non-String here would crash that flow with ArgumentError.
119
- redirect_uri = string_param(:redirect_uri)
120
- session[:return_to_after_authenticating] = redirect_uri if redirect_uri
121
-
122
113
  redirect_to engine_path(login_verify_path), status: :see_other
123
114
  end
124
115
 
@@ -75,7 +75,7 @@ module StandardId
75
75
  }
76
76
  redirect_override = invoke_after_sign_in(account, context)
77
77
 
78
- session.delete(:standard_id_otp_payload)
78
+ clear_passwordless_handoff
79
79
 
80
80
  # after_authentication_url returns whatever was stashed in
81
81
  # session[:return_to_after_authenticating] — which could be an attacker-controlled
@@ -85,7 +85,7 @@ module StandardId
85
85
  destination = redirect_override || (safe_destination?(fallback) ? fallback : safe_post_signin_default)
86
86
  redirect_after_authentication destination, notice: "Successfully signed in"
87
87
  rescue StandardId::AuthenticationDenied => e
88
- session.delete(:standard_id_otp_payload)
88
+ clear_passwordless_handoff
89
89
  handle_authentication_denied(e, account: account, newly_created: newly_created)
90
90
  end
91
91
 
@@ -96,19 +96,10 @@ module StandardId
96
96
  end
97
97
 
98
98
  def require_otp_payload!
99
- signed_payload = session[:standard_id_otp_payload]
100
-
101
- if signed_payload.blank?
102
- redirect_to engine_path(login_path), alert: "Please start the login process"
103
- return
104
- end
105
-
106
- begin
107
- @otp_data = Rails.application.message_verifier(:otp).verify(signed_payload).symbolize_keys
108
- rescue ActiveSupport::MessageVerifier::InvalidSignature
109
- session.delete(:standard_id_otp_payload)
110
- redirect_to engine_path(login_path), alert: "Your verification session has expired. Please try again."
111
- end
99
+ @otp_data = read_passwordless_handoff
100
+ redirect_to engine_path(login_path), alert: "Please start the login process" if @otp_data.nil?
101
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
102
+ redirect_to engine_path(login_path), alert: "Your verification session has expired. Please try again."
112
103
  end
113
104
 
114
105
  # Global web.passwordless_registration AND the active scope's
@@ -3,46 +3,42 @@ module StandardId
3
3
  module VerifyEmail
4
4
  class ConfirmController < BaseController
5
5
  # Per-IP limit on code confirmation (20 per 15 minutes, shared with OTP
6
- # verify). The per-challenge attempt cap alone doesn't stop distributed
7
- # guessing across many challenges; both show and update probe a code.
6
+ # verify). Otp.verify's per-challenge attempt cap alone doesn't stop
7
+ # distributed guessing across many challenges.
8
8
  rate_limit to: StandardId.config.rate_limits.otp_verify_per_ip,
9
9
  within: 15.minutes,
10
10
  name: "verify-email-confirm-ip",
11
11
  only: [:show, :update],
12
12
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
13
13
 
14
- before_action :prepare_code_challenge
15
-
16
14
  def show
17
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
15
+ return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if target.blank? || code.blank?
16
+ # Does not check the code: a GET that answered valid/invalid would be
17
+ # a guessing oracle that never counts an attempt. PATCH verifies.
18
18
  render plain: "verify email confirm", status: :ok
19
19
  end
20
20
 
21
21
  def update
22
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
22
+ # Otp.verify locks the challenge, compares in constant time, counts
23
+ # the failed attempt and burns the challenge at the per-challenge cap.
24
+ return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if code.blank?
25
+
26
+ result = StandardId::Otp.verify(realm: "verification", target: target, code: code, channel: :email, request: request)
27
+ return redirect_to(standard_id_web.login_path, alert: result.error) unless result.success?
23
28
 
24
- identifier = StandardId::EmailIdentifier.find_by(value: @challenge.target)
25
- if identifier.present?
26
- identifier.verify!
27
- end
28
- @challenge.use!
29
+ StandardId::EmailIdentifier.find_by(value: target)&.verify!
29
30
 
30
31
  redirect_to standard_id_web.login_path, notice: "Your email has been verified. Please sign in.", status: :see_other
31
32
  end
32
33
 
33
34
  private
34
35
 
35
- def prepare_code_challenge
36
- email = params[:email].to_s.strip.downcase
37
- code = params[:code].to_s
38
- return @challenge = nil if email.blank? || code.blank?
39
-
40
- @challenge = StandardId::CodeChallenge.active.find_by(
41
- realm: "verification",
42
- channel: "email",
43
- target: email,
44
- code: code
45
- )
36
+ def target
37
+ @target ||= params[:email].to_s.strip.downcase
38
+ end
39
+
40
+ def code
41
+ params[:code].to_s
46
42
  end
47
43
  end
48
44
  end
@@ -3,46 +3,42 @@ module StandardId
3
3
  module VerifyPhone
4
4
  class ConfirmController < BaseController
5
5
  # Per-IP limit on code confirmation (20 per 15 minutes, shared with OTP
6
- # verify). The per-challenge attempt cap alone doesn't stop distributed
7
- # guessing across many challenges; both show and update probe a code.
6
+ # verify). Otp.verify's per-challenge attempt cap alone doesn't stop
7
+ # distributed guessing across many challenges.
8
8
  rate_limit to: StandardId.config.rate_limits.otp_verify_per_ip,
9
9
  within: 15.minutes,
10
10
  name: "verify-phone-confirm-ip",
11
11
  only: [:show, :update],
12
12
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
13
13
 
14
- before_action :prepare_code_challenge
15
-
16
14
  def show
17
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
15
+ return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if target.blank? || code.blank?
16
+ # Does not check the code: a GET that answered valid/invalid would be
17
+ # a guessing oracle that never counts an attempt. PATCH verifies.
18
18
  render plain: "verify phone confirm", status: :ok
19
19
  end
20
20
 
21
21
  def update
22
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
22
+ # Otp.verify locks the challenge, compares in constant time, counts
23
+ # the failed attempt and burns the challenge at the per-challenge cap.
24
+ return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if code.blank?
25
+
26
+ result = StandardId::Otp.verify(realm: "verification", target: target, code: code, channel: :sms, request: request)
27
+ return redirect_to(standard_id_web.login_path, alert: result.error) unless result.success?
23
28
 
24
- identifier = StandardId::PhoneNumberIdentifier.find_by(value: @challenge.target)
25
- if identifier.present?
26
- identifier.verify!
27
- end
28
- @challenge.use!
29
+ StandardId::PhoneNumberIdentifier.find_by(value: target)&.verify!
29
30
 
30
31
  redirect_to standard_id_web.login_path, notice: "Your phone number has been verified. Please sign in.", status: :see_other
31
32
  end
32
33
 
33
34
  private
34
35
 
35
- def prepare_code_challenge
36
- phone = params[:phone_number].to_s.strip
37
- code = params[:code].to_s
38
- return @challenge = nil if phone.blank? || code.blank?
39
-
40
- @challenge = StandardId::CodeChallenge.active.find_by(
41
- realm: "verification",
42
- channel: "sms",
43
- target: phone,
44
- code: code
45
- )
36
+ def target
37
+ @target ||= params[:phone_number].to_s.strip
38
+ end
39
+
40
+ def code
41
+ params[:code].to_s
46
42
  end
47
43
  end
48
44
  end
@@ -22,16 +22,57 @@ module StandardId
22
22
  def submit
23
23
  return false unless valid?
24
24
 
25
+ account = @password_credential.account
26
+
27
+ # The password change and the revocation commit together, so a failure
28
+ # cannot leave the new password in place with old tokens still live.
29
+ # The SESSION_REVOKED events revoke_all_for! publishes therefore run
30
+ # before the commit; each is rescued, so a subscriber cannot roll the
31
+ # reset back, but one that reads the rows must do so after commit
32
+ # (e.g. from a job, which Rails enqueues after commit).
25
33
  ActiveRecord::Base.transaction do
26
34
  @password_credential.update!(password: password, password_confirmation: password_confirmation)
27
- @password_credential.account.sessions.destroy_all
35
+ revoke_access!(account)
28
36
  end
29
37
 
38
+ publish_reset_completed(account)
39
+
30
40
  true
31
41
  rescue ActiveRecord::RecordInvalid => e
32
42
  errors.add(:base, e.record.errors.full_messages.join(", "))
33
43
  false
34
44
  end
45
+
46
+ private
47
+
48
+ # The reset has already happened, so a failing subscriber must not turn
49
+ # it into an error page; log and carry on, as Session does for
50
+ # SESSION_REVOKED.
51
+ def publish_reset_completed(account)
52
+ StandardId::Events.publish(
53
+ StandardId::Events::CREDENTIAL_PASSWORD_RESET_COMPLETED,
54
+ credential: @password_credential,
55
+ account: account
56
+ )
57
+ rescue StandardError => e
58
+ logger = StandardId.logger
59
+ return unless logger.respond_to?(:error)
60
+
61
+ logger.error(
62
+ "[StandardId::Web::ResetPasswordConfirmForm] Failed to publish " \
63
+ "CREDENTIAL_PASSWORD_RESET_COMPLETED for account #{account.id}: #{e.class}: #{e.message}"
64
+ )
65
+ end
66
+
67
+ # Revoke every session (and the refresh tokens under them), then any
68
+ # refresh token with no session (e.g. one minted by a grant flow). Revoking
69
+ # rather than destroying keeps the audit trail and fires SESSION_REVOKED;
70
+ # sessions.destroy_all used to nullify refresh_tokens.session_id
71
+ # (dependent: :nullify), leaving those tokens active after the reset.
72
+ def revoke_access!(account)
73
+ StandardId::Session.revoke_all_for!(account, reason: "password_reset")
74
+ StandardId::RefreshToken.where(account_id: account.id, revoked_at: nil).update_all(revoked_at: Time.current)
75
+ end
35
76
  end
36
77
  end
37
78
  end
@@ -1,11 +1,18 @@
1
1
  module StandardId
2
- class ApplicationMailer < ActionMailer::Base
2
+ # Inherits from `StandardId.config.mailer_parent_class` (ActionMailer::Base by
3
+ # default). The class is resolved when this file loads, which is after the
4
+ # host's initializers have run.
5
+ class ApplicationMailer < StandardId.mailer_parent_class
3
6
  # Use a delivery job that doesn't log its arguments — the gem's mailers pass
4
7
  # sensitive params (OTP code, password-reset token) that ActiveJob would
5
8
  # otherwise print in plaintext. Applies to all StandardId mailers.
6
9
  self.delivery_job = StandardId::SecureMailDeliveryJob
7
10
 
8
- default from: "from@example.com"
9
- layout "mailer"
11
+ # A host parent brings its own defaults and layout; keep the gem's only on
12
+ # the bare ActionMailer::Base.
13
+ if superclass == ActionMailer::Base
14
+ default from: "from@example.com"
15
+ layout "mailer"
16
+ end
10
17
  end
11
18
  end
@@ -176,7 +176,9 @@ module StandardId
176
176
  )
177
177
  end
178
178
 
179
- # DB state is already committed by the time this runs; event publishing is
179
+ # DB state is already committed by the time this runs (unless the caller
180
+ # wraps revoke_all_for! in its own transaction, as Web::ResetPasswordConfirmForm
181
+ # does); event publishing is
180
182
  # best-effort audit emission. A failing subscriber must not short-circuit the
181
183
  # loop and leave later sessions without their SESSION_REVOKED event, which
182
184
  # would permanently desync audit-trail consumers from the DB.
@@ -108,6 +108,11 @@ StandardId.configure do |c|
108
108
  # Default: nil (StandardId's packaged layout)
109
109
  # c.web_layout = "application"
110
110
 
111
+ # The class StandardId's mailers inherit from. Set it to your own mailer base
112
+ # so OTP and password-reset mail picks up your layout, helpers and locale.
113
+ # Default: "ActionMailer::Base"
114
+ # c.mailer_parent_class = "ApplicationMailer"
115
+
111
116
  # Expose current_account as current_user (useful for ActionPolicy / Pundit).
112
117
  # Default: false
113
118
  # c.alias_current_user = true
@@ -9,6 +9,11 @@ StandardId::ConfigSchema.define do
9
9
  field :cache_store, type: :any, default: nil
10
10
  field :logger, type: :any, default: nil
11
11
  field :web_layout, type: :string, default: nil
12
+ # The class StandardId's mailers inherit from, e.g. "ApplicationMailer" so
13
+ # they pick up the host's layout, helpers, default_url_options and locale
14
+ # handling. Read once, when the gem's mailers load, so set it in an
15
+ # initializer.
16
+ field :mailer_parent_class, type: :string, default: "ActionMailer::Base"
12
17
  # Removed in 0.43 (deprecated since 0.1.7): assigning either raises
13
18
  # StandardId::ConfigurationError with this hint.
14
19
  removed :passwordless_email_sender,
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.53.0"
2
+ VERSION = "0.54.1"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -116,6 +116,21 @@ module StandardId
116
116
  config.account_class_name.constantize
117
117
  end
118
118
 
119
+ # The class StandardId::ApplicationMailer inherits from
120
+ # (`c.mailer_parent_class`); it must be an ActionMailer::Base subclass,
121
+ # anything else raises ConfigurationError. safe_constantize returns nil only when the
122
+ # name itself is undefined; an error raised while loading that class (a
123
+ # typo inside the host's mailer) still propagates as itself.
124
+ def mailer_parent_class
125
+ name = config.mailer_parent_class
126
+ parent = name.to_s.safe_constantize
127
+ return parent if parent.is_a?(Class) && parent <= ActionMailer::Base
128
+
129
+ raise ConfigurationError,
130
+ "c.mailer_parent_class = #{name.inspect} does not name an ActionMailer::Base subclass. " \
131
+ "Set it to your mailer base, e.g. \"ApplicationMailer\", or leave it unset for ActionMailer::Base."
132
+ end
133
+
119
134
  def scope_for(name)
120
135
  return nil if config.scopes.blank? || name.blank?
121
136
  scope_hash = config.scopes[name.to_sym]
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.53.0
4
+ version: 0.54.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim