standard_id 0.42.0 → 0.43.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +69 -7
  4. data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
  5. data/app/controllers/standard_id/web/login_controller.rb +1 -2
  6. data/app/controllers/standard_id/web/verify_email/start_controller.rb +16 -9
  7. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +16 -9
  8. data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
  9. data/app/mailers/standard_id/passwordless_mailer.rb +59 -3
  10. data/app/models/standard_id/code_challenge.rb +4 -0
  11. data/app/views/standard_id/passwordless_mailer/otp_email.html.erb +5 -5
  12. data/app/views/standard_id/passwordless_mailer/otp_email.text.erb +5 -5
  13. data/app/views/standard_id/passwordless_mailer/verification_email.html.erb +24 -0
  14. data/app/views/standard_id/passwordless_mailer/verification_email.text.erb +12 -0
  15. data/config/locales/en.yml +22 -0
  16. data/lib/generators/standard_id/install/templates/standard_id.rb +5 -6
  17. data/lib/standard_id/config/schema.rb +18 -31
  18. data/lib/standard_id/config_schema.rb +66 -1
  19. data/lib/standard_id/engine.rb +1 -0
  20. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +51 -14
  21. data/lib/standard_id/otp.rb +20 -28
  22. data/lib/standard_id/passwordless/base_strategy.rb +39 -20
  23. data/lib/standard_id/passwordless/email_strategy.rb +0 -5
  24. data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
  25. data/lib/standard_id/provider_registry.rb +2 -23
  26. data/lib/standard_id/scope_config.rb +22 -22
  27. data/lib/standard_id/testing/config_helpers.rb +55 -0
  28. data/lib/standard_id/testing/provider_examples.rb +1 -1
  29. data/lib/standard_id/testing.rb +1 -0
  30. data/lib/standard_id/version.rb +1 -1
  31. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e1701f28a06762afc1eac4d5d31408180ab6b89bf617d420124301ad55c0f8b
4
- data.tar.gz: be9d2d78656afd4d0d2e67e17bbf1fdb3cd6c586af6ad85e5514385b1b52f025
3
+ metadata.gz: 8003e8ad18d3a88d9684bb3877d88bbc1d9d207a897776e6ef3a6cd01b0f1191
4
+ data.tar.gz: 3fb388482c096695f36d79ee02e122e1cbede9fc1d11b14c6236a4d423a82a0b
5
5
  SHA512:
6
- metadata.gz: 924d298113db49b3014dd440b49d038476f23bab8474cf02a661839079580a4ee772c8cdaeb0cf330bfe85fa437b6042af1ffbf1d299d8a4ecbf7ac091c0bafa
7
- data.tar.gz: a9f4da6de783d8e7d0c8f862c2a5ca80c708856746a45ec61605f51160ef8d85b596b1fbad7b75bda3e5cae6311061fb43424db74005e0c24d9ba7fb3a4c55ab
6
+ metadata.gz: 2aade7f1bba3cb85827120d4ac19e55b0a2a039955097cd43af6ef75a84f1516080c4e6c0fc36a87d15731bb332603eab987c93130e69e2e2f5701a5ac929053
7
+ data.tar.gz: c0a8bc153ac8db1de5f5186fb014e4ed89e0b5db06ef7e295ee276f032fc59ec39a591eb6e54e440263a22d43faf8b426f6e1acf4ab09fed72870e96c4e17f85
data/CHANGELOG.md CHANGED
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.43.1] - 2026-09-24
11
+
12
+ 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".
13
+
14
+ ### Fixed
15
+
16
+ - **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.
17
+ - **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").
18
+ - **`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:
19
+ - `"sent"` — the built-in mailer enqueued the email (`deliver_later` returned a job);
20
+ - `"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`);
21
+ - `"delegated"` — a host `PASSWORDLESS_CODE_GENERATED` subscriber is responsible (global `:custom`, `Otp.issue(delivery: :custom)`, or SMS), so the engine cannot confirm a send.
22
+
23
+ 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"`.
24
+
25
+ ### Added
26
+
27
+ - **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.
28
+ - `PasswordlessDeliverySubscriber.handles?(payload)` — whether the built-in mailer delivers a given `PASSWORDLESS_CODE_GENERATED` payload.
29
+ - `CodeChallenge#built_in_delivered` — a transient flag (not persisted) the built-in subscriber sets once it has enqueued the email.
30
+
31
+ ### Upgrade notes
32
+
33
+ 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.
34
+
35
+ ## [0.43.0] - 2026-09-24
36
+
37
+ **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**.
38
+
39
+ ### Upgrade notes
40
+
41
+ If your app boots on 0.42 with no `StandardId` deprecation warning, **you need change nothing**. Checked against `origin/main` of fundbright-web, jumpdrive-web, luminality-web, nutripod-web and sidekick-web: none assigns a removed setting, uses the singular scope `profile_type:`, calls `Otp.issue(delivery: :custom)`, defines `Providers::Base.setup` or references a `DEPRECATOR` constant. What remains is comments (jumpdrive-web and luminality-web's initializers still carry the old commented-out `# c.passwordless_*_sender` lines; jumpdrive-web the commented `# c.rate_limits.password_login_per_*` lines) — worth deleting, harmless to keep.
42
+
43
+ | Removed | What to do instead |
44
+ |---|---|
45
+ | `c.passwordless_email_sender`, `c.passwordless_sms_sender` | A `StandardId::Events::PASSWORDLESS_CODE_GENERATED` subscriber (skip when `event[:skip_sender]`), with `c.passwordless.delivery = :custom` (the default) — as fundbright-web already does. |
46
+ | `c.passwordless.enabled` | `c.web.passwordless_login` (it had no effect since 0.8). |
47
+ | `c.oauth.client_id`, `c.oauth.client_secret` | Delete — never read. |
48
+ | `c.rate_limits.password_login_per_ip`, `password_login_per_email` | `c.rate_limits.login_per_ip`, `login_per_email`. `RateLimitHandling.login_per_ip` / `.login_per_email` now read only these. |
49
+ | Scope config `profile_type: "X"` | `profile_types: ["X"]`. |
50
+ | `Providers::Base.setup` (called with a warning when a provider defined it) | Initialize in the plugin's Railtie; `setup` is no longer called. standard_id-apple 0.6, standard_id-google 0.5 and standard_id-provider define none. |
51
+ | `StandardId::ScopeConfig::DEPRECATOR`, `StandardId::ProviderRegistry::DEPRECATOR` | `StandardId.deprecator` (still registered as `Rails.application.deprecators[:standard_id]`). |
52
+
53
+ Assigning a removed setting now raises `StandardId::ConfigurationError` **at boot**, with the replacement in the message (`StandardId.config.oauth.client_id was removed in StandardId 0.43: …`), rather than the generic "Unknown field" — and, for the two base-scope senders written through the top-level config, rather than being silently stored and ignored. Reading `StandardId.config.passwordless_email_sender` still returns `nil`, so a host spec asserting it is unset (fundbright-web's `spec/lib/otp_delivery_spec.rb`) keeps passing. A scope still using `profile_type:` raises at boot too (`ScopeConfig.validate_all!`, run by the engine): ignoring the key would have left the scope with no profile requirement at all.
54
+
55
+ `ignored_columns` for `ClientApplication#refresh_token_lifetime` is **kept**. All five apps have merged the column-drop migration, but it only runs with each app's next production deploy, and ignoring an absent column is harmless. It goes in a later minor.
56
+
57
+ `ostruct` was already dropped from the runtime dependencies in 0.42 (nothing in `app/` or `lib/` uses `OpenStruct`); it stays a development dependency for the specs.
58
+
59
+ ### Changed
60
+
61
+ - **`StandardId::Otp.issue(delivery: :custom)` no longer needs a sender callback.** It hard-required `passwordless_email_sender` / `passwordless_sms_sender` (raising `ConfigurationError` without one) even after 0.42 deprecated them. It now publishes `PASSWORDLESS_CODE_GENERATED` like the other modes, with `delivery: :custom` in the payload; the engine's `PasswordlessDeliverySubscriber` skips such events even when `c.passwordless.delivery` is `:built_in`, so the host's own subscriber is the only one that delivers. Nothing is sent unless the host subscribes. The YARD docs and README table, which still said `:custom` "calls `passwordless_email_sender`", are fixed.
62
+ - **`PASSWORDLESS_CODE_GENERATED` payload gains `delivery:`** — the `Otp.issue` mode (`:built_in` / `:custom` / `:manual`), `nil` when the code was not issued through `Otp.issue` (sign-in). `skip_sender` is unchanged.
63
+ - **WebEngine `verify_email` / `verify_phone` start actions issue their code through `Otp.issue`** (realm `"verification"`, 10-minute expiry as before), so delivery goes through the event — the built-in mailer under `delivery: :built_in`, the host subscriber otherwise. They used to call the sender callbacks directly and sent nothing without them. They now also get the strategy's format validation, `username_validator`, retry-delay cooldown and previous-code invalidation; a rejected target renders 422.
64
+ - The passwordless strategies no longer have a `sender_callback`.
65
+
66
+ ### Added
67
+
68
+ - **`StandardId::CleanupAllJob`** runs the four cleanup jobs inline — one recurring entry, so the schedule can carry one cron monitor (the gem's jobs carry none). A failure in one does not skip the rest; the first error is re-raised afterwards so the run still fails its check-in. Subclass it to attach a Sentry cron monitor — README *Scheduled Maintenance* has the snippet. This is jumpdrive-web's `StandardIdCleanupJob`, upstreamed: jumpdrive-web can make its job a subclass (keeping its monitor); the other four apps can collapse their four entries into one if they want a monitor.
69
+ - **`ConfigSchema::Scope#assigned?(field)`** — true only when the host assigned the field (even to `nil`). `key?` is true for every declared field, since defaults are written at config build, so it could not tell a provider field set in the initializer from one falling back to ENV.
70
+ - **`ConfigSchema::Scope#refresh_defaults!`** re-resolves the default of every unassigned field — including provider ENV fallbacks, which are otherwise read once at boot.
71
+ - **`StandardId::Testing::ConfigHelpers#with_provider_env`** (`require "standard_id/testing"`; also `StandardId::Testing.with_provider_env`): sets ENV variables, re-resolves unassigned fields, runs the block and restores both, so a host can test "set `APPLE_CLIENT_ID` → provider enabled". The provider round-trip helper now restores a never-assigned field via `assigned?`.
72
+ - `ConfigSchema` DSL `removed :name, "hint"` for settings taken out of the schema.
73
+
74
+ ### Documentation
75
+
76
+ - README Sentry span snippet: `finish` now guards `set_span(parent)` (`Scope#set_span` raises `ArgumentError` on `nil`) and says why a missing parent means no span.
77
+ - README: ENV-fallback resolution timing, `assigned?` and `with_provider_env`; the passwordless-delivery section shows the `skip_sender` guard and every payload key.
78
+ - `docs/MIGRATION_GUIDE.md`: a 0.42 → 0.43 section.
79
+
10
80
  ## [0.42.0] - 2026-09-24
11
81
 
12
82
  ### Upgrade
data/README.md CHANGED
@@ -299,6 +299,30 @@ Assign a field only to read it from somewhere else — a differently named
299
299
  variable, Rails credentials. An explicit assignment, even of `nil`, always wins
300
300
  over the ENV fallback.
301
301
 
302
+ The fallback is resolved **once, when the config is built at boot** — setting
303
+ `ENV` later (in a spec, say) changes nothing by itself. Two helpers (0.43+):
304
+
305
+ - `StandardId.config.social.assigned?(:apple_client_id)` is true only when the
306
+ host assigned the field. (`key?` is true for every declared field, assigned or
307
+ not, because defaults are written into the scope at build time.)
308
+ - In specs, `with_provider_env` (from `require "standard_id/testing"`) sets the
309
+ variables, re-resolves every unassigned field, runs the block and restores
310
+ both — so "set the env var → provider enabled" is testable:
311
+
312
+ ```ruby
313
+ RSpec.describe "Apple sign-in" do
314
+ include StandardId::Testing::ConfigHelpers
315
+
316
+ it "turns on with APPLE_CLIENT_ID" do
317
+ with_provider_env("APPLE_CLIENT_ID" => "com.example.web") do
318
+ expect(StandardId.social_provider_enabled?(:apple)).to be(true)
319
+ end
320
+ end
321
+ end
322
+ ```
323
+
324
+ A field your initializer assigns explicitly ignores ENV, inside the helper too.
325
+
302
326
  ```ruby
303
327
  StandardId.configure do |config|
304
328
  # Only needed when not using the canonical ENV names above:
@@ -518,6 +542,7 @@ Subscribe to the `PASSWORDLESS_CODE_GENERATED` event to deliver OTP codes:
518
542
  ```ruby
519
543
  # config/initializers/standard_id_events.rb
520
544
  StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
545
+ next if event[:skip_sender] # Otp.issue(delivery: :manual) — the caller delivers
521
546
  case event[:channel]
522
547
  when "email"
523
548
  UserMailer.send_code(event[:identifier], event[:code_challenge].code).deliver_now
@@ -527,13 +552,28 @@ StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do
527
552
  end
528
553
  ```
529
554
 
555
+ Set `c.passwordless.delivery = :custom` (the default) so the engine's built-in
556
+ `PasswordlessMailer` stays out of the way; with `:built_in` the engine emails
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
565
+ `I18n.locale`, `Current.*` and the like are still available.
566
+
530
567
  Event payload includes:
531
568
  - `channel` - `"email"` or `"sms"`
532
569
  - `identifier` - The email address or phone number
533
570
  - `code_challenge` - The code challenge object with `.code` method
534
571
  - `expires_at` - When the code expires
572
+ - `realm` - The OTP realm (`"authentication"` for sign-in)
573
+ - `skip_sender` - `true` for `Otp.issue(delivery: :manual)`; don't deliver
574
+ - `delivery` - The `Otp.issue` delivery mode (`:built_in` / `:custom` / `:manual`), `nil` otherwise
535
575
 
536
- > **Note**: If you're using the deprecated `passwordless_email_sender` or `passwordless_sms_sender` callbacks, see the [Migration Guide](docs/MIGRATION_GUIDE.md) for upgrade instructions.
576
+ > **Note**: `passwordless_email_sender` / `passwordless_sms_sender` were removed in 0.43; see the [Migration Guide](docs/MIGRATION_GUIDE.md).
537
577
 
538
578
  ### Using OTP for non-authentication flows
539
579
 
@@ -575,9 +615,9 @@ end
575
615
 
576
616
  | Mode | Behavior |
577
617
  |-------------|--------------------------------------------------------------------------|
578
- | `:built_in` | Uses the engine's bundled `PasswordlessMailer` (email only). |
579
- | `:custom` | Invokes `passwordless_email_sender` / `passwordless_sms_sender` callback.|
580
- | `:manual` | Skips delivery; returns the raw `code` on the result for caller to deliver. |
618
+ | `:built_in` (default) | Follows `c.passwordless.delivery`: the engine's `PasswordlessMailer` (email only) when that is `:built_in`, otherwise your `PASSWORDLESS_CODE_GENERATED` subscriber. |
619
+ | `:custom` | Your `PASSWORDLESS_CODE_GENERATED` subscriber delivers (payload `delivery: :custom`); the engine mailer never does, even under a global `:built_in`. Nothing is sent unless you subscribe. |
620
+ | `:manual` | Skips delivery (payload `skip_sender: true`); returns the raw `code` on the result for the caller to deliver. |
581
621
 
582
622
  **Realm isolation.** `realm:` is a free-form string that partitions challenges by purpose. A code issued for realm `"widget_contact_verification"` cannot be used to verify against realm `"authentication"` (or any other realm) — even for the same `target`. Choose a stable string per flow.
583
623
 
@@ -660,7 +700,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
660
700
  | | `oauth.code.consumed` | `authorization_code`, `client_id`, `account` | After an authorization code is exchanged |
661
701
  | Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms) | Before generating an OTP |
662
702
  | | `passwordless.code.generated` | `code_challenge`, `identifier`, `channel`, `expires_at` | After an OTP is created |
663
- | | `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) |
664
704
  | | `passwordless.code.verified` | `code_challenge`, `account`, `channel` | After OTP verification succeeds |
665
705
  | | `passwordless.code.failed` | `identifier`, `channel`, `attempts` | After OTP verification fails |
666
706
  | | `passwordless.account.created` | `account`, `channel`, `identifier` | When an account is created via passwordless flow |
@@ -795,6 +835,7 @@ module StandardIdSentrySpans
795
835
  def self.start(name, _id, payload)
796
836
  scope = Sentry.get_current_scope
797
837
  parent = scope&.get_span
838
+ # No transaction in progress (a job, a console) → no parent → no span.
798
839
  span = parent&.start_child(
799
840
  op: "standard_id.#{name.delete_suffix('.standard_id')}",
800
841
  description: Array(payload[:audience]).join(", ").presence
@@ -808,7 +849,10 @@ module StandardIdSentrySpans
808
849
  return unless span
809
850
 
810
851
  span.finish
811
- Sentry.get_current_scope.set_span(parent)
852
+ # Restore the parent. Guarded: Scope#set_span raises ArgumentError on nil,
853
+ # and the current scope may have changed since #start.
854
+ scope = Sentry.get_current_scope
855
+ scope.set_span(parent) if scope && parent
812
856
  end
813
857
  end
814
858
 
@@ -1350,7 +1394,8 @@ end
1350
1394
  | `rescue_to_oauth_error(message_prefix = nil) { ... }` | Lets `StandardId::OAuthError` through; wraps anything else in one, keeping `cause` |
1351
1395
 
1352
1396
  A plugin using `env:`, `required:` or these helpers should depend on
1353
- `standard_id >= 0.42`. `Providers::Base.setup` was removed in 0.42 — do
1397
+ `standard_id >= 0.42`. `Providers::Base.setup` is no longer called (removed
1398
+ from the base class in 0.42; the call-with-a-warning shim went in 0.43) — do
1354
1399
  one-off initialization in your own Railtie instead.
1355
1400
 
1356
1401
  **Testing a plugin, or an app that uses one:**
@@ -1482,6 +1527,23 @@ Retention is bounded by the grace windows, not the cadence; each job is a single
1482
1527
  schedule: every hour at minute 13
1483
1528
  ```
1484
1529
 
1530
+ **One entry, one cron monitor (0.43+).** `StandardId::CleanupAllJob` runs all four inline; a failure in one does not skip the rest, and the first error is re-raised afterwards so the run still fails. Schedule it instead of the four when you want a single recurring entry — e.g. to put the schedule under one Sentry cron monitor (the gem's jobs carry none). Subclass it to attach the monitor:
1531
+
1532
+ ```ruby
1533
+ # app/jobs/standard_id_cleanup_job.rb
1534
+ class StandardIdCleanupJob < StandardId::CleanupAllJob
1535
+ include Sentry::Cron::MonitorCheckIns
1536
+ sentry_monitor_check_ins slug: "standard-id-cleanup",
1537
+ monitor_config: Sentry::Cron::MonitorConfig.from_crontab("7 * * * *", checkin_margin: 5, max_runtime: 10)
1538
+ end
1539
+ ```
1540
+
1541
+ ```yaml
1542
+ standard_id_cleanup:
1543
+ class: StandardIdCleanupJob
1544
+ schedule: every hour at minute 7
1545
+ ```
1546
+
1485
1547
  Rake wrappers (`standard_id:cleanup:all`, `:sessions`, `:refresh_tokens`, `:authorization_codes`, `:code_challenges`) run the same jobs inline. See [docs/OPERATIONS.md](docs/OPERATIONS.md) for sidekiq-cron, whenever and system-cron examples.
1486
1548
 
1487
1549
  ## Contributing
@@ -41,29 +41,15 @@ module StandardId
41
41
  end
42
42
  end
43
43
 
44
- # Resolve the effective per-IP login rate limit, preferring the
45
- # mechanism-agnostic `login_per_ip` alias and falling back to the deprecated
46
- # `password_login_per_ip` when the host left the alias at its default (i.e.
47
- # only configured the old name). The new alias wins whenever explicitly set.
48
- # Mirrors the max_attempts -> max_attempts_per_challenge deprecation-alias
49
- # precedent (prefer-new, fall-back-to-old).
44
+ # Effective per-IP login rate limit (`rate_limits.login_per_ip`). The
45
+ # deprecated `password_login_per_ip` fallback was removed in 0.43.
50
46
  def self.login_per_ip
51
- resolve_login_alias(:login_per_ip, :password_login_per_ip)
47
+ StandardId.config.rate_limits.login_per_ip
52
48
  end
53
49
 
54
- # Effective per-email login rate limit; see .login_per_ip.
50
+ # Effective per-email login rate limit (`rate_limits.login_per_email`).
55
51
  def self.login_per_email
56
- resolve_login_alias(:login_per_email, :password_login_per_email)
57
- end
58
-
59
- # Return the alias value unless it still equals its schema default, in which
60
- # case fall back to the deprecated field. Both fields share the same default,
61
- # so the effective default is unchanged when neither is set.
62
- def self.resolve_login_alias(new_field, old_field)
63
- rate_limits = StandardId.config.rate_limits
64
- default = StandardId.config.__schema__.field_for(:rate_limits, new_field).default_value
65
- new_value = rate_limits[new_field]
66
- new_value == default ? rate_limits[old_field] : new_value
52
+ StandardId.config.rate_limits.login_per_email
67
53
  end
68
54
 
69
55
  private
@@ -11,8 +11,7 @@ module StandardId
11
11
  layout "public"
12
12
 
13
13
  # RAR-51: Rate limit login attempts by IP (20 per 15 minutes). Reads the
14
- # mechanism-agnostic `login_per_ip` alias, falling back to the deprecated
15
- # `password_login_per_ip` — this action branches password OR passwordless,
14
+ # mechanism-agnostic `login_per_ip` — this action branches password OR passwordless,
16
15
  # so on a passwordless app this governs the OTP-send limit, not a password
17
16
  # login. See StandardId::RateLimitHandling.login_per_ip.
18
17
  rate_limit to: StandardId::RateLimitHandling.login_per_ip,
@@ -32,20 +32,27 @@ 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
- challenge = StandardId::CodeChallenge.create!(
38
+ # Issued through the passwordless strategy (validation, retry delay,
39
+ # previous-code invalidation) so delivery goes through the
40
+ # PASSWORDLESS_CODE_GENERATED event like every other OTP. Before 0.43
41
+ # this called the since-removed passwordless_email_sender directly.
42
+ result = StandardId::Otp.issue(
39
43
  realm: "verification",
40
- channel: "email",
41
44
  target: email,
42
- code: StandardId::Passwordless.generate_otp_code,
43
- expires_at: 10.minutes.from_now,
44
- ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
45
- user_agent: request.user_agent
45
+ channel: :email,
46
+ request: request,
47
+ expires_in: 10.minutes
46
48
  )
47
-
48
- StandardId.config.passwordless_email_sender&.call(email, challenge.code)
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 ...").
53
+ flash[:alert] = result.error_message
54
+ render plain: result.error_message, status: :unprocessable_content and return
55
+ end
49
56
 
50
57
  redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
51
58
  end
@@ -32,20 +32,27 @@ 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
- challenge = StandardId::CodeChallenge.create!(
38
+ # Issued through the passwordless strategy (validation, retry delay,
39
+ # previous-code invalidation) so delivery goes through the
40
+ # PASSWORDLESS_CODE_GENERATED event like every other OTP. Before 0.43
41
+ # this called the since-removed passwordless_sms_sender directly.
42
+ result = StandardId::Otp.issue(
39
43
  realm: "verification",
40
- channel: "sms",
41
44
  target: phone,
42
- code: StandardId::Passwordless.generate_otp_code,
43
- expires_at: 10.minutes.from_now,
44
- ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
45
- user_agent: request.user_agent
45
+ channel: :sms,
46
+ request: request,
47
+ expires_in: 10.minutes
46
48
  )
47
-
48
- StandardId.config.passwordless_sms_sender&.call(phone, challenge.code)
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 ...").
53
+ flash[:alert] = result.error_message
54
+ render plain: result.error_message, status: :unprocessable_content and return
55
+ end
49
56
 
50
57
  redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
51
58
  end
@@ -0,0 +1,46 @@
1
+ module StandardId
2
+ # Runs all four expired-row cleanup jobs inline, in one job.
3
+ #
4
+ # Schedule this ONE job instead of the four individual ones when you want a
5
+ # single recurring entry — typically so the schedule carries one cron
6
+ # monitor (Sentry, Honeybadger, …) rather than four. Subclass it in the host
7
+ # to attach the monitor:
8
+ #
9
+ # # app/jobs/standard_id_cleanup_job.rb
10
+ # class StandardIdCleanupJob < StandardId::CleanupAllJob
11
+ # include Sentry::Cron::MonitorCheckIns
12
+ # sentry_monitor_check_ins slug: "standard-id-cleanup",
13
+ # monitor_config: Sentry::Cron::MonitorConfig.from_crontab("7 * * * *")
14
+ # end
15
+ #
16
+ # One table's DELETE failing (a lock timeout, say) does not skip the others;
17
+ # the first error is re-raised once all four have run, so the run still
18
+ # fails its cron check-in and lands in the queue's failed executions.
19
+ #
20
+ # Each job keeps its default grace windows (see README "Scheduled
21
+ # Maintenance"); retention is bounded by those, not by the cadence.
22
+ class CleanupAllJob < ApplicationJob
23
+ queue_as :default
24
+
25
+ # @return [Array<Class>] the cleanup jobs run, in order
26
+ def self.jobs
27
+ [
28
+ StandardId::CleanupExpiredSessionsJob,
29
+ StandardId::CleanupExpiredRefreshTokensJob,
30
+ StandardId::CleanupExpiredAuthorizationCodesJob,
31
+ StandardId::CleanupExpiredCodeChallengesJob
32
+ ]
33
+ end
34
+
35
+ def perform
36
+ errors = self.class.jobs.filter_map do |job|
37
+ job.perform_now
38
+ nil
39
+ rescue StandardError => e
40
+ Rails.logger.warn("[StandardId] #{job.name} failed: #{e.class}: #{e.message}")
41
+ e
42
+ end
43
+ raise errors.first if errors.any?
44
+ end
45
+ end
46
+ 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."
@@ -253,10 +253,10 @@ StandardId.configure do |c|
253
253
  # Account.create!(email: identifier.value)
254
254
  # }
255
255
 
256
- # c.passwordless_email_sender / c.passwordless_sms_sender are DEPRECATED
257
- # (assigning them emits a StandardId deprecation warning). Deliver codes from
258
- # an event subscriber instead — it runs synchronously in the request, so
259
- # I18n.locale etc. are still available:
256
+ # With c.passwordless.delivery = :custom, deliver codes from an event
257
+ # subscriber (c.passwordless_email_sender / _sms_sender were removed in
258
+ # 0.43). It runs synchronously in the request, so I18n.locale etc. are still
259
+ # available. The same subscriber delivers Otp.issue(delivery: :custom) codes.
260
260
  #
261
261
  # StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
262
262
  # next if event[:skip_sender] # Otp.issue(delivery: :manual)
@@ -510,8 +510,7 @@ StandardId.configure do |c|
510
510
 
511
511
  # Login limits. The login action branches password OR passwordless, so on a
512
512
  # passwordless app these govern the OTP-SEND limit. Prefer the
513
- # mechanism-agnostic names; the old password_login_per_ip/_per_email names
514
- # still work but emit a deprecation warning (the new name wins when both are set).
513
+ # mechanism-agnostic names (password_login_per_ip/_per_email were removed in 0.43).
515
514
  # c.rate_limits.login_per_ip = 20 # per 15 minutes
516
515
  # c.rate_limits.login_per_email = 5 # per 15 minutes
517
516
  # c.rate_limits.otp_verify_per_ip = 20 # per 15 minutes