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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +69 -7
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
- data/app/controllers/standard_id/web/login_controller.rb +1 -2
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +16 -9
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +16 -9
- data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
- data/app/mailers/standard_id/passwordless_mailer.rb +59 -3
- data/app/models/standard_id/code_challenge.rb +4 -0
- data/app/views/standard_id/passwordless_mailer/otp_email.html.erb +5 -5
- data/app/views/standard_id/passwordless_mailer/otp_email.text.erb +5 -5
- data/app/views/standard_id/passwordless_mailer/verification_email.html.erb +24 -0
- data/app/views/standard_id/passwordless_mailer/verification_email.text.erb +12 -0
- data/config/locales/en.yml +22 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +5 -6
- data/lib/standard_id/config/schema.rb +18 -31
- data/lib/standard_id/config_schema.rb +66 -1
- data/lib/standard_id/engine.rb +1 -0
- data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +51 -14
- data/lib/standard_id/otp.rb +20 -28
- data/lib/standard_id/passwordless/base_strategy.rb +39 -20
- data/lib/standard_id/passwordless/email_strategy.rb +0 -5
- data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
- data/lib/standard_id/provider_registry.rb +2 -23
- data/lib/standard_id/scope_config.rb +22 -22
- data/lib/standard_id/testing/config_helpers.rb +55 -0
- data/lib/standard_id/testing/provider_examples.rb +1 -1
- data/lib/standard_id/testing.rb +1 -0
- data/lib/standard_id/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8003e8ad18d3a88d9684bb3877d88bbc1d9d207a897776e6ef3a6cd01b0f1191
|
|
4
|
+
data.tar.gz: 3fb388482c096695f36d79ee02e122e1cbede9fc1d11b14c6236a4d423a82a0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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**:
|
|
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` |
|
|
579
|
-
| `:custom` |
|
|
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
|
|
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
|
-
|
|
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`
|
|
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
|
-
#
|
|
45
|
-
#
|
|
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
|
-
|
|
47
|
+
StandardId.config.rate_limits.login_per_ip
|
|
52
48
|
end
|
|
53
49
|
|
|
54
|
-
# Effective per-email login rate limit
|
|
50
|
+
# Effective per-email login rate limit (`rate_limits.login_per_email`).
|
|
55
51
|
def self.login_per_email
|
|
56
|
-
|
|
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`
|
|
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:
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
user_agent: request.user_agent
|
|
45
|
+
channel: :email,
|
|
46
|
+
request: request,
|
|
47
|
+
expires_in: 10.minutes
|
|
46
48
|
)
|
|
47
|
-
|
|
48
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
user_agent: request.user_agent
|
|
45
|
+
channel: :sms,
|
|
46
|
+
request: request,
|
|
47
|
+
expires_in: 10.minutes
|
|
46
48
|
)
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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:
|
|
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
|
|
15
|
-
<p
|
|
14
|
+
<p><%= otp_copy(:greeting) %></p>
|
|
15
|
+
<p><%= otp_copy(:intro) %></p>
|
|
16
16
|
<div class="code"><%= @otp_code %></div>
|
|
17
|
-
<p
|
|
18
|
-
<p
|
|
17
|
+
<p><%= otp_copy(:expiry, count: @expires_in_minutes) %></p>
|
|
18
|
+
<p><%= otp_copy(:ignore) %></p>
|
|
19
19
|
<div class="footer">
|
|
20
|
-
<p
|
|
20
|
+
<p><%= otp_copy(:footer) %></p>
|
|
21
21
|
</div>
|
|
22
22
|
</div>
|
|
23
23
|
</body>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
<%= otp_copy(:greeting) %>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<%= otp_copy(:intro) %>
|
|
4
4
|
|
|
5
5
|
<%= @otp_code %>
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
<%= otp_copy(:expiry, count: @expires_in_minutes) %>
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
<%= otp_copy(:ignore) %>
|
|
10
10
|
|
|
11
11
|
--
|
|
12
|
-
|
|
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,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.
|
|
257
|
-
# (
|
|
258
|
-
#
|
|
259
|
-
#
|
|
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
|
|
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
|