standard_id 0.43.0 → 0.43.2

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: 8f9798d4a8c4db5cbc69cc0c4f569cc41e825b5df159124fab15f39923ca602f
4
- data.tar.gz: 1b06dc3eee0795f25633df9dc93dae5376c0bc5f6e1b0cd97ef3ce37fca6fc02
3
+ metadata.gz: 4bb59a29db3ed35482b44a1a0755a3827d7e02a6ad3a78bad5e5bd22e309d048
4
+ data.tar.gz: 93491ec90c91e2fdc6cf900bc62f9475ee786c723f4591dc40446964fbac1492
5
5
  SHA512:
6
- metadata.gz: 10594cb5f3206999becca9fb582bfcc714f8371713160192ddf72fdeb39fe4f9552e9d551991fcf294af2eca71fcd8266f8c1f0a27e94c069a7f597581a54d8e
7
- data.tar.gz: 1ababa07f26cea7f6240666372f25ada38a7c3672313306c1ce7eddedded43efaff08b27df71d50c1b78f75e75d6f909f421236c455d1ac9c1658015752bdc4c
6
+ metadata.gz: 0daceb3e839301ec3b294b8aa36af72441ec5b52ea82bcdcb7066fbc1bfef97cb5efaa11d62f729995c4a7ad22b22afd946e3ed1ff4999fa694a3dc2446b8a50
7
+ data.tar.gz: ab971a917f3ea552ca3c54d6928a88086bfef17066131cb727a12df721d6bfd2988fb348a33ded3fd529d09102ea053b904ff5146129e9b5008e10e64d7b2f15
data/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.43.2] - 2026-09-25
11
+
12
+ ### Fixed
13
+
14
+ - **`CleanupExpiredSessionsJob` failed on every run once an expired session had a refresh token** (sidekick-web SIDEKICK-WEB-3K / cron monitor SIDEKICK-WEB-3M: `PG::ForeignKeyViolation … violates foreign key constraint "fk_rails_db44ba6f6e" on table "standard_id_refresh_tokens"`). `standard_id_refresh_tokens.session_id` references `standard_id_sessions` with no `ON DELETE` action, and `Session.where(...).delete_all` skipped the model's `dependent: :nullify`. This affected every host that schedules the job, directly or through `CleanupAllJob`, and nothing was ever cleaned up. Now, per batch of 1,000 (`batch_size:`), in one transaction, with the candidate rows re-checked under `FOR UPDATE SKIP LOCKED`:
15
+ - **An expired session that still has a live refresh token (unrevoked, unexpired) is kept.** Refresh tokens deliberately outlive their session's expiry: `RefreshTokenFlow#validate_parent_session!` checks revocation, not expiry, since 0.35.x, so `refresh_token_lifetime` alone decides how long a client stays signed in (jumpdrive-web runs 1-day browser sessions against 30-day refresh tokens). Deleting such a session would detach its token and lose "revoking this session ends its access". Revoking the token, as `Session#destroy` does, would sign the client out early. The session is collected on a later run, once its tokens are dead, so at most `refresh_token_lifetime` after it would otherwise have gone.
16
+ - **Dead refresh tokens (revoked or expired) of the sessions being deleted are detached** (`session_id` → `NULL`, as `dependent: :nullify` does) rather than deleted. `CleanupExpiredRefreshTokensJob` removes them on its own window, and until then a replayed revoked token still triggers reuse detection.
17
+ - A host whose copy of the migration added `on_delete: :nullify` to that FK (sidekick-web's `db/schema.rb` shows one, although its production constraint evidently has none) no longer has live tokens silently detached from their sessions either.
18
+ - The other cleanup jobs had no such hazard. Nothing references `standard_id_authorization_codes` or `standard_id_code_challenges`, and the only foreign key into `standard_id_refresh_tokens` is its own `previous_token_id`, which is `ON DELETE SET NULL`. `standard_id_refresh_tokens.session_id` is the only foreign key into `standard_id_sessions`, in the gem, standard_id-provider and all five apps.
19
+
20
+ ### Changed
21
+
22
+ - **Requires Rails 8.1** (`rails >= 8.1`, was `>= 8.0`; #344, merged since 0.43.1). All five consumer apps already run Rails 8.1.3.1, and 8.0 was never tested in CI.
23
+
24
+ ### Upgrade notes
25
+
26
+ Bump only. The first run after upgrading deletes, in batches, the backlog of expired sessions the job never managed to remove.
27
+
28
+ ## [0.43.1] - 2026-09-24
29
+
30
+ Fixes found while shipping 0.43.0. **Take this instead of 0.43.0** — hosts on `c.passwordless.delivery = :built_in` with the WebEngine mounted (jumpdrive-web, nutripod-web) would otherwise email email-verification codes as "your sign-in code".
31
+
32
+ ### Fixed
33
+
34
+ - **Verification codes no longer use the sign-in email.** The built-in `PasswordlessDeliverySubscriber` sent `PasswordlessMailer#otp_email` ("Your sign-in code" / "Use the following code to sign in") for every realm, which 0.43.0 made reachable from WebEngine `verify_email/start` (realm `"verification"`). Only the `"authentication"` realm gets `otp_email` now. Every other realm — `verify_email`, `Otp.issue` contact verification, step-up — gets the new **`PasswordlessMailer#verification_email`** ("Your verification code" / "Use the following code to complete your verification"). Both emails state the challenge's actual expiry (the new `expires_in_minutes` param) instead of assuming `code_ttl`. There is still no built-in SMS delivery.
35
+ - **The WebEngine `verify_email` / `verify_phone` start 422 now gives the real reason.** The response body used to be a fixed `"invalid email"` / `"invalid phone"`; it now matches the flash: the format error, the host `username_validator`'s message, or the retry cooldown ("Please wait N seconds before requesting another code").
36
+ - **`PASSWORDLESS_CODE_SENT` no longer claims `delivery_status: "sent"` for codes the engine never delivered.** It was emitted as `"sent"` after every non-manual issue, including when delivery belonged to a host subscriber, or to none at all (`Otp.issue(delivery: :custom)` with no subscriber). It is still emitted in the same cases (so audit trails that record `standard_id.passwordless.*` keep their row), but `delivery_status` is now:
37
+ - `"sent"` — the built-in mailer enqueued the email (`deliver_later` returned a job);
38
+ - `"failed"` — the built-in mailer was responsible but did not enqueue it: the enqueue raised (the subscriber logs the error) or Active Job refused it (`deliver_later` returned `false`);
39
+ - `"delegated"` — a host `PASSWORDLESS_CODE_GENERATED` subscriber is responsible (global `:custom`, `Otp.issue(delivery: :custom)`, or SMS), so the engine cannot confirm a send.
40
+
41
+ Never emitted for `Otp.issue(delivery: :manual)`, as before. None of the five apps subscribes to `PASSWORDLESS_CODE_SENT` or reads `delivery_status`. fundbright-web, luminality-web and nutripod-web audit `standard_id.passwordless.*`, and jumpdrive-web audits all `standard_id.*`, so their stored `passwordless.code.sent` rows will now carry `"delegated"` (fundbright-web, luminality-web) or `"sent"` / `"failed"` (jumpdrive-web, nutripod-web) instead of a blanket `"sent"`.
42
+
43
+ ### Added
44
+
45
+ - **i18n for the built-in OTP emails** (`config/locales/en.yml`): `standard_id.passwordless_mailer.{otp_email,verification_email}.{subject,greeting,intro,expiry,ignore,footer}`. Override any key in the host's locale files. The gem ships only `en`; in any other `I18n.locale` a key the host has not translated falls back to the English copy (no "translation missing", with or without I18n fallbacks enabled). An **assigned** `c.passwordless.mailer_subject` still wins for the sign-in subject (jumpdrive-web sets one), and its unassigned default equals the i18n default, so nothing changes for a host that overrides neither.
46
+ - `PasswordlessDeliverySubscriber.handles?(payload)` — whether the built-in mailer delivers a given `PASSWORDLESS_CODE_GENERATED` payload.
47
+ - `CodeChallenge#built_in_delivered` — a transient flag (not persisted) the built-in subscriber sets once it has enqueued the email.
48
+
49
+ ### Upgrade notes
50
+
51
+ Nothing is required. A host that overrides `app/views/standard_id/passwordless_mailer/otp_email.*` (luminality-web, via its React email build) keeps its sign-in template. With `:built_in` delivery, verification-realm codes now render the gem's `verification_email` template unless the host overrides that one too. luminality-web calls `PasswordlessMailer.with(...).otp_email` directly for its own verification flow; that call is unchanged.
52
+
10
53
  ## [0.43.0] - 2026-09-24
11
54
 
12
55
  **Breaking minor.** Removes everything 0.42 deprecated. Every consumer cleared those warnings in its Phase 3 adoption, so for the five apps the upgrade is a version bump — see **Upgrade notes**.
data/README.md CHANGED
@@ -554,7 +554,14 @@ end
554
554
 
555
555
  Set `c.passwordless.delivery = :custom` (the default) so the engine's built-in
556
556
  `PasswordlessMailer` stays out of the way; with `:built_in` the engine emails
557
- the code itself. The subscriber runs synchronously inside the request, so
557
+ the code itself — `otp_email` ("Your sign-in code") for the `"authentication"`
558
+ realm and `verification_email` ("Your verification code") for every other realm
559
+ (WebEngine `verify_email`, `Otp.issue` contact verification / step-up). There is
560
+ no built-in SMS delivery. The copy is in `config/locales/en.yml` under
561
+ `standard_id.passwordless_mailer.{otp_email,verification_email}.*` (`subject`,
562
+ `greeting`, `intro`, `expiry`, `ignore`, `footer`); override any key in your own
563
+ locale files, or the templates in `app/views/standard_id/passwordless_mailer/`.
564
+ An assigned `c.passwordless.mailer_subject` still sets the sign-in subject. The subscriber runs synchronously inside the request, so
558
565
  `I18n.locale`, `Current.*` and the like are still available.
559
566
 
560
567
  Event payload includes:
@@ -693,7 +700,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
693
700
  | | `oauth.code.consumed` | `authorization_code`, `client_id`, `account` | After an authorization code is exchanged |
694
701
  | Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms) | Before generating an OTP |
695
702
  | | `passwordless.code.generated` | `code_challenge`, `identifier`, `channel`, `expires_at` | After an OTP is created |
696
- | | `passwordless.code.sent` | `identifier`, `channel`, `delivery_status` | After an OTP is delivered |
703
+ | | `passwordless.code.sent` | `identifier`, `channel`, `realm`, `delivery_status` | After an OTP is handed to delivery (not for `Otp.issue(delivery: :manual)`). `delivery_status`: `"sent"` (built-in mailer enqueued it), `"failed"` (built-in mailer was responsible but did not enqueue), `"delegated"` (a host `PASSWORDLESS_CODE_GENERATED` subscriber delivers — the engine can't confirm it) |
697
704
  | | `passwordless.code.verified` | `code_challenge`, `account`, `channel` | After OTP verification succeeds |
698
705
  | | `passwordless.code.failed` | `identifier`, `channel`, `attempts` | After OTP verification fails |
699
706
  | | `passwordless.account.created` | `account`, `channel`, `identifier` | When an account is created via passwordless flow |
@@ -32,7 +32,7 @@ module StandardId
32
32
  email = params[:email].to_s.strip.downcase
33
33
  if email.blank?
34
34
  flash[:alert] = "Please enter your email address"
35
- render plain: "missing email", status: :unprocessable_content and return
35
+ render plain: flash[:alert], status: :unprocessable_content and return
36
36
  end
37
37
 
38
38
  # Issued through the passwordless strategy (validation, retry delay,
@@ -47,8 +47,11 @@ module StandardId
47
47
  expires_in: 10.minutes
48
48
  )
49
49
  unless result.success?
50
+ # The body carries the same reason as the flash: a malformed
51
+ # target, the host's username_validator message, or the retry
52
+ # cooldown ("Please wait N seconds ...").
50
53
  flash[:alert] = result.error_message
51
- render plain: "invalid email", status: :unprocessable_content and return
54
+ render plain: result.error_message, status: :unprocessable_content and return
52
55
  end
53
56
 
54
57
  redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
@@ -32,7 +32,7 @@ module StandardId
32
32
  phone = params[:phone_number].to_s.strip
33
33
  if phone.blank? || !(phone.match?(/\A\+?[1-9]\d{1,14}\z/))
34
34
  flash[:alert] = "Please enter a valid phone number"
35
- render plain: "invalid phone", status: :unprocessable_content and return
35
+ render plain: flash[:alert], status: :unprocessable_content and return
36
36
  end
37
37
 
38
38
  # Issued through the passwordless strategy (validation, retry delay,
@@ -47,8 +47,11 @@ module StandardId
47
47
  expires_in: 10.minutes
48
48
  )
49
49
  unless result.success?
50
+ # The body carries the same reason as the flash: a malformed
51
+ # target, the host's username_validator message, or the retry
52
+ # cooldown ("Please wait N seconds ...").
50
53
  flash[:alert] = result.error_message
51
- render plain: "invalid phone", status: :unprocessable_content and return
54
+ render plain: result.error_message, status: :unprocessable_content and return
52
55
  end
53
56
 
54
57
  redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
@@ -2,14 +2,70 @@ module StandardId
2
2
  class CleanupExpiredSessionsJob < ApplicationJob
3
3
  queue_as :default
4
4
 
5
+ DEFAULT_BATCH_SIZE = 1_000
6
+
5
7
  # Delete sessions that expired more than `grace_period_seconds` ago.
6
8
  # A grace period avoids deleting sessions that just expired and might
7
9
  # still be referenced in in-flight requests.
8
10
  # Accepts integer seconds for reliable ActiveJob serialization across all queue adapters.
9
- def perform(grace_period_seconds: 7.days.to_i)
11
+ #
12
+ # Refresh tokens reference their session (standard_id_refresh_tokens.session_id,
13
+ # a foreign key with no ON DELETE action in the gem's migration), and
14
+ # `delete_all` skips the model's `dependent: :nullify`, so a bare delete
15
+ # failed the whole run once any expired session had a refresh token
16
+ # (sidekick-web SIDEKICK-WEB-3K). Per batch, in one transaction:
17
+ #
18
+ # * An expired session that still has a LIVE refresh token (unrevoked,
19
+ # unexpired) is kept. Refresh tokens deliberately outlive their session's
20
+ # expiry — RefreshTokenFlow#validate_parent_session! checks revocation,
21
+ # not expiry, so refresh_token_lifetime alone governs how long a client
22
+ # stays signed in. Deleting the session would detach the token from it and
23
+ # lose "revoking this session ends its access"; revoking the token (what
24
+ # Session#destroy does) would sign the client out early. The session is
25
+ # collected on a later run, once its tokens are dead — at most
26
+ # refresh_token_lifetime later.
27
+ # * Dead refresh tokens (revoked or expired) of the sessions being deleted
28
+ # are detached (`session_id` → NULL, the model's `dependent: :nullify`),
29
+ # not deleted: CleanupExpiredRefreshTokensJob removes them on its own
30
+ # window, and until then a replayed revoked token still triggers reuse
31
+ # detection.
32
+ #
33
+ # Batches (`in_batches`) keep each transaction and its locks small.
34
+ def perform(grace_period_seconds: 7.days.to_i, batch_size: DEFAULT_BATCH_SIZE)
10
35
  cutoff = grace_period_seconds.seconds.ago
11
- deleted = StandardId::Session.where("expires_at < ?", cutoff).delete_all
36
+ deleted = 0
37
+
38
+ StandardId::Session.where("expires_at < ?", cutoff).in_batches(of: batch_size) do |batch|
39
+ deleted += delete_batch(batch.pluck(:id), cutoff)
40
+ end
41
+
12
42
  Rails.logger.info("[StandardId] Cleaned up #{deleted} expired sessions older than #{cutoff}")
13
43
  end
44
+
45
+ private
46
+
47
+ def delete_batch(candidate_ids, cutoff)
48
+ StandardId::Session.transaction do
49
+ # Re-check under a row lock: a sign-in may have revived one of these
50
+ # rows (OauthSessionPersistence reuses an expired, unrevoked device
51
+ # session and bumps expires_at) or issued it a refresh token since the
52
+ # batch was read. SKIP LOCKED leaves a row another transaction holds for
53
+ # the next run. (SQLite ignores the lock clause; it serialises writers.)
54
+ ids = StandardId::Session
55
+ .where(id: candidate_ids)
56
+ .where("expires_at < ?", cutoff)
57
+ .where.not(id: live_refresh_tokens.select(:session_id))
58
+ .lock("FOR UPDATE SKIP LOCKED")
59
+ .pluck(:id)
60
+ next 0 if ids.empty?
61
+
62
+ StandardId::RefreshToken.where(session_id: ids).update_all(session_id: nil)
63
+ StandardId::Session.where(id: ids).delete_all
64
+ end
65
+ end
66
+
67
+ def live_refresh_tokens
68
+ StandardId::RefreshToken.active.where.not(session_id: nil)
69
+ end
14
70
  end
15
71
  end
@@ -1,16 +1,72 @@
1
1
  module StandardId
2
+ # Built-in OTP emails, sent by Events::Subscribers::PasswordlessDeliverySubscriber
3
+ # when `c.passwordless.delivery = :built_in`.
4
+ #
5
+ # * #otp_email — the sign-in code (realm "authentication").
6
+ # * #verification_email — every other realm: WebEngine verify_email/start
7
+ # (realm "verification") and `Otp.issue(realm: ...)` for contact
8
+ # verification, step-up and the like. It must not tell the recipient the
9
+ # code is for signing in.
10
+ #
11
+ # Copy lives under `standard_id.passwordless_mailer.<action>.*` in
12
+ # config/locales/en.yml; override any key in the host's locale files, or
13
+ # override the templates in app/views/standard_id/passwordless_mailer/.
14
+ # `c.passwordless.mailer_subject`, when assigned, still sets the sign-in
15
+ # subject (it wins over the i18n key).
2
16
  class PasswordlessMailer < ApplicationMailer
3
17
  layout false
4
18
 
19
+ helper_method :otp_copy
20
+
21
+ # @param email [String]
22
+ # @param otp_code [String]
23
+ # @param expires_in_minutes [Integer, nil] defaults to passwordless.code_ttl
5
24
  def otp_email
6
- @otp_code = params[:otp_code]
7
- @email = params[:email]
25
+ assign_otp_params
26
+ subject = if StandardId.config.passwordless.assigned?(:mailer_subject)
27
+ StandardId.config.passwordless.mailer_subject
28
+ else
29
+ otp_copy(:subject, default: StandardId.config.passwordless.mailer_subject)
30
+ end
31
+
32
+ mail(to: @email, from: StandardId.config.passwordless.mailer_from, subject: subject)
33
+ end
34
+
35
+ # @param email [String]
36
+ # @param otp_code [String]
37
+ # @param realm [String, nil] the OTP realm, available to overriding templates
38
+ # @param expires_in_minutes [Integer, nil] defaults to passwordless.code_ttl
39
+ def verification_email
40
+ assign_otp_params
41
+ @realm = params[:realm]
8
42
 
9
43
  mail(
10
44
  to: @email,
11
45
  from: StandardId.config.passwordless.mailer_from,
12
- subject: StandardId.config.passwordless.mailer_subject
46
+ subject: otp_copy(:subject)
13
47
  )
14
48
  end
49
+
50
+ # Copy for the current action from
51
+ # standard_id.passwordless_mailer.<action>.<key>, in I18n.locale. Falls
52
+ # back to the gem's English copy when the host has no translation for
53
+ # that locale (the gem ships only `en`, and a host need not enable
54
+ # I18n fallbacks), so a zh-SG request never renders "translation missing".
55
+ #
56
+ # @param key [Symbol, String]
57
+ # @param default [String, nil] final fallback when even `en` has no key
58
+ def otp_copy(key, default: nil, **options)
59
+ scope = "standard_id.passwordless_mailer.#{action_name}"
60
+ english = I18n.t(key, scope: scope, locale: :en, default: default, **options)
61
+ I18n.t(key, scope: scope, default: english, **options)
62
+ end
63
+
64
+ private
65
+
66
+ def assign_otp_params
67
+ @otp_code = params[:otp_code]
68
+ @email = params[:email]
69
+ @expires_in_minutes = params[:expires_in_minutes] || (StandardId.config.passwordless.code_ttl / 60)
70
+ end
15
71
  end
16
72
  end
@@ -1,5 +1,9 @@
1
1
  module StandardId
2
2
  class CodeChallenge < ApplicationRecord
3
+ # Transient (not persisted): set by the built-in PasswordlessDeliverySubscriber
4
+ # once it has enqueued the email, so the passwordless strategy can report
5
+ # PASSWORDLESS_CODE_SENT truthfully.
6
+ attr_accessor :built_in_delivered
3
7
  self.table_name = "standard_id_code_challenges"
4
8
 
5
9
  # Well-known realms used by the engine itself. Host apps may create
@@ -11,13 +11,13 @@
11
11
  </head>
12
12
  <body>
13
13
  <div class="container">
14
- <p>Hi,</p>
15
- <p>Use the following code to sign in:</p>
14
+ <p><%= otp_copy(:greeting) %></p>
15
+ <p><%= otp_copy(:intro) %></p>
16
16
  <div class="code"><%= @otp_code %></div>
17
- <p>This code will expire in <%= StandardId.config.passwordless.code_ttl / 60 %> minutes.</p>
18
- <p>If you did not request this code, you can safely ignore this email.</p>
17
+ <p><%= otp_copy(:expiry, count: @expires_in_minutes) %></p>
18
+ <p><%= otp_copy(:ignore) %></p>
19
19
  <div class="footer">
20
- <p>This is an automated message. Please do not reply.</p>
20
+ <p><%= otp_copy(:footer) %></p>
21
21
  </div>
22
22
  </div>
23
23
  </body>
@@ -1,12 +1,12 @@
1
- Hi,
1
+ <%= otp_copy(:greeting) %>
2
2
 
3
- Use the following code to sign in:
3
+ <%= otp_copy(:intro) %>
4
4
 
5
5
  <%= @otp_code %>
6
6
 
7
- This code will expire in <%= StandardId.config.passwordless.code_ttl / 60 %> minutes.
7
+ <%= otp_copy(:expiry, count: @expires_in_minutes) %>
8
8
 
9
- If you did not request this code, you can safely ignore this email.
9
+ <%= otp_copy(:ignore) %>
10
10
 
11
11
  --
12
- This is an automated message. Please do not reply.
12
+ <%= otp_copy(:footer) %>
@@ -0,0 +1,24 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <style>
6
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; }
7
+ .container { max-width: 480px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; padding: 40px; }
8
+ .code { font-size: 32px; font-weight: bold; letter-spacing: 8px; text-align: center; padding: 20px; background-color: #f0f0f0; border-radius: 6px; margin: 24px 0; font-family: monospace; }
9
+ .footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div class="container">
14
+ <p><%= otp_copy(:greeting) %></p>
15
+ <p><%= otp_copy(:intro) %></p>
16
+ <div class="code"><%= @otp_code %></div>
17
+ <p><%= otp_copy(:expiry, count: @expires_in_minutes) %></p>
18
+ <p><%= otp_copy(:ignore) %></p>
19
+ <div class="footer">
20
+ <p><%= otp_copy(:footer) %></p>
21
+ </div>
22
+ </div>
23
+ </body>
24
+ </html>
@@ -0,0 +1,12 @@
1
+ <%= otp_copy(:greeting) %>
2
+
3
+ <%= otp_copy(:intro) %>
4
+
5
+ <%= @otp_code %>
6
+
7
+ <%= otp_copy(:expiry, count: @expires_in_minutes) %>
8
+
9
+ <%= otp_copy(:ignore) %>
10
+
11
+ --
12
+ <%= otp_copy(:footer) %>
@@ -0,0 +1,22 @@
1
+ en:
2
+ standard_id:
3
+ passwordless_mailer:
4
+ otp_email:
5
+ # Used only when c.passwordless.mailer_subject is not assigned.
6
+ subject: "Your sign-in code"
7
+ greeting: "Hi,"
8
+ intro: "Use the following code to sign in:"
9
+ expiry:
10
+ one: "This code will expire in %{count} minute."
11
+ other: "This code will expire in %{count} minutes."
12
+ ignore: "If you did not request this code, you can safely ignore this email."
13
+ footer: "This is an automated message. Please do not reply."
14
+ verification_email:
15
+ subject: "Your verification code"
16
+ greeting: "Hi,"
17
+ intro: "Use the following code to complete your verification:"
18
+ expiry:
19
+ one: "This code will expire in %{count} minute."
20
+ other: "This code will expire in %{count} minutes."
21
+ ignore: "If you did not request this code, you can safely ignore this email."
22
+ footer: "This is an automated message. Please do not reply."
@@ -1,31 +1,56 @@
1
1
  module StandardId
2
2
  module Events
3
3
  module Subscribers
4
+ # Built-in OTP email delivery (`c.passwordless.delivery = :built_in`).
5
+ #
6
+ # Sign-in codes (realm "authentication") get PasswordlessMailer#otp_email;
7
+ # every other realm — WebEngine email verification, Otp.issue step-up /
8
+ # contact verification — gets #verification_email, so a verification code
9
+ # never arrives as "your sign-in code". There is no built-in SMS delivery.
10
+ #
11
+ # On a successful enqueue it marks the code challenge
12
+ # (CodeChallenge#built_in_delivered), which is how the strategy knows to
13
+ # report PASSWORDLESS_CODE_SENT as "sent" rather than "failed".
4
14
  class PasswordlessDeliverySubscriber < Base
5
15
  subscribe_to StandardId::Events::PASSWORDLESS_CODE_GENERATED
6
16
 
17
+ # Whether the built-in mailer is responsible for delivering this
18
+ # PASSWORDLESS_CODE_GENERATED payload.
19
+ #
20
+ # @param payload [Hash] event payload (symbol or string keys)
21
+ # @return [Boolean]
22
+ def self.handles?(payload)
23
+ payload = payload.with_indifferent_access
24
+ # Per-call manual delivery (Otp.issue(delivery: :manual)) and
25
+ # Otp.issue(delivery: :custom) leave delivery to the caller / host
26
+ # subscriber, even when the engine mailer is the global default.
27
+ return false if payload[:skip_sender]
28
+ return false if payload[:delivery]&.to_sym == :custom
29
+
30
+ StandardId.config.passwordless.delivery == :built_in && payload[:channel].to_s == "email"
31
+ end
32
+
7
33
  def call(event)
8
- # Per-call manual-delivery request takes precedence over the global
9
- # delivery config. Honors Otp.issue(delivery: :manual) callers who
10
- # deliver the code themselves (custom widget flows, step-up
11
- # challenges, etc.) and would otherwise receive a duplicate email
12
- # from this subscriber when c.passwordless.delivery == :built_in.
13
- return if event[:skip_sender]
14
- # Otp.issue(delivery: :custom): the host's own subscriber delivers
15
- # this code, even where the engine mailer is the global default.
16
- return if event[:delivery]&.to_sym == :custom
17
- return unless built_in_delivery?
18
- return unless event[:channel] == "email"
34
+ return unless self.class.handles?(event.payload)
19
35
 
20
36
  identifier = event[:identifier]
21
- code = event[:code_challenge]&.code
37
+ challenge = event[:code_challenge]
38
+ code = challenge&.code
22
39
 
23
40
  return if identifier.blank? || code.blank?
24
41
 
25
- StandardId::PasswordlessMailer.with(
42
+ enqueued = StandardId::PasswordlessMailer.with(
26
43
  email: identifier,
27
- otp_code: code
28
- ).otp_email.deliver_later
44
+ otp_code: code,
45
+ realm: event[:realm],
46
+ expires_in_minutes: expires_in_minutes(event[:expires_at])
47
+ ).public_send(mailer_action(event[:realm])).deliver_later
48
+
49
+ # deliver_later returns false (without raising) when Active Job
50
+ # refuses the enqueue, e.g. an aborting before_enqueue callback.
51
+ if enqueued && challenge.respond_to?(:built_in_delivered=)
52
+ challenge.built_in_delivered = true
53
+ end
29
54
  end
30
55
 
31
56
  def handle_error(error, event)
@@ -37,8 +62,17 @@ module StandardId
37
62
 
38
63
  private
39
64
 
40
- def built_in_delivery?
41
- StandardId.config.passwordless.delivery == :built_in
65
+ def mailer_action(realm)
66
+ realm.blank? || realm.to_s == StandardId::Passwordless::DEFAULT_REALM ? :otp_email : :verification_email
67
+ end
68
+
69
+ def expires_in_minutes(expires_at)
70
+ return nil if expires_at.blank?
71
+
72
+ expires_at = Time.zone.parse(expires_at) if expires_at.is_a?(String)
73
+ [((expires_at - Time.current) / 60.0).ceil, 1].max
74
+ rescue ArgumentError, TypeError, NoMethodError
75
+ nil
42
76
  end
43
77
  end
44
78
  end
@@ -45,8 +45,8 @@ module StandardId
45
45
  # can honor a per-call manual-delivery request. Without this,
46
46
  # Otp.issue(delivery: :manual) silently double-delivers when
47
47
  # c.passwordless.delivery == :built_in.
48
- emit_code_generated(challenge, username, skip_sender: skip_sender, delivery: delivery)
49
- emit_code_sent(username) unless skip_sender
48
+ generated_payload = emit_code_generated(challenge, username, skip_sender: skip_sender, delivery: delivery)
49
+ emit_code_sent(username, delivery_status: delivery_status_for(challenge, generated_payload)) unless skip_sender
50
50
  challenge
51
51
  end
52
52
 
@@ -190,9 +190,9 @@ module StandardId
190
190
  )
191
191
  end
192
192
 
193
+ # @return [Hash] the published payload (used to decide the delivery status)
193
194
  def emit_code_generated(challenge, username, skip_sender: false, delivery: nil)
194
- StandardId::Events.publish(
195
- StandardId::Events::PASSWORDLESS_CODE_GENERATED,
195
+ payload = {
196
196
  code_challenge: challenge,
197
197
  identifier: username,
198
198
  channel: connection_type,
@@ -200,16 +200,31 @@ module StandardId
200
200
  expires_at: challenge.expires_at,
201
201
  skip_sender: skip_sender,
202
202
  delivery: delivery
203
- )
203
+ }
204
+ StandardId::Events.publish(StandardId::Events::PASSWORDLESS_CODE_GENERATED, payload)
205
+ payload
206
+ end
207
+
208
+ # delivery_status of PASSWORDLESS_CODE_SENT:
209
+ # - "sent" — the built-in mailer (delivery :built_in) enqueued the email.
210
+ # - "failed" — the built-in mailer was responsible but did not enqueue
211
+ # it (its error was logged by the subscriber).
212
+ # - "delegated" — delivery belongs to the host's PASSWORDLESS_CODE_GENERATED
213
+ # subscriber (delivery :custom, or SMS); the engine cannot
214
+ # know whether it sent anything.
215
+ def delivery_status_for(challenge, generated_payload)
216
+ return "delegated" unless StandardId::Events::Subscribers::PasswordlessDeliverySubscriber.handles?(generated_payload)
217
+
218
+ challenge.built_in_delivered ? "sent" : "failed"
204
219
  end
205
220
 
206
- def emit_code_sent(username)
221
+ def emit_code_sent(username, delivery_status:)
207
222
  StandardId::Events.publish(
208
223
  StandardId::Events::PASSWORDLESS_CODE_SENT,
209
224
  identifier: username,
210
225
  channel: connection_type,
211
226
  realm: @realm,
212
- delivery_status: "sent"
227
+ delivery_status: delivery_status
213
228
  )
214
229
  end
215
230
  end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.43.0"
2
+ VERSION = "0.43.2"
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.43.0
4
+ version: 0.43.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '8.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: bcrypt
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -200,6 +200,8 @@ files:
200
200
  - app/views/standard_id/password_reset_mailer/reset_email.text.erb
201
201
  - app/views/standard_id/passwordless_mailer/otp_email.html.erb
202
202
  - app/views/standard_id/passwordless_mailer/otp_email.text.erb
203
+ - app/views/standard_id/passwordless_mailer/verification_email.html.erb
204
+ - app/views/standard_id/passwordless_mailer/verification_email.text.erb
203
205
  - app/views/standard_id/web/account/edit.html.erb
204
206
  - app/views/standard_id/web/account/show.html.erb
205
207
  - app/views/standard_id/web/auth/callback/providers/mobile_callback.html.erb
@@ -214,6 +216,7 @@ files:
214
216
  - app/views/standard_id/web/signup/show.html.erb
215
217
  - config/brakeman.ignore
216
218
  - config/initializers/generators.rb
219
+ - config/locales/en.yml
217
220
  - config/routes/api.rb
218
221
  - config/routes/web.rb
219
222
  - db/migrate/20250830000000_create_standard_id_client_applications.rb
@@ -342,7 +345,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
342
345
  - !ruby/object:Gem::Version
343
346
  version: '0'
344
347
  requirements: []
345
- rubygems_version: 4.0.3
348
+ rubygems_version: 4.0.10
346
349
  specification_version: 4
347
350
  summary: A comprehensive authentication engine for Rails, built on the security primitives
348
351
  introduced in Rails 8.