standard_id 0.41.0 → 0.42.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -1
  3. data/README.md +251 -13
  4. data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
  5. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +13 -3
  6. data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
  7. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  8. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  9. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  10. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  11. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  12. data/app/models/standard_id/application_record.rb +26 -0
  13. data/app/models/standard_id/authorization_code.rb +3 -1
  14. data/app/models/standard_id/client_application.rb +11 -0
  15. data/app/models/standard_id/identifier.rb +1 -0
  16. data/app/models/standard_id/session.rb +2 -1
  17. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  18. data/app/views/standard_id/web/login/show.html.erb +5 -5
  19. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  20. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  21. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  22. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  23. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  24. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  25. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  26. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  27. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  28. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  29. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  30. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  31. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  32. data/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
  33. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  34. data/lib/generators/standard_id/install/templates/standard_id.rb +45 -15
  35. data/lib/standard_id/checks/migrations.rb +61 -0
  36. data/lib/standard_id/config/schema.rb +40 -10
  37. data/lib/standard_id/config_schema.rb +28 -5
  38. data/lib/standard_id/deprecator.rb +17 -0
  39. data/lib/standard_id/engine.rb +21 -0
  40. data/lib/standard_id/instrumentation.rb +49 -0
  41. data/lib/standard_id/migration_check.rb +183 -0
  42. data/lib/standard_id/migration_helpers.rb +65 -0
  43. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  44. data/lib/standard_id/oauth/oauth_session_persistence.rb +66 -24
  45. data/lib/standard_id/oauth/refresh_token_flow.rb +23 -2
  46. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  47. data/lib/standard_id/passwordless.rb +13 -1
  48. data/lib/standard_id/provider_registry.rb +113 -2
  49. data/lib/standard_id/providers/base.rb +222 -8
  50. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  51. data/lib/standard_id/scope_config.rb +24 -7
  52. data/lib/standard_id/testing/provider_examples.rb +117 -0
  53. data/lib/standard_id/testing.rb +1 -0
  54. data/lib/standard_id/version.rb +1 -1
  55. data/lib/standard_id.rb +26 -0
  56. metadata +24 -17
  57. data/config/initializers/migration_helpers.rb +0 -32
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8cbe67c8d05fe6c7daeeb4d4a44562507e1d5104c32eeba94b701e98e6afe3ea
4
- data.tar.gz: 8f9cb74cf37bb063c66547c7f563005f870a5a86509fe8f89b0bb57893f0ed82
3
+ metadata.gz: 2e1701f28a06762afc1eac4d5d31408180ab6b89bf617d420124301ad55c0f8b
4
+ data.tar.gz: be9d2d78656afd4d0d2e67e17bbf1fdb3cd6c586af6ad85e5514385b1b52f025
5
5
  SHA512:
6
- metadata.gz: e8110399efd07b7b0455f2b8a5ee2c93acfb7dd9bb3847c1a7b43a78aec874b1709aebc05d7be37877007b81f19946322496745793da0418757ecd3de0349121
7
- data.tar.gz: '08736fdbf31a259270c02c232f3a8e163c1dc80ca7f5fd3a4dba2168adb812aa91307bf662b7766bc0078b532bf549be64998342cf90f4e5c54048d31f2becd8'
6
+ metadata.gz: 924d298113db49b3014dd440b49d038476f23bab8474cf02a661839079580a4ee772c8cdaeb0cf330bfe85fa437b6042af1ffbf1d299d8a4ecbf7ac091c0bafa
7
+ data.tar.gz: a9f4da6de783d8e7d0c8f862c2a5ca80c708856746a45ec61605f51160ef8d85b596b1fbad7b75bda3e5cae6311061fb43424db74005e0c24d9ba7fb3a4c55ab
data/CHANGELOG.md CHANGED
@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.42.0] - 2026-09-24
11
+
12
+ ### Upgrade
13
+
14
+ - **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.
15
+ - **sidekick-web:** replace `config/initializers/standard_id_tracing.rb` with the README's Sentry subscriber for `StandardId::Instrumentation` (see Added).
16
+ - **Every host but fundbright-web:** schedule the cleanup jobs you are missing (README *Scheduled Maintenance*).
17
+ - **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.
18
+ - **Hosts with `APPLE_*` / `GOOGLE_*` variables in their environment:** those now enable the provider even if never assigned — see the ENV-fallback entry under Changed.
19
+
20
+ ### Added
21
+
22
+ - **`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).
23
+ - **`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.
24
+ - **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.
25
+ - **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.
26
+ - **`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.
27
+ - **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`).
28
+ - **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.
29
+ - **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.
30
+ - **`Providers::Base.flow_for(params)`** resolves the API callback flow (`:web` / `:mobile`).
31
+ - **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.
32
+ - **`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.
33
+
34
+ ### Changed
35
+
36
+ - **`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:
37
+ - WebEngine `login_verify`: `web.passwordless_registration && scope.allow_registration`.
38
+ - 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.
39
+ - The Inertia `enabled_mechanisms.passwordless_registration` prop reports the effective per-request value.
40
+ - 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.
41
+ - 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).
42
+ - `ScopeConfig#allow_registration?` and `ScopeConfig.registration_allowed?(global, scope_config)` added; an explicit `allow_registration: nil` now means the default (`true`).
43
+ - **`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.
44
+ - **`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.
45
+ - Gemspec summary (and README/AGENTS) say Rails 8, matching `rails >= 8.0`, instead of "Rails 7/8".
46
+ - **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.
47
+ - **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`).
48
+
49
+ ### Deprecated
50
+
51
+ 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.
52
+
53
+ - **`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.
54
+ - **`oauth.client_id` / `oauth.client_secret`** — never read. OAuth clients are `ClientApplication` / `ClientSecretCredential` records. Delete the lines.
55
+ - **`passwordless.enabled`** — no effect since 0.8. Use `web.passwordless_login`.
56
+ - **`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).
57
+ - **Scope config `profile_type:` (singular)** — already warned; now through the registered deprecator. Use `profile_types: [...]`.
58
+ - **`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.
59
+ - **`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.
60
+
61
+ ### Fixed
62
+
63
+ - **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:
64
+ - `AuthorizationCode.lookup` preloads `:account` (read by the token exchange's `OAUTH_CODE_CONSUMED` event and `token_account` — Sentry FUNDBRIGHT-WEB-X).
65
+ - Client-secret authentication (`TokenGrantFlow#validate_client_secret!`) preloads `:client_application` (read by the client-credentials `AUTHENTICATION_SUCCEEDED` event and `token_client`).
66
+ - The social-login identifier lookup and `PasswordResetDeliveryJob` preload `:account`.
67
+ - `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.
68
+ - **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.
69
+
70
+ - **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.
71
+
72
+ ### Documentation
73
+
74
+ - **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`.
75
+
76
+ ## [0.41.1] - 2026-09-24
77
+
78
+ 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.
79
+
80
+ ### Upgrade
81
+
82
+ Read this before bumping — the order matters if you have not yet deployed 0.41.0's migration:
83
+
84
+ 1. **Deploy 0.41.1 first, WITHOUT running `20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications`.** 0.41.1 ignores the column (see Fixed), so running processes stop reading and writing it.
85
+ 2. **Run `20260915000000` in a later deploy.** With strong_migrations it still needs wrapping: `safety_assured { remove_column ... }` inside the copied migration's `up` — the column is already ignored, which is the condition strong_migrations asks you to confirm. Hosts that already ran it under 0.41.0 have nothing to do here.
86
+ 3. **Install and run the new `20260924000000_add_unique_active_device_index_to_standard_id_sessions`** (`bin/rails standard_id:install:migrations`). It is idempotent, builds CONCURRENTLY on Postgres, and needs no `safety_assured` in the host: the one raw-SQL step (detaching duplicate rows) asserts itself safe to StrongMigrations when that gem is loaded. It detaches any existing duplicate active device rows before building the index (see Fixed); nothing is revoked.
87
+ 4. **Hosts carrying the sidekick-web prepend patches can delete them:** `config/initializers/standard_id_refresh_token_strict_loading.rb`, `standard_id_rotation_leeway.rb` and `standard_id_device_session_upsert.rb` (and their specs). The upsert patch's signature guard will keep passing, so it will not remind you — remove it deliberately.
88
+
89
+ ### Fixed
90
+
91
+ - **Signing in again after signing out no longer resurrects the revoked device session.** `OauthSessionPersistence.upsert_device_session!` looked the device's row up with a bare `find_by(account:, device_id:)`. Sign-out (`/oauth/revoke` under the default `:account` `revocation_scope`) revokes every active `DeviceSession`, so the next sign-in reused the revoked row, every refresh token minted afterwards pointed at a revoked parent, and `RefreshTokenFlow#validate_parent_session!` refused the very first refresh. The client was sent back to sign-in each time its access token expired, on every device, forever — sidekick-web's companion app, where the audit trail showed dozens of sign-ins a day and not one successful refresh.
92
+
93
+ Only rows with `revoked_at: nil` are now reused (newest first); a revoked row stays as history and the sign-in gets a new one. Expiry is deliberately still not part of eligibility — an expired-but-unrevoked row is reused with its `expires_at` bumped, as before. The insert runs inside a savepoint, and on `ActiveRecord::RecordNotUnique` the row that won a concurrent first sign-in is reused rather than failing the token request.
94
+
95
+ **New migration `20260924000000`**: a partial unique index on `standard_id_sessions (account_id, device_id) WHERE revoked_at IS NULL AND device_id IS NOT NULL`, so "one active session per device" is a database invariant rather than a property of the account row lock. Before building it, each duplicated group of active rows keeps its newest row as-is and has `:detached:<id>` appended to the others' `device_id`. Detached rows stay active — their refresh tokens keep working until they expire or are revoked — they are just no longer the row a new sign-in reuses. `down` drops the index and leaves the detached suffixes in place (strip `:detached:<id>` to recover the original).
96
+
97
+ - **The refresh-token reuse leeway no longer 500s under strict loading.** When `refresh_token_reuse_leeway` graces a replayed token, `graced_successor_for` loaded the successor without its `:session`, and `validate_parent_session!` then read it lazily — `StrictLoadingViolationError` on exactly the retry the leeway exists to rescue (sidekick-web SIDEKICK-WEB-3E). The successor is now `eager_load(:session)`ed like the primary lookup. The existing grace specs used session-less tokens, where a nil foreign key never queries; the new ones link the family to a `DeviceSession`.
98
+
99
+ - **Two graced retries racing to rotate the same successor no longer log the client out.** Two retries of one lost response can both pass `graced_successor_for` while the successor is untouched, then race to rotate it. The loser landed in `handle_concurrent_reuse!` and revoked the family, killing the token the winner had just issued. A request served from a graced successor that loses the rotation race now gets `invalid_grant` **without** revoking anything; the client keeps the winner's response. A request presenting its token directly and losing the race still revokes the family, and a later replay of the superseded token still finds a used successor and revokes as before — the leeway is not widened.
100
+
101
+ - **A lifecycle hook rejecting a brand-new account no longer 500s under strict loading.** `LifecycleHooks#destroy_newly_created_account` read `account.sessions` / `account.identifiers` lazily, so a `before_sign_in` / `after_sign_in` rejection of a just-created signup raised `StrictLoadingViolationError` instead of redirecting to login, and left the orphaned account behind. The cleanup now reads each association through `.strict_loading(false)`.
102
+
103
+ - **`StandardId::ClientApplication` ignores the dropped `refresh_token_lifetime` column.** 0.41.0 removed the column without ignoring it first, so on a rolling deploy processes still on the old code named it in every INSERT/UPDATE once the migration ran. `self.ignored_columns += %w[refresh_token_lifetime]` makes the deploy-then-drop order in **Upgrade** safe. It will be removed in a future minor.
104
+
105
+ - **The documented per-challenge OTP attempt ceiling was wrong.** `passwordless.max_attempts_per_challenge` is unset by default and falls back to `max_attempts` (default `3`), so an untouched install burns a challenge after 3 wrong codes — but the install generator template, the schema comment and the 0.16.0 entry below all said `5`. They now say `3`, and a spec pins the template's value to the runtime default. The literal `5` in the resolver is a real last resort (both settings nil or zero, since a zero ceiling would burn every challenge on its first wrong code) and is now named `StandardId::Passwordless::FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE`. No behaviour change.
106
+
107
+ ### Changed
108
+
109
+ - **The dummy app runs with `strict_loading_by_default = true`** and `:raise`, as every consumer does. `spec/dummy/config/initializers/strict_loading.rb` exempts the gem models hosts exempt (`Identifier`, `Session`, `Credential`, `PasswordCredential`, `ClientSecretCredential`, `AuthorizationCode`); `RefreshToken`, `ClientApplication`, `ClientGrant`, `CodeChallenge` and `Account` stay strict. `STRICT_LOADING=full bundle exec rspec` drops the exemptions to show the remaining backlog. Test-suite only; no runtime change.
110
+
10
111
  ## [0.41.0] - 2026-09-15
11
112
 
12
113
  ### Added
@@ -1137,7 +1238,7 @@ An explicit per-grant `profile_id` parameter is intentionally out of scope for t
1137
1238
 
1138
1239
  ### Security
1139
1240
 
1140
- - **OTP verification race-condition fix and per-challenge brute-force defenses** — `VerificationService.verify` now wraps the challenge lookup, failed-attempt increment, and consumption in a single `SELECT ... FOR UPDATE` transaction, closing the TOCTOU window between "find active challenge" and "mark it used." Failed-attempt counting is now atomic and scoped to the specific challenge (previously a loose read-modify-write on the account). Events are deferred to post-commit so observers never see rolled-back state. New `config.passwordless.max_attempts_per_challenge` (default `5`) supersedes the now-deprecated account-wide `max_attempts` (kept as a fallback for existing installs). (#169)
1241
+ - **OTP verification race-condition fix and per-challenge brute-force defenses** — `VerificationService.verify` now wraps the challenge lookup, failed-attempt increment, and consumption in a single `SELECT ... FOR UPDATE` transaction, closing the TOCTOU window between "find active challenge" and "mark it used." Failed-attempt counting is now atomic and scoped to the specific challenge (previously a loose read-modify-write on the account). Events are deferred to post-commit so observers never see rolled-back state. New `config.passwordless.max_attempts_per_challenge` (unset by default, so the effective ceiling is `max_attempts`' default of `3`; `5` is only a last-resort fallback when both are unset or zero) supersedes the now-deprecated account-wide `max_attempts` (kept as a fallback for existing installs). (#169)
1141
1242
  - **JWT audience enforcement at decode time** — `JwtService.decode` now accepts an `allowed_audiences:` kwarg and raises `StandardId::InvalidAudienceError` on mismatch. `Api::TokenManager#verify_jwt_token` threads `config.oauth.allowed_audiences` through automatically, so cross-audience JWT replay is now blocked even on controllers that forget to include the `AudienceVerification` concern. Production emits a warning when `allowed_audiences` is unset. (#170, #174)
1142
1243
  - **Web flow polish** — password-reset delivery moved to an async job with a constant-time success response (closes enumeration timing leak); OAuth `redirect_uri` validation tightened to exact scheme+host+port+path match at both registration and authorize time (blocks query-string piggyback); engine logs a warning when the host app has no `secret_key_base` configured so encrypted session cookies can't silently fall back to plaintext. New `reset_password` config scope with `:delivery` (`:custom` default, `:built_in` opt-in) and mailer-sender/subject knobs. `CREDENTIAL_PASSWORD_RESET_INITIATED` event now fires from the job. (#171)
1143
1244
  - **Per-client PKCE enforcement at the authorize endpoint** — honors the existing `require_pkce` column on `ClientApplication`. Requests missing `code_challenge` are rejected with `invalid_request` when the client requires PKCE. Per-client `code_challenge_methods` replaces the global S256-only hardcode (case-insensitive). New validation blocks public clients from opting out (`public_clients_must_require_pkce`). (#175)
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 7/8. StandardId provides a complete, secure-by-default solution for identity management, reducing boilerplate and eliminating common security pitfalls.
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,27 @@ 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
+
286
302
  ```ruby
287
303
  StandardId.configure do |config|
288
- # Google OAuth
289
- config.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
290
- config.social.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
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"]
304
+ # Only needed when not using the canonical ENV names above:
305
+ config.social.apple_private_key = Rails.application.credentials.dig(:apple, :private_key)
306
+
298
307
  config.social.allowed_redirect_url_prefixes = ["sidekicklabs://"]
299
308
 
300
309
  # Optional: adjust which attributes are persisted during social signup
@@ -309,6 +318,30 @@ end
309
318
 
310
319
  `social_info` is an indifferent-access hash containing at least `email`, `name`, and `provider_id`.
311
320
 
321
+ **Is a provider on?** A provider is *enabled* when its client ID is present.
322
+ Ask StandardId rather than checking the client ID yourself:
323
+
324
+ ```ruby
325
+ StandardId.social_provider_enabled?(:google) # => false when the plugin is absent, too
326
+ StandardId.enabled_social_providers # => { "google" => StandardId::Providers::Google }
327
+ StandardId::Providers::Apple.configuration_errors
328
+ # => ["apple_private_key is required when apple_client_id is set"]
329
+ ```
330
+
331
+ **Boot-time check.** Once every plugin has registered, StandardId checks each
332
+ enabled provider for missing required fields — for example an Apple client ID
333
+ without the private key, whose sign-in flow would start fine and then fail at
334
+ the callback, after the user had already authenticated with Apple. By default
335
+ it logs a warning. To fail the deploy instead:
336
+
337
+ ```ruby
338
+ config.social.provider_misconfiguration = :raise # raises in production, warns elsewhere
339
+ ```
340
+
341
+ Which fields are required is declared by each plugin (`required: true`, see
342
+ [Writing a Provider Plugin](#writing-a-provider-plugin)), so the check covers a
343
+ provider once its plugin release declares them.
344
+
312
345
  To handle social login completion (e.g., for analytics or audit logging), subscribe to the `SOCIAL_AUTH_COMPLETED` event:
313
346
 
314
347
  ```ruby
@@ -361,6 +394,7 @@ interface Props {
361
394
  connection: string | null
362
395
  flash: { notice?: string; alert?: string }
363
396
  social_providers: { google_enabled: boolean; apple_enabled: boolean }
397
+ enabled_social_providers: string[]
364
398
  }
365
399
 
366
400
  export default function LoginShow({ redirect_uri, flash, social_providers }: Props) {
@@ -455,7 +489,8 @@ Authentication pages receive the following props:
455
489
  | `redirect_uri` | `string` | URL to redirect to after authentication |
456
490
  | `connection` | `string \| null` | Social provider connection (if any) |
457
491
  | `flash` | `{ notice?: string, alert?: string }` | Flash messages |
458
- | `social_providers` | `{ google_enabled: boolean, apple_enabled: boolean }` | Available social providers |
492
+ | `social_providers` | `{ [name]_enabled: boolean }` | One flag per registered provider. `google_enabled` and `apple_enabled` are always present (false when the plugin is absent) |
493
+ | `enabled_social_providers` | `string[]` | Names of the enabled providers, e.g. `["google"]` — iterate this rather than hard-coding provider names |
459
494
  | `errors` | `object` | Validation errors (on form submission failures) |
460
495
 
461
496
  #### Using Authentication in Host App Controllers
@@ -738,6 +773,48 @@ StandardAudit::AuditLog.from_ip("192.168.1.1")
738
773
 
739
774
  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
775
 
776
+ ### Instrumentation (tracing spans)
777
+
778
+ 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:
779
+
780
+ | Event (`StandardId::Instrumentation::…`) | Wraps | Payload |
781
+ |---|---|---|
782
+ | `AUTHENTICATE` — `authenticate.standard_id` | every token grant's `authenticate!` (for `refresh_token`: JWT decode + token lookup + reuse detection) | `flow`, `grant_type` |
783
+ | `AUDIENCE_PROFILE_BINDING` — `audience_profile_binding.standard_id` | audience→profile binding (account load + resolver) | `flow`, `grant_type`, `audience` |
784
+ | `AUDIENCE_PROFILE_RESOLVE` — `audience_profile_resolve.standard_id` | `Oauth::AudienceProfileResolver.resolve!` (nested inside the binding event) | `audience` |
785
+
786
+ A raised error appears as `:exception` / `:exception_object` in the finish payload. Sentry child spans, nested the same way:
787
+
788
+ ```ruby
789
+ # config/initializers/standard_id_tracing.rb
790
+ return unless defined?(Sentry)
791
+
792
+ module StandardIdSentrySpans
793
+ STACK = :standard_id_sentry_spans
794
+
795
+ def self.start(name, _id, payload)
796
+ scope = Sentry.get_current_scope
797
+ parent = scope&.get_span
798
+ span = parent&.start_child(
799
+ op: "standard_id.#{name.delete_suffix('.standard_id')}",
800
+ description: Array(payload[:audience]).join(", ").presence
801
+ )
802
+ (Thread.current[STACK] ||= []) << [span, parent]
803
+ scope.set_span(span) if span
804
+ end
805
+
806
+ def self.finish(_name, _id, _payload)
807
+ span, parent = Thread.current[STACK]&.pop
808
+ return unless span
809
+
810
+ span.finish
811
+ Sentry.get_current_scope.set_span(parent)
812
+ end
813
+ end
814
+
815
+ ActiveSupport::Notifications.subscribe(StandardId::Instrumentation::PATTERN, StandardIdSentrySpans)
816
+ ```
817
+
741
818
  ## Account Status (Activation/Deactivation)
742
819
 
743
820
  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 +1259,120 @@ secret = client.create_client_secret!(name: "Production Secret")
1182
1259
  new_secret = client.rotate_client_secret!
1183
1260
  ```
1184
1261
 
1262
+ ## Writing a Provider Plugin
1263
+
1264
+ Social providers ship as separate gems (`standard_id-google`,
1265
+ `standard_id-apple`) that subclass `StandardId::Providers::Base` and register
1266
+ themselves. A minimal plugin is a provider class plus a two-line entry file.
1267
+
1268
+ ```ruby
1269
+ # lib/standard_id/github.rb — the gem's entry file
1270
+ require "standard_id"
1271
+ require "standard_id/github/providers/github"
1272
+
1273
+ # Defines the Railtie that registers the provider after the host app has
1274
+ # initialized. No hand-written railtie.rb, no `if defined?(Rails)` guard.
1275
+ StandardId::Providers.plugin_railtie(:github, "StandardId::Providers::GitHub")
1276
+ ```
1277
+
1278
+ ```ruby
1279
+ # lib/standard_id/github/providers/github.rb
1280
+ module StandardId
1281
+ module Providers
1282
+ class GitHub < Base
1283
+ AUTH_ENDPOINT = "https://github.com/login/oauth/authorize".freeze
1284
+ TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token".freeze
1285
+
1286
+ class << self
1287
+ def provider_name = "github"
1288
+ def default_scope = "read:user user:email"
1289
+ def supported_authorization_params = %i[scope login allow_signup]
1290
+
1291
+ # Fields land in the `social` config scope. `env:` and `required:` are
1292
+ # read by StandardId (>= 0.42) and not passed to ConfigSchema.
1293
+ def config_schema
1294
+ {
1295
+ github_client_id: { type: :string, default: nil }, # ENV GITHUB_CLIENT_ID
1296
+ github_client_secret: { type: :string, default: nil, required: true },
1297
+ github_enterprise_host: { type: :string, default: nil, env: false } # no ENV fallback
1298
+ }
1299
+ end
1300
+
1301
+ def authorization_url(state:, redirect_uri:, **options)
1302
+ build_authorization_url(
1303
+ endpoint: AUTH_ENDPOINT,
1304
+ client_id: StandardId.config.github_client_id,
1305
+ redirect_uri:, state:, options:,
1306
+ defaults: { scope: default_scope }
1307
+ )
1308
+ end
1309
+
1310
+ def get_user_info(code: nil, redirect_uri: nil, **)
1311
+ rescue_to_oauth_error do
1312
+ raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
1313
+
1314
+ response = HttpClient.post_form(TOKEN_ENDPOINT, { code:, redirect_uri:, ... })
1315
+ parsed = JSON.parse(response.body)
1316
+ build_response(fetch_profile(parsed["access_token"]), tokens: extract_tokens(parsed))
1317
+ end
1318
+ end
1319
+ end
1320
+ end
1321
+ end
1322
+ end
1323
+ ```
1324
+
1325
+ **Required interface:** `provider_name`, `authorization_url`, `get_user_info`.
1326
+
1327
+ **Optional hooks** (all class methods, with safe defaults):
1328
+
1329
+ | Hook | Default | Purpose |
1330
+ |------|---------|---------|
1331
+ | `config_schema` | `{}` | `social` config fields. Per-field `env:` (String, or `false` to opt out; default is the upper-cased field name) and `required: true` |
1332
+ | `enabling_config_field` | `:<provider_name>_client_id` if declared | Field whose presence switches the provider on |
1333
+ | `required_config_fields` | fields with `required: true` | Must be present while enabled; reported by `configuration_errors` and the boot check |
1334
+ | `enabled?` / `configuration_errors` / `configured?` | derived from the two above | Override for enablement rules the fields cannot express |
1335
+ | `default_scope` | `nil` | Scope for the social-login grant |
1336
+ | `supported_authorization_params` | `[]` | Params `build_authorization_url` forwards from `options`. Include `:nonce` for OIDC |
1337
+ | `resolve_params(params, context:)` | `params` | Adjust params per flow (`context[:flow]` is `:web` or `:mobile`) |
1338
+ | `flow_for(params)` | `:web` only for `flow=web` on providers that `supports_mobile_callback?`, else `:mobile` | Flow for the API callback |
1339
+ | `skip_csrf?` | `false` | `true` for POST (form_post) callbacks |
1340
+ | `supports_mobile_callback?` | `false` | Enables the server-side redirect back to a native app |
1341
+
1342
+ **Protected helpers** for use inside those methods — signatures are stable:
1343
+
1344
+ | Helper | Does |
1345
+ |--------|------|
1346
+ | `build_response(user_info, tokens:)` | The standard `get_user_info` return value |
1347
+ | `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 |
1348
+ | `extract_tokens(parsed_token)` | `{ access_token:, refresh_token:, id_token: }` from a token response, nils dropped |
1349
+ | `verify_nonce!(expected:, actual:)` | Constant-time nonce check; no-op when `expected` is blank. Raises `InvalidRequestError` without echoing either value |
1350
+ | `rescue_to_oauth_error(message_prefix = nil) { ... }` | Lets `StandardId::OAuthError` through; wraps anything else in one, keeping `cause` |
1351
+
1352
+ A plugin using `env:`, `required:` or these helpers should depend on
1353
+ `standard_id >= 0.42`. `Providers::Base.setup` was removed in 0.42 — do
1354
+ one-off initialization in your own Railtie instead.
1355
+
1356
+ **Testing a plugin, or an app that uses one:**
1357
+
1358
+ ```ruby
1359
+ # spec/rails_helper.rb
1360
+ require "standard_id/testing"
1361
+
1362
+ # spec/initializers/standard_id_providers_spec.rb
1363
+ RSpec.describe "StandardId social providers" do
1364
+ it_behaves_like "a registered StandardId provider", :google
1365
+ it_behaves_like "a registered StandardId provider", :apple
1366
+ end
1367
+
1368
+ expect(:apple).to be_a_registered_standard_id_provider.with_config_fields(:apple_client_id, :apple_team_id)
1369
+ ```
1370
+
1371
+ The shared example checks the provider is registered, that its fields
1372
+ (default: its whole `config_schema`) are declared on the `social` scope, and
1373
+ that each accepts a write the way `config/initializers/standard_id.rb` makes
1374
+ one.
1375
+
1185
1376
  ## Schema DSL
1186
1377
 
1187
1378
  Schema is declared using a routes-like DSL and can be extended by provider gems:
@@ -1242,9 +1433,56 @@ bundle exec rspec spec/controllers/
1242
1433
  rejected with `invalid_request`.
1243
1434
  - Rate limiting on authentication endpoints
1244
1435
 
1436
+ ## Missing Migrations
1437
+
1438
+ `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:
1439
+
1440
+ - **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).
1441
+ - **`StandardId::MigrationCheck.pending(check_database: true)`** returns every gem migration that is `:not_installed` or installed but `:not_run` (one `schema_migrations` read).
1442
+ - **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:
1443
+
1444
+ ```ruby
1445
+ c.register_check :standard_id_migrations, StandardId::Checks::Migrations, critical: false
1446
+ ```
1447
+
1448
+ Two cases are built in and documented in `StandardId::MigrationCheck`:
1449
+
1450
+ - **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.
1451
+ - **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.
1452
+
1453
+ Any other migration you deliberately skipped goes in `config.ignored_migrations`, by name or original version.
1454
+
1245
1455
  ## Scheduled Maintenance
1246
1456
 
1247
- StandardId ships cleanup jobs (`StandardId::CleanupExpiredSessionsJob`, `StandardId::CleanupExpiredRefreshTokensJob`) and rake wrappers (`standard_id:cleanup:all`, `:sessions`, `:refresh_tokens`) to prune expired rows. See [docs/OPERATIONS.md](docs/OPERATIONS.md) for SolidQueue, sidekiq-cron, whenever, and system-cron scheduling examples.
1457
+ 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):
1458
+
1459
+ | Job | Deletes | Grace windows (`perform` kwargs) | Recommended cadence |
1460
+ |---|---|---|---|
1461
+ | `StandardId::CleanupExpiredSessionsJob` | browser/device/service sessions expired > grace | `grace_period_seconds:` 7 days | hourly (minute 6) |
1462
+ | `StandardId::CleanupExpiredRefreshTokensJob` | refresh tokens expired or revoked > grace | `grace_period_seconds:` 7 days | hourly (minute 3) |
1463
+ | `StandardId::CleanupExpiredAuthorizationCodesJob` | OAuth authorization codes expired > 7 days or consumed > 1 day | `grace_period_seconds:`, `consumed_grace_period_seconds:` | hourly (minute 9) |
1464
+ | `StandardId::CleanupExpiredCodeChallengesJob` | OTP code challenges expired > 7 days or used > 1 day | `grace_period_seconds:`, `used_grace_period_seconds:` | hourly (minute 13) |
1465
+
1466
+ 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.
1467
+
1468
+ `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:
1469
+
1470
+ ```yaml
1471
+ standard_id_cleanup_expired_sessions:
1472
+ class: StandardId::CleanupExpiredSessionsJob
1473
+ schedule: every hour at minute 6
1474
+ standard_id_cleanup_expired_refresh_tokens:
1475
+ class: StandardId::CleanupExpiredRefreshTokensJob
1476
+ schedule: every hour at minute 3
1477
+ standard_id_cleanup_expired_authorization_codes:
1478
+ class: StandardId::CleanupExpiredAuthorizationCodesJob
1479
+ schedule: every hour at minute 9
1480
+ standard_id_cleanup_expired_code_challenges:
1481
+ class: StandardId::CleanupExpiredCodeChallengesJob
1482
+ schedule: every hour at minute 13
1483
+ ```
1484
+
1485
+ 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
1486
 
1249
1487
  ## Contributing
1250
1488
 
@@ -39,14 +39,27 @@ module StandardId
39
39
  notice: flash[:notice],
40
40
  alert: flash[:alert]
41
41
  }.compact,
42
- social_providers: {
43
- google_enabled: StandardId.config.google_client_id.present?,
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
- passwordless_registration: web.passwordless_registration
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
@@ -208,13 +208,23 @@ module StandardId
208
208
 
209
209
  # Destroy a newly created account and all its dependents.
210
210
  # Used when after_sign_in rejects a just-created account to avoid orphans.
211
+ #
212
+ # Every association read goes through `.strict_loading(false)`: the account
213
+ # was built in this request, so none of its associations are loaded, and a
214
+ # host running `strict_loading_by_default = true` (most consumers) would
215
+ # otherwise raise StrictLoadingViolationError on `account.sessions` — turning
216
+ # a hook's clean rejection of a new signup into a 500 and leaving the
217
+ # orphaned account behind. Relation-level `strict_loading(false)` also covers
218
+ # the records it loads, so `identifier.credentials` below is safe too.
211
219
  def destroy_newly_created_account(account)
212
220
  return unless account&.persisted?
213
221
 
214
222
  ActiveRecord::Base.transaction do
215
- account.sessions.destroy_all
216
- account.identifiers.each { |i| i.credentials.destroy_all }
217
- account.identifiers.destroy_all
223
+ account.sessions.strict_loading(false).destroy_all
224
+ identifiers = account.identifiers.strict_loading(false).to_a
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.
227
+ identifiers.each(&:destroy!)
218
228
  account.destroy
219
229
  end
220
230
  end
@@ -61,7 +61,10 @@ module StandardId
61
61
  # @param username [String] the identifier value (email or phone number)
62
62
  # @param code [String] the OTP code to verify
63
63
  # @param connection [String] the delivery channel ("email" or "sms"), defaults to "email"
64
- # @param allow_registration [Boolean] whether to create a new account if none exists (default: true)
64
+ # @param allow_registration [Boolean] whether to create a new account if none exists (default: true).
65
+ # When the including controller resolves a scope (StandardId::LifecycleHooks#current_scope_config)
66
+ # and that scope sets `allow_registration: false`, registration is refused regardless —
67
+ # a scope can only restrict.
65
68
  # @return [StandardId::Passwordless::VerificationService::Result] a result with:
66
69
  # - success? -- true when verification succeeded
67
70
  # - account -- the authenticated/created account (nil on failure)
@@ -77,8 +80,14 @@ module StandardId
77
80
  code: code,
78
81
  connection: connection,
79
82
  request: request,
80
- allow_registration: allow_registration
83
+ allow_registration: StandardId::ScopeConfig.registration_allowed?(allow_registration, passwordless_scope_config)
81
84
  )
82
85
  end
86
+
87
+ # The active ScopeConfig when the controller also includes
88
+ # StandardId::LifecycleHooks; nil otherwise (no scope → no restriction).
89
+ def passwordless_scope_config
90
+ respond_to?(:current_scope_config, true) ? current_scope_config : nil
91
+ end
83
92
  end
84
93
  end
@@ -38,7 +38,7 @@ module StandardId
38
38
 
39
39
  emit_social_user_info_fetched(provider, social_info, email)
40
40
 
41
- identifier = StandardId::EmailIdentifier.find_by(value: email)
41
+ identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: email)
42
42
 
43
43
  if identifier.present?
44
44
  validate_social_link!(identifier, provider)
@@ -55,19 +55,12 @@ module StandardId
55
55
  # must not emit. The error re-raises into the standard
56
56
  # handle_oauth_error JSON response.
57
57
  def fetch_provider_user_info
58
- get_user_info_from_provider(flow: resolve_flow_for(provider.provider_name))
58
+ get_user_info_from_provider(flow: provider.flow_for(params))
59
59
  rescue StandardId::OAuthError => e
60
60
  emit_social_auth_failed(e)
61
61
  raise
62
62
  end
63
63
 
64
- def resolve_flow_for(connection)
65
- return :mobile unless connection == "apple"
66
-
67
- flow_param = params[:flow].to_s.downcase
68
- flow_param == "web" ? :web : :mobile
69
- end
70
-
71
64
  # The `except` list is the trust boundary — non-reserved values are
72
65
  # host-supplied opaque attribution data, never interpreted by the gem.
73
66
  def forwarded_request_params
@@ -106,6 +106,8 @@ module StandardId
106
106
  end
107
107
  end
108
108
 
109
+ # Global web.passwordless_registration AND the active scope's
110
+ # allow_registration (verify_passwordless_otp applies the scope half).
109
111
  def passwordless_registration_enabled?
110
112
  StandardId.config.web.passwordless_registration
111
113
  end
@@ -15,7 +15,7 @@ module StandardId
15
15
  normalized = email.to_s.strip.downcase
16
16
  return if normalized.blank?
17
17
 
18
- identifier = StandardId::EmailIdentifier.find_by(value: normalized)
18
+ identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: normalized)
19
19
  return if identifier.nil?
20
20
 
21
21
  password_credential = identifier.account