standard_id 0.53.0 → 0.54.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: 1aa1e111cbaf2abc19d4b43e57a7c276054bf0522418c7303f855659cb3c259f
4
- data.tar.gz: 583e0568a53fbf9880c11cd0fbf4e17ae2b98d73b52b2db2725ed5dbe4a5dab0
3
+ metadata.gz: de8ced4a8e48a3b14437ece4082079510d1f7466737be90f3dffe3ac3959d56c
4
+ data.tar.gz: af90eba1873dd457c207ce3151d75215cf5de2e8a5cb37d42ab32a1227fb5017
5
5
  SHA512:
6
- metadata.gz: d5410924e96b90aca074e3a322cfa7e59fc54c5412e41927157b19f03c723355724295a67cc9ddb3dec70ca38e548c1bf8062431a46859c6ab153c6416f3b9e6
7
- data.tar.gz: 3216249e86cd957cc0196518374b2b21073ef03f3f134988f97bedc064ecbbbd9e10a7be88a48606ae7502c9b1dcdecbd8fdd606bea9aa81b361269e2cc9cbbc
6
+ metadata.gz: d5bb7b2e920c26ae28e3b8ddd09ea4c475bb8b0562b89f057adf9573737fc225c43470faf94e202545d3840a4ab2b41e6abc8b17836fd8272a6b17711e47d5f6
7
+ data.tar.gz: b64139dbd6641e978e75d4dec5f89a14891b603755edf6e67e2a566c5ace604d678aaeb4f75a6e924f1da2d04fdd5af3de8b0f005c05c344486aa2796d45b096
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.54.0] - 2026-10-22
11
+
12
+ ### Added
13
+
14
+ - **`PasswordlessFlow#start_passwordless_login(username:, connection:, return_to:)`.**
15
+ Sends the code and stores the signed hand-off the WebEngine's `login_verify` page
16
+ requires, so a host controller (a sign-up or invite form) can start a passwordless
17
+ login and redirect to `standard_id_web.login_verify_path` without copying the
18
+ gem's `message_verifier(:otp)` / `session[:standard_id_otp_payload]` internals.
19
+ Nothing is stored when the code cannot be sent. The gem's own login controllers
20
+ now use the same code. (rarebit-one/rarebit-sre#479)
21
+ - **`c.mailer_parent_class`** (default `"ActionMailer::Base"`). The class
22
+ StandardId's mailers inherit from; set it to `"ApplicationMailer"` so built-in OTP
23
+ and password-reset mail uses the host's layout, helpers, `default_url_options`
24
+ and locale handling. With a host parent the gem no longer sets its placeholder
25
+ `default from:` or `layout "mailer"`, so the parent's apply. A value that is
26
+ not an `ActionMailer::Base` subclass raises `StandardId::ConfigurationError`
27
+ naming the setting.
28
+ (rarebit-one/rarebit-sre#479)
29
+
10
30
  ## [0.53.0] - 2026-10-22
11
31
 
12
32
  ### 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
@@ -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
@@ -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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim