standard_id 0.41.1 → 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 +111 -0
- data/README.md +310 -17
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
- data/app/controllers/standard_id/web/login_controller.rb +1 -2
- data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
- 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/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
- data/app/models/concerns/standard_id/credentiable.rb +8 -1
- data/app/models/standard_id/application_record.rb +26 -0
- data/app/models/standard_id/authorization_code.rb +3 -1
- data/app/models/standard_id/identifier.rb +1 -0
- data/app/models/standard_id/session.rb +2 -1
- data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
- data/app/views/standard_id/web/login/show.html.erb +5 -5
- data/app/views/standard_id/web/signup/show.html.erb +3 -3
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
- data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
- data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
- data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
- data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +34 -20
- data/lib/standard_id/config_schema.rb +94 -6
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +22 -0
- data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
- data/lib/standard_id/instrumentation.rb +49 -0
- data/lib/standard_id/migration_check.rb +183 -0
- data/lib/standard_id/migration_helpers.rb +65 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
- data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- 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 +95 -5
- data/lib/standard_id/providers/base.rb +222 -8
- data/lib/standard_id/providers/plugin_railtie.rb +59 -0
- data/lib/standard_id/scope_config.rb +43 -26
- data/lib/standard_id/testing/config_helpers.rb +55 -0
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +2 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +25 -17
- data/config/initializers/migration_helpers.rb +0 -32
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,117 @@ 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
|
+
|
|
55
|
+
## [0.42.0] - 2026-09-24
|
|
56
|
+
|
|
57
|
+
### Upgrade
|
|
58
|
+
|
|
59
|
+
- **Hosts can delete their `StandardId::* .strict_loading_by_default = false` block** (fundbright-web, luminality-web, nutripod-web `config/initializers/strict_loading.rb`; sidekick-web's StandardId lines in the same file). Every gem model now works under `strict_loading_by_default = true` + `:raise` through every gem flow; see Fixed. Keep an exemption only if *your own* code lazily traverses a gem association (e.g. `identifier.account` in a host controller) — prefer `includes` there instead.
|
|
60
|
+
- **sidekick-web:** replace `config/initializers/standard_id_tracing.rb` with the README's Sentry subscriber for `StandardId::Instrumentation` (see Added).
|
|
61
|
+
- **Every host but fundbright-web:** schedule the cleanup jobs you are missing (README *Scheduled Maintenance*).
|
|
62
|
+
- **Watch for deprecation warnings** (now routed through `Rails.application.deprecators`, so they follow each app's `config.active_support.deprecation`): fundbright-web's `passwordless_email_sender` is the one live use among consumers — see Deprecated.
|
|
63
|
+
- **Hosts with `APPLE_*` / `GOOGLE_*` variables in their environment:** those now enable the provider even if never assigned — see the ENV-fallback entry under Changed.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **`ActiveSupport::Notifications` instrumentation of the token endpoint** (`StandardId::Instrumentation`): `authenticate.standard_id` around every token grant's `authenticate!`, `audience_profile_binding.standard_id` around audience→profile binding, and `audience_profile_resolve.standard_id` around `AudienceProfileResolver.resolve!` (nested inside the binding event). Block events with `flow` / `grant_type` / `audience` payloads; `StandardId::Instrumentation::PATTERN` subscribes to all three and to none of the `standard_id.*` domain events. The README has a drop-in Sentry span subscriber. **sidekick-web can delete `config/initializers/standard_id_tracing.rb`** (which prepends Sentry spans onto the private `RefreshTokenFlow#authenticate!`, `TokenGrantFlow#enforce_audience_profile_binding!` and `AudienceProfileResolver.resolve!`) in favour of that subscriber; span ops stay `standard_id.authenticate` / `.audience_profile_binding` / `.audience_profile_resolve` (the prepend used `standard_id.refresh_token_flow.authenticate` etc. — update any saved Sentry queries).
|
|
68
|
+
- **`rails g standard_id:install` schedules all four cleanup jobs** in `config/recurring.yml` (Solid Queue, under `production:`, hourly and staggered) when that file exists; idempotent, `--skip-recurring` to opt out. The post-install checklist now lists all four jobs (it named two). A new README *Scheduled Maintenance* table lists every job, its grace windows and the recommended cadence, with a paste-ready snippet. Only fundbright-web schedules all four today; **luminality-web, nutripod-web, sidekick-web and jumpdrive-web should add the missing ones** (paste the README snippet). No engine-side `schedule_cleanup_jobs` switch: Solid Queue reads a single schedule file, so an engine cannot register recurring tasks cleanly.
|
|
69
|
+
- **Missing-migration guard.** `StandardId::MigrationCheck` compares the gem's `db/migrate` to the host's migration files **by name** (surviving `install:migrations`' re-timestamping) and, optionally, to `schema_migrations`. A boot check (`config.missing_migrations`: `:warn` by default in development/test, `:ignore` elsewhere, `:raise` opt-in; file-system only, never touches the database) catches gem migrations that were never copied — the way fundbright-web, luminality-web and nutripod-web ran for months without `20260416180511`. `StandardId::Checks::Migrations` is a standard_health-compatible, non-critical check (one directory scan + one `schema_migrations` read, `:ok` memoized per process). `config.ignored_migrations` skips a deliberately superseded/deferred one. Two built-in exceptions: `SUPERSEDED_BY` treats `20260414200000` (plain 4-column code_challenges index) as satisfied when `20260416180511` (its partial, concurrent replacement) is installed, so fundbright-web and luminality-web, which skipped it on purpose, are not flagged; `DEFERRED_UPGRADE_STEPS` reports `20260915000000` (the column drop hosts hold until 0.41.1+ is deployed) as a pending upgrade step (`severity: :info`: info log at boot, `:ok` with `pending_upgrade_steps` in the health check), never as an error. Checked against every consumer's `origin/main`: no errors, only that pending step.
|
|
70
|
+
- **Provider-plugin helpers on `StandardId::Providers::Base`** (protected class methods, stable signatures): `rescue_to_oauth_error`, `verify_nonce!`, `build_authorization_url` and `extract_tokens`. standard_id-apple and standard_id-google each carry private copies of all four and can drop them in their next releases. `verify_nonce!` compares in constant time and — unlike the plugin copies — never puts the expected nonce in its error message.
|
|
71
|
+
- **`StandardId::Providers.plugin_railtie(:name, klass)`** defines the Railtie that registers a provider plugin, replacing the near-identical `railtie.rb` and `if defined?(Rails)` guard every plugin carries.
|
|
72
|
+
- **Provider enablement:** `Providers::Base.enabled?`, `configuration_errors`, `configured?`, `enabling_config_field` and `required_config_fields`; `StandardId.enabled_social_providers` and `StandardId.social_provider_enabled?(name)`. Plugins mark fields `required: true` in `config_schema`. Use `StandardId.social_provider_enabled?(:google)` instead of `StandardId.config.google_client_id.present?` (sidekick-web's `Bootstrap::SetupController` and `InvitationAcceptanceController`).
|
|
73
|
+
- **Boot-time provider validation.** Once every plugin has registered, an enabled provider missing required fields is logged as a warning; `c.social.provider_misconfiguration = :raise` raises `StandardId::ConfigurationError` in production instead (still a warning elsewhere). This is the two-stage Apple credential check sidekick-web rebuilds by hand in `config/initializers/standard_health.rb`; it applies to a provider once its plugin declares its required fields.
|
|
74
|
+
- **ENV defaults for provider config fields.** A field the host never assigns falls back to the ENV variable named after it, upper-cased (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `APPLE_CLIENT_ID`, `APPLE_MOBILE_CLIENT_ID`, `APPLE_PRIVATE_KEY`, `APPLE_KEY_ID`, `APPLE_TEAM_ID`) — the scheme sidekick-web runs in production and the install template already used. Plugins can rename a field's variable with `env: "NAME"` or opt out with `env: false`. Explicit configuration, even `nil`, always wins.
|
|
75
|
+
- **`Providers::Base.flow_for(params)`** resolves the API callback flow (`:web` / `:mobile`).
|
|
76
|
+
- **Inertia prop `enabled_social_providers`** (`string[]`), and `social_providers` now carries `<name>_enabled` for every registered provider. `google_enabled` / `apple_enabled` are still always present.
|
|
77
|
+
- **`StandardId::Testing` provider examples:** `it_behaves_like "a registered StandardId provider", :google` and the `be_a_registered_standard_id_provider.with_config_fields(...)` matcher, replacing the duplicated `spec/initializers/standard_id_provider_*_spec.rb` in sidekick-web and luminality-web. Loaded by `require "standard_id/testing"` under RSpec.
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
|
|
81
|
+
- **`ScopeConfig#allow_registration` is now enforced** (it was documented "reserved for future use" and never read). Passwordless sign-in may create an account iff the global switch allows it **and** the active scope's `allow_registration` is not `false` — a scope can only restrict:
|
|
82
|
+
- WebEngine `login_verify`: `web.passwordless_registration && scope.allow_registration`.
|
|
83
|
+
- Host controllers using `StandardId::PasswordlessFlow#verify_passwordless_otp`: the caller's `allow_registration:` (default `true`) `&&` the scope from `LifecycleHooks#current_scope_config`, when the controller includes it.
|
|
84
|
+
- The Inertia `enabled_mechanisms.passwordless_registration` prop reports the effective per-request value.
|
|
85
|
+
- With no active scope nothing changes. Consumers checked: nutripod-web (`storefront: allow_registration: true`, passes `true`) and fundbright-web (all scopes `false`, already passes `false`) see no behaviour change.
|
|
86
|
+
- Not applied to the API OAuth `passwordless_otp` grant (no scope is resolved on API token requests — `scope_resolver` is a WebEngine concept and host resolvers may assume a browser session), nor to password signup / social account creation, which have their own switches (`web.signup`, provider config).
|
|
87
|
+
- `ScopeConfig#allow_registration?` and `ScopeConfig.registration_allowed?(global, scope_config)` added; an explicit `allow_registration: nil` now means the default (`true`).
|
|
88
|
+
- **`primary_key_type` / `foreign_key_type` are no longer monkey-patched onto every `ActiveRecord::Migration`.** They moved to `StandardId::MigrationHelpers`, which the gem's migrations now `include` explicitly. Host copies installed before this release (which call the helpers without the include) keep working: a migration class defined from a file whose name is a StandardId migration (with or without the `.standard_id` suffix) gets the module automatically. A host's **own** migration that happened to call `primary_key_type` without defining it will now raise `NameError` — define it in that migration (Active Storage's copies define their own and are unaffected). The engine's `config/initializers/migration_helpers.rb` is gone.
|
|
89
|
+
- **`ostruct` is no longer a runtime dependency** — nothing in `app/` or `lib/` uses it; it is now a development dependency for the specs' `OpenStruct` doubles. On Ruby 4 `ostruct` is a bundled gem, so a host using `OpenStruct` must list it in its own Gemfile — fundbright-web, luminality-web and nutripod-web already do; sidekick-web and jumpdrive-web don't use it.
|
|
90
|
+
- Gemspec summary (and README/AGENTS) say Rails 8, matching `rails >= 8.0`, instead of "Rails 7/8".
|
|
91
|
+
- **No more hard-coded provider names in the host.** Inertia `auth_page_props`, the built-in ERB login/signup views and the API callback (`connection == "apple"`) now go through `ProviderRegistry` and provider capability methods.
|
|
92
|
+
- **Behaviour change — ENV fallback:** an app that has `APPLE_CLIENT_ID` (etc.) in its environment but never assigned `apple_client_id` now has that provider enabled. If that variable means something else in your app, assign the field explicitly (`c.social.apple_client_id = nil`).
|
|
93
|
+
|
|
94
|
+
### Deprecated
|
|
95
|
+
|
|
96
|
+
Runtime warnings only, all through `StandardId.deprecator` — everything below still works exactly as before. Config settings are removed in v2.0; `Providers::Base.setup` stops being called in 1.0.
|
|
97
|
+
|
|
98
|
+
- **`StandardId.deprecator`**, registered as `Rails.application.deprecators[:standard_id]`, so StandardId warnings follow the host's `config.active_support.deprecation` behaviour (`:raise` in test, `:log`/`:notify` elsewhere) and `Rails.application.deprecators.silence`. `ScopeConfig::DEPRECATOR` and `ProviderRegistry::DEPRECATOR` are now aliases of it; the former previously was a private, unregistered instance that only ever printed to stderr. Schema fields can declare `deprecated: "message"`; assigning a non-nil value warns (pointing at the assigning line), reads never do.
|
|
99
|
+
- **`oauth.client_id` / `oauth.client_secret`** — never read. OAuth clients are `ClientApplication` / `ClientSecretCredential` records. Delete the lines.
|
|
100
|
+
- **`passwordless.enabled`** — no effect since 0.8. Use `web.passwordless_login`.
|
|
101
|
+
- **`rate_limits.password_login_per_ip` / `password_login_per_email`** — use `rate_limits.login_per_ip` / `login_per_email` (same values; they also govern passwordless OTP sends).
|
|
102
|
+
- **Scope config `profile_type:` (singular)** — already warned; now through the registered deprecator. Use `profile_types: [...]`.
|
|
103
|
+
- **`passwordless_email_sender` / `passwordless_sms_sender`** — deprecated since 0.1.7 in docs, now at runtime. Deliver from a `StandardId::Events::PASSWORDLESS_CODE_GENERATED` subscriber (skip when `event[:skip_sender]`), which runs synchronously inside the request — so fundbright-web's `I18n.locale` capture for `AuthMailer` works unchanged — and leave `passwordless.delivery = :custom` so the engine's built-in mailer stays out of the way. Callers of `Otp.issue(delivery: :custom)` switch to the default `delivery: :built_in`, which then defers to that subscriber. Note: the WebEngine `verify_email` / `verify_phone` start actions still call the senders directly and emit no event, so a host relying on them should keep the sender until that is addressed.
|
|
104
|
+
- **`Providers::Base.setup`** is removed from the base class (no known plugin overrode it). A provider that still defines `setup` keeps having it called on `register`, with a deprecation warning (through the shared `StandardId.deprecator`); it will stop being called in 1.0.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **Gem flows no longer lazy-load under strict loading, so gem models run strict.** The dummy app previously exempted `Identifier`, `Session`, `Credential`, `PasswordCredential`, `ClientSecretCredential` and `AuthorizationCode`; that list is gone and the whole suite runs with every gem model strict. Fixed reads:
|
|
109
|
+
- `AuthorizationCode.lookup` preloads `:account` (read by the token exchange's `OAUTH_CODE_CONSUMED` event and `token_account` — Sentry FUNDBRIGHT-WEB-X).
|
|
110
|
+
- Client-secret authentication (`TokenGrantFlow#validate_client_secret!`) preloads `:client_application` (read by the client-credentials `AUTHENTICATION_SUCCEEDED` event and `token_client`).
|
|
111
|
+
- The social-login identifier lookup and `PasswordResetDeliveryJob` preload `:account`.
|
|
112
|
+
- `Session.revoke_sessions!` without an `account:` (the OAuth revocation endpoint), `Session#revoke!`'s `SESSION_REVOKED` event and `Identifier#mark_account_verified!` (after-commit callback on `verify!`) preload `:account` onto the already-loaded record via the new `StandardId::ApplicationRecord.preload_associations` — they receive records they did not query, so no `includes` could reach them.
|
|
113
|
+
- **One declared exemption:** `Credentiable`'s `has_one :credential` (on `PasswordCredential` and `ClientSecretCredential`) is declared `strict_loading: false`. Its `touch: true` makes Rails read the association from inside its own save/destroy callbacks (`Builder::HasOne.touch_record`), which no call site can preload; without it every credential save raised. It is a one-row read, never an N+1.
|
|
114
|
+
|
|
115
|
+
- **Hosts running StrongMigrations no longer have to hand-wrap `20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups`.** Its partial `standard_id_code_challenges (realm, channel, target, created_at) WHERE used_at IS NULL` index trips StrongMigrations' "non-unique index with more than three columns" check, so fundbright, luminality and nutripod each had to wrap it in `safety_assured` when installing it. The shape is deliberate (three equality predicates plus the `ORDER BY created_at` column, partial, built CONCURRENTLY), so the migration now asserts that one `add_index` safe itself when StrongMigrations is loaded — the same pattern `20260924000000` uses. Hosts that already installed a hand-wrapped copy need do nothing.
|
|
116
|
+
|
|
117
|
+
### Documentation
|
|
118
|
+
|
|
119
|
+
- **0.41.1 upgrade note, added after the fact:** because 0.41.1 lists `refresh_token_lifetime` in `StandardId::ClientApplication.ignored_columns`, any host code that still *assigns* it (dynamic client registration, seeds, rake tasks, factories/specs) now raises `ActiveModel::UnknownAttributeError`. Remove those writes when upgrading — the value was never honoured; refresh-token lifetime is the global `oauth.refresh_token_lifetime`.
|
|
120
|
+
|
|
10
121
|
## [0.41.1] - 2026-09-24
|
|
11
122
|
|
|
12
123
|
Bug-fix release. Four of these were found in sidekick-web, which has been carrying host-side prepend patches for three of them; the dummy app now runs with `strict_loading_by_default` so this class of bug fails in the gem's own suite first.
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# StandardId
|
|
2
2
|
|
|
3
|
-
A comprehensive authentication engine for Rails applications, built on the security primitives introduced in Rails
|
|
3
|
+
A comprehensive authentication engine for Rails applications, built on the security primitives introduced in Rails 8. StandardId provides a complete, secure-by-default solution for identity management, reducing boilerplate and eliminating common security pitfalls.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -283,18 +283,51 @@ same code raised and apps wrapped the writes in
|
|
|
283
283
|
`Rails.application.config.after_initialize { ... }`. That wrapper is no longer
|
|
284
284
|
necessary; existing ones keep working unchanged.
|
|
285
285
|
|
|
286
|
+
**ENV defaults (0.42+).** Every provider field you never assign falls back to
|
|
287
|
+
the ENV variable named after it, upper-cased. With these set, the provider
|
|
288
|
+
credentials need no lines in your initializer at all:
|
|
289
|
+
|
|
290
|
+
| Field | ENV variable |
|
|
291
|
+
|-------|--------------|
|
|
292
|
+
| `google_client_id` / `google_client_secret` | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` |
|
|
293
|
+
| `apple_client_id` (web Services ID) | `APPLE_CLIENT_ID` |
|
|
294
|
+
| `apple_mobile_client_id` (native bundle ID) | `APPLE_MOBILE_CLIENT_ID` |
|
|
295
|
+
| `apple_private_key` (.p8 PEM, newlines intact) | `APPLE_PRIVATE_KEY` |
|
|
296
|
+
| `apple_key_id` / `apple_team_id` | `APPLE_KEY_ID` / `APPLE_TEAM_ID` |
|
|
297
|
+
|
|
298
|
+
Assign a field only to read it from somewhere else — a differently named
|
|
299
|
+
variable, Rails credentials. An explicit assignment, even of `nil`, always wins
|
|
300
|
+
over the ENV fallback.
|
|
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
|
+
|
|
286
326
|
```ruby
|
|
287
327
|
StandardId.configure do |config|
|
|
288
|
-
#
|
|
289
|
-
config.social.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
# Apple Sign In
|
|
293
|
-
config.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"]
|
|
294
|
-
config.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
|
|
295
|
-
config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"]
|
|
296
|
-
config.social.apple_key_id = ENV["APPLE_KEY_ID"]
|
|
297
|
-
config.social.apple_team_id = ENV["APPLE_TEAM_ID"]
|
|
328
|
+
# Only needed when not using the canonical ENV names above:
|
|
329
|
+
config.social.apple_private_key = Rails.application.credentials.dig(:apple, :private_key)
|
|
330
|
+
|
|
298
331
|
config.social.allowed_redirect_url_prefixes = ["sidekicklabs://"]
|
|
299
332
|
|
|
300
333
|
# Optional: adjust which attributes are persisted during social signup
|
|
@@ -309,6 +342,30 @@ end
|
|
|
309
342
|
|
|
310
343
|
`social_info` is an indifferent-access hash containing at least `email`, `name`, and `provider_id`.
|
|
311
344
|
|
|
345
|
+
**Is a provider on?** A provider is *enabled* when its client ID is present.
|
|
346
|
+
Ask StandardId rather than checking the client ID yourself:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
StandardId.social_provider_enabled?(:google) # => false when the plugin is absent, too
|
|
350
|
+
StandardId.enabled_social_providers # => { "google" => StandardId::Providers::Google }
|
|
351
|
+
StandardId::Providers::Apple.configuration_errors
|
|
352
|
+
# => ["apple_private_key is required when apple_client_id is set"]
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**Boot-time check.** Once every plugin has registered, StandardId checks each
|
|
356
|
+
enabled provider for missing required fields — for example an Apple client ID
|
|
357
|
+
without the private key, whose sign-in flow would start fine and then fail at
|
|
358
|
+
the callback, after the user had already authenticated with Apple. By default
|
|
359
|
+
it logs a warning. To fail the deploy instead:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
config.social.provider_misconfiguration = :raise # raises in production, warns elsewhere
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Which fields are required is declared by each plugin (`required: true`, see
|
|
366
|
+
[Writing a Provider Plugin](#writing-a-provider-plugin)), so the check covers a
|
|
367
|
+
provider once its plugin release declares them.
|
|
368
|
+
|
|
312
369
|
To handle social login completion (e.g., for analytics or audit logging), subscribe to the `SOCIAL_AUTH_COMPLETED` event:
|
|
313
370
|
|
|
314
371
|
```ruby
|
|
@@ -361,6 +418,7 @@ interface Props {
|
|
|
361
418
|
connection: string | null
|
|
362
419
|
flash: { notice?: string; alert?: string }
|
|
363
420
|
social_providers: { google_enabled: boolean; apple_enabled: boolean }
|
|
421
|
+
enabled_social_providers: string[]
|
|
364
422
|
}
|
|
365
423
|
|
|
366
424
|
export default function LoginShow({ redirect_uri, flash, social_providers }: Props) {
|
|
@@ -455,7 +513,8 @@ Authentication pages receive the following props:
|
|
|
455
513
|
| `redirect_uri` | `string` | URL to redirect to after authentication |
|
|
456
514
|
| `connection` | `string \| null` | Social provider connection (if any) |
|
|
457
515
|
| `flash` | `{ notice?: string, alert?: string }` | Flash messages |
|
|
458
|
-
| `social_providers` | `{
|
|
516
|
+
| `social_providers` | `{ [name]_enabled: boolean }` | One flag per registered provider. `google_enabled` and `apple_enabled` are always present (false when the plugin is absent) |
|
|
517
|
+
| `enabled_social_providers` | `string[]` | Names of the enabled providers, e.g. `["google"]` — iterate this rather than hard-coding provider names |
|
|
459
518
|
| `errors` | `object` | Validation errors (on form submission failures) |
|
|
460
519
|
|
|
461
520
|
#### Using Authentication in Host App Controllers
|
|
@@ -483,6 +542,7 @@ Subscribe to the `PASSWORDLESS_CODE_GENERATED` event to deliver OTP codes:
|
|
|
483
542
|
```ruby
|
|
484
543
|
# config/initializers/standard_id_events.rb
|
|
485
544
|
StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
|
|
545
|
+
next if event[:skip_sender] # Otp.issue(delivery: :manual) — the caller delivers
|
|
486
546
|
case event[:channel]
|
|
487
547
|
when "email"
|
|
488
548
|
UserMailer.send_code(event[:identifier], event[:code_challenge].code).deliver_now
|
|
@@ -492,13 +552,21 @@ StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do
|
|
|
492
552
|
end
|
|
493
553
|
```
|
|
494
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
|
+
|
|
495
560
|
Event payload includes:
|
|
496
561
|
- `channel` - `"email"` or `"sms"`
|
|
497
562
|
- `identifier` - The email address or phone number
|
|
498
563
|
- `code_challenge` - The code challenge object with `.code` method
|
|
499
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
|
|
500
568
|
|
|
501
|
-
> **Note**:
|
|
569
|
+
> **Note**: `passwordless_email_sender` / `passwordless_sms_sender` were removed in 0.43; see the [Migration Guide](docs/MIGRATION_GUIDE.md).
|
|
502
570
|
|
|
503
571
|
### Using OTP for non-authentication flows
|
|
504
572
|
|
|
@@ -540,9 +608,9 @@ end
|
|
|
540
608
|
|
|
541
609
|
| Mode | Behavior |
|
|
542
610
|
|-------------|--------------------------------------------------------------------------|
|
|
543
|
-
| `:built_in` |
|
|
544
|
-
| `:custom` |
|
|
545
|
-
| `: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. |
|
|
546
614
|
|
|
547
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.
|
|
548
616
|
|
|
@@ -738,6 +806,52 @@ StandardAudit::AuditLog.from_ip("192.168.1.1")
|
|
|
738
806
|
|
|
739
807
|
See the [StandardAudit README](https://github.com/rarebit-one/standard_audit) for the full query interface, async processing, GDPR compliance, and multi-tenancy support.
|
|
740
808
|
|
|
809
|
+
### Instrumentation (tracing spans)
|
|
810
|
+
|
|
811
|
+
Separately from the domain events above, the OAuth token endpoint is instrumented with `ActiveSupport::Notifications` block events so you can add tracing spans or timings without patching gem internals. Names follow the Rails `<event>.<library>` convention, so `standard_id.*` audit subscribers never see them:
|
|
812
|
+
|
|
813
|
+
| Event (`StandardId::Instrumentation::…`) | Wraps | Payload |
|
|
814
|
+
|---|---|---|
|
|
815
|
+
| `AUTHENTICATE` — `authenticate.standard_id` | every token grant's `authenticate!` (for `refresh_token`: JWT decode + token lookup + reuse detection) | `flow`, `grant_type` |
|
|
816
|
+
| `AUDIENCE_PROFILE_BINDING` — `audience_profile_binding.standard_id` | audience→profile binding (account load + resolver) | `flow`, `grant_type`, `audience` |
|
|
817
|
+
| `AUDIENCE_PROFILE_RESOLVE` — `audience_profile_resolve.standard_id` | `Oauth::AudienceProfileResolver.resolve!` (nested inside the binding event) | `audience` |
|
|
818
|
+
|
|
819
|
+
A raised error appears as `:exception` / `:exception_object` in the finish payload. Sentry child spans, nested the same way:
|
|
820
|
+
|
|
821
|
+
```ruby
|
|
822
|
+
# config/initializers/standard_id_tracing.rb
|
|
823
|
+
return unless defined?(Sentry)
|
|
824
|
+
|
|
825
|
+
module StandardIdSentrySpans
|
|
826
|
+
STACK = :standard_id_sentry_spans
|
|
827
|
+
|
|
828
|
+
def self.start(name, _id, payload)
|
|
829
|
+
scope = Sentry.get_current_scope
|
|
830
|
+
parent = scope&.get_span
|
|
831
|
+
# No transaction in progress (a job, a console) → no parent → no span.
|
|
832
|
+
span = parent&.start_child(
|
|
833
|
+
op: "standard_id.#{name.delete_suffix('.standard_id')}",
|
|
834
|
+
description: Array(payload[:audience]).join(", ").presence
|
|
835
|
+
)
|
|
836
|
+
(Thread.current[STACK] ||= []) << [span, parent]
|
|
837
|
+
scope.set_span(span) if span
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def self.finish(_name, _id, _payload)
|
|
841
|
+
span, parent = Thread.current[STACK]&.pop
|
|
842
|
+
return unless span
|
|
843
|
+
|
|
844
|
+
span.finish
|
|
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
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
ActiveSupport::Notifications.subscribe(StandardId::Instrumentation::PATTERN, StandardIdSentrySpans)
|
|
853
|
+
```
|
|
854
|
+
|
|
741
855
|
## Account Status (Activation/Deactivation)
|
|
742
856
|
|
|
743
857
|
StandardId provides an optional `AccountStatus` concern for managing account activation and deactivation. This uses Rails enum with the event system to enforce status checks and handle side effects without modifying core authentication logic.
|
|
@@ -1182,6 +1296,121 @@ secret = client.create_client_secret!(name: "Production Secret")
|
|
|
1182
1296
|
new_secret = client.rotate_client_secret!
|
|
1183
1297
|
```
|
|
1184
1298
|
|
|
1299
|
+
## Writing a Provider Plugin
|
|
1300
|
+
|
|
1301
|
+
Social providers ship as separate gems (`standard_id-google`,
|
|
1302
|
+
`standard_id-apple`) that subclass `StandardId::Providers::Base` and register
|
|
1303
|
+
themselves. A minimal plugin is a provider class plus a two-line entry file.
|
|
1304
|
+
|
|
1305
|
+
```ruby
|
|
1306
|
+
# lib/standard_id/github.rb — the gem's entry file
|
|
1307
|
+
require "standard_id"
|
|
1308
|
+
require "standard_id/github/providers/github"
|
|
1309
|
+
|
|
1310
|
+
# Defines the Railtie that registers the provider after the host app has
|
|
1311
|
+
# initialized. No hand-written railtie.rb, no `if defined?(Rails)` guard.
|
|
1312
|
+
StandardId::Providers.plugin_railtie(:github, "StandardId::Providers::GitHub")
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
```ruby
|
|
1316
|
+
# lib/standard_id/github/providers/github.rb
|
|
1317
|
+
module StandardId
|
|
1318
|
+
module Providers
|
|
1319
|
+
class GitHub < Base
|
|
1320
|
+
AUTH_ENDPOINT = "https://github.com/login/oauth/authorize".freeze
|
|
1321
|
+
TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token".freeze
|
|
1322
|
+
|
|
1323
|
+
class << self
|
|
1324
|
+
def provider_name = "github"
|
|
1325
|
+
def default_scope = "read:user user:email"
|
|
1326
|
+
def supported_authorization_params = %i[scope login allow_signup]
|
|
1327
|
+
|
|
1328
|
+
# Fields land in the `social` config scope. `env:` and `required:` are
|
|
1329
|
+
# read by StandardId (>= 0.42) and not passed to ConfigSchema.
|
|
1330
|
+
def config_schema
|
|
1331
|
+
{
|
|
1332
|
+
github_client_id: { type: :string, default: nil }, # ENV GITHUB_CLIENT_ID
|
|
1333
|
+
github_client_secret: { type: :string, default: nil, required: true },
|
|
1334
|
+
github_enterprise_host: { type: :string, default: nil, env: false } # no ENV fallback
|
|
1335
|
+
}
|
|
1336
|
+
end
|
|
1337
|
+
|
|
1338
|
+
def authorization_url(state:, redirect_uri:, **options)
|
|
1339
|
+
build_authorization_url(
|
|
1340
|
+
endpoint: AUTH_ENDPOINT,
|
|
1341
|
+
client_id: StandardId.config.github_client_id,
|
|
1342
|
+
redirect_uri:, state:, options:,
|
|
1343
|
+
defaults: { scope: default_scope }
|
|
1344
|
+
)
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
def get_user_info(code: nil, redirect_uri: nil, **)
|
|
1348
|
+
rescue_to_oauth_error do
|
|
1349
|
+
raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
|
|
1350
|
+
|
|
1351
|
+
response = HttpClient.post_form(TOKEN_ENDPOINT, { code:, redirect_uri:, ... })
|
|
1352
|
+
parsed = JSON.parse(response.body)
|
|
1353
|
+
build_response(fetch_profile(parsed["access_token"]), tokens: extract_tokens(parsed))
|
|
1354
|
+
end
|
|
1355
|
+
end
|
|
1356
|
+
end
|
|
1357
|
+
end
|
|
1358
|
+
end
|
|
1359
|
+
end
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
**Required interface:** `provider_name`, `authorization_url`, `get_user_info`.
|
|
1363
|
+
|
|
1364
|
+
**Optional hooks** (all class methods, with safe defaults):
|
|
1365
|
+
|
|
1366
|
+
| Hook | Default | Purpose |
|
|
1367
|
+
|------|---------|---------|
|
|
1368
|
+
| `config_schema` | `{}` | `social` config fields. Per-field `env:` (String, or `false` to opt out; default is the upper-cased field name) and `required: true` |
|
|
1369
|
+
| `enabling_config_field` | `:<provider_name>_client_id` if declared | Field whose presence switches the provider on |
|
|
1370
|
+
| `required_config_fields` | fields with `required: true` | Must be present while enabled; reported by `configuration_errors` and the boot check |
|
|
1371
|
+
| `enabled?` / `configuration_errors` / `configured?` | derived from the two above | Override for enablement rules the fields cannot express |
|
|
1372
|
+
| `default_scope` | `nil` | Scope for the social-login grant |
|
|
1373
|
+
| `supported_authorization_params` | `[]` | Params `build_authorization_url` forwards from `options`. Include `:nonce` for OIDC |
|
|
1374
|
+
| `resolve_params(params, context:)` | `params` | Adjust params per flow (`context[:flow]` is `:web` or `:mobile`) |
|
|
1375
|
+
| `flow_for(params)` | `:web` only for `flow=web` on providers that `supports_mobile_callback?`, else `:mobile` | Flow for the API callback |
|
|
1376
|
+
| `skip_csrf?` | `false` | `true` for POST (form_post) callbacks |
|
|
1377
|
+
| `supports_mobile_callback?` | `false` | Enables the server-side redirect back to a native app |
|
|
1378
|
+
|
|
1379
|
+
**Protected helpers** for use inside those methods — signatures are stable:
|
|
1380
|
+
|
|
1381
|
+
| Helper | Does |
|
|
1382
|
+
|--------|------|
|
|
1383
|
+
| `build_response(user_info, tokens:)` | The standard `get_user_info` return value |
|
|
1384
|
+
| `build_authorization_url(endpoint:, client_id:, redirect_uri:, state:, options: {}, defaults: {}, response_type: "code")` | `client_id`, `redirect_uri`, `response_type`, `state`, then each `supported_authorization_params` entry from `options` or `defaults`; nils dropped |
|
|
1385
|
+
| `extract_tokens(parsed_token)` | `{ access_token:, refresh_token:, id_token: }` from a token response, nils dropped |
|
|
1386
|
+
| `verify_nonce!(expected:, actual:)` | Constant-time nonce check; no-op when `expected` is blank. Raises `InvalidRequestError` without echoing either value |
|
|
1387
|
+
| `rescue_to_oauth_error(message_prefix = nil) { ... }` | Lets `StandardId::OAuthError` through; wraps anything else in one, keeping `cause` |
|
|
1388
|
+
|
|
1389
|
+
A plugin using `env:`, `required:` or these helpers should depend on
|
|
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
|
|
1392
|
+
one-off initialization in your own Railtie instead.
|
|
1393
|
+
|
|
1394
|
+
**Testing a plugin, or an app that uses one:**
|
|
1395
|
+
|
|
1396
|
+
```ruby
|
|
1397
|
+
# spec/rails_helper.rb
|
|
1398
|
+
require "standard_id/testing"
|
|
1399
|
+
|
|
1400
|
+
# spec/initializers/standard_id_providers_spec.rb
|
|
1401
|
+
RSpec.describe "StandardId social providers" do
|
|
1402
|
+
it_behaves_like "a registered StandardId provider", :google
|
|
1403
|
+
it_behaves_like "a registered StandardId provider", :apple
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
expect(:apple).to be_a_registered_standard_id_provider.with_config_fields(:apple_client_id, :apple_team_id)
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
The shared example checks the provider is registered, that its fields
|
|
1410
|
+
(default: its whole `config_schema`) are declared on the `social` scope, and
|
|
1411
|
+
that each accepts a write the way `config/initializers/standard_id.rb` makes
|
|
1412
|
+
one.
|
|
1413
|
+
|
|
1185
1414
|
## Schema DSL
|
|
1186
1415
|
|
|
1187
1416
|
Schema is declared using a routes-like DSL and can be extended by provider gems:
|
|
@@ -1242,9 +1471,73 @@ bundle exec rspec spec/controllers/
|
|
|
1242
1471
|
rejected with `invalid_request`.
|
|
1243
1472
|
- Rate limiting on authentication endpoints
|
|
1244
1473
|
|
|
1474
|
+
## Missing Migrations
|
|
1475
|
+
|
|
1476
|
+
`standard_id:install:migrations` copies the engine's migrations with new timestamps, so a gem migration that was never copied is invisible to Rails' own pending-migration check. StandardId checks for that itself, by migration name:
|
|
1477
|
+
|
|
1478
|
+
- **At boot** (file-system only, no database): `config.missing_migrations` — `:warn` (default in development/test: logs and prints to stderr), `:raise` (fails boot with `StandardId::ConfigurationError`), or `:ignore` (default in every other environment — it never raises in production unless you opt in).
|
|
1479
|
+
- **`StandardId::MigrationCheck.pending(check_database: true)`** returns every gem migration that is `:not_installed` or installed but `:not_run` (one `schema_migrations` read).
|
|
1480
|
+
- **Health check** — `StandardId::Checks::Migrations` is [standard_health](https://github.com/rarebit-one/standard_health)-compatible (duck-typed, no dependency). Register it non-critical so a missing migration degrades `/health/ready` without failing it:
|
|
1481
|
+
|
|
1482
|
+
```ruby
|
|
1483
|
+
c.register_check :standard_id_migrations, StandardId::Checks::Migrations, critical: false
|
|
1484
|
+
```
|
|
1485
|
+
|
|
1486
|
+
Two cases are built in and documented in `StandardId::MigrationCheck`:
|
|
1487
|
+
|
|
1488
|
+
- **Superseded** (`SUPERSEDED_BY`): `20260414200000_add_target_created_at_index_to_code_challenges` counts as satisfied when `20260416180511` (whose partial, concurrently built index replaces it) is installed.
|
|
1489
|
+
- **Deferred upgrade steps** (`DEFERRED_UPGRADE_STEPS`): `20260915000000_remove_refresh_token_lifetime_…` is reported with `severity: :info`, as a pending upgrade step to run once 0.41.1+ is deployed everywhere. It is logged at info level at boot and listed under `pending_upgrade_steps` in an `:ok` health result. It never warns, raises or degrades.
|
|
1490
|
+
|
|
1491
|
+
Any other migration you deliberately skipped goes in `config.ignored_migrations`, by name or original version.
|
|
1492
|
+
|
|
1245
1493
|
## Scheduled Maintenance
|
|
1246
1494
|
|
|
1247
|
-
StandardId
|
|
1495
|
+
StandardId never deletes expired rows on its own. Four cleanup jobs do, and **all four must be scheduled** — an unscheduled one lets its table grow forever (code challenges hold the plaintext OTP):
|
|
1496
|
+
|
|
1497
|
+
| Job | Deletes | Grace windows (`perform` kwargs) | Recommended cadence |
|
|
1498
|
+
|---|---|---|---|
|
|
1499
|
+
| `StandardId::CleanupExpiredSessionsJob` | browser/device/service sessions expired > grace | `grace_period_seconds:` 7 days | hourly (minute 6) |
|
|
1500
|
+
| `StandardId::CleanupExpiredRefreshTokensJob` | refresh tokens expired or revoked > grace | `grace_period_seconds:` 7 days | hourly (minute 3) |
|
|
1501
|
+
| `StandardId::CleanupExpiredAuthorizationCodesJob` | OAuth authorization codes expired > 7 days or consumed > 1 day | `grace_period_seconds:`, `consumed_grace_period_seconds:` | hourly (minute 9) |
|
|
1502
|
+
| `StandardId::CleanupExpiredCodeChallengesJob` | OTP code challenges expired > 7 days or used > 1 day | `grace_period_seconds:`, `used_grace_period_seconds:` | hourly (minute 13) |
|
|
1503
|
+
|
|
1504
|
+
Retention is bounded by the grace windows, not the cadence; each job is a single `DELETE`, so running hourly keeps that statement small on busy tables (daily is fine for small apps). Stagger them off minute 0.
|
|
1505
|
+
|
|
1506
|
+
`rails g standard_id:install` adds all four to `config/recurring.yml` (Solid Queue) under `production:` when that file exists (`--skip-recurring` to opt out; re-running is a no-op). An engine cannot register Solid Queue recurring tasks itself — Solid Queue reads one schedule file — so existing apps should paste this under their `production:` key:
|
|
1507
|
+
|
|
1508
|
+
```yaml
|
|
1509
|
+
standard_id_cleanup_expired_sessions:
|
|
1510
|
+
class: StandardId::CleanupExpiredSessionsJob
|
|
1511
|
+
schedule: every hour at minute 6
|
|
1512
|
+
standard_id_cleanup_expired_refresh_tokens:
|
|
1513
|
+
class: StandardId::CleanupExpiredRefreshTokensJob
|
|
1514
|
+
schedule: every hour at minute 3
|
|
1515
|
+
standard_id_cleanup_expired_authorization_codes:
|
|
1516
|
+
class: StandardId::CleanupExpiredAuthorizationCodesJob
|
|
1517
|
+
schedule: every hour at minute 9
|
|
1518
|
+
standard_id_cleanup_expired_code_challenges:
|
|
1519
|
+
class: StandardId::CleanupExpiredCodeChallengesJob
|
|
1520
|
+
schedule: every hour at minute 13
|
|
1521
|
+
```
|
|
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
|
+
|
|
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.
|
|
1248
1541
|
|
|
1249
1542
|
## Contributing
|
|
1250
1543
|
|
|
@@ -39,14 +39,27 @@ module StandardId
|
|
|
39
39
|
notice: flash[:notice],
|
|
40
40
|
alert: flash[:alert]
|
|
41
41
|
}.compact,
|
|
42
|
-
social_providers:
|
|
43
|
-
|
|
44
|
-
apple_enabled: StandardId.config.apple_client_id.present?
|
|
45
|
-
},
|
|
42
|
+
social_providers: social_provider_flags,
|
|
43
|
+
enabled_social_providers: StandardId.enabled_social_providers.keys,
|
|
46
44
|
enabled_mechanisms: web_enabled_mechanisms
|
|
47
45
|
}.deep_merge(additional_props)
|
|
48
46
|
end
|
|
49
47
|
|
|
48
|
+
# `{ "<name>_enabled": Boolean }` for every registered provider.
|
|
49
|
+
#
|
|
50
|
+
# google_enabled / apple_enabled are always present (false when the
|
|
51
|
+
# plugin is not installed) so front ends written against the original
|
|
52
|
+
# two-key shape keep working. New front ends should iterate
|
|
53
|
+
# `enabled_social_providers` instead.
|
|
54
|
+
LEGACY_SOCIAL_PROVIDER_FLAGS = { google_enabled: false, apple_enabled: false }.freeze
|
|
55
|
+
private_constant :LEGACY_SOCIAL_PROVIDER_FLAGS
|
|
56
|
+
|
|
57
|
+
def social_provider_flags
|
|
58
|
+
StandardId::ProviderRegistry.all.each_with_object(LEGACY_SOCIAL_PROVIDER_FLAGS.dup) do |(name, provider), flags|
|
|
59
|
+
flags[:"#{name}_enabled"] = provider.enabled?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
50
63
|
def web_enabled_mechanisms
|
|
51
64
|
web = StandardId.config.web
|
|
52
65
|
{
|
|
@@ -58,7 +71,12 @@ module StandardId
|
|
|
58
71
|
email_verification: web.email_verification,
|
|
59
72
|
phone_verification: web.phone_verification,
|
|
60
73
|
sessions_management: web.sessions_management,
|
|
61
|
-
|
|
74
|
+
# Effective value for this request: the active scope's
|
|
75
|
+
# allow_registration can switch it off (see ScopeConfig#allow_registration).
|
|
76
|
+
passwordless_registration: StandardId::ScopeConfig.registration_allowed?(
|
|
77
|
+
web.passwordless_registration,
|
|
78
|
+
respond_to?(:current_scope_config, true) ? current_scope_config : nil
|
|
79
|
+
)
|
|
62
80
|
}
|
|
63
81
|
end
|
|
64
82
|
end
|
|
@@ -223,6 +223,7 @@ module StandardId
|
|
|
223
223
|
account.sessions.strict_loading(false).destroy_all
|
|
224
224
|
identifiers = account.identifiers.strict_loading(false).to_a
|
|
225
225
|
identifiers.each { |i| i.credentials.strict_loading(false).destroy_all }
|
|
226
|
+
# Deliberately destroy! (unlike the destroy_all calls above): a failed identifier destroy raises and rolls back the whole cleanup, failing loud instead of leaving a half-cleaned orphan.
|
|
226
227
|
identifiers.each(&:destroy!)
|
|
227
228
|
account.destroy
|
|
228
229
|
end
|