standard_id 0.31.0 → 0.33.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.
@@ -19,6 +19,22 @@ StandardId::ConfigSchema.define do
19
19
  field :inertia_component_namespace, type: :string, default: "standard_id"
20
20
  field :alias_current_user, type: :boolean, default: false
21
21
 
22
+ # Opt the gem's own associations out of an app-wide
23
+ # `strict_loading_by_default = true`.
24
+ #
25
+ # `nil` (default) means "say nothing": the `strict_loading:` option is
26
+ # omitted from the association declarations entirely, so they inherit the
27
+ # owner's setting exactly as they always have. `false` opts them out; `true`
28
+ # forces them on even in an app that has strict loading off.
29
+ #
30
+ # This must stay tri-state and `nil` must mean OMITTED, not `false`. Rails
31
+ # checks `reflection.options.key?(:strict_loading)` before consulting the
32
+ # owner (ActiveRecord::Associations::Association#violates_strict_loading?),
33
+ # so declaring `strict_loading: nil` would put the key in the hash and make
34
+ # `reflection.strict_loading?` return false — silently disabling strict
35
+ # loading for every app that never asked for it.
36
+ field :association_strict_loading, type: :any, default: nil
37
+
22
38
  # Scope-aware authentication: maps scope names to profile-based access config.
23
39
  # Each scope is a hash with keys: :profile_types (Array<String>), :after_sign_in_path,
24
40
  # :no_profile_message, :label, :allow_registration, :authorizer.
@@ -173,18 +189,24 @@ StandardId::ConfigSchema.define do
173
189
  field :device_session_lifetime, type: :integer, default: 2592000 # 30 days in seconds
174
190
  field :service_session_lifetime, type: :integer, default: 7776000 # 90 days in seconds
175
191
 
176
- # BCrypt cost factor for the session token digest. Default `nil` means
177
- # use bcrypt-ruby's built-in default (cost 12 in production, MIN_COST
178
- # in the test env). Since session tokens are 256-bit random
179
- # (`SecureRandom.urlsafe_base64(32)`), any cost >= 10 is well beyond
180
- # realistic brute-force, and dropping from 12 to 10 saves ~200ms of
181
- # CPU per session creation. Host apps with many logins-per-second can
182
- # set this to `10`; apps that value hash work over login latency can
183
- # leave it alone or raise it.
184
- #
185
- # When set, value is clamped to BCrypt::Engine::MIN_COST..MAX_COST.
186
- # Applies only to newly-created sessions; existing token_digests keep
187
- # their original cost.
192
+ # Opt IN to BCrypt for the session token digest, at this cost factor.
193
+ #
194
+ # Default `nil` means HMAC-SHA256 under `secret_key_base`, which is the
195
+ # right primitive here: session tokens are 256-bit random
196
+ # (`SecureRandom.urlsafe_base64(32)`), and BCrypt's cost factor exists to
197
+ # slow brute-force of LOW-entropy secrets. Against a token that cannot be
198
+ # guessed at any hash speed the stretching bought nothing, while costing a
199
+ # measured ~181 ms of CPU on every authenticated API request at cost 12 —
200
+ # note this is paid per REQUEST, not merely per session creation.
201
+ #
202
+ # Set it only if you specifically want hash work on this path anyway. When
203
+ # set, the value is clamped to BCrypt::Engine::MIN_COST..MAX_COST.
204
+ #
205
+ # Changing it is always safe and never strands a session:
206
+ # Session#authenticate_token reads the scheme off the stored digest (BCrypt
207
+ # digests are self-identifying by their `$2<x>$` prefix), so both schemes
208
+ # verify side by side and existing rows are never rewritten. As before, a
209
+ # change applies only to newly-created sessions.
188
210
  field :token_digest_cost, type: :integer, default: nil
189
211
 
190
212
  # Callable that resolves the session class to create for a given auth flow.
@@ -294,6 +316,80 @@ StandardId::ConfigSchema.define do
294
316
  # OAuth clients), so it is opt-in: a deployment must explicitly turn it on.
295
317
  field :dynamic_registration_enabled, type: :boolean, default: false
296
318
 
319
+ # Enable the RFC 7662 token introspection endpoint (POST /oauth/introspect).
320
+ #
321
+ # When false (the default), the endpoint is fully absent (404) and
322
+ # `introspection_endpoint` is NOT advertised in the discovery documents. An
323
+ # endpoint that answers questions about other people's tokens is not something
324
+ # to expose by accident, so it is opt-in — same posture as
325
+ # `dynamic_registration_enabled`.
326
+ #
327
+ # Confidential clients only: the caller authenticates with client_id +
328
+ # client_secret (HTTP Basic or form body). Every failure renders
329
+ # `{"active": false}` with no other members, per RFC 7662 §2.2.
330
+ #
331
+ # KNOW THE LIMIT BEFORE BUILDING ON IT: access tokens are stateless — never
332
+ # persisted, and carrying no `sid` — so a revoked session's access token
333
+ # introspects as `active: true` until its `exp`. Introspection answers "did we
334
+ # mint this, and is it unexpired?", NOT "is it still honoured?". Keep
335
+ # `access_token_lifetime` short. Refresh tokens ARE persisted and are checked
336
+ # against the row, so a revoked refresh token introspects as inactive
337
+ # immediately.
338
+ field :introspection_enabled, type: :boolean, default: false
339
+
340
+ # Where the discovery documents say the ENDPOINTS live.
341
+ #
342
+ # The issuer and the endpoint base are different things. The issuer is a
343
+ # stable security identifier (RFC 8414 §2) that clients match byte-for-byte
344
+ # against their discovery URL and against the `iss` claim; it is never
345
+ # derived from the request and cannot be overridden. This says where
346
+ # /authorize, /oauth/token etc. actually are.
347
+ #
348
+ # nil (default) — the issuer. Byte-identical to the behaviour before this
349
+ # option existed. Already correct if your issuer carries
350
+ # the ApiEngine mount path (e.g. "https://host/auth/api").
351
+ # WRONG if it does not: the document then advertises
352
+ # endpoints at the bare origin, which 404.
353
+ # :request — request.base_url + the detected mount path. What you want
354
+ # when ApiEngine is mounted under a prefix ("/api",
355
+ # "/api/v1") the issuer does not carry.
356
+ # "https://..." — used verbatim.
357
+ # callable — receives (request:). Use when a proxy rewrites
358
+ # request.base_url, or when ApiEngine is mounted more than
359
+ # once (e.g. under host constraints) so no single detected
360
+ # path is right.
361
+ #
362
+ # Request-derived is opt-in rather than the default on purpose: defaulting to
363
+ # it would silently rewrite the document of every app whose issuer host
364
+ # differs from the host serving the request — the split-host setup a separate
365
+ # issuer exists to express.
366
+ field :discovery_endpoint_base, type: :any, default: nil
367
+
368
+ # Members of the discovery documents that cannot be derived, as
369
+ # `{ member => value }`. Values may be static or callable.
370
+ #
371
+ # A callable receives one Hash — `{ origin:, endpoint_base:, issuer:,
372
+ # request: }`, where `origin` is scheme+host+port with no path.
373
+ #
374
+ # A `nil` value REMOVES the member rather than emitting null.
375
+ #
376
+ # Setting `issuer` RAISES — see discovery_endpoint_base above.
377
+ #
378
+ # c.oauth.discovery_metadata_overrides = {
379
+ # # a host-owned shim that injects the audience before handing off to the
380
+ # # engine's /authorize
381
+ # authorization_endpoint: ->(ctx) { "#{ctx[:origin]}/oauth/authorize" },
382
+ # # deliberately narrower than everything the server can mint
383
+ # scopes_supported: %w[mcp mcp:read],
384
+ # # MUST mirror your dynamic-registration policy: advertising
385
+ # # client_secret_* while DCR only accepts public clients makes
386
+ # # spec-following clients register in a way DCR then rejects
387
+ # token_endpoint_auth_methods_supported: %w[none],
388
+ # # omit a member entirely
389
+ # grant_types_supported: nil
390
+ # }
391
+ field :discovery_metadata_overrides, type: :hash, default: -> { {} }
392
+
297
393
  # Callable resolving the polymorphic owner assigned to clients created via
298
394
  # Dynamic Client Registration (the `owner` association on ClientApplication
299
395
  # is required). Example: `-> { Organization.default }`.
@@ -439,5 +535,12 @@ StandardId::ConfigSchema.define do
439
535
  # Dynamic client registration (RFC 7591) — throttle the open registration
440
536
  # endpoint by IP so an enabled deployment can't be flooded with client rows.
441
537
  field :dynamic_registration_per_ip, type: :integer, default: 10 # per hour
538
+
539
+ # Token introspection (RFC 7662) — throttle by IP so the endpoint cannot be
540
+ # used to bulk-classify stolen tokens. Note the limit renders the ordinary
541
+ # `{"active": false}` / 200 rather than 429: a 429 would distinguish
542
+ # "throttled" from "token invalid" and turn the limiter into a token-validity
543
+ # oracle.
544
+ field :introspection_per_ip, type: :integer, default: 30 # per 15 minutes
442
545
  end
443
546
  end
@@ -10,6 +10,33 @@ module StandardId
10
10
  class Engine < ::Rails::Engine
11
11
  isolate_namespace StandardId
12
12
 
13
+ # Declare provider plugins' `social` config fields BEFORE the host app's
14
+ # `config/initializers/standard_id.rb` runs, so an initializer can write
15
+ # `c.social.google_client_id` the way every doc says it can.
16
+ #
17
+ # Without this, the only thing that declared those fields was
18
+ # `ProviderRegistry.register`, called from each plugin Railtie's
19
+ # `config.after_initialize` — which runs long after
20
+ # `:load_config_initializers`. The host's write hit
21
+ # `ConfigSchema::Scope#[]=` → `validate!` against a schema that did not yet
22
+ # know the field and raised `StandardId::ConfigurationError`, with nothing
23
+ # in the message to hint that the cause was boot ordering. Consuming apps
24
+ # each independently rediscovered the same
25
+ # `Rails.application.config.after_initialize { ... }` wrapper to get around
26
+ # it, and the install template plus two plugin READMEs documented three
27
+ # mutually inconsistent forms, two of which raised.
28
+ #
29
+ # `before:` rather than a plain positional declaration: engine initializers
30
+ # otherwise run in `Rails::Engine`-collection order relative to the
31
+ # application's own, and `:load_config_initializers` is an application
32
+ # bootstrap step, so an explicit edge is the only ordering guarantee.
33
+ #
34
+ # This needs no plugin release — provider classes are required at
35
+ # gem-require time, so they are already loaded here.
36
+ initializer "standard_id.provider_config_schemas", before: :load_config_initializers do
37
+ StandardId::ProviderRegistry.declare_config_schemas!
38
+ end
39
+
13
40
  initializer "standard_id.filter_parameters" do |app|
14
41
  app.config.filter_parameters += %i[
15
42
  code_verifier
@@ -39,6 +66,12 @@ module StandardId
39
66
  "Set StandardId.config.issuer in your initializer for production use.")
40
67
  end
41
68
 
69
+ # Fail loudly if `association_strict_loading` was assigned after the
70
+ # host's Account class body already ran — that assignment has no effect,
71
+ # and its only symptom otherwise is a StrictLoadingViolationError deep in
72
+ # a request or a silent N+1 in production.
73
+ StandardId::AssociationStrictLoading.verify_consistency!
74
+
42
75
  # Validate configured callables have the right shape and that every
43
76
  # claim listed in scope_claims has a resolver registered. Raising here
44
77
  # surfaces typos at boot instead of at callback time in production.
@@ -5,28 +5,57 @@ module StandardId
5
5
  # * /.well-known/oauth-authorization-server (RFC 8414)
6
6
  #
7
7
  # Both well-known controllers render this single builder so the two
8
- # documents cannot drift. Endpoint URLs are derived from the configured
9
- # issuer.
8
+ # documents cannot drift.
10
9
  #
11
- # NOTE on mounting (RFC 8414 caveat): the ApiEngine is consumer-mounted at
12
- # a sub-path (e.g. `/auth/api`), so the gem can only serve
13
- # `/auth/api/.well-known/oauth-authorization-server`. A strict RFC 8414
14
- # client that derives a *root-anchored* metadata URL from a path-carrying
15
- # issuer would probe `<host>/.well-known/oauth-authorization-server/auth/api`,
16
- # which falls outside any engine mount. Hosts that need the root-anchored
17
- # form must add their own root route — the gem cannot.
10
+ # ## `issuer` and the endpoint base are two different things
11
+ #
12
+ # This used to derive every endpoint from the issuer
13
+ # (`base = issuer.to_s.chomp("/")`), which is wrong for any app mounting
14
+ # `ApiEngine` under a prefix its issuer does not carry: the document
15
+ # advertised `<issuer>/oauth/token` while the endpoint actually lived at
16
+ # `<origin>/api/oauth/token`. Every consuming app hand-rolled a replacement
17
+ # controller because of it.
18
+ #
19
+ # The two are now separate, and must stay separate:
20
+ #
21
+ # * `issuer` is a stable **security identifier** (RFC 8414 §2). Clients
22
+ # compare it byte-for-byte with the URL they used for discovery and with
23
+ # the `iss` claim of issued tokens. It is never derived from the request
24
+ # and can never be overridden — see .apply_overrides!.
25
+ # * `endpoint_base` is where the endpoints actually are. It defaults to the
26
+ # issuer, which preserves the previous behaviour exactly for apps whose
27
+ # issuer already carries the mount path.
28
+ #
29
+ # ## Overrides
30
+ #
31
+ # Some members are not derivable at all — a host-owned authorization shim
32
+ # that injects an audience, a scope list deliberately narrower than what the
33
+ # server can mint, an auth-method list that must mirror the host's dynamic
34
+ # registration policy. `overrides` covers those; see
35
+ # StandardId::Oauth::DiscoveryResolver for the config surface.
18
36
  module DiscoveryDocument
19
37
  module_function
20
38
 
21
- # @param issuer [String] the configured issuer (e.g. "https://auth.example.com")
39
+ # @param issuer [String] the configured issuer, emitted verbatim
40
+ # @param endpoint_base [String, nil] base URL for the endpoints. Defaults
41
+ # to `issuer` — the previous behaviour — so existing callers are
42
+ # unaffected.
22
43
  # @param registration_enabled [Boolean] when true, advertises the RFC 7591
23
44
  # dynamic client registration endpoint. The well-known controllers pass
24
45
  # `StandardId.config.oauth.dynamic_registration_enabled` here, so the
25
46
  # `registration_endpoint` is emitted only when DCR is turned on. Defaults
26
47
  # to false so callers (and tests) that omit it get no registration_endpoint.
48
+ # @param introspection_enabled [Boolean] when true, advertises the RFC 7662
49
+ # introspection endpoint. The well-known controllers pass
50
+ # `StandardId.config.oauth.introspection_enabled`, so it is advertised only
51
+ # when the endpoint actually exists (the controller 404s when off).
52
+ # Defaults to false, matching registration_enabled.
53
+ # @param overrides [Hash] members to replace, add, or (with a nil value)
54
+ # remove. Already resolved — callables are evaluated by the caller.
27
55
  # @return [Hash]
28
- def build(issuer, registration_enabled: false)
29
- base = issuer.to_s.chomp("/")
56
+ def build(issuer, endpoint_base: nil, registration_enabled: false,
57
+ introspection_enabled: false, overrides: {})
58
+ base = (endpoint_base.presence || issuer).to_s.chomp("/")
30
59
 
31
60
  doc = {
32
61
  issuer: issuer,
@@ -55,6 +84,38 @@ module StandardId
55
84
  doc[:jwks_uri] = "#{base}/.well-known/jwks.json" if StandardId::JwtService.asymmetric?
56
85
 
57
86
  doc[:registration_endpoint] = "#{base}/oauth/register" if registration_enabled
87
+ doc[:introspection_endpoint] = "#{base}/oauth/introspect" if introspection_enabled
88
+
89
+ apply_overrides!(doc, overrides)
90
+ end
91
+
92
+ # Merge resolved overrides into the document.
93
+ #
94
+ # A `nil` value REMOVES the member rather than emitting `null`. Real apps
95
+ # need that — one omits `scopes_supported` entirely — and a `null` would be
96
+ # worse than either alternative, since RFC 8414 members are typed.
97
+ #
98
+ # `issuer` is REFUSED rather than silently dropped. It is a security
99
+ # identifier clients match byte-for-byte against both their discovery URL
100
+ # and the `iss` claim; letting a metadata override diverge it from what the
101
+ # token service actually stamps would produce a document that validates
102
+ # against nothing, and the failure would surface a long way from here.
103
+ def apply_overrides!(doc, overrides)
104
+ return doc if overrides.blank?
105
+
106
+ overrides = overrides.symbolize_keys
107
+ if overrides.key?(:issuer)
108
+ raise StandardId::ConfigurationError,
109
+ "discovery_metadata_overrides cannot set `issuer`. The issuer is a stable " \
110
+ "security identifier (RFC 8414 §2): clients compare it byte-for-byte with " \
111
+ "the URL they used for discovery and with the `iss` claim of issued tokens, " \
112
+ "so it must stay exactly StandardId.config.issuer. To move where the " \
113
+ "ENDPOINTS live, set config.oauth.discovery_endpoint_base instead."
114
+ end
115
+
116
+ overrides.each do |key, value|
117
+ value.nil? ? doc.delete(key) : doc[key] = value
118
+ end
58
119
 
59
120
  doc
60
121
  end
@@ -0,0 +1,158 @@
1
+ module StandardId
2
+ module Oauth
3
+ # Resolves the three request-dependent inputs of a discovery document:
4
+ # the issuer, the endpoint base, and the overrides.
5
+ #
6
+ # Lives apart from DiscoveryDocument so the builder stays a pure function of
7
+ # its arguments (and stays trivially testable without a request), while all
8
+ # the "where am I actually mounted" reasoning is in one place.
9
+ module DiscoveryResolver
10
+ # Values of `discovery_endpoint_base` that mean "derive it from the
11
+ # request". Compared as strings so `:request` and `"request"` both work —
12
+ # this is set from an initializer and the symbol/string distinction is not
13
+ # something a config file should have to get right.
14
+ REQUEST_DERIVED = %w[request].freeze
15
+
16
+ module_function
17
+
18
+ # The issuer, verbatim from config. Never derived from the request, never
19
+ # overridable: RFC 8414 §2 makes it a stable security identifier that
20
+ # clients match byte-for-byte against their discovery URL and against the
21
+ # `iss` claim of issued tokens.
22
+ #
23
+ # @return [String, nil]
24
+ def issuer
25
+ StandardId.config.issuer
26
+ end
27
+
28
+ # Base URL the advertised endpoints hang off, from
29
+ # `config.oauth.discovery_endpoint_base`:
30
+ #
31
+ # nil (default) — the issuer. EXACTLY the behaviour before this option
32
+ # existed, so an upgrade changes no document. Correct
33
+ # already for an app whose issuer carries the mount path
34
+ # (`https://host/auth/api`); wrong, and the bug this
35
+ # option exists for, when it does not.
36
+ # :request — `request.base_url` + the detected mount path. This is
37
+ # what an app mounting ApiEngine under a prefix wants.
38
+ # String — used verbatim.
39
+ # callable — called with `(request:)`. The escape hatch for a proxy
40
+ # that rewrites `request.base_url`, and for an app that
41
+ # mounts ApiEngine more than once (under host constraints,
42
+ # say) where no single detected path is right.
43
+ #
44
+ # Request-derived is deliberately OPT-IN rather than the default. Making it
45
+ # the default would silently rewrite the document of every app whose issuer
46
+ # host differs from the host serving the request — which is exactly the
47
+ # split-host setup a separate issuer exists to express.
48
+ #
49
+ # @param request [ActionDispatch::Request, nil]
50
+ # @return [String, nil]
51
+ def endpoint_base(request: nil)
52
+ configured = StandardId.config.oauth.discovery_endpoint_base
53
+
54
+ return issuer if configured.blank?
55
+
56
+ if configured.respond_to?(:call)
57
+ resolved = configured.call(request: request)
58
+ return resolved.present? ? resolved.to_s.chomp("/") : issuer
59
+ end
60
+
61
+ return configured.to_s.chomp("/") unless REQUEST_DERIVED.include?(configured.to_s)
62
+ return issuer if request.nil?
63
+
64
+ "#{request.base_url}#{mount_path(request)}".chomp("/")
65
+ end
66
+
67
+ # The path ApiEngine is mounted at, as seen from this request.
68
+ #
69
+ # Two cases, and the difference is why this is not a config lookup:
70
+ #
71
+ # * The document is served from INSIDE the engine mount (the gem's own
72
+ # `scope ".well-known"` routes). Rails sets SCRIPT_NAME to the mount
73
+ # prefix, so `request.script_name` IS the mount path, exactly, with no
74
+ # detection or guessing. This is the case that used to produce 404-ing
75
+ # endpoint URLs.
76
+ # * The document is served from a ROOT route drawn by
77
+ # `standard_id_well_known_routes` (RFC 8615 clients probe the origin
78
+ # root, which is outside any engine mount). There `script_name` is
79
+ # empty, so the helper stamps the mount path it was given into the
80
+ # route defaults and we read it back from `params`.
81
+ #
82
+ # @return [String] "" when neither is available, which resolves to the
83
+ # origin root — correct for an app that mounts ApiEngine at "/".
84
+ def mount_path(request)
85
+ from_defaults = request.params[MOUNT_PATH_PARAM].presence
86
+ return normalize_path(from_defaults) if from_defaults
87
+
88
+ normalize_path(request.script_name)
89
+ end
90
+
91
+ # Route default the routing helper uses to carry the mount path to a
92
+ # root-served document. Named rather than positional so it cannot collide
93
+ # with a host's own params.
94
+ MOUNT_PATH_PARAM = :standard_id_mount_path
95
+
96
+ # Resolve `config.oauth.discovery_metadata_overrides` for this request.
97
+ #
98
+ # Values may be static or callable. A callable receives one Hash argument
99
+ # so members can be built from the request without each app re-deriving the
100
+ # origin:
101
+ #
102
+ # {
103
+ # # host-owned shim that injects the audience before handing off to the
104
+ # # engine's /authorize
105
+ # authorization_endpoint: ->(ctx) { "#{ctx[:origin]}/oauth/authorize" },
106
+ # # deliberately narrower than what the server can mint
107
+ # scopes_supported: %w[mcp mcp:read],
108
+ # # must mirror the host's DCR policy, or spec-following clients
109
+ # # register in a way that policy then rejects
110
+ # token_endpoint_auth_methods_supported: %w[none],
111
+ # # remove a member entirely
112
+ # grant_types_supported: nil
113
+ # }
114
+ #
115
+ # Context keys: `:origin` (scheme+host+port, no path), `:endpoint_base`,
116
+ # `:issuer`, `:request`.
117
+ #
118
+ # @return [Hash] with callables evaluated
119
+ def metadata_overrides(request: nil, endpoint_base: nil)
120
+ configured = StandardId.config.oauth.discovery_metadata_overrides
121
+ return {} if configured.blank?
122
+
123
+ context = {
124
+ origin: request&.base_url,
125
+ endpoint_base: endpoint_base,
126
+ issuer: issuer,
127
+ request: request
128
+ }
129
+
130
+ configured.to_h.each_with_object({}) do |(key, value), acc|
131
+ acc[key.to_sym] = value.respond_to?(:call) ? value.call(context) : value
132
+ end
133
+ end
134
+
135
+ # Everything a well-known controller needs, in one call.
136
+ #
137
+ # @return [Hash] `{ issuer:, endpoint_base:, overrides: }`
138
+ def resolve(request: nil)
139
+ base = endpoint_base(request: request)
140
+
141
+ {
142
+ issuer: issuer,
143
+ endpoint_base: base,
144
+ overrides: metadata_overrides(request: request, endpoint_base: base)
145
+ }
146
+ end
147
+
148
+ # "" or "/foo" — never "/" and never a trailing slash, so string
149
+ # concatenation against an origin cannot produce a double slash.
150
+ def normalize_path(path)
151
+ normalized = path.to_s.chomp("/")
152
+ return "" if normalized.empty?
153
+
154
+ normalized.start_with?("/") ? normalized : "/#{normalized}"
155
+ end
156
+ end
157
+ end
158
+ end
@@ -18,11 +18,76 @@ module StandardId
18
18
  def register(name, provider_class)
19
19
  validate_provider!(provider_class)
20
20
  providers[name.to_s] = provider_class
21
- register_config_schema(provider_class)
21
+ declare_config_schema(provider_class)
22
22
  provider_class.setup if provider_class.respond_to?(:setup)
23
23
  provider_class
24
24
  end
25
25
 
26
+ # Declare the `social` config fields of every provider class that has been
27
+ # LOADED, whether or not it has been `register`ed yet.
28
+ #
29
+ # Called by a core Engine initializer that runs `before:
30
+ # :load_config_initializers` — see StandardId::Engine. It exists because
31
+ # provider plugins register themselves from their Railtie's
32
+ # `config.after_initialize`, which runs long AFTER the host's
33
+ # `config/initializers/standard_id.rb`. A host initializer writing
34
+ # `c.social.google_client_id` therefore hit `Scope#[]=` → `validate!`
35
+ # before the field existed and raised StandardId::ConfigurationError, with
36
+ # nothing in the message to suggest the cause was ordering. Every consuming
37
+ # app that used a provider plugin independently discovered the same
38
+ # `Rails.application.config.after_initialize { ... }` wrapper to work
39
+ # around it.
40
+ #
41
+ # This is safe to do early because provider classes are required at
42
+ # gem-require time (`require "standard_id/google/providers/google"` in the
43
+ # plugin's entry file), so `Providers::Base.subclasses` is already
44
+ # populated before any initializer runs.
45
+ #
46
+ # Only FIELD DECLARATION moves earlier. Full `register` — which also runs
47
+ # `validate_provider!` and the provider's `setup` — deliberately stays in
48
+ # `after_initialize`, where the host's configuration is complete and
49
+ # `setup` can rely on it.
50
+ #
51
+ # Idempotent: `ConfigSchema#add_field` uses `compute_if_absent`, so a field
52
+ # already declared here is untouched when the plugin later calls `register`.
53
+ #
54
+ # @return [Array<Class>] the provider classes whose fields were declared
55
+ def declare_config_schemas!
56
+ provider_classes.each { |provider_class| declare_config_schema(provider_class) }
57
+ end
58
+
59
+ # Provider classes known to the process: every loaded subclass of
60
+ # Providers::Base, plus anything already registered (a registered class
61
+ # need not be a direct subclass).
62
+ #
63
+ # @return [Array<Class>]
64
+ def provider_classes
65
+ (StandardId::Providers::Base.subclasses + providers.values).uniq
66
+ end
67
+
68
+ # Declare one provider's config fields against the `social` scope.
69
+ #
70
+ # Thread-safe and idempotent — adding the same field twice is a no-op.
71
+ #
72
+ # `add_field` is retroactive: `ConfigSchema::Scope#validate!` and `#[]`
73
+ # both consult the schema live (the latter falling back to
74
+ # `field_for(...).default_value` for an unwritten key), so declaring a
75
+ # field after `StandardId.config` has been built works exactly as if it
76
+ # had been declared before. That is what makes the existing host-side
77
+ # `after_initialize` wrappers keep working untouched.
78
+ #
79
+ # @param provider_class [Class] Provider implementation class
80
+ def declare_config_schema(provider_class)
81
+ return unless provider_class.respond_to?(:config_schema)
82
+
83
+ schema = provider_class.config_schema
84
+ return if schema.nil? || schema.empty?
85
+
86
+ schema.each do |field_name, options|
87
+ StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **options)
88
+ end
89
+ end
90
+
26
91
  # Get provider by name
27
92
  # @param name [Symbol, String] Provider identifier
28
93
  # @return [Class] Provider class
@@ -49,18 +114,6 @@ module StandardId
49
114
 
50
115
  private
51
116
 
52
- # Register provider's config schema fields with the StandardId schema.
53
- # Thread-safe and idempotent — adding the same field twice is a no-op.
54
- # @param provider_class [Class] Provider implementation class
55
- def register_config_schema(provider_class)
56
- schema = provider_class.config_schema
57
- return if schema.nil? || schema.empty?
58
-
59
- schema.each do |field_name, options|
60
- StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **options)
61
- end
62
- end
63
-
64
117
  def validate_provider!(provider_class)
65
118
  unless provider_class.is_a?(Class)
66
119
  raise InvalidProviderError,