standard_id 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +61 -6
- 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 +12 -8
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
- data/app/jobs/standard_id/cleanup_all_job.rb +46 -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 +3 -0
- data/lib/standard_id/otp.rb +20 -28
- data/lib/standard_id/passwordless/base_strategy.rb +18 -14
- 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 +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f9798d4a8c4db5cbc69cc0c4f569cc41e825b5df159124fab15f39923ca602f
|
|
4
|
+
data.tar.gz: 1b06dc3eee0795f25633df9dc93dae5376c0bc5f6e1b0cd97ef3ce37fca6fc02
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10594cb5f3206999becca9fb582bfcc714f8371713160192ddf72fdeb39fe4f9552e9d551991fcf294af2eca71fcd8266f8c1f0a27e94c069a7f597581a54d8e
|
|
7
|
+
data.tar.gz: 1ababa07f26cea7f6240666372f25ada38a7c3672313306c1ce7eddedded43efaff08b27df71d50c1b78f75e75d6f909f421236c455d1ac9c1658015752bdc4c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.43.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
**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**.
|
|
13
|
+
|
|
14
|
+
### Upgrade notes
|
|
15
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
| Removed | What to do instead |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `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. |
|
|
21
|
+
| `c.passwordless.enabled` | `c.web.passwordless_login` (it had no effect since 0.8). |
|
|
22
|
+
| `c.oauth.client_id`, `c.oauth.client_secret` | Delete — never read. |
|
|
23
|
+
| `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. |
|
|
24
|
+
| Scope config `profile_type: "X"` | `profile_types: ["X"]`. |
|
|
25
|
+
| `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. |
|
|
26
|
+
| `StandardId::ScopeConfig::DEPRECATOR`, `StandardId::ProviderRegistry::DEPRECATOR` | `StandardId.deprecator` (still registered as `Rails.application.deprecators[:standard_id]`). |
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
`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.
|
|
31
|
+
|
|
32
|
+
`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.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **`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.
|
|
37
|
+
- **`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.
|
|
38
|
+
- **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.
|
|
39
|
+
- The passwordless strategies no longer have a `sender_callback`.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **`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.
|
|
44
|
+
- **`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.
|
|
45
|
+
- **`ConfigSchema::Scope#refresh_defaults!`** re-resolves the default of every unassigned field — including provider ENV fallbacks, which are otherwise read once at boot.
|
|
46
|
+
- **`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?`.
|
|
47
|
+
- `ConfigSchema` DSL `removed :name, "hint"` for settings taken out of the schema.
|
|
48
|
+
|
|
49
|
+
### Documentation
|
|
50
|
+
|
|
51
|
+
- 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.
|
|
52
|
+
- README: ENV-fallback resolution timing, `assigned?` and `with_provider_env`; the passwordless-delivery section shows the `skip_sender` guard and every payload key.
|
|
53
|
+
- `docs/MIGRATION_GUIDE.md`: a 0.42 → 0.43 section.
|
|
54
|
+
|
|
10
55
|
## [0.42.0] - 2026-09-24
|
|
11
56
|
|
|
12
57
|
### 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,21 @@ 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. The subscriber runs synchronously inside the request, so
|
|
558
|
+
`I18n.locale`, `Current.*` and the like are still available.
|
|
559
|
+
|
|
530
560
|
Event payload includes:
|
|
531
561
|
- `channel` - `"email"` or `"sms"`
|
|
532
562
|
- `identifier` - The email address or phone number
|
|
533
563
|
- `code_challenge` - The code challenge object with `.code` method
|
|
534
564
|
- `expires_at` - When the code expires
|
|
565
|
+
- `realm` - The OTP realm (`"authentication"` for sign-in)
|
|
566
|
+
- `skip_sender` - `true` for `Otp.issue(delivery: :manual)`; don't deliver
|
|
567
|
+
- `delivery` - The `Otp.issue` delivery mode (`:built_in` / `:custom` / `:manual`), `nil` otherwise
|
|
535
568
|
|
|
536
|
-
> **Note**:
|
|
569
|
+
> **Note**: `passwordless_email_sender` / `passwordless_sms_sender` were removed in 0.43; see the [Migration Guide](docs/MIGRATION_GUIDE.md).
|
|
537
570
|
|
|
538
571
|
### Using OTP for non-authentication flows
|
|
539
572
|
|
|
@@ -575,9 +608,9 @@ end
|
|
|
575
608
|
|
|
576
609
|
| Mode | Behavior |
|
|
577
610
|
|-------------|--------------------------------------------------------------------------|
|
|
578
|
-
| `:built_in` |
|
|
579
|
-
| `:custom` |
|
|
580
|
-
| `:manual` | Skips delivery; returns the raw `code` on the result for caller to deliver. |
|
|
611
|
+
| `:built_in` (default) | Follows `c.passwordless.delivery`: the engine's `PasswordlessMailer` (email only) when that is `:built_in`, otherwise your `PASSWORDLESS_CODE_GENERATED` subscriber. |
|
|
612
|
+
| `: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. |
|
|
613
|
+
| `:manual` | Skips delivery (payload `skip_sender: true`); returns the raw `code` on the result for the caller to deliver. |
|
|
581
614
|
|
|
582
615
|
**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
616
|
|
|
@@ -795,6 +828,7 @@ module StandardIdSentrySpans
|
|
|
795
828
|
def self.start(name, _id, payload)
|
|
796
829
|
scope = Sentry.get_current_scope
|
|
797
830
|
parent = scope&.get_span
|
|
831
|
+
# No transaction in progress (a job, a console) → no parent → no span.
|
|
798
832
|
span = parent&.start_child(
|
|
799
833
|
op: "standard_id.#{name.delete_suffix('.standard_id')}",
|
|
800
834
|
description: Array(payload[:audience]).join(", ").presence
|
|
@@ -808,7 +842,10 @@ module StandardIdSentrySpans
|
|
|
808
842
|
return unless span
|
|
809
843
|
|
|
810
844
|
span.finish
|
|
811
|
-
|
|
845
|
+
# Restore the parent. Guarded: Scope#set_span raises ArgumentError on nil,
|
|
846
|
+
# and the current scope may have changed since #start.
|
|
847
|
+
scope = Sentry.get_current_scope
|
|
848
|
+
scope.set_span(parent) if scope && parent
|
|
812
849
|
end
|
|
813
850
|
end
|
|
814
851
|
|
|
@@ -1350,7 +1387,8 @@ end
|
|
|
1350
1387
|
| `rescue_to_oauth_error(message_prefix = nil) { ... }` | Lets `StandardId::OAuthError` through; wraps anything else in one, keeping `cause` |
|
|
1351
1388
|
|
|
1352
1389
|
A plugin using `env:`, `required:` or these helpers should depend on
|
|
1353
|
-
`standard_id >= 0.42`. `Providers::Base.setup`
|
|
1390
|
+
`standard_id >= 0.42`. `Providers::Base.setup` is no longer called (removed
|
|
1391
|
+
from the base class in 0.42; the call-with-a-warning shim went in 0.43) — do
|
|
1354
1392
|
one-off initialization in your own Railtie instead.
|
|
1355
1393
|
|
|
1356
1394
|
**Testing a plugin, or an app that uses one:**
|
|
@@ -1482,6 +1520,23 @@ Retention is bounded by the grace windows, not the cadence; each job is a single
|
|
|
1482
1520
|
schedule: every hour at minute 13
|
|
1483
1521
|
```
|
|
1484
1522
|
|
|
1523
|
+
**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:
|
|
1524
|
+
|
|
1525
|
+
```ruby
|
|
1526
|
+
# app/jobs/standard_id_cleanup_job.rb
|
|
1527
|
+
class StandardIdCleanupJob < StandardId::CleanupAllJob
|
|
1528
|
+
include Sentry::Cron::MonitorCheckIns
|
|
1529
|
+
sentry_monitor_check_ins slug: "standard-id-cleanup",
|
|
1530
|
+
monitor_config: Sentry::Cron::MonitorConfig.from_crontab("7 * * * *", checkin_margin: 5, max_runtime: 10)
|
|
1531
|
+
end
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
```yaml
|
|
1535
|
+
standard_id_cleanup:
|
|
1536
|
+
class: StandardIdCleanupJob
|
|
1537
|
+
schedule: every hour at minute 7
|
|
1538
|
+
```
|
|
1539
|
+
|
|
1485
1540
|
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
1541
|
|
|
1487
1542
|
## 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,
|
|
@@ -35,17 +35,21 @@ module StandardId
|
|
|
35
35
|
render plain: "missing email", 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
|
+
flash[:alert] = result.error_message
|
|
51
|
+
render plain: "invalid email", status: :unprocessable_content and return
|
|
52
|
+
end
|
|
49
53
|
|
|
50
54
|
redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
|
|
51
55
|
end
|
|
@@ -35,17 +35,21 @@ module StandardId
|
|
|
35
35
|
render plain: "invalid phone", 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
|
+
flash[:alert] = result.error_message
|
|
51
|
+
render plain: "invalid phone", status: :unprocessable_content and return
|
|
52
|
+
end
|
|
49
53
|
|
|
50
54
|
redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
|
|
51
55
|
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
|
|
@@ -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
|
|
@@ -9,11 +9,12 @@ StandardId::ConfigSchema.define do
|
|
|
9
9
|
field :cache_store, type: :any, default: nil
|
|
10
10
|
field :logger, type: :any, default: nil
|
|
11
11
|
field :web_layout, type: :string, default: nil
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
# Removed in 0.43 (deprecated since 0.1.7): assigning either raises
|
|
13
|
+
# StandardId::ConfigurationError with this hint.
|
|
14
|
+
removed :passwordless_email_sender,
|
|
15
|
+
"deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and set c.passwordless.delivery = :custom. See docs/MIGRATION_GUIDE.md."
|
|
16
|
+
removed :passwordless_sms_sender,
|
|
17
|
+
"deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber (skip it when event[:skip_sender] is true). See docs/MIGRATION_GUIDE.md."
|
|
17
18
|
field :issuer, type: :string, default: nil
|
|
18
19
|
|
|
19
20
|
# Whether `JwtService.decode` REQUIRES a matching `iss` claim.
|
|
@@ -72,9 +73,8 @@ StandardId::ConfigSchema.define do
|
|
|
72
73
|
# Scope-aware authentication: maps scope names to profile-based access config.
|
|
73
74
|
# Each scope is a hash with keys: :profile_types (Array<String>), :after_sign_in_path,
|
|
74
75
|
# :no_profile_message, :label, :allow_registration, :authorizer.
|
|
75
|
-
# The legacy :profile_type (singular
|
|
76
|
-
#
|
|
77
|
-
# warning fires on use).
|
|
76
|
+
# The legacy :profile_type (singular) key was removed in 0.43; a scope that still
|
|
77
|
+
# sets it raises StandardId::ConfigurationError at boot.
|
|
78
78
|
field :scopes, type: :any, default: {}
|
|
79
79
|
|
|
80
80
|
# Callable that resolves the active scope name for a given request/session.
|
|
@@ -153,11 +153,8 @@ StandardId::ConfigSchema.define do
|
|
|
153
153
|
end
|
|
154
154
|
|
|
155
155
|
scope :passwordless do
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
# still boot.
|
|
159
|
-
field :enabled, type: :boolean, default: false,
|
|
160
|
-
deprecated: "it has had no effect since 0.8. Use web.passwordless_login (WebEngine) instead, and remove this line. Removal in v2.0."
|
|
156
|
+
# Removed in 0.43 (no effect since 0.8).
|
|
157
|
+
removed :enabled, "it had no effect since 0.8. Use web.passwordless_login (WebEngine) instead, and delete this line."
|
|
161
158
|
field :connection, type: :string, default: "email"
|
|
162
159
|
field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
|
|
163
160
|
|
|
@@ -312,12 +309,10 @@ StandardId::ConfigSchema.define do
|
|
|
312
309
|
# RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS.
|
|
313
310
|
field :refresh_token_reuse_leeway, type: :integer, default: 0
|
|
314
311
|
field :token_lifetimes, type: :hash, default: -> { {} }
|
|
315
|
-
#
|
|
316
|
-
# rows with ClientSecretCredential secrets.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
field :client_secret, type: :string, default: nil,
|
|
320
|
-
deprecated: "it is not read anywhere — client secrets are StandardId::ClientSecretCredential records. Remove this line. Removal in v2.0."
|
|
312
|
+
# Removed in 0.43 — never read by the gem. OAuth clients are
|
|
313
|
+
# StandardId::ClientApplication rows with ClientSecretCredential secrets.
|
|
314
|
+
removed :client_id, "it was never read — OAuth clients are StandardId::ClientApplication records. Delete this line."
|
|
315
|
+
removed :client_secret, "it was never read — client secrets are StandardId::ClientSecretCredential records. Delete this line."
|
|
321
316
|
field :scope_claims, type: :hash, default: -> { {} }
|
|
322
317
|
field :claim_resolvers, type: :hash, default: -> { {} }
|
|
323
318
|
# List of audience values that tokens issued and accepted by this app may
|
|
@@ -601,17 +596,9 @@ StandardId::ConfigSchema.define do
|
|
|
601
596
|
field :login_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
602
597
|
field :login_per_email, type: :integer, default: 5 # per 15 minutes
|
|
603
598
|
|
|
604
|
-
#
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
# `login_per_*` alias at its default, the login controller falls back to
|
|
608
|
-
# these values (see StandardId::RateLimitHandling.login_per_ip). New name
|
|
609
|
-
# wins when explicitly set. Mirrors the max_attempts ->
|
|
610
|
-
# max_attempts_per_challenge deprecation-alias precedent.
|
|
611
|
-
field :password_login_per_ip, type: :integer, default: 20, # per 15 minutes; deprecated alias of login_per_ip
|
|
612
|
-
deprecated: "use rate_limits.login_per_ip (same meaning; it also governs passwordless OTP sends). Removal in v2.0."
|
|
613
|
-
field :password_login_per_email, type: :integer, default: 5, # per 15 minutes; deprecated alias of login_per_email
|
|
614
|
-
deprecated: "use rate_limits.login_per_email (same meaning; it also governs passwordless OTP sends). Removal in v2.0."
|
|
599
|
+
# Removed in 0.43: the mechanism-specific names of login_per_ip / login_per_email.
|
|
600
|
+
removed :password_login_per_ip, "use rate_limits.login_per_ip (same meaning; it also governs passwordless OTP sends)."
|
|
601
|
+
removed :password_login_per_email, "use rate_limits.login_per_email (same meaning; it also governs passwordless OTP sends)."
|
|
615
602
|
|
|
616
603
|
# RAR-60: OTP verification
|
|
617
604
|
field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
@@ -630,7 +617,7 @@ StandardId::ConfigSchema.define do
|
|
|
630
617
|
field :signup_per_ip, type: :integer, default: 10 # per hour
|
|
631
618
|
|
|
632
619
|
# API OTP-initiation limits — the API counterpart of the web *login* limits
|
|
633
|
-
# (login_per_*
|
|
620
|
+
# (login_per_*): the API passwordless
|
|
634
621
|
# `start` action and the web login action both drive the passwordless
|
|
635
622
|
# `start!` strategy. NOT the counterpart of verification_start_*
|
|
636
623
|
# (email/phone verification), which bypasses the strategy via a direct
|
|
@@ -28,7 +28,11 @@ module StandardId
|
|
|
28
28
|
def build = instance.apply(Config.new)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def initialize
|
|
31
|
+
def initialize
|
|
32
|
+
@scopes = Concurrent::Map.new
|
|
33
|
+
@removed = Concurrent::Map.new
|
|
34
|
+
end
|
|
35
|
+
|
|
32
36
|
def scopes = @scopes
|
|
33
37
|
def scope?(name) = @scopes.key?(name.to_sym)
|
|
34
38
|
def field?(scope_name, field_name) = !!@scopes[scope_name.to_sym]&.key?(field_name.to_sym)
|
|
@@ -44,6 +48,18 @@ module StandardId
|
|
|
44
48
|
fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default, deprecated) }
|
|
45
49
|
end
|
|
46
50
|
|
|
51
|
+
# Record a field that has been REMOVED from the schema. Assigning it raises
|
|
52
|
+
# StandardId::ConfigurationError carrying +message+ (what to do instead),
|
|
53
|
+
# rather than the generic "Unknown field" — or, for a base-scope field
|
|
54
|
+
# assigned through the top-level config, rather than being silently
|
|
55
|
+
# stored on the top-level OrderedOptions and ignored.
|
|
56
|
+
def add_removed_field(scope:, name:, message:)
|
|
57
|
+
@removed[[scope.to_sym, name.to_sym]] = message
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String, nil] the removal hint for a removed field, else nil
|
|
61
|
+
def removed_field_message(scope_name, field_name) = @removed[[scope_name.to_sym, field_name.to_sym]]
|
|
62
|
+
|
|
47
63
|
# Register a scope without adding a field. Allows `define { scope :foo }` so
|
|
48
64
|
# provider gems can later `add_field(scope: :foo, ...)` against an existing scope.
|
|
49
65
|
def ensure_scope(name)
|
|
@@ -107,6 +123,10 @@ module StandardId
|
|
|
107
123
|
def field(name, type: :string, default: nil, deprecated: nil, **)
|
|
108
124
|
@schema.add_field(scope: @scope_name, name: name, type: type, default: default, deprecated: deprecated)
|
|
109
125
|
end
|
|
126
|
+
|
|
127
|
+
def removed(name, message)
|
|
128
|
+
@schema.add_removed_field(scope: @scope_name, name: name, message: message)
|
|
129
|
+
end
|
|
110
130
|
end
|
|
111
131
|
|
|
112
132
|
# Per-scope OrderedOptions. Validates writes, casts and dups Array/Hash values
|
|
@@ -127,9 +147,45 @@ module StandardId
|
|
|
127
147
|
def []=(key, value)
|
|
128
148
|
validate!(key)
|
|
129
149
|
warn_if_deprecated(key, value)
|
|
150
|
+
assigned_keys << key.to_sym
|
|
130
151
|
super(key.to_sym, value)
|
|
131
152
|
end
|
|
132
153
|
|
|
154
|
+
# Whether the host explicitly assigned +key+ (even to nil).
|
|
155
|
+
#
|
|
156
|
+
# Unlike #key?, which is also true for every field whose schema default
|
|
157
|
+
# was written when the config was built (so it is true for a provider
|
|
158
|
+
# field the host never touched), this is only true after an assignment.
|
|
159
|
+
#
|
|
160
|
+
# @param key [Symbol, String]
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def assigned?(key) = assigned_keys.include?(key.to_sym)
|
|
163
|
+
|
|
164
|
+
# Deleting a key also forgets that it was assigned, so reads fall back
|
|
165
|
+
# to the (live) schema default again.
|
|
166
|
+
def delete(key)
|
|
167
|
+
assigned_keys.delete(key.to_sym)
|
|
168
|
+
super(key.to_sym)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Re-evaluate the schema default of every field the host never assigned.
|
|
172
|
+
#
|
|
173
|
+
# Defaults are resolved once, when the config is built — including the
|
|
174
|
+
# ENV fallback of provider fields (`GOOGLE_CLIENT_ID` etc.). Call this
|
|
175
|
+
# after changing ENV to make unassigned fields pick the new value up;
|
|
176
|
+
# explicitly assigned fields are left alone. Meant for tests — see
|
|
177
|
+
# StandardId::Testing.with_provider_env.
|
|
178
|
+
#
|
|
179
|
+
# @return [self]
|
|
180
|
+
def refresh_defaults!
|
|
181
|
+
@schema.scopes[@scope_name]&.each_value do |field|
|
|
182
|
+
next if assigned?(field.name)
|
|
183
|
+
|
|
184
|
+
RAW_SET.bind_call(self, field.name, field.default_value)
|
|
185
|
+
end
|
|
186
|
+
self
|
|
187
|
+
end
|
|
188
|
+
|
|
133
189
|
def [](key)
|
|
134
190
|
sym = key.to_sym
|
|
135
191
|
validate!(sym) unless key?(sym) || resolver
|
|
@@ -153,8 +209,14 @@ module StandardId
|
|
|
153
209
|
|
|
154
210
|
private
|
|
155
211
|
|
|
212
|
+
def assigned_keys = (@assigned_keys ||= Set.new)
|
|
213
|
+
|
|
156
214
|
def validate!(key)
|
|
157
215
|
return if @schema.field?(@scope_name, key)
|
|
216
|
+
if (message = @schema.removed_field_message(@scope_name, key))
|
|
217
|
+
raise StandardId::ConfigurationError,
|
|
218
|
+
"StandardId.config.#{config_path(key)} was removed in StandardId 0.43: #{message}"
|
|
219
|
+
end
|
|
158
220
|
raise StandardId::ConfigurationError,
|
|
159
221
|
"Unknown field '#{key}' for scope '#{@scope_name}'. Valid fields: #{@schema.scopes[@scope_name]&.keys}"
|
|
160
222
|
end
|
|
@@ -215,6 +277,9 @@ module StandardId
|
|
|
215
277
|
sym = key.to_sym
|
|
216
278
|
if __schema__ && !__schema__.scope?(sym) && !key?(sym) && (target = unique_scope_for(sym))
|
|
217
279
|
self[target][sym] = value
|
|
280
|
+
elsif __schema__ && (message = __schema__.removed_field_message(:base, sym))
|
|
281
|
+
raise StandardId::ConfigurationError,
|
|
282
|
+
"StandardId.config.#{sym} was removed in StandardId 0.43: #{message}"
|
|
218
283
|
else
|
|
219
284
|
super(sym, value)
|
|
220
285
|
end
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -97,6 +97,7 @@ module StandardId
|
|
|
97
97
|
# surfaces typos at boot instead of at callback time in production.
|
|
98
98
|
StandardId::Config::CallableValidator.validate!
|
|
99
99
|
StandardId::Config::ScopeClaimsValidator.validate!
|
|
100
|
+
StandardId::ScopeConfig.validate_all!
|
|
100
101
|
|
|
101
102
|
StandardId::Engine.verify_host_cookie_encryption!(app)
|
|
102
103
|
StandardId::MigrationCheck.verify_at_boot!
|
|
@@ -11,6 +11,9 @@ module StandardId
|
|
|
11
11
|
# challenges, etc.) and would otherwise receive a duplicate email
|
|
12
12
|
# from this subscriber when c.passwordless.delivery == :built_in.
|
|
13
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
|
|
14
17
|
return unless built_in_delivery?
|
|
15
18
|
return unless event[:channel] == "email"
|
|
16
19
|
|
data/lib/standard_id/otp.rb
CHANGED
|
@@ -44,15 +44,24 @@ module StandardId
|
|
|
44
44
|
#
|
|
45
45
|
# == Delivery modes
|
|
46
46
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# * +:
|
|
52
|
-
# +
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
47
|
+
# Every mode publishes +StandardId::Events::PASSWORDLESS_CODE_GENERATED+
|
|
48
|
+
# (synchronously, inside the call); the payload carries +delivery:+ and
|
|
49
|
+
# +skip_sender:+ so subscribers can tell the modes apart.
|
|
50
|
+
#
|
|
51
|
+
# * +:built_in+ (default) — follow the global
|
|
52
|
+
# +StandardId.config.passwordless.delivery+: when that is +:built_in+ the
|
|
53
|
+
# engine's +PasswordlessMailer+ sends the code (+channel: :email+ only);
|
|
54
|
+
# otherwise the host's own +PASSWORDLESS_CODE_GENERATED+ subscriber does.
|
|
55
|
+
# * +:custom+ — the host's +PASSWORDLESS_CODE_GENERATED+ subscriber delivers
|
|
56
|
+
# this code; the engine mailer never does, even when the global setting is
|
|
57
|
+
# +:built_in+. Use it for a realm whose message differs from the sign-in
|
|
58
|
+
# email. Nothing is sent unless the host subscribes. (Before 0.43 this
|
|
59
|
+
# called +passwordless_email_sender+ / +passwordless_sms_sender+, both
|
|
60
|
+
# removed in 0.43.)
|
|
61
|
+
# * +:manual+ — no subscriber should deliver (+skip_sender: true+); the raw
|
|
62
|
+
# +code+ is returned on the result so the caller can deliver it however
|
|
63
|
+
# they like. Useful for custom widget/embedded flows that want full
|
|
64
|
+
# control over the channel.
|
|
56
65
|
module Otp
|
|
57
66
|
VALID_CHANNELS = %w[email sms].freeze
|
|
58
67
|
VALID_DELIVERIES = %i[built_in custom manual].freeze
|
|
@@ -112,13 +121,6 @@ module StandardId
|
|
|
112
121
|
return failure_issue_result(:invalid_request, "target: is required")
|
|
113
122
|
end
|
|
114
123
|
|
|
115
|
-
# Fail loud when the caller asked for :custom delivery but has not
|
|
116
|
-
# configured the corresponding sender callback. Without this guard
|
|
117
|
-
# BaseStrategy#start! would silently skip delivery (the sender_callback
|
|
118
|
-
# is nil and `&.call` no-ops) while Otp.issue still returned a success
|
|
119
|
-
# result — making host apps believe the OTP was sent when it was not.
|
|
120
|
-
assert_custom_sender_configured!(channel_s) if delivery_sym == :custom
|
|
121
|
-
|
|
122
124
|
strategy = build_strategy(channel_s, request, realm: realm_s)
|
|
123
125
|
|
|
124
126
|
begin
|
|
@@ -127,7 +129,8 @@ module StandardId
|
|
|
127
129
|
code_length: code_length,
|
|
128
130
|
expires_in: normalize_expires_in(expires_in),
|
|
129
131
|
metadata: metadata,
|
|
130
|
-
skip_sender: delivery_sym == :manual
|
|
132
|
+
skip_sender: delivery_sym == :manual,
|
|
133
|
+
delivery: delivery_sym
|
|
131
134
|
)
|
|
132
135
|
rescue StandardId::InvalidRequestError => e
|
|
133
136
|
# Validation failures from the strategy (invalid email/phone format,
|
|
@@ -226,17 +229,6 @@ module StandardId
|
|
|
226
229
|
error_message: error_message
|
|
227
230
|
)
|
|
228
231
|
end
|
|
229
|
-
|
|
230
|
-
def assert_custom_sender_configured!(channel)
|
|
231
|
-
attr = channel == "email" ? :passwordless_email_sender : :passwordless_sms_sender
|
|
232
|
-
sender = StandardId.config.public_send(attr)
|
|
233
|
-
return if sender.respond_to?(:call)
|
|
234
|
-
|
|
235
|
-
raise StandardId::ConfigurationError,
|
|
236
|
-
"Otp.issue(delivery: :custom) requires StandardId.config.#{attr} to be a callable " \
|
|
237
|
-
"(got #{sender.inspect}). Configure it in an initializer, or use delivery: :built_in " \
|
|
238
|
-
"to let the engine's event subscriber deliver, or delivery: :manual to receive the raw code."
|
|
239
|
-
end
|
|
240
232
|
end
|
|
241
233
|
|
|
242
234
|
# Minimal stand-in used when no request is available (e.g. jobs, tests).
|
|
@@ -12,14 +12,23 @@ module StandardId
|
|
|
12
12
|
raise NotImplementedError
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Start flow: validate recipient, create challenge, and
|
|
16
|
-
#
|
|
15
|
+
# Start flow: validate recipient, create challenge, and publish
|
|
16
|
+
# PASSWORDLESS_CODE_GENERATED — delivery happens in a subscriber to that
|
|
17
|
+
# event (the engine's PasswordlessDeliverySubscriber when
|
|
18
|
+
# c.passwordless.delivery == :built_in, otherwise the host's own).
|
|
19
|
+
#
|
|
20
|
+
# attrs: { connection:, username:, code_length:, expires_in:, metadata:, skip_sender:, delivery: }
|
|
21
|
+
#
|
|
22
|
+
# +delivery+ (:built_in / :custom / :manual, or nil when not called via
|
|
23
|
+
# StandardId::Otp.issue) is forwarded in the event payload; see
|
|
24
|
+
# Otp.issue for what each value means to the subscribers.
|
|
17
25
|
def start!(attrs)
|
|
18
26
|
username = attrs[:username]
|
|
19
27
|
code_length = attrs[:code_length]
|
|
20
28
|
expires_in = attrs[:expires_in]
|
|
21
29
|
metadata = attrs[:metadata] || {}
|
|
22
30
|
skip_sender = attrs[:skip_sender] == true
|
|
31
|
+
delivery = attrs[:delivery]&.to_sym
|
|
23
32
|
|
|
24
33
|
validate_username!(username)
|
|
25
34
|
run_username_validator!(username)
|
|
@@ -33,11 +42,10 @@ module StandardId
|
|
|
33
42
|
)
|
|
34
43
|
# skip_sender is forwarded into the event payload so subscribers that
|
|
35
44
|
# deliver on PASSWORDLESS_CODE_GENERATED (e.g. PasswordlessDeliverySubscriber)
|
|
36
|
-
# can honor a per-call manual-delivery request
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
emit_code_generated(challenge, username, skip_sender: skip_sender)
|
|
40
|
-
sender_callback&.call(username, challenge.code) unless skip_sender
|
|
45
|
+
# can honor a per-call manual-delivery request. Without this,
|
|
46
|
+
# Otp.issue(delivery: :manual) silently double-delivers when
|
|
47
|
+
# c.passwordless.delivery == :built_in.
|
|
48
|
+
emit_code_generated(challenge, username, skip_sender: skip_sender, delivery: delivery)
|
|
41
49
|
emit_code_sent(username) unless skip_sender
|
|
42
50
|
challenge
|
|
43
51
|
end
|
|
@@ -156,11 +164,6 @@ module StandardId
|
|
|
156
164
|
raise NotImplementedError
|
|
157
165
|
end
|
|
158
166
|
|
|
159
|
-
def sender_callback
|
|
160
|
-
# Implement in subclasses
|
|
161
|
-
nil
|
|
162
|
-
end
|
|
163
|
-
|
|
164
167
|
private
|
|
165
168
|
|
|
166
169
|
# Extract request parameters safely. Returns an empty hash if the request
|
|
@@ -187,7 +190,7 @@ module StandardId
|
|
|
187
190
|
)
|
|
188
191
|
end
|
|
189
192
|
|
|
190
|
-
def emit_code_generated(challenge, username, skip_sender: false)
|
|
193
|
+
def emit_code_generated(challenge, username, skip_sender: false, delivery: nil)
|
|
191
194
|
StandardId::Events.publish(
|
|
192
195
|
StandardId::Events::PASSWORDLESS_CODE_GENERATED,
|
|
193
196
|
code_challenge: challenge,
|
|
@@ -195,7 +198,8 @@ module StandardId
|
|
|
195
198
|
channel: connection_type,
|
|
196
199
|
realm: @realm,
|
|
197
200
|
expires_at: challenge.expires_at,
|
|
198
|
-
skip_sender: skip_sender
|
|
201
|
+
skip_sender: skip_sender,
|
|
202
|
+
delivery: delivery
|
|
199
203
|
)
|
|
200
204
|
end
|
|
201
205
|
|
|
@@ -20,11 +20,6 @@ module StandardId
|
|
|
20
20
|
identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: normalized)
|
|
21
21
|
identifier&.account
|
|
22
22
|
end
|
|
23
|
-
|
|
24
|
-
def sender_callback
|
|
25
|
-
return nil if StandardId.config.passwordless.delivery == :built_in
|
|
26
|
-
StandardId.config.passwordless_email_sender
|
|
27
|
-
end
|
|
28
23
|
end
|
|
29
24
|
end
|
|
30
25
|
end
|
|
@@ -9,11 +9,6 @@ module StandardId
|
|
|
9
9
|
# StandardId (see Providers::Base) rather than to ConfigSchema.
|
|
10
10
|
PROVIDER_FIELD_OPTIONS = %i[env required].freeze
|
|
11
11
|
|
|
12
|
-
# The gem-wide StandardId.deprecator (registered in
|
|
13
|
-
# Rails.application.deprecators), kept as a constant for existing callers.
|
|
14
|
-
# The Base.setup message names its own removal version (1.0).
|
|
15
|
-
DEPRECATOR = StandardId.deprecator
|
|
16
|
-
|
|
17
12
|
@providers = Concurrent::Map.new
|
|
18
13
|
|
|
19
14
|
class << self
|
|
@@ -28,7 +23,6 @@ module StandardId
|
|
|
28
23
|
validate_provider!(provider_class)
|
|
29
24
|
providers[name.to_s] = provider_class
|
|
30
25
|
declare_config_schema(provider_class)
|
|
31
|
-
run_deprecated_setup(provider_class)
|
|
32
26
|
provider_class
|
|
33
27
|
end
|
|
34
28
|
|
|
@@ -53,9 +47,8 @@ module StandardId
|
|
|
53
47
|
# populated before any initializer runs.
|
|
54
48
|
#
|
|
55
49
|
# Only FIELD DECLARATION moves earlier. Full `register` — which also runs
|
|
56
|
-
# `validate_provider!`
|
|
57
|
-
#
|
|
58
|
-
# `setup` can rely on it.
|
|
50
|
+
# `validate_provider!` — deliberately stays in `after_initialize`, where
|
|
51
|
+
# the host's configuration is complete.
|
|
59
52
|
#
|
|
60
53
|
# Idempotent: `ConfigSchema#add_field` uses `compute_if_absent`, so a field
|
|
61
54
|
# already declared here is untouched when the plugin later calls `register`.
|
|
@@ -207,20 +200,6 @@ module StandardId
|
|
|
207
200
|
end
|
|
208
201
|
end
|
|
209
202
|
|
|
210
|
-
# Providers::Base.setup was removed in 0.42: no known plugin overrode
|
|
211
|
-
# it, and register — its only caller — runs from after_initialize, where
|
|
212
|
-
# a plugin's own Railtie can do the same work. A provider that still
|
|
213
|
-
# defines `setup` keeps working, with a deprecation warning.
|
|
214
|
-
def run_deprecated_setup(provider_class)
|
|
215
|
-
return unless provider_class.respond_to?(:setup)
|
|
216
|
-
|
|
217
|
-
DEPRECATOR.warn(
|
|
218
|
-
"#{provider_class.name || provider_class}.setup is deprecated and will not be called " \
|
|
219
|
-
"by StandardId 1.0. Move provider initialization into the plugin's Railtie."
|
|
220
|
-
)
|
|
221
|
-
provider_class.setup
|
|
222
|
-
end
|
|
223
|
-
|
|
224
203
|
def production?
|
|
225
204
|
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
226
205
|
end
|
|
@@ -24,35 +24,35 @@ module StandardId
|
|
|
24
24
|
:allow_registration,
|
|
25
25
|
:authorizer
|
|
26
26
|
|
|
27
|
-
# Kept as an alias for hosts/specs that reference it; it is the gem-wide
|
|
28
|
-
# StandardId.deprecator, registered in Rails.application.deprecators.
|
|
29
|
-
DEPRECATOR = StandardId.deprecator
|
|
30
|
-
|
|
31
27
|
# Normalize profile-type inputs from config.
|
|
32
28
|
#
|
|
33
|
-
# Accepts:
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
29
|
+
# Accepts :profile_types — an Array of profile-type class names (a single
|
|
30
|
+
# String is wrapped). The singular :profile_type key was removed in 0.43
|
|
31
|
+
# (deprecated in 0.42) and now raises: silently ignoring it would leave the
|
|
32
|
+
# scope with NO profile requirement, admitting every account.
|
|
37
33
|
#
|
|
38
|
-
#
|
|
34
|
+
# @return [Array<String>] possibly empty
|
|
35
|
+
# @raise [StandardId::ConfigurationError] when the removed :profile_type key is present
|
|
39
36
|
def self.extract_profile_types(config)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
raise ArgumentError, "Scope config cannot set both :profile_type and :profile_types — use :profile_types"
|
|
37
|
+
if config.key?(:profile_type) || config.key?("profile_type")
|
|
38
|
+
raise StandardId::ConfigurationError,
|
|
39
|
+
"StandardId scope config key :profile_type was removed in StandardId 0.43. " \
|
|
40
|
+
"Use profile_types: [...] (an Array of profile-type class names) instead."
|
|
45
41
|
end
|
|
46
42
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
Array(config[:profile_types]).map(&:to_s).reject(&:blank?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build every scope in StandardId.config.scopes once, so a scope config the
|
|
47
|
+
# gem can no longer read (e.g. the removed :profile_type key) fails at boot
|
|
48
|
+
# rather than on the first sign-in under that scope. Run by the engine.
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
# @raise [StandardId::ConfigurationError]
|
|
52
|
+
def self.validate_all!(scopes = StandardId.config.scopes)
|
|
53
|
+
return if scopes.blank?
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
scopes.each { |name, scope_hash| new(name, scope_hash || {}) }
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def initialize(name, config = {})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Testing
|
|
3
|
+
# Helpers for specs that exercise StandardId configuration defaults —
|
|
4
|
+
# chiefly the ENV fallback of provider fields (`GOOGLE_CLIENT_ID`,
|
|
5
|
+
# `APPLE_CLIENT_ID`, …).
|
|
6
|
+
#
|
|
7
|
+
# Those defaults are resolved ONCE, when the config is built at boot, so
|
|
8
|
+
# setting ENV inside a spec changes nothing on its own. `with_provider_env`
|
|
9
|
+
# sets the variables, re-resolves every field the host never assigned, runs
|
|
10
|
+
# the block, then restores both:
|
|
11
|
+
#
|
|
12
|
+
# RSpec.describe "Apple sign-in" do
|
|
13
|
+
# include StandardId::Testing::ConfigHelpers
|
|
14
|
+
#
|
|
15
|
+
# it "is enabled once APPLE_CLIENT_ID is set" do
|
|
16
|
+
# with_provider_env("APPLE_CLIENT_ID" => "com.example.web") do
|
|
17
|
+
# expect(StandardId.social_provider_enabled?(:apple)).to be(true)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# (also callable as `StandardId::Testing.with_provider_env(...)`).
|
|
23
|
+
#
|
|
24
|
+
# A field the host assigned explicitly — even to nil — ignores ENV by
|
|
25
|
+
# design; `StandardId.config.social.assigned?(:apple_client_id)` tells the
|
|
26
|
+
# two cases apart (`key?` is true for every declared field, assigned or
|
|
27
|
+
# not).
|
|
28
|
+
module ConfigHelpers
|
|
29
|
+
# @param env [Hash{String => String, nil}] variables to set; nil unsets.
|
|
30
|
+
# Usually passed brace-less (`with_provider_env("APPLE_CLIENT_ID" => "x")`),
|
|
31
|
+
# which Ruby delivers as keywords — hence **vars.
|
|
32
|
+
# @param scope [Symbol] config scope whose defaults to re-resolve
|
|
33
|
+
# @yield with the variables set and the scope's defaults re-resolved
|
|
34
|
+
# @return [Object] the block's value
|
|
35
|
+
def with_provider_env(env = {}, scope: :social, **vars)
|
|
36
|
+
env = env.merge(vars)
|
|
37
|
+
previous = env.to_h { |name, _| [name.to_s, ENV[name.to_s]] }
|
|
38
|
+
env.each { |name, value| write_env(name.to_s, value) }
|
|
39
|
+
StandardId.config[scope].refresh_defaults!
|
|
40
|
+
yield
|
|
41
|
+
ensure
|
|
42
|
+
previous&.each { |name, value| write_env(name, value) }
|
|
43
|
+
StandardId.config[scope].refresh_defaults!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def write_env(name, value)
|
|
49
|
+
value.nil? ? ENV.delete(name) : ENV[name] = value.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
extend ConfigHelpers
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -60,7 +60,7 @@ module StandardId
|
|
|
60
60
|
# fallback keeps working for later examples.
|
|
61
61
|
def round_trip(field, value)
|
|
62
62
|
social = StandardId.config.social
|
|
63
|
-
assigned = social.
|
|
63
|
+
assigned = social.assigned?(field)
|
|
64
64
|
original = social.to_h[field.to_sym]
|
|
65
65
|
social.public_send(:"#{field}=", value)
|
|
66
66
|
social.public_send(field)
|
data/lib/standard_id/testing.rb
CHANGED
data/lib/standard_id/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.43.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -166,6 +166,7 @@ files:
|
|
|
166
166
|
- app/forms/standard_id/web/signup_form.rb
|
|
167
167
|
- app/helpers/standard_id/application_helper.rb
|
|
168
168
|
- app/jobs/standard_id/application_job.rb
|
|
169
|
+
- app/jobs/standard_id/cleanup_all_job.rb
|
|
169
170
|
- app/jobs/standard_id/cleanup_expired_authorization_codes_job.rb
|
|
170
171
|
- app/jobs/standard_id/cleanup_expired_code_challenges_job.rb
|
|
171
172
|
- app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb
|
|
@@ -303,6 +304,7 @@ files:
|
|
|
303
304
|
- lib/standard_id/session_type_resolver.rb
|
|
304
305
|
- lib/standard_id/testing.rb
|
|
305
306
|
- lib/standard_id/testing/authentication_helpers.rb
|
|
307
|
+
- lib/standard_id/testing/config_helpers.rb
|
|
306
308
|
- lib/standard_id/testing/factories/credentials.rb
|
|
307
309
|
- lib/standard_id/testing/factories/identifiers.rb
|
|
308
310
|
- lib/standard_id/testing/factories/oauth.rb
|