standard_id 0.41.1 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +310 -17
  4. data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
  5. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
  6. data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
  7. data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
  8. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  10. data/app/controllers/standard_id/web/login_controller.rb +1 -2
  11. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  12. data/app/controllers/standard_id/web/verify_email/start_controller.rb +12 -8
  13. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
  14. data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
  15. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  16. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  17. data/app/models/standard_id/application_record.rb +26 -0
  18. data/app/models/standard_id/authorization_code.rb +3 -1
  19. data/app/models/standard_id/identifier.rb +1 -0
  20. data/app/models/standard_id/session.rb +2 -1
  21. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  22. data/app/views/standard_id/web/login/show.html.erb +5 -5
  23. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  24. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  25. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  26. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  27. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  28. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  29. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  30. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  31. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  32. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  33. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  34. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  35. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  36. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  37. data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
  38. data/lib/standard_id/checks/migrations.rb +61 -0
  39. data/lib/standard_id/config/schema.rb +34 -20
  40. data/lib/standard_id/config_schema.rb +94 -6
  41. data/lib/standard_id/deprecator.rb +17 -0
  42. data/lib/standard_id/engine.rb +22 -0
  43. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
  44. data/lib/standard_id/instrumentation.rb +49 -0
  45. data/lib/standard_id/migration_check.rb +183 -0
  46. data/lib/standard_id/migration_helpers.rb +65 -0
  47. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  48. data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
  49. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  50. data/lib/standard_id/otp.rb +20 -28
  51. data/lib/standard_id/passwordless/base_strategy.rb +18 -14
  52. data/lib/standard_id/passwordless/email_strategy.rb +0 -5
  53. data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
  54. data/lib/standard_id/provider_registry.rb +95 -5
  55. data/lib/standard_id/providers/base.rb +222 -8
  56. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  57. data/lib/standard_id/scope_config.rb +43 -26
  58. data/lib/standard_id/testing/config_helpers.rb +55 -0
  59. data/lib/standard_id/testing/provider_examples.rb +117 -0
  60. data/lib/standard_id/testing.rb +2 -0
  61. data/lib/standard_id/version.rb +1 -1
  62. data/lib/standard_id.rb +26 -0
  63. metadata +25 -17
  64. data/config/initializers/migration_helpers.rb +0 -32
@@ -1,3 +1,6 @@
1
+ require "uri"
2
+ require "active_support/security_utils"
3
+
1
4
  module StandardId
2
5
  module Providers
3
6
  # Base class for social login providers.
@@ -225,17 +228,116 @@ module StandardId
225
228
  []
226
229
  end
227
230
 
228
- # Optional setup hook called when provider is registered.
231
+ # --------------------------------------------------------------------
232
+ # Configuration & enablement
233
+ # --------------------------------------------------------------------
234
+ #
235
+ # Each entry returned by {config_schema} may carry two provider-level
236
+ # options in addition to the ConfigSchema ones (`type:`, `default:`).
237
+ # They are consumed by StandardId and never reach ConfigSchema:
238
+ #
239
+ # - `env:` — the ENV variable the field falls back to when the host app
240
+ # never assigns it. Defaults to the upper-cased field name
241
+ # (`google_client_id` → `GOOGLE_CLIENT_ID`), which is the canonical
242
+ # naming scheme. Pass a String to use another variable, or `false` to
243
+ # disable the ENV fallback for that field. Explicit configuration
244
+ # (`c.social.google_client_id = ...`, including an explicit `nil`)
245
+ # always wins over the ENV fallback.
246
+ # - `required: true` — the field must be present whenever the provider
247
+ # is {enabled?}. Missing required fields are reported by
248
+ # {configuration_errors} and by the boot-time check (see
249
+ # `c.social.provider_misconfiguration`).
250
+ #
251
+ # Both options require standard_id >= 0.42. A plugin that declares them
252
+ # should depend on `standard_id >= 0.42` — older versions pass the
253
+ # options through to ConfigSchema and raise ArgumentError.
254
+ #
255
+ # @example
256
+ # def self.config_schema
257
+ # {
258
+ # github_client_id: { type: :string, default: nil },
259
+ # github_client_secret: { type: :string, default: nil, required: true },
260
+ # github_enterprise_host: { type: :string, default: nil, env: false }
261
+ # }
262
+ # end
263
+
264
+ # Config field whose presence switches this provider on.
229
265
  #
230
- # Override this method to perform initialization tasks like:
231
- # - Registering additional routes
232
- # - Adding custom validations
233
- # - Setting up caching for JWKS
266
+ # Defaults to `:"<provider_name>_client_id"` when that field is part of
267
+ # {config_schema}, otherwise nil (a provider with no enabling field is
268
+ # always enabled once registered). Override when the provider keys off a
269
+ # different field.
234
270
  #
235
- # @return [void]
271
+ # @return [Symbol, nil]
272
+ def enabling_config_field
273
+ field = :"#{provider_name}_client_id"
274
+ config_schema.key?(field) ? field : nil
275
+ end
276
+
277
+ # Config fields that must be present whenever the provider is enabled.
236
278
  #
237
- def setup
238
- # Override in subclasses if needed
279
+ # Defaults to the {config_schema} fields declared with `required: true`.
280
+ #
281
+ # @return [Array<Symbol>]
282
+ def required_config_fields
283
+ config_schema.select { |_name, options| options.is_a?(Hash) && options[:required] }.keys.map(&:to_sym)
284
+ end
285
+
286
+ # Whether the host app has switched this provider on.
287
+ #
288
+ # True when {enabling_config_field} is present in the configuration (or
289
+ # when the provider has no enabling field). Says nothing about whether
290
+ # the rest of the configuration is complete — see {configuration_errors}
291
+ # and {configured?}.
292
+ #
293
+ # @return [Boolean]
294
+ def enabled?
295
+ field = enabling_config_field
296
+ return true if field.nil?
297
+
298
+ config_value(field).present?
299
+ end
300
+
301
+ # Human-readable problems with this provider's configuration.
302
+ #
303
+ # Empty when the provider is disabled: a provider nobody switched on
304
+ # cannot be misconfigured. Messages name fields, never their values.
305
+ #
306
+ # @return [Array<String>]
307
+ def configuration_errors
308
+ return [] unless enabled?
309
+
310
+ missing = required_config_fields.select { |field| config_value(field).blank? }
311
+ return [] if missing.empty?
312
+
313
+ trigger = enabling_config_field ? " when #{enabling_config_field} is set" : ""
314
+ missing.map { |field| "#{field} is required#{trigger}" }
315
+ end
316
+
317
+ # Enabled and free of {configuration_errors}.
318
+ #
319
+ # @return [Boolean]
320
+ def configured?
321
+ enabled? && configuration_errors.empty?
322
+ end
323
+
324
+ # The flow a native/API callback is running, used as `context[:flow]`
325
+ # for {resolve_params}.
326
+ #
327
+ # Called by the API callback endpoint (`/api/oauth/callback/:provider`).
328
+ # The default honours an explicit `flow=web` param only for providers
329
+ # that {supports_mobile_callback?} — those are the providers whose web
330
+ # and native flows differ (e.g. Apple's distinct Services ID vs bundle
331
+ # ID audiences). Everything else is treated as `:mobile`, which is what
332
+ # the API endpoint served before this hook existed. Override to
333
+ # recognise other flows.
334
+ #
335
+ # @param params [#[]] Request params
336
+ # @return [Symbol] `:web` or `:mobile`
337
+ def flow_for(params)
338
+ return :mobile unless supports_mobile_callback?
339
+
340
+ params[:flow].to_s.downcase == "web" ? :web : :mobile
239
341
  end
240
342
 
241
343
  protected
@@ -252,6 +354,118 @@ module StandardId
252
354
  tokens: tokens.compact
253
355
  }.with_indifferent_access
254
356
  end
357
+
358
+ # Read one of this provider's config fields.
359
+ #
360
+ # Goes through the top-level `StandardId.config` accessor — the same
361
+ # way the provider plugins read their own credentials — so the value
362
+ # seen here always matches the one the provider will use.
363
+ #
364
+ # @param field [Symbol, String]
365
+ # @return [Object, nil]
366
+ def config_value(field)
367
+ StandardId.config.public_send(field)
368
+ end
369
+
370
+ # Run the block, re-raising any non-OAuth error as StandardId::OAuthError.
371
+ #
372
+ # StandardId::OAuthError (and subclasses such as InvalidRequestError)
373
+ # propagate unchanged. Anything else — network, JSON, OpenSSL, JWT
374
+ # errors — is wrapped so callers only ever handle OAuthError, with the
375
+ # original exception kept as `cause`.
376
+ #
377
+ # @param message_prefix [String, nil] Prepended to the wrapped error's
378
+ # message, e.g. "Failed to fetch JWK" → "Failed to fetch JWK: <msg>"
379
+ # @return [Object] the block's return value
380
+ # @raise [StandardId::OAuthError]
381
+ #
382
+ # @example
383
+ # def fetch_user_info(access_token:)
384
+ # rescue_to_oauth_error do
385
+ # response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
386
+ # JSON.parse(response.body)
387
+ # end
388
+ # end
389
+ #
390
+ def rescue_to_oauth_error(message_prefix = nil)
391
+ yield
392
+ rescue StandardId::OAuthError
393
+ raise
394
+ rescue StandardError => e
395
+ message = message_prefix ? "#{message_prefix}: #{e.message}" : e.message
396
+ raise StandardId::OAuthError, message, cause: e
397
+ end
398
+
399
+ # Verify an ID token's `nonce` claim against the one the server issued.
400
+ #
401
+ # No-op when `expected` is blank (flows without a server-generated
402
+ # nonce). Comparison is constant-time. The error message deliberately
403
+ # does not include either nonce: the expected value is a server-side
404
+ # secret for the duration of the flow, and error messages end up in
405
+ # redirects, logs and error trackers.
406
+ #
407
+ # @param expected [String, nil] Nonce stored when the flow started
408
+ # @param actual [String, nil] `nonce` claim from the verified ID token
409
+ # @return [void]
410
+ # @raise [StandardId::InvalidRequestError] on mismatch
411
+ def verify_nonce!(expected:, actual:)
412
+ return if expected.blank?
413
+ return if actual.is_a?(String) && ActiveSupport::SecurityUtils.secure_compare(actual, expected.to_s)
414
+
415
+ raise StandardId::InvalidRequestError, "ID token nonce mismatch"
416
+ end
417
+
418
+ # Build an OAuth 2.0 authorization-code URL.
419
+ #
420
+ # Emits `client_id`, `redirect_uri`, `response_type`, `state`, then one
421
+ # entry per {supported_authorization_params}, taking the caller's value
422
+ # from `options` or falling back to `defaults`. Nil values are dropped.
423
+ #
424
+ # @param endpoint [String] Provider authorization endpoint
425
+ # @param client_id [String]
426
+ # @param redirect_uri [String]
427
+ # @param state [String]
428
+ # @param options [Hash] Caller-supplied authorization params
429
+ # @param defaults [Hash] Per-param fallbacks (e.g. `{ scope: "openid email" }`)
430
+ # @param response_type [String]
431
+ # @return [String]
432
+ #
433
+ # @example
434
+ # def self.authorization_url(state:, redirect_uri:, **options)
435
+ # build_authorization_url(
436
+ # endpoint: AUTH_ENDPOINT,
437
+ # client_id: StandardId.config.github_client_id,
438
+ # redirect_uri:, state:, options:,
439
+ # defaults: { scope: DEFAULT_SCOPE }
440
+ # )
441
+ # end
442
+ #
443
+ def build_authorization_url(endpoint:, client_id:, redirect_uri:, state:, options: {}, defaults: {}, response_type: "code")
444
+ query = {
445
+ client_id: client_id,
446
+ redirect_uri: redirect_uri,
447
+ response_type: response_type,
448
+ state: state
449
+ }
450
+
451
+ supported_authorization_params.each do |param|
452
+ query[param] = options[param] || defaults[param]
453
+ end
454
+
455
+ "#{endpoint}?#{URI.encode_www_form(query.compact)}"
456
+ end
457
+
458
+ # Pick the standard tokens out of a token-endpoint response.
459
+ #
460
+ # @param parsed_token [Hash] Parsed token response (String or Symbol keys)
461
+ # @return [Hash{Symbol => String}] `access_token`, `refresh_token` and
462
+ # `id_token`, nil entries removed
463
+ def extract_tokens(parsed_token)
464
+ %i[access_token refresh_token id_token].each_with_object({}) do |key, tokens|
465
+ value = parsed_token[key.to_s] || parsed_token[key]
466
+ tokens[key] = value unless value.nil?
467
+ end
468
+ end
255
469
  end
256
470
  end
257
471
  end
@@ -0,0 +1,59 @@
1
+ module StandardId
2
+ module Providers
3
+ # Namespace for the Railties generated by {Providers.plugin_railtie}.
4
+ module Railties; end
5
+
6
+ class << self
7
+ # Define the Railtie that registers a provider plugin with StandardId.
8
+ #
9
+ # Replaces the hand-written `lib/standard_id/<name>/railtie.rb` every
10
+ # provider plugin used to carry. Call it from the plugin's entry file,
11
+ # after the provider class is required:
12
+ #
13
+ # # lib/standard_id/github.rb
14
+ # require "standard_id"
15
+ # require "standard_id/github/providers/github"
16
+ #
17
+ # StandardId::Providers.plugin_railtie(:github, "StandardId::Providers::GitHub")
18
+ #
19
+ # The generated Railtie (`StandardId::Providers::Railties::<Name>`)
20
+ # registers the provider from `config.after_initialize`, once the host's
21
+ # configuration is complete. Its config fields are declared earlier, before
22
+ # `config/initializers` run, by StandardId's own engine initializer — the
23
+ # plugin has nothing further to do for that.
24
+ #
25
+ # Outside Rails (no `Rails::Railtie`) nothing is defined and nil is
26
+ # returned, so the entry file needs no `if defined?(Rails)` guard.
27
+ #
28
+ # Idempotent: calling it again for the same name returns the existing
29
+ # Railtie rather than defining a second one (which would register twice).
30
+ #
31
+ # @param name [Symbol, String] Provider identifier, as passed to
32
+ # ProviderRegistry.register (e.g. :google)
33
+ # @param provider_class [Class, String] The provider class, or its name.
34
+ # A String is constantized when the Railtie runs, so the entry file may
35
+ # call this before the class is loaded.
36
+ # @return [Class, nil] the Railtie class
37
+ def plugin_railtie(name, provider_class)
38
+ return nil unless defined?(::Rails::Railtie)
39
+
40
+ const_name = name.to_s.camelize
41
+ if Railties.const_defined?(const_name, false)
42
+ return Railties.const_get(const_name, false)
43
+ end
44
+
45
+ provider_name = name.to_sym
46
+ railtie = Class.new(::Rails::Railtie)
47
+ Railties.const_set(const_name, railtie)
48
+
49
+ railtie.config.after_initialize do
50
+ klass = provider_class.is_a?(String) ? provider_class.constantize : provider_class
51
+ StandardId::ProviderRegistry.register(provider_name, klass)
52
+ Rails.logger&.debug("[StandardId] registered provider #{provider_name}")
53
+ end
54
+
55
+ railtie
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,7 +1,11 @@
1
1
  module StandardId
2
2
  class ScopeConfig
3
3
  # @!attribute [r] allow_registration
4
- # Reserved for future use — controls whether new accounts can register under this scope.
4
+ # Whether passwordless sign-in under this scope may create a new account
5
+ # (default true). It can only RESTRICT: registration happens iff the
6
+ # global switch allows it (web.passwordless_registration for the
7
+ # WebEngine; the caller's `allow_registration:` for host controllers using
8
+ # StandardId::PasswordlessFlow) AND this is true. See #allow_registration?.
5
9
  # @!attribute [r] profile_types
6
10
  # Array of profile-type class names accepted by this scope. Any profile matching any of
7
11
  # these types satisfies the built-in profile check.
@@ -20,37 +24,35 @@ module StandardId
20
24
  :allow_registration,
21
25
  :authorizer
22
26
 
23
- # Shared deprecator instance. Creating a new ActiveSupport::Deprecation on
24
- # every extract_profile_types call bypasses the host app's configured
25
- # deprecation behaviour (Rails 7.1+ routes through deprecation registries)
26
- # and allocates for every scope init. One instance is enough.
27
- DEPRECATOR = ActiveSupport::Deprecation.new("2.0", "StandardId")
28
-
29
27
  # Normalize profile-type inputs from config.
30
28
  #
31
- # Accepts:
32
- # - :profile_types (plural) — array of strings (preferred).
33
- # - :profile_type (singular) — single string, retained for back-compat. Emits a
34
- # deprecation warning when present.
29
+ # Accepts :profile_types — an Array of profile-type class names (a single
30
+ # String is wrapped). The singular :profile_type key was removed in 0.43
31
+ # (deprecated in 0.42) and now raises: silently ignoring it would leave the
32
+ # scope with NO profile requirement, admitting every account.
35
33
  #
36
- # Returns an Array<String> (possibly empty).
34
+ # @return [Array<String>] possibly empty
35
+ # @raise [StandardId::ConfigurationError] when the removed :profile_type key is present
37
36
  def self.extract_profile_types(config)
38
- plural = config[:profile_types]
39
- singular = config[:profile_type]
40
-
41
- if singular && plural
42
- raise ArgumentError, "Scope config cannot set both :profile_type and :profile_types — use :profile_types"
37
+ if config.key?(:profile_type) || config.key?("profile_type")
38
+ raise StandardId::ConfigurationError,
39
+ "StandardId scope config key :profile_type was removed in StandardId 0.43. " \
40
+ "Use profile_types: [...] (an Array of profile-type class names) instead."
43
41
  end
44
42
 
45
- if singular
46
- DEPRECATOR.warn(
47
- "StandardId scope config key :profile_type is deprecated and will be removed in v2.0. " \
48
- "Use :profile_types (an Array of profile-type strings) instead."
49
- )
50
- return Array(singular).map(&:to_s).reject(&:blank?)
51
- end
43
+ Array(config[:profile_types]).map(&:to_s).reject(&:blank?)
44
+ end
45
+
46
+ # Build every scope in StandardId.config.scopes once, so a scope config the
47
+ # gem can no longer read (e.g. the removed :profile_type key) fails at boot
48
+ # rather than on the first sign-in under that scope. Run by the engine.
49
+ #
50
+ # @return [void]
51
+ # @raise [StandardId::ConfigurationError]
52
+ def self.validate_all!(scopes = StandardId.config.scopes)
53
+ return if scopes.blank?
52
54
 
53
- Array(plural).map(&:to_s).reject(&:blank?)
55
+ scopes.each { |name, scope_hash| new(name, scope_hash || {}) }
54
56
  end
55
57
 
56
58
  def initialize(name, config = {})
@@ -59,7 +61,8 @@ module StandardId
59
61
  @after_sign_in_path = config[:after_sign_in_path]
60
62
  @no_profile_message = config[:no_profile_message] || default_no_profile_message
61
63
  @label = config[:label] || name.to_s.humanize
62
- @allow_registration = config.fetch(:allow_registration, true)
64
+ # A present-but-nil key means "not configured", i.e. the default (true).
65
+ @allow_registration = config[:allow_registration].nil? ? true : config[:allow_registration]
63
66
  @authorizer = config[:authorizer]
64
67
  end
65
68
 
@@ -82,6 +85,20 @@ module StandardId
82
85
  authorizer.respond_to?(:call)
83
86
  end
84
87
 
88
+ def allow_registration?
89
+ allow_registration != false
90
+ end
91
+
92
+ # Combine a global/caller registration switch with a (possibly nil) scope.
93
+ # No scope → the global value unchanged; a scope can only turn it off.
94
+ #
95
+ # @param global [Boolean]
96
+ # @param scope_config [StandardId::ScopeConfig, nil]
97
+ def self.registration_allowed?(global, scope_config)
98
+ return false unless global
99
+ scope_config.nil? || scope_config.allow_registration?
100
+ end
101
+
85
102
  private
86
103
 
87
104
  def default_no_profile_message
@@ -0,0 +1,55 @@
1
+ module StandardId
2
+ module Testing
3
+ # Helpers for specs that exercise StandardId configuration defaults —
4
+ # chiefly the ENV fallback of provider fields (`GOOGLE_CLIENT_ID`,
5
+ # `APPLE_CLIENT_ID`, …).
6
+ #
7
+ # Those defaults are resolved ONCE, when the config is built at boot, so
8
+ # setting ENV inside a spec changes nothing on its own. `with_provider_env`
9
+ # sets the variables, re-resolves every field the host never assigned, runs
10
+ # the block, then restores both:
11
+ #
12
+ # RSpec.describe "Apple sign-in" do
13
+ # include StandardId::Testing::ConfigHelpers
14
+ #
15
+ # it "is enabled once APPLE_CLIENT_ID is set" do
16
+ # with_provider_env("APPLE_CLIENT_ID" => "com.example.web") do
17
+ # expect(StandardId.social_provider_enabled?(:apple)).to be(true)
18
+ # end
19
+ # end
20
+ # end
21
+ #
22
+ # (also callable as `StandardId::Testing.with_provider_env(...)`).
23
+ #
24
+ # A field the host assigned explicitly — even to nil — ignores ENV by
25
+ # design; `StandardId.config.social.assigned?(:apple_client_id)` tells the
26
+ # two cases apart (`key?` is true for every declared field, assigned or
27
+ # not).
28
+ module ConfigHelpers
29
+ # @param env [Hash{String => String, nil}] variables to set; nil unsets.
30
+ # Usually passed brace-less (`with_provider_env("APPLE_CLIENT_ID" => "x")`),
31
+ # which Ruby delivers as keywords — hence **vars.
32
+ # @param scope [Symbol] config scope whose defaults to re-resolve
33
+ # @yield with the variables set and the scope's defaults re-resolved
34
+ # @return [Object] the block's value
35
+ def with_provider_env(env = {}, scope: :social, **vars)
36
+ env = env.merge(vars)
37
+ previous = env.to_h { |name, _| [name.to_s, ENV[name.to_s]] }
38
+ env.each { |name, value| write_env(name.to_s, value) }
39
+ StandardId.config[scope].refresh_defaults!
40
+ yield
41
+ ensure
42
+ previous&.each { |name, value| write_env(name, value) }
43
+ StandardId.config[scope].refresh_defaults!
44
+ end
45
+
46
+ private
47
+
48
+ def write_env(name, value)
49
+ value.nil? ? ENV.delete(name) : ENV[name] = value.to_s
50
+ end
51
+ end
52
+
53
+ extend ConfigHelpers
54
+ end
55
+ end
@@ -0,0 +1,117 @@
1
+ # RSpec support for host apps (and provider plugins) that want to pin down
2
+ # "this provider plugin is installed, registered, and its config fields are
3
+ # writable from config/initializers/standard_id.rb".
4
+ #
5
+ # Loaded automatically by `require "standard_id/testing"` when RSpec is
6
+ # present, or explicitly:
7
+ #
8
+ # require "standard_id/testing/provider_examples"
9
+ #
10
+ # Shared example — the one-liner most apps want:
11
+ #
12
+ # RSpec.describe "StandardId social providers" do
13
+ # it_behaves_like "a registered StandardId provider", :google
14
+ # it_behaves_like "a registered StandardId provider", :apple,
15
+ # config_fields: %i[apple_client_id apple_private_key apple_key_id apple_team_id]
16
+ # end
17
+ #
18
+ # `config_fields:` defaults to every field in the provider's `config_schema`.
19
+ #
20
+ # Matcher — for ad-hoc assertions:
21
+ #
22
+ # expect(:google).to be_a_registered_standard_id_provider
23
+ # expect(:apple).to be_a_registered_standard_id_provider.with_config_fields(:apple_client_id, :apple_team_id)
24
+ #
25
+ require "standard_id"
26
+
27
+ module StandardId
28
+ module Testing
29
+ module ProviderExamples
30
+ module_function
31
+
32
+ # Problems preventing `name` from counting as a registered provider with
33
+ # the given social config fields. Empty when all is well.
34
+ #
35
+ # @param name [Symbol, String]
36
+ # @param fields [Array<Symbol>, nil] nil = the provider's config_schema keys
37
+ # @return [Array<String>]
38
+ def problems(name, fields = nil)
39
+ return ["provider #{name.inspect} is not registered with StandardId::ProviderRegistry"] unless StandardId::ProviderRegistry.registered?(name)
40
+
41
+ fields = config_fields_for(name) if fields.nil?
42
+ fields.filter_map do |field|
43
+ next "#{field} is not declared on the social config scope" unless StandardId::ConfigSchema.instance.field?(:social, field)
44
+
45
+ begin
46
+ StandardId.config.social.public_send(field)
47
+ nil
48
+ rescue StandardError => e
49
+ "reading social.#{field} raised #{e.class}: #{e.message}"
50
+ end
51
+ end
52
+ end
53
+
54
+ def config_fields_for(name)
55
+ StandardId::ProviderRegistry.get(name).config_schema.keys.map(&:to_sym)
56
+ end
57
+
58
+ # Write `value` through the same setter a host initializer uses, then
59
+ # restore the field exactly — including "never assigned", so an ENV
60
+ # fallback keeps working for later examples.
61
+ def round_trip(field, value)
62
+ social = StandardId.config.social
63
+ assigned = social.assigned?(field)
64
+ original = social.to_h[field.to_sym]
65
+ social.public_send(:"#{field}=", value)
66
+ social.public_send(field)
67
+ ensure
68
+ if assigned
69
+ social[field.to_sym] = original
70
+ else
71
+ social.delete(field.to_sym)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ if defined?(RSpec::Matchers) && RSpec::Matchers.respond_to?(:define)
79
+ RSpec::Matchers.define :be_a_registered_standard_id_provider do
80
+ chain(:with_config_fields) { |*fields| @fields = fields.flatten.map(&:to_sym) }
81
+
82
+ match do |name|
83
+ @problems = StandardId::Testing::ProviderExamples.problems(name, @fields)
84
+ @problems.empty?
85
+ end
86
+
87
+ failure_message do |name|
88
+ "expected #{name.inspect} to be a registered StandardId provider, but:\n " + @problems.join("\n ")
89
+ end
90
+
91
+ failure_message_when_negated do |name|
92
+ "expected #{name.inspect} not to be a registered StandardId provider"
93
+ end
94
+ end
95
+ end
96
+
97
+ if defined?(RSpec) && RSpec.respond_to?(:shared_examples)
98
+ RSpec.shared_examples "a registered StandardId provider" do |name, config_fields: nil|
99
+ fields_for = -> { config_fields || StandardId::Testing::ProviderExamples.config_fields_for(name) }
100
+
101
+ it "registers the #{name} provider" do
102
+ expect(StandardId::ProviderRegistry.registered?(name)).to be(true),
103
+ "#{name.inspect} is not registered — is its plugin gem in the Gemfile, and did the app boot its Railtie?"
104
+ end
105
+
106
+ it "declares the #{name} config fields on the social scope" do
107
+ expect(name).to be_a_registered_standard_id_provider.with_config_fields(fields_for.call)
108
+ end
109
+
110
+ it "accepts writes to the #{name} config fields, as config/initializers/standard_id.rb does" do
111
+ fields_for.call.each do |field|
112
+ expect(StandardId::Testing::ProviderExamples.round_trip(field, "probe-value")).to eq("probe-value"),
113
+ "writing social.#{field} did not round-trip"
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,5 +1,7 @@
1
1
  require "standard_id/testing/authentication_helpers"
2
2
  require "standard_id/testing/request_helpers"
3
+ require "standard_id/testing/config_helpers"
4
+ require "standard_id/testing/provider_examples" if defined?(RSpec)
3
5
 
4
6
  module StandardId
5
7
  module Testing
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.41.1"
2
+ VERSION = "0.43.0"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  require "standard_id/version"
2
+ require "standard_id/deprecator"
3
+ require "standard_id/instrumentation"
2
4
  require "standard_id/current_attributes"
3
5
  require "standard_id/engine"
4
6
  require "standard_id/web_engine"
@@ -7,6 +9,9 @@ require "standard_id/config_schema"
7
9
  require "standard_id/config/schema"
8
10
  require "standard_id/scope_config"
9
11
  require "standard_id/association_strict_loading"
12
+ require "standard_id/migration_check"
13
+ require "standard_id/migration_helpers"
14
+ require "standard_id/checks/migrations"
10
15
  require "standard_id/errors"
11
16
  require "standard_id/events"
12
17
  require "standard_id/events/subscribers/base"
@@ -65,6 +70,7 @@ require "concurrent/delay"
65
70
 
66
71
  require "standard_id/providers/base"
67
72
  require "standard_id/provider_registry"
73
+ require "standard_id/providers/plugin_railtie"
68
74
 
69
75
  module StandardId
70
76
  CONFIG = Concurrent::Delay.new { ConfigSchema.build }
@@ -105,6 +111,26 @@ module StandardId
105
111
  ScopeConfig.new(name, scope_hash)
106
112
  end
107
113
 
114
+ # Registered social providers the host app has switched on.
115
+ #
116
+ # @return [Hash{String => Class}] Provider name => provider class
117
+ # @see StandardId::Providers::Base.enabled?
118
+ def enabled_social_providers
119
+ ProviderRegistry.enabled
120
+ end
121
+
122
+ # Whether the named social provider is registered AND switched on.
123
+ #
124
+ # Safe to call for a provider whose plugin gem is not installed — returns
125
+ # false rather than raising. Prefer this over checking a client ID
126
+ # directly (`StandardId.config.google_client_id.present?`).
127
+ #
128
+ # @param name [Symbol, String] e.g. :google
129
+ # @return [Boolean]
130
+ def social_provider_enabled?(name)
131
+ ProviderRegistry.registered?(name) && ProviderRegistry.get(name).enabled?
132
+ end
133
+
108
134
  def skip_host_authorization(framework: nil, callback: nil)
109
135
  AuthorizationBypass.apply(framework: framework, callback: callback)
110
136
  end