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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +33 -0
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +65 -1
- data/app/controllers/standard_id/web/login_controller.rb +4 -13
- data/app/controllers/standard_id/web/login_verify_controller.rb +6 -15
- data/app/mailers/standard_id/application_mailer.rb +10 -3
- data/lib/generators/standard_id/install/templates/standard_id_reference.rb +5 -0
- data/lib/standard_id/config/schema.rb +5 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +15 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de8ced4a8e48a3b14437ece4082079510d1f7466737be90f3dffe3ac3959d56c
|
|
4
|
+
data.tar.gz: af90eba1873dd457c207ce3151d75215cf5de2e8a5cb37d42ab32a1227fb5017
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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,
|
data/lib/standard_id/version.rb
CHANGED
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]
|