standard_id 0.52.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: d91a49478fd5f477e7a20395c4c2f5b142b3bd813e96145af349143935a96482
4
- data.tar.gz: 00d708402823a344e451032db69e6097c62ecc77d80ce66d4d1d68b24fed478a
3
+ metadata.gz: de8ced4a8e48a3b14437ece4082079510d1f7466737be90f3dffe3ac3959d56c
4
+ data.tar.gz: af90eba1873dd457c207ce3151d75215cf5de2e8a5cb37d42ab32a1227fb5017
5
5
  SHA512:
6
- metadata.gz: 101b91f61aa5d72e333322c21c0fbe160105a9adda2bc851046ea625400e4fcda61cbc6131fdcefd8f6c7927f54f1308834be383e2d4fec1d83110276a3b9087
7
- data.tar.gz: 853ef8ee5ea04b8feb98c1343025b692c8c41d9a597c3e2ae82bf31c32e16f03d810375849d0d04e5412ff96966643bdaf8529fdddeb3f30cca7f73d32edb282
6
+ metadata.gz: d5bb7b2e920c26ae28e3b8ddd09ea4c475bb8b0562b89f057adf9573737fc225c43470faf94e202545d3840a4ab2b41e6abc8b17836fd8272a6b17711e47d5f6
7
+ data.tar.gz: b64139dbd6641e978e75d4dec5f89a14891b603755edf6e67e2a566c5ace604d678aaeb4f75a6e924f1da2d04fdd5af3de8b0f005c05c344486aa2796d45b096
data/CHANGELOG.md CHANGED
@@ -7,6 +7,62 @@ 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
+
30
+ ## [0.53.0] - 2026-10-22
31
+
32
+ ### Added
33
+
34
+ - **`StandardId::Testing::RequestHelpers#sign_in_as(account, scope: nil)`.** Signs an
35
+ account in for the following requests in a request spec: it creates a real
36
+ `BrowserSession` and writes the encrypted `session_token` cookie a real sign-in
37
+ writes, built with the host's own cookie jar, so no test-only route is added to
38
+ the app. `scope:` also writes `standard_id_scopes` into the Rails session cookie
39
+ (cookie session store only; any other store raises `ArgumentError`, before
40
+ anything is created). Like a real sign-in it always replaces the Rails session
41
+ cookie, so a later `sign_in_as` never inherits an earlier one's scopes or any
42
+ other session data (flash, CSRF token). Replaces per-app controller
43
+ stubs and `/test/set-session` routes.
44
+ - **`RequestHelpers#bearer_headers_for(account, **jwt_options)`:** `build_jwt` plus
45
+ `bearer_auth_header` in one call.
46
+
47
+ ### Changed
48
+
49
+ - **"Production" follows `APP_ENVIRONMENT`, failing closed.** The new
50
+ `StandardId::AppEnvironment.production?` decides every production check in the
51
+ gem: the bypass-code guard (when `passwordless.production_env_detector` is
52
+ unset), the provider-misconfiguration `:raise` mode, and the empty
53
+ `allowed_audiences` boot warning. It reads `APP_ENVIRONMENT` when set (only
54
+ `staging`, `preview`, `development` and `test` are non-production; any other
55
+ value is production), else `Rails.env.production?`. A staging box with
56
+ `RAILS_ENV=production` and `APP_ENVIRONMENT=staging` is therefore no longer
57
+ treated as production, and `-> { AppEnv.production? }` detectors can be
58
+ deleted. Same convention as standard_audit, standard_health and
59
+ standard_singpass.
60
+ - **`rails g standard_id:install` writes a minimal initializer.** It holds only
61
+ `account_class_name`, plus commented `issuer` and `c.preset :inertia_web` lines,
62
+ instead of several hundred lines of commented defaults that apps then kept
63
+ verbatim. `--with-reference` writes
64
+ the annotated initializer as before. Existing initializers are not touched.
65
+
10
66
  ## [0.52.0] - 2026-10-22
11
67
 
12
68
  ### Added
@@ -14,10 +70,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
70
  - **`c.preset :inertia_web`.** Applies the settings an Inertia app with
15
71
  passwordless or social sign-in flips: `use_inertia` and `alias_current_user`
16
72
  true; `web.signup`, `web.password_login` and `web.password_reset` false;
17
- `oauth.discovery_endpoint_base = :request`. Four of the five estate apps set
18
- exactly this by hand. A preset fills only settings the host has not assigned
19
- (via `Scope#assigned?`), so explicit assignments win in either order; an
20
- unknown name raises `ArgumentError`. The public defaults are unchanged.
73
+ `oauth.discovery_endpoint_base = :request`. Each setting is hand-set in four
74
+ of the five estate apps, though the full bundle matches only two of them. A
75
+ preset fills only settings the host has not assigned (via
76
+ `Scope#assigned?`), so explicit assignments win in either order; an unknown
77
+ name raises `ArgumentError`. The public defaults are unchanged.
21
78
  Presets live in `StandardId::Config::Presets`.
22
79
 
23
80
  ## [0.51.2] - 2026-10-21
data/README.md CHANGED
@@ -63,6 +63,13 @@ $ bundle install
63
63
  rails generate standard_id:install
64
64
  ```
65
65
 
66
+ This writes a short `config/initializers/standard_id.rb` holding only what an
67
+ app must decide (`account_class_name`, plus commented `issuer` and
68
+ `c.preset :inertia_web` lines). Add a setting only when you change it from the
69
+ default. `--with-reference` writes the annotated initializer instead, with every
70
+ commonly changed setting and its default commented out; it is a reference to
71
+ read, not a file to keep whole.
72
+
66
73
  ### 2. Configure Your Account Model
67
74
 
68
75
  ```ruby
@@ -810,6 +817,14 @@ locale files, or the templates in `app/views/standard_id/passwordless_mailer/`.
810
817
  An assigned `c.passwordless.mailer_subject` still sets the sign-in subject. The subscriber runs synchronously inside the request, so
811
818
  `I18n.locale`, `Current.*` and the like are still available.
812
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
+
813
828
  Event payload includes:
814
829
  - `channel` - `"email"` or `"sms"`
815
830
  - `identifier` - The email address or phone number
@@ -821,6 +836,31 @@ Event payload includes:
821
836
 
822
837
  > **Note**: `passwordless_email_sender` / `passwordless_sms_sender` were removed in 0.43; see the [Migration Guide](docs/MIGRATION_GUIDE.md).
823
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
+
824
864
  ### Using OTP for non-authentication flows
825
865
 
826
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.
@@ -869,15 +909,19 @@ end
869
909
 
870
910
  **Bypass code (E2E testing).** When `StandardId.config.passwordless.bypass_code` is set (and the deploy is not production), `Otp.verify` accepts the bypass code for **any realm**. This replaces per-app bypass ENV checks and works consistently across `Otp.verify` and the built-in passwordless login flow. Never set `bypass_code` in production — it will raise if you try.
871
911
 
872
- By default "production" means `Rails.env.production?`. Apps that distinguish a physical deploy environment from `RAILS_ENV` (e.g. a staging box that still runs `RAILS_ENV=production`) can override the decision with a callable:
912
+ "Production" is decided by `StandardId::AppEnvironment`, the same rule every
913
+ check in the gem uses: `APP_ENVIRONMENT` when it is set, where only `staging`,
914
+ `preview`, `development` and `test` count as non-production (any other value,
915
+ including a typo, is production), else `Rails.env.production?`. So a staging box
916
+ running `RAILS_ENV=production` with `APP_ENVIRONMENT=staging` is not
917
+ production, and needs no extra wiring:
873
918
 
874
919
  ```ruby
875
- # Allow the bypass code on a physically-staging box, still refused on real prod.
876
- c.passwordless.production_env_detector = -> { AppEnv.production? }
877
- c.passwordless.bypass_code = ENV["STANDARD_ID_BYPASS_CODE"] unless AppEnv.production?
920
+ c.passwordless.bypass_code = ENV["STANDARD_ID_BYPASS_CODE"] unless StandardId::AppEnvironment.production?
878
921
  ```
879
922
 
880
- `production_env_detector` takes no arguments and returns a boolean; when `nil` (the default) the gem falls back to `Rails.env.production?`, so existing consumers are unaffected.
923
+ `production_env_detector` (a callable taking no arguments, returning a boolean)
924
+ still overrides that, for an app with a different discriminator.
881
925
 
882
926
  **Back-compat.** The existing passwordless authentication flow continues to work unchanged. `Otp.issue`/`Otp.verify` are a new addition — you can migrate direct `CodeChallenge.create!` calls at your own pace.
883
927
 
@@ -1943,6 +1987,38 @@ Notes:
1943
1987
 
1944
1988
  ## Testing
1945
1989
 
1990
+ ### Signing in from your app's request specs
1991
+
1992
+ ```ruby
1993
+ # spec/rails_helper.rb
1994
+ require "standard_id/testing"
1995
+
1996
+ RSpec.configure do |config|
1997
+ config.include StandardId::Testing::RequestHelpers, type: :request
1998
+ end
1999
+ ```
2000
+
2001
+ ```ruby
2002
+ sign_in_as(account) # a real BrowserSession, via the session_token cookie
2003
+ sign_in_as(account, scope: :platform) # also grants standard_id_scopes (current_scope_names)
2004
+ get "/dashboard"
2005
+
2006
+ get "/api/orders", headers: bearer_headers_for(account, scope: "orders.read")
2007
+ ```
2008
+
2009
+ `sign_in_as` creates a real `BrowserSession` and writes the same encrypted
2010
+ `session_token` cookie a real sign-in writes, so `current_account`,
2011
+ `require_browser_session!`, expiry and revocation behave exactly as in
2012
+ production. It adds nothing to your app: the cookies are built with your app's
2013
+ own encrypted cookie jar, so there is no test-only route. `scope:` writes the
2014
+ Rails session cookie and so needs the cookie session store (the Rails default);
2015
+ with another store it raises `ArgumentError`, and you sign in through your own
2016
+ scoped login request instead. It replaces controller stubbing
2017
+ (`stub_web_authentication`, `allow_any_instance_of(...).to receive(:current_user)`)
2018
+ and per-app `/test/set-session` routes.
2019
+
2020
+ ### This gem's own suite
2021
+
1946
2022
  StandardId includes comprehensive test coverage:
1947
2023
 
1948
2024
  ```bash
@@ -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
@@ -28,6 +28,8 @@ module StandardId
28
28
 
29
29
  class_option :skip_initializer, type: :boolean, default: false,
30
30
  desc: "Do not write config/initializers/standard_id.rb"
31
+ class_option :with_reference, type: :boolean, default: false,
32
+ desc: "Write the annotated initializer (every common setting with its default) instead of the minimal one"
31
33
  class_option :skip_routes, type: :boolean, default: false,
32
34
  desc: "Do not append engine mount lines to config/routes.rb"
33
35
  class_option :skip_migrations, type: :boolean, default: false,
@@ -51,7 +53,8 @@ module StandardId
51
53
  def create_initializer_file
52
54
  return say_status("skip", "config/initializers/standard_id.rb (--skip-initializer)", :yellow) if options[:skip_initializer]
53
55
 
54
- template "standard_id.rb", "config/initializers/standard_id.rb"
56
+ source = options[:with_reference] ? "standard_id_reference.rb" : "standard_id.rb"
57
+ template source, "config/initializers/standard_id.rb"
55
58
  end
56
59
 
57
60
  def mount_engines