standard_id 0.55.1 → 0.56.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: 79b115262b448690a7d1429716da8b36e6422be79e9406868684a3744e0cf3e8
4
- data.tar.gz: a43dc5ed9ea2ff5bd5317809f79c9326d1df14a2115f63e631090a7fe15042cd
3
+ metadata.gz: 076ceacf137b604ea48f42f6955f7eb9786656cab0112adc5967f06d5b6a178b
4
+ data.tar.gz: cb179e91df123b9d5c996626a6af219b170faa1e458c6d185dc6ad597f32c044
5
5
  SHA512:
6
- metadata.gz: a184a92b4a8f865db6e4fd4f05cbaa9e44d1156407ed61d30530ee6379269cc9cc3da0d9f64b800c7fc44461abc28d2df44ebe2d4562a6dbde96d0f69a106ec7
7
- data.tar.gz: abc3fe5d187c94408406cfeba9e27a699f5542b30a1979c268c7b5fe7d2986ea29b56dcb6d4b8399b8e0df3a7ed56271951a49eec9a25076d5cd568a1a30b9c5
6
+ metadata.gz: e8f41357e84dbe80520836d321dd3f65e1b81329fe50c34e5c005975612fb77a1bf3d919eb87289c6ddf3ba29f95a22bc84ac45c7d1c9815411ae3efcb6161ae
7
+ data.tar.gz: 7c9637e8e58f301576d8d5564843b19ae2d58599aa94427cf8ffd3985385badbdb5a9b2b2fe9052fa4ef3a55989b59c1362e5a95e39d933c1cd444062830f0c7
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.56.1] - 2026-10-27
11
+
12
+ ### Fixed
13
+
14
+ - **`credential.password.created` from the API email + password sign-up now carries a usable
15
+ credential chain.** `EmailPassword::Registration` created the `Credential` row with
16
+ `Credential.create!`, but `PasswordCredential`'s `has_one :credential, touch: true` had already
17
+ read (and cached) the association as `nil` in `after_create`. A subscriber walking
18
+ `event[:credential].credential.identifier.account` got `nil`; it now gets the account, with the
19
+ whole chain loaded in memory, so strict-loading hosts never load it lazily.
20
+ `Web::SignupForm` was unaffected (it builds the chain with nested attributes); it now has the
21
+ same regression spec.
22
+
23
+ ## [0.56.0] - 2026-10-26
24
+
25
+ ### Added
26
+
27
+ - **`c.events.log_login_identifiers` (default `false`).** `LoggingSubscriber` now redacts the submitted
28
+ email/login in `AUTHENTICATION_ATTEMPT_STARTED` (debug) and `AUTHENTICATION_FAILED` (warn) log lines by
29
+ default: email-shaped values are logged as `lo***@domain`; everything else as `lo***`. Set
30
+ `c.events.log_login_identifiers = true` to restore the previous behaviour (full value logged) for apps
31
+ that need it for debugging.
32
+
33
+ - **Boot-time warning when `verify_issuer: false` is combined with a configured `issuer`.** The existing
34
+ warning fires when `issuer` is blank; this companion fires when `issuer` is set but `verify_issuer` is
35
+ explicitly `false`, reminding operators it is a migration flag to remove once all in-flight tokens have
36
+ expired.
37
+
38
+ ### Fixed
39
+
40
+ - **Inertia history is now cleared on sign-out** for hosts that set `use_inertia = true`.
41
+ `Web::LogoutController#create` and the current-session branch of
42
+ `Web::SessionsController#destroy` now pass `inertia: { clear_history: true }` on
43
+ the redirect, which stores a session flag that the next Inertia render consumes
44
+ (`clearHistory: true` in the page JSON). Without this, the old history-encryption
45
+ key remained in `sessionStorage` and `Back` could still decrypt pages viewed while
46
+ signed in. Hosts without `use_inertia` are unaffected.
47
+
48
+ Rolling this out requires a release followed by a `/rollout-gem` to all five
49
+ consumers (fundbright-web, nutripod-web, jumpdrive-web, luminality-web,
50
+ sidekick-web).
51
+
10
52
  ## [0.55.1] - 2026-10-23
11
53
 
12
54
  ### Fixed
@@ -14,7 +14,8 @@ module StandardId
14
14
 
15
15
  def create
16
16
  revoke_current_session!
17
- redirect_to logout_destination, notice: "Successfully signed out"
17
+ redirect_to logout_destination, notice: "Successfully signed out",
18
+ **(use_inertia? ? { inertia: { clear_history: true } } : {})
18
19
  end
19
20
 
20
21
  private
@@ -15,7 +15,8 @@ module StandardId
15
15
  if session == current_session
16
16
  # If revoking current session, sign out and redirect
17
17
  revoke_current_session!
18
- redirect_to "/", notice: "Session revoked. You have been signed out."
18
+ redirect_to "/", notice: "Session revoked. You have been signed out.",
19
+ **(use_inertia? ? { inertia: { clear_history: true } } : {})
19
20
  else
20
21
  # Revoke other session
21
22
  session.revoke!
@@ -153,6 +153,20 @@ StandardId::ConfigSchema.define do
153
153
  scope :events do
154
154
  field :enable_logging, type: :boolean, default: false
155
155
 
156
+ # Whether to log the full login identifier (email / username) in
157
+ # `AUTHENTICATION_ATTEMPT_STARTED` and `AUTHENTICATION_FAILED` log lines.
158
+ #
159
+ # Defaults to `false`: a redacted form is logged instead
160
+ # (`local-part[0..1]***@domain` for email-shaped values, first two
161
+ # characters + *** for everything else). This prevents email addresses from
162
+ # accumulating in log sinks (Sentry, Datadog, etc.) on every failed login
163
+ # attempt, including attacker probes.
164
+ #
165
+ # Set to `true` only for apps that have already accepted the privacy
166
+ # trade-off and need the full value for debugging — typically behind an
167
+ # environment flag that is off in production.
168
+ field :log_login_identifiers, type: :boolean, default: false
169
+
156
170
  # Publish `<key>_id` / `<key>_gid` alongside every record-valued payload key
157
171
  # (rarebit-one/rarebit-ops#296).
158
172
  #
@@ -105,7 +105,13 @@ module StandardId
105
105
  account: account, value: email, signup_source: EmailPassword::SIGNUP_SOURCE
106
106
  )
107
107
  password_credential = StandardId::PasswordCredential.create!(login: email, password: password)
108
- StandardId::Credential.create!(identifier: identifier, credentialable: password_credential)
108
+ # Created through the association, not Credential.create!: the
109
+ # has_one's touch: true reads password_credential.credential in
110
+ # after_create, caching nil, so a subscriber walking
111
+ # credential.identifier.account would get nil. Going through the
112
+ # association leaves the whole chain loaded in memory, which also
113
+ # keeps strict-loading hosts from lazily loading identifier/account.
114
+ password_credential.create_credential!(identifier: identifier)
109
115
 
110
116
  StandardId::Events.publish(
111
117
  StandardId::Events::ACCOUNT_CREATED,
@@ -81,10 +81,7 @@ module StandardId
81
81
  StandardId::Events::Subscribers::PasswordlessDeliverySubscriber.attach
82
82
  StandardId::Events::Subscribers::PasswordResetDeliverySubscriber.attach
83
83
 
84
- if StandardId.config.issuer.blank?
85
- Rails.logger.warn("[StandardId] No issuer configured. JWT tokens will not include or verify the 'iss' claim. " \
86
- "Set StandardId.config.issuer in your initializer for production use.")
87
- end
84
+ StandardId::Engine.warn_about_issuer_config!
88
85
 
89
86
  # Fail loudly if `association_strict_loading` was assigned after the
90
87
  # host's Account class body already ran — that assignment has no effect,
@@ -175,5 +172,21 @@ module StandardId
175
172
  "audiences (e.g., ['web', 'api']) to close cross-audience replay vectors."
176
173
  )
177
174
  end
175
+
176
+ # Warn when the issuer configuration is missing or when `verify_issuer`
177
+ # is explicitly `false` with an issuer set (migration flag left behind).
178
+ # Extracted to a module method so specs can exercise it without booting
179
+ # a second Rails app.
180
+ def self.warn_about_issuer_config!
181
+ if StandardId.config.issuer.blank?
182
+ Rails.logger.warn("[StandardId] No issuer configured. JWT tokens will not include or verify the 'iss' claim. " \
183
+ "Set StandardId.config.issuer in your initializer for production use.")
184
+ elsif StandardId.config.verify_issuer == false
185
+ Rails.logger.warn("[StandardId] verify_issuer is explicitly false but an issuer is configured " \
186
+ "(#{StandardId.config.issuer.inspect}). Tokens will include the 'iss' claim but " \
187
+ "incoming tokens are NOT required to carry it. This is a migration flag — remove " \
188
+ "verify_issuer: false from your initializer once all in-flight tokens have expired.")
189
+ end
190
+ end
178
191
  end
179
192
  end
@@ -110,7 +110,10 @@ module StandardId
110
110
  payload[:account_id] = event[:account_id]
111
111
  end
112
112
 
113
- payload[:login] = event[:account_lookup] || event[:login] || event[:username] if event[:account_lookup] || event[:login] || event[:username]
113
+ raw_login = event[:account_lookup] || event[:login] || event[:username]
114
+ if raw_login
115
+ payload[:login] = log_login_identifiers? ? raw_login : redact_login(raw_login)
116
+ end
114
117
  payload[:auth_method] = event[:auth_method] if event[:auth_method]
115
118
  payload[:grant_type] = event[:grant_type] if event[:grant_type]
116
119
  payload[:provider] = event[:provider] if event[:provider]
@@ -120,6 +123,40 @@ module StandardId
120
123
 
121
124
  payload
122
125
  end
126
+
127
+ def log_login_identifiers?
128
+ config = StandardId.config
129
+ return false unless config.respond_to?(:events)
130
+
131
+ config.events.log_login_identifiers
132
+ end
133
+
134
+ # Redact a login identifier for logging.
135
+ #
136
+ # Email-shaped values (`local@domain`): keep the first two characters of
137
+ # the local part, mask the rest as `***`, keep the domain verbatim.
138
+ # Everything else: keep the first two characters, mask the rest as `***`.
139
+ #
140
+ # The local part's length is not exposed (all masks are `***`), and the
141
+ # domain is kept so operators can still tell at a glance which service
142
+ # the attempt targeted (e.g. gmail.com vs corporate SSO).
143
+ def redact_login(value)
144
+ str = value.to_s
145
+ # Split on the FIRST '@' only (limit 2) to detect email shape without
146
+ # a regex. A regex such as /\A(.+)@(.+)\z/ is polynomial on strings
147
+ # like "a@a@a@…" because the two greedy `.+` quantifiers can match the
148
+ # same characters in exponentially many ways — a ReDoS vector on
149
+ # attacker-controlled login values. String#split is linear.
150
+ parts = str.split("@", 2)
151
+ if parts.length == 2
152
+ local, domain = parts
153
+ prefix = local.length > 2 ? local[0, 2] : local[0, 1]
154
+ "#{prefix}***@#{domain}"
155
+ else
156
+ prefix = str.length > 2 ? str[0, 2] : str[0, 1]
157
+ "#{prefix}***"
158
+ end
159
+ end
123
160
  end
124
161
  end
125
162
  end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.55.1"
2
+ VERSION = "0.56.1"
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.55.1
4
+ version: 0.56.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim