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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -1
- data/README.md +251 -13
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +13 -3
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
- 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_verify_controller.rb +2 -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/client_application.rb +11 -0
- 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/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +45 -15
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +40 -10
- data/lib/standard_id/config_schema.rb +28 -5
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +21 -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/oauth_session_persistence.rb +66 -24
- data/lib/standard_id/oauth/refresh_token_flow.rb +23 -2
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- data/lib/standard_id/passwordless.rb +13 -1
- data/lib/standard_id/provider_registry.rb +113 -2
- 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 +24 -7
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +1 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +24 -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: 2e1701f28a06762afc1eac4d5d31408180ab6b89bf617d420124301ad55c0f8b
|
|
4
|
+
data.tar.gz: be9d2d78656afd4d0d2e67e17bbf1fdb3cd6c586af6ad85e5514385b1b52f025
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
#
|
|
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"]
|
|
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` | `{
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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.
|
|
217
|
-
|
|
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:
|
|
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
|