standard_id 0.41.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +310 -17
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
- data/app/controllers/standard_id/web/login_controller.rb +1 -2
- data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +12 -8
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
- data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
- data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
- data/app/models/concerns/standard_id/credentiable.rb +8 -1
- data/app/models/standard_id/application_record.rb +26 -0
- data/app/models/standard_id/authorization_code.rb +3 -1
- data/app/models/standard_id/identifier.rb +1 -0
- data/app/models/standard_id/session.rb +2 -1
- data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
- data/app/views/standard_id/web/login/show.html.erb +5 -5
- data/app/views/standard_id/web/signup/show.html.erb +3 -3
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
- data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
- data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
- data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
- data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +34 -20
- data/lib/standard_id/config_schema.rb +94 -6
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +22 -0
- data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
- data/lib/standard_id/instrumentation.rb +49 -0
- data/lib/standard_id/migration_check.rb +183 -0
- data/lib/standard_id/migration_helpers.rb +65 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
- data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- data/lib/standard_id/otp.rb +20 -28
- data/lib/standard_id/passwordless/base_strategy.rb +18 -14
- data/lib/standard_id/passwordless/email_strategy.rb +0 -5
- data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
- data/lib/standard_id/provider_registry.rb +95 -5
- data/lib/standard_id/providers/base.rb +222 -8
- data/lib/standard_id/providers/plugin_railtie.rb +59 -0
- data/lib/standard_id/scope_config.rb +43 -26
- data/lib/standard_id/testing/config_helpers.rb +55 -0
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +2 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +25 -17
- data/config/initializers/migration_helpers.rb +0 -32
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
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 [
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/standard_id/testing.rb
CHANGED
data/lib/standard_id/version.rb
CHANGED
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
|