standard_id 0.32.0 → 0.34.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.
@@ -3,11 +3,24 @@ module StandardId
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- has_many :identifiers, class_name: "StandardId::Identifier", dependent: :restrict_with_exception
7
- has_many :credentials, class_name: "StandardId::Credential", through: :identifiers, source: :credentials, dependent: :restrict_with_exception
8
- has_many :sessions, class_name: "StandardId::Session", dependent: :restrict_with_exception
9
- has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :restrict_with_exception
10
- has_many :client_applications, class_name: "StandardId::ClientApplication", as: :owner, dependent: :restrict_with_exception
6
+ # `strict_loading:` is passed at declaration time — the supported Rails
7
+ # API, and the only thing that works for the `:through` association below,
8
+ # where re-declaration in the host risks ordering breakage. It resolves to
9
+ # NO OPTION AT ALL unless `config.association_strict_loading` is set, so
10
+ # these associations keep inheriting the owner's setting by default. See
11
+ # StandardId::AssociationStrictLoading for why `nil` must not become
12
+ # `strict_loading: nil`.
13
+ #
14
+ # This block runs when the HOST's Account class body runs, so the config
15
+ # must already be set by then; `AssociationStrictLoading.verify_consistency!`
16
+ # turns a too-late assignment into a boot failure instead of a silent one.
17
+ strict_loading_option = StandardId::AssociationStrictLoading.option
18
+
19
+ has_many :identifiers, class_name: "StandardId::Identifier", dependent: :restrict_with_exception, **strict_loading_option
20
+ has_many :credentials, class_name: "StandardId::Credential", through: :identifiers, source: :credentials, dependent: :restrict_with_exception, **strict_loading_option
21
+ has_many :sessions, class_name: "StandardId::Session", dependent: :restrict_with_exception, **strict_loading_option
22
+ has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :restrict_with_exception, **strict_loading_option
23
+ has_many :client_applications, class_name: "StandardId::ClientApplication", as: :owner, dependent: :restrict_with_exception, **strict_loading_option
11
24
 
12
25
  accepts_nested_attributes_for :identifiers
13
26
  end
@@ -2,7 +2,10 @@ module StandardId
2
2
  class Identifier < ApplicationRecord
3
3
  belongs_to :account, class_name: StandardId.config.account_class_name
4
4
 
5
- has_many :credentials, class_name: "StandardId::Credential", dependent: :restrict_with_exception
5
+ # See StandardId::AssociationStrictLoading — resolves to no option at all
6
+ # unless config.association_strict_loading is set.
7
+ has_many :credentials, class_name: "StandardId::Credential", dependent: :restrict_with_exception,
8
+ **StandardId::AssociationStrictLoading.option
6
9
 
7
10
  scope :verified, -> { where.not(verified_at: nil) }
8
11
  scope :unverified, -> { where(verified_at: nil) }
@@ -5,7 +5,10 @@ module StandardId
5
5
  self.table_name = "standard_id_sessions"
6
6
 
7
7
  belongs_to :account, class_name: StandardId.config.account_class_name
8
- has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :nullify
8
+ # See StandardId::AssociationStrictLoading — resolves to no option at all
9
+ # unless config.association_strict_loading is set.
10
+ has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :nullify,
11
+ **StandardId::AssociationStrictLoading.option
9
12
 
10
13
  before_destroy :revoke_active_refresh_tokens, prepend: true
11
14
 
@@ -97,6 +100,118 @@ module StandardId
97
100
  false
98
101
  end
99
102
 
103
+ # Counts returned by the bulk-revocation class methods. Callers use these to
104
+ # emit their own aggregate event (see
105
+ # StandardId::Api::Oauth::RevocationsController#emit_token_revoked) — the
106
+ # bulk methods deliberately do NOT publish an aggregate themselves.
107
+ RevocationResult = Struct.new(:sessions_revoked, :refresh_tokens_revoked, keyword_init: true)
108
+
109
+ # Revoke every not-yet-revoked session for an account, cascading to refresh
110
+ # tokens, in two set-based UPDATEs.
111
+ #
112
+ # Honours the current scope, so callers narrow as they need:
113
+ #
114
+ # StandardId::Session.revoke_all_for!(account, reason: "password_reset")
115
+ # StandardId::DeviceSession.active.revoke_all_for!(account, reason: "logout")
116
+ #
117
+ # Why this exists rather than `sessions.each(&:revoke!)`: the loop is O(N)
118
+ # UPDATEs plus O(N) refresh-token cascades, and the call sites are admin bulk
119
+ # actions and password resets.
120
+ #
121
+ # Events: one SESSION_REVOKED per revoked session, never a single aggregate —
122
+ # subscribers must not need a second code path for bulk revocation. Each is
123
+ # published individually and individually rescued (see .revoke_sessions!).
124
+ #
125
+ # @param account [ActiveRecord::Base, String, Integer] the account, or its id
126
+ # @param reason [String, nil] carried on each SESSION_REVOKED event
127
+ # @return [RevocationResult] counts of revoked sessions and refresh tokens
128
+ def self.revoke_all_for!(account, reason: nil)
129
+ account_id = account.is_a?(ActiveRecord::Base) ? account.id : account
130
+ sessions = where(account_id: account_id).where(revoked_at: nil).to_a
131
+ account_record = account.is_a?(ActiveRecord::Base) ? account : nil
132
+
133
+ revoke_sessions!(sessions, account: account_record, reason: reason)
134
+ end
135
+
136
+ # Set-based revocation of an explicit collection of sessions.
137
+ #
138
+ # Bulk-revoke in two queries (one UPDATE per table) instead of issuing
139
+ # session.revoke! per row, which would be O(N) UPDATEs plus another O(N)
140
+ # cascades to refresh_tokens.
141
+ #
142
+ # Tradeoff: update_all skips ActiveRecord callbacks, so the per-row
143
+ # SESSION_REVOKED event emitted by #revoke! (via its after_commit) does not
144
+ # fire automatically. We re-emit it explicitly below so audit-trail
145
+ # subscribers (account status/locking, etc.) still see one event per revoked
146
+ # session — the semantics are preserved, only the SQL shape has changed.
147
+ #
148
+ # @param sessions [Enumerable<StandardId::Session>] sessions to revoke; all
149
+ # must belong to the same account
150
+ # @param account [ActiveRecord::Base, nil] the shared account, when the
151
+ # caller already has it loaded. Passed through rather than read per row:
152
+ # `session.account` would issue N extra SELECTs. Falls back to
153
+ # `sessions.first.account`.
154
+ # @param reason [String, nil] carried on each SESSION_REVOKED event
155
+ # @return [RevocationResult] counts of revoked sessions and refresh tokens
156
+ def self.revoke_sessions!(sessions, account: nil, reason: nil)
157
+ sessions = sessions.to_a
158
+ return RevocationResult.new(sessions_revoked: 0, refresh_tokens_revoked: 0) if sessions.empty?
159
+
160
+ now = Time.current
161
+ session_ids = sessions.map(&:id)
162
+ refresh_tokens_revoked = 0
163
+
164
+ ActiveRecord::Base.transaction do
165
+ StandardId::Session.where(id: session_ids).update_all(revoked_at: now)
166
+ refresh_tokens_revoked = StandardId::RefreshToken
167
+ .where(session_id: session_ids, revoked_at: nil)
168
+ .update_all(revoked_at: now)
169
+ end
170
+
171
+ publish_session_revocations(sessions, account: account, reason: reason, now: now)
172
+
173
+ RevocationResult.new(
174
+ sessions_revoked: sessions.size,
175
+ refresh_tokens_revoked: refresh_tokens_revoked
176
+ )
177
+ end
178
+
179
+ # DB state is already committed by the time this runs; event publishing is
180
+ # best-effort audit emission. A failing subscriber must not short-circuit the
181
+ # loop and leave later sessions without their SESSION_REVOKED event, which
182
+ # would permanently desync audit-trail consumers from the DB.
183
+ def self.publish_session_revocations(sessions, account:, reason:, now:)
184
+ shared_account = account || sessions.first.account
185
+
186
+ sessions.each do |session|
187
+ session.revoked_at = now
188
+
189
+ begin
190
+ StandardId::Events.publish(
191
+ StandardId::Events::SESSION_REVOKED,
192
+ session: session,
193
+ account: shared_account,
194
+ reason: reason
195
+ )
196
+ rescue StandardError => e
197
+ # The rescue exists so a failing subscriber cannot short-circuit the
198
+ # loop. Logging must therefore not be able to raise either, or the
199
+ # guarantee evaporates on the first bad log call: StandardId.logger is
200
+ # a memoized `config.logger || Rails.logger`, so whatever the first
201
+ # reader saw is what every later caller gets — including a value that
202
+ # is not a logger at all.
203
+ logger = StandardId.logger
204
+ next unless logger.respond_to?(:error)
205
+
206
+ logger.error(
207
+ "[StandardId::Session] Failed to publish SESSION_REVOKED " \
208
+ "for session #{session.id}: #{e.class}: #{e.message}"
209
+ )
210
+ end
211
+ end
212
+ end
213
+ private_class_method :publish_session_revocations
214
+
100
215
  attr_reader :token
101
216
 
102
217
  before_validation :generate_token, :generate_token_digest, :generate_lookup_hash, on: :create
data/config/routes/api.rb CHANGED
@@ -18,6 +18,11 @@ StandardId::ApiEngine.routes.draw do
18
18
  resource :token, only: [:create]
19
19
  resource :revoke, only: [:create], controller: :revocations
20
20
 
21
+ # RFC 7662 Token Introspection -> POST /oauth/introspect.
22
+ # The controller returns 404 when oauth.introspection_enabled is false, so
23
+ # the endpoint is fully absent unless explicitly enabled.
24
+ resource :introspect, only: [:create], controller: :introspections
25
+
21
26
  # RFC 7591 Dynamic Client Registration -> POST /oauth/register.
22
27
  # The controller returns 404 when oauth.dynamic_registration_enabled is
23
28
  # false, so the endpoint is fully absent unless explicitly enabled.
@@ -20,6 +20,60 @@ StandardId.configure do |c|
20
20
  # Default: nil
21
21
  # c.issuer = "https://auth.example.com"
22
22
 
23
+ # ---------------------------------------------------------------------------
24
+ # Discovery documents (/.well-known/openid-configuration and
25
+ # /.well-known/oauth-authorization-server)
26
+ # ---------------------------------------------------------------------------
27
+ # READ THIS IF YOU MOUNT ApiEngine UNDER A PREFIX (/api, /api/v1, ...).
28
+ #
29
+ # The issuer and the endpoint base are different things. `issuer` is a stable
30
+ # security identifier (RFC 8414 §2) that clients compare byte-for-byte with the
31
+ # URL they used for discovery and with the `iss` claim of issued tokens. It is
32
+ # never derived from the request and cannot be overridden.
33
+ #
34
+ # `discovery_endpoint_base` is where /authorize, /oauth/token etc. ACTUALLY
35
+ # are. It defaults to the issuer, which is right only if your issuer carries
36
+ # the mount path (e.g. "https://host/auth/api"). If it does not, the document
37
+ # advertises endpoints at the bare origin and every one of them 404s.
38
+ #
39
+ # nil (default) — the issuer
40
+ # :request — request.base_url + the detected mount path <- prefixed mounts
41
+ # "https://..." — verbatim
42
+ # callable — receives (request:); for a proxy that rewrites base_url, or
43
+ # an app mounting ApiEngine more than once
44
+ #
45
+ # Default: nil
46
+ # c.oauth.discovery_endpoint_base = :request
47
+
48
+ # Members that cannot be derived. Values static or callable; a callable gets
49
+ # { origin:, endpoint_base:, issuer:, request: } (origin has no path). A nil
50
+ # value REMOVES the member rather than emitting null. Setting `issuer` raises.
51
+ # Default: {}
52
+ # c.oauth.discovery_metadata_overrides = {
53
+ # # a host-owned shim that injects the audience before handing off to the
54
+ # # engine's /authorize
55
+ # authorization_endpoint: ->(ctx) { "#{ctx[:origin]}/oauth/authorize" },
56
+ # # narrower than everything the server can mint
57
+ # scopes_supported: %w[mcp mcp:read],
58
+ # # MUST mirror your dynamic-registration policy, or spec-following clients
59
+ # # register in a way that policy then rejects
60
+ # token_endpoint_auth_methods_supported: %w[none],
61
+ # # omit a member entirely
62
+ # userinfo_endpoint: nil
63
+ # }
64
+ #
65
+ # RFC 8615 clients probe the ORIGIN ROOT, which is outside every engine mount,
66
+ # so those routes must be drawn in config/routes.rb — the gem cannot:
67
+ #
68
+ # standard_id_well_known_routes at: "/api" # your ApiEngine mount path
69
+ #
70
+ # That draws the root form AND the RFC 8414 §3.1 path-inserted form
71
+ # (/.well-known/oauth-authorization-server/api). The second looks redundant
72
+ # and is not: it is the URL Claude Code actually requests against a
73
+ # path-carrying issuer. Accepts only:/except: (:oauth_authorization_server,
74
+ # :openid_configuration, :jwks) — e.g. `only: :jwks` if you keep your own
75
+ # metadata controller. See docs/MIGRATION_GUIDE.md.
76
+
23
77
  # Login URL used for redirects when authentication is required but missing.
24
78
  # Default: "/login"
25
79
  # c.login_url = "/login"
@@ -44,6 +98,31 @@ StandardId.configure do |c|
44
98
  # Default: false
45
99
  # c.alias_current_user = true
46
100
 
101
+ # Opt StandardId's OWN associations out of an app-wide
102
+ # `strict_loading_by_default = true`.
103
+ #
104
+ # Affects Account#identifiers/credentials/sessions/refresh_tokens/
105
+ # client_applications, StandardId::Session#refresh_tokens and
106
+ # StandardId::Identifier#credentials. Set it to false if strict loading is on
107
+ # app-wide and you don't want to eager-load the gem's internals at every call
108
+ # site (the gem lazy-loads some of them itself).
109
+ #
110
+ # Tri-state, and nil is NOT the same as false:
111
+ # nil — declare no `strict_loading:` option at all; the associations
112
+ # inherit your model's setting, exactly as before this option
113
+ # existed. This is the default and preserves existing behaviour.
114
+ # false — opt out: these associations may lazy-load even with strict
115
+ # loading on app-wide.
116
+ # true — opt in: force strict loading on them even if it is off app-wide.
117
+ #
118
+ # Must be set HERE, in an initializer — not in an `after_initialize` block.
119
+ # The associations read it when your Account class body runs, so a later
120
+ # assignment has no effect. StandardId raises a ConfigurationError at boot if
121
+ # it detects that, rather than letting it fail silently.
122
+ #
123
+ # Default: nil
124
+ # c.association_strict_loading = false
125
+
47
126
  # Default scope applied when loading the current account — eager-load the
48
127
  # associations your views/controllers touch to avoid N+1s.
49
128
  # Default: nil
@@ -241,6 +320,29 @@ StandardId.configure do |c|
241
320
  # Must return a Hash. Reserved JWT keys (sub, exp, iat, etc.) are excluded.
242
321
  # c.oauth.custom_claims = ->(account:, **) { { channel_id: account.channel_id } }
243
322
 
323
+ # RFC 7662 token introspection (POST /oauth/introspect).
324
+ #
325
+ # Off by default: the endpoint returns 404 and `introspection_endpoint` is not
326
+ # advertised in the discovery documents. An endpoint that answers questions
327
+ # about other people's tokens is not something to expose by accident.
328
+ #
329
+ # Confidential clients only — the caller presents client_id + client_secret
330
+ # (HTTP Basic or form body). Every failure renders `{"active": false}` with no
331
+ # other members, per RFC 7662 §2.2, including a tripped rate limit (a 429
332
+ # would distinguish "throttled" from "token invalid" and make the limiter a
333
+ # token-validity oracle).
334
+ #
335
+ # KNOW THE LIMIT BEFORE BUILDING AN AUTHORIZATION GATE ON THIS. Access tokens
336
+ # are stateless — never persisted, and carrying no `sid` — so a revoked
337
+ # session's access token introspects as `active: true` until its `exp`.
338
+ # Introspection answers "did we mint this, and is it unexpired?", NOT "is it
339
+ # still honoured?". The mitigation is a short access-token lifetime. Refresh
340
+ # tokens ARE persisted and are checked against the row, so a revoked refresh
341
+ # token introspects as inactive immediately.
342
+ #
343
+ # Default: false
344
+ # c.oauth.introspection_enabled = true
345
+
244
346
  # JWT signing configuration.
245
347
  #
246
348
  # Default: :hs256 symmetric signing with Rails.application.secret_key_base.
@@ -313,8 +415,20 @@ StandardId.configure do |c|
313
415
  # ---------------------------------------------------------------------------
314
416
  # Social login
315
417
  # ---------------------------------------------------------------------------
316
- # Requires the standard_id-google and/or standard_id-apple provider gems to
317
- # be installed and registered.
418
+ # These fields are declared by the PROVIDER PLUGIN GEMS, not by standard_id
419
+ # itself, so each line below requires the matching gem in your Gemfile:
420
+ # `standard_id-google` for the google_* fields, `standard_id-apple` for the
421
+ # apple_* fields. Uncommenting a line without its gem raises
422
+ # StandardId::ConfigurationError ("Unknown field ... for scope 'social'") —
423
+ # the field genuinely does not exist, because nothing declared it.
424
+ #
425
+ # With the gem present, writing them here — in a plain initializer, in the
426
+ # `social` scope, exactly as shown — is correct and supported. standard_id
427
+ # >= 0.33.0 declares every loaded provider's fields before initializers run.
428
+ # On 0.32.0 and earlier these same lines raised, because the fields were only
429
+ # declared later from the plugin's Railtie, and apps worked around it by
430
+ # wrapping the writes in `Rails.application.config.after_initialize`. That
431
+ # wrapper is no longer needed, and it still works if you have one.
318
432
 
319
433
  # c.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
320
434
  # c.social.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
@@ -377,6 +491,12 @@ StandardId.configure do |c|
377
491
  # c.rate_limits.api_passwordless_start_per_target = 5 # per 15 minutes
378
492
  # c.rate_limits.api_token_per_ip = 30 # per 15 minutes
379
493
 
494
+ # Token introspection (only relevant with c.oauth.introspection_enabled).
495
+ # Throttles by IP so the endpoint can't be used to bulk-classify stolen
496
+ # tokens. Note the limit renders the ordinary {"active": false} / 200 rather
497
+ # than 429 — see the introspection_enabled comment above for why.
498
+ # c.rate_limits.introspection_per_ip = 30 # per 15 minutes
499
+
380
500
  # Optional per-audience tightening on top of api_token_per_ip. Only token
381
501
  # requests targeting a configured audience count toward its cap (per IP,
382
502
  # per 15 minutes); unlisted audiences are governed by the global ceiling.
@@ -0,0 +1,121 @@
1
+ module StandardId
2
+ # Resolves the `strict_loading:` option the gem's own associations declare,
3
+ # from `StandardId.config.association_strict_loading`.
4
+ #
5
+ # WHY THIS EXISTS
6
+ #
7
+ # Apps that set `strict_loading_by_default = true` globally cannot fix the
8
+ # gem's associations by re-declaring them: they are declared inside
9
+ # `StandardId::AccountAssociations`, and `credentials` is a `has_many
10
+ # :through`, where re-declaration risks ordering breakage. So two consuming
11
+ # apps reached into Rails internals instead —
12
+ #
13
+ # Account.reflect_on_association(assoc)&.options&.[]=(:strict_loading, false)
14
+ #
15
+ # — both with a comment asking for exactly this hook. Passing
16
+ # `strict_loading:` at declaration time is the supported Rails API, and unlike
17
+ # re-declaration it works for the `:through` association.
18
+ #
19
+ # WHY A HELPER RATHER THAN `strict_loading: config.association_strict_loading`
20
+ #
21
+ # Because `nil` must mean OMITTED, not `false`. Rails checks
22
+ # `reflection.options.key?(:strict_loading)` *before* consulting the owner:
23
+ #
24
+ # # ActiveRecord::Associations::Association#violates_strict_loading?
25
+ # return reflection.strict_loading? if reflection.options.key?(:strict_loading)
26
+ # owner.strict_loading? && !owner.strict_loading_n_plus_one_only?
27
+ #
28
+ # so declaring `strict_loading: nil` would put the key in the options hash and
29
+ # make `reflection.strict_loading?` return `!!nil` == false — silently
30
+ # disabling strict loading on every gem association in every app that never
31
+ # asked for it. This returns an empty hash in that case instead, so the option
32
+ # is genuinely absent and the owner's setting still governs.
33
+ module AssociationStrictLoading
34
+ module_function
35
+
36
+ # Splat into a `has_many` / `has_one` / `belongs_to` declaration:
37
+ #
38
+ # has_many :sessions, class_name: "StandardId::Session",
39
+ # **StandardId::AssociationStrictLoading.option
40
+ #
41
+ # @return [Hash] `{}` when unconfigured, else `{ strict_loading: <value> }`
42
+ def option
43
+ value = StandardId.config.association_strict_loading
44
+ return {} if value.nil?
45
+
46
+ { strict_loading: value }
47
+ end
48
+
49
+ # Associations declared with `**option`, as `owner_class_name => [names]`.
50
+ # Used by the boot-time consistency check.
51
+ #
52
+ # Account's are keyed by the configured account class rather than listed
53
+ # here, since the host owns that constant.
54
+ GEM_OWNED = {
55
+ "StandardId::Session" => %i[refresh_tokens],
56
+ "StandardId::Identifier" => %i[credentials]
57
+ }.freeze
58
+
59
+ ACCOUNT_ASSOCIATIONS = %i[
60
+ identifiers credentials sessions refresh_tokens client_applications
61
+ ].freeze
62
+
63
+ # Raise if a declared association disagrees with the configured value.
64
+ #
65
+ # The `included do` block in AccountAssociations reads the config when the
66
+ # host's `Account` class body runs — during autoload, which can happen
67
+ # *before* an initializer that sets `association_strict_loading`. The result
68
+ # is silent: strict loading behaves as though the setting were never made,
69
+ # and the app either raises StrictLoadingViolationError deep in a request or
70
+ # quietly N+1s in production. This converts that into a boot failure that
71
+ # names the ordering problem.
72
+ #
73
+ # Called from the Engine's `after_initialize`, when both the config and the
74
+ # host's model are settled.
75
+ #
76
+ # @raise [StandardId::ConfigurationError]
77
+ def verify_consistency!
78
+ configured = StandardId.config.association_strict_loading
79
+ return if configured.nil?
80
+
81
+ mismatched = mismatched_associations(configured)
82
+ return if mismatched.empty?
83
+
84
+ raise StandardId::ConfigurationError, <<~MESSAGE.strip
85
+ StandardId.config.association_strict_loading is #{configured.inspect}, but these
86
+ associations were declared without it: #{mismatched.join(', ')}.
87
+
88
+ This means the model class body ran BEFORE the config was set. StandardId's
89
+ associations read the setting at declaration time, so it must be assigned in
90
+ `config/initializers/standard_id.rb` — not in an `after_initialize` block, and
91
+ not anywhere else that can run after your Account class is autoloaded.
92
+
93
+ Left unchecked this fails silently: strict loading behaves as though you never
94
+ made the setting, and the app either raises
95
+ ActiveRecord::StrictLoadingViolationError inside a request or quietly N+1s in
96
+ production.
97
+ MESSAGE
98
+ end
99
+
100
+ def mismatched_associations(configured)
101
+ pairs = GEM_OWNED.flat_map do |class_name, names|
102
+ names.map { |name| [class_name.constantize, name] }
103
+ end
104
+
105
+ account_class = begin
106
+ StandardId.account_class
107
+ rescue StandardError
108
+ nil
109
+ end
110
+ pairs += ACCOUNT_ASSOCIATIONS.map { |name| [account_class, name] } if account_class
111
+
112
+ pairs.filter_map do |klass, name|
113
+ reflection = klass.reflect_on_association(name)
114
+ next if reflection.nil?
115
+ next if reflection.options[:strict_loading] == configured
116
+
117
+ "#{klass.name}##{name}"
118
+ end
119
+ end
120
+ end
121
+ end
@@ -12,6 +12,26 @@ StandardId::ConfigSchema.define do
12
12
  field :passwordless_email_sender, type: :any, default: nil
13
13
  field :passwordless_sms_sender, type: :any, default: nil
14
14
  field :issuer, type: :string, default: nil
15
+
16
+ # Whether `JwtService.decode` REQUIRES a matching `iss` claim.
17
+ #
18
+ # `nil` (default) means "follow the issuer": verification is on exactly
19
+ # when `issuer` is set. That is the historic behaviour and is what you
20
+ # want once an issuer has always been configured.
21
+ #
22
+ # Set `false` to MINT an `iss` claim without yet REQUIRING one. This
23
+ # exists because the two were coupled, and that coupling makes adopting an
24
+ # issuer an all-or-nothing flag day: every token already in flight was
25
+ # minted without an `iss`, so turning the issuer on rejected all of them at
26
+ # once — every access token and, far worse, every refresh token. For an app
27
+ # that had never set an issuer there was no safe single step, which is
28
+ # exactly the position nutripod-web was stuck in (rarebit-one/nutripod-web#1111).
29
+ #
30
+ # The migration is then: set `issuer` with `verify_issuer = false`, wait
31
+ # out `refresh_token_lifetime` (the long pole — access tokens are short),
32
+ # then remove the override. Setting `true` with no issuer raises at boot
33
+ # rather than silently verifying nothing.
34
+ field :verify_issuer, type: :boolean, default: nil
15
35
  field :login_url, type: :string, default: nil
16
36
  field :allowed_post_logout_redirect_uris, type: :array, default: []
17
37
  field :account_scope, type: :any, default: nil
@@ -19,6 +39,22 @@ StandardId::ConfigSchema.define do
19
39
  field :inertia_component_namespace, type: :string, default: "standard_id"
20
40
  field :alias_current_user, type: :boolean, default: false
21
41
 
42
+ # Opt the gem's own associations out of an app-wide
43
+ # `strict_loading_by_default = true`.
44
+ #
45
+ # `nil` (default) means "say nothing": the `strict_loading:` option is
46
+ # omitted from the association declarations entirely, so they inherit the
47
+ # owner's setting exactly as they always have. `false` opts them out; `true`
48
+ # forces them on even in an app that has strict loading off.
49
+ #
50
+ # This must stay tri-state and `nil` must mean OMITTED, not `false`. Rails
51
+ # checks `reflection.options.key?(:strict_loading)` before consulting the
52
+ # owner (ActiveRecord::Associations::Association#violates_strict_loading?),
53
+ # so declaring `strict_loading: nil` would put the key in the hash and make
54
+ # `reflection.strict_loading?` return false — silently disabling strict
55
+ # loading for every app that never asked for it.
56
+ field :association_strict_loading, type: :any, default: nil
57
+
22
58
  # Scope-aware authentication: maps scope names to profile-based access config.
23
59
  # Each scope is a hash with keys: :profile_types (Array<String>), :after_sign_in_path,
24
60
  # :no_profile_message, :label, :allow_registration, :authorizer.
@@ -300,6 +336,80 @@ StandardId::ConfigSchema.define do
300
336
  # OAuth clients), so it is opt-in: a deployment must explicitly turn it on.
301
337
  field :dynamic_registration_enabled, type: :boolean, default: false
302
338
 
339
+ # Enable the RFC 7662 token introspection endpoint (POST /oauth/introspect).
340
+ #
341
+ # When false (the default), the endpoint is fully absent (404) and
342
+ # `introspection_endpoint` is NOT advertised in the discovery documents. An
343
+ # endpoint that answers questions about other people's tokens is not something
344
+ # to expose by accident, so it is opt-in — same posture as
345
+ # `dynamic_registration_enabled`.
346
+ #
347
+ # Confidential clients only: the caller authenticates with client_id +
348
+ # client_secret (HTTP Basic or form body). Every failure renders
349
+ # `{"active": false}` with no other members, per RFC 7662 §2.2.
350
+ #
351
+ # KNOW THE LIMIT BEFORE BUILDING ON IT: access tokens are stateless — never
352
+ # persisted, and carrying no `sid` — so a revoked session's access token
353
+ # introspects as `active: true` until its `exp`. Introspection answers "did we
354
+ # mint this, and is it unexpired?", NOT "is it still honoured?". Keep
355
+ # `access_token_lifetime` short. Refresh tokens ARE persisted and are checked
356
+ # against the row, so a revoked refresh token introspects as inactive
357
+ # immediately.
358
+ field :introspection_enabled, type: :boolean, default: false
359
+
360
+ # Where the discovery documents say the ENDPOINTS live.
361
+ #
362
+ # The issuer and the endpoint base are different things. The issuer is a
363
+ # stable security identifier (RFC 8414 §2) that clients match byte-for-byte
364
+ # against their discovery URL and against the `iss` claim; it is never
365
+ # derived from the request and cannot be overridden. This says where
366
+ # /authorize, /oauth/token etc. actually are.
367
+ #
368
+ # nil (default) — the issuer. Byte-identical to the behaviour before this
369
+ # option existed. Already correct if your issuer carries
370
+ # the ApiEngine mount path (e.g. "https://host/auth/api").
371
+ # WRONG if it does not: the document then advertises
372
+ # endpoints at the bare origin, which 404.
373
+ # :request — request.base_url + the detected mount path. What you want
374
+ # when ApiEngine is mounted under a prefix ("/api",
375
+ # "/api/v1") the issuer does not carry.
376
+ # "https://..." — used verbatim.
377
+ # callable — receives (request:). Use when a proxy rewrites
378
+ # request.base_url, or when ApiEngine is mounted more than
379
+ # once (e.g. under host constraints) so no single detected
380
+ # path is right.
381
+ #
382
+ # Request-derived is opt-in rather than the default on purpose: defaulting to
383
+ # it would silently rewrite the document of every app whose issuer host
384
+ # differs from the host serving the request — the split-host setup a separate
385
+ # issuer exists to express.
386
+ field :discovery_endpoint_base, type: :any, default: nil
387
+
388
+ # Members of the discovery documents that cannot be derived, as
389
+ # `{ member => value }`. Values may be static or callable.
390
+ #
391
+ # A callable receives one Hash — `{ origin:, endpoint_base:, issuer:,
392
+ # request: }`, where `origin` is scheme+host+port with no path.
393
+ #
394
+ # A `nil` value REMOVES the member rather than emitting null.
395
+ #
396
+ # Setting `issuer` RAISES — see discovery_endpoint_base above.
397
+ #
398
+ # c.oauth.discovery_metadata_overrides = {
399
+ # # a host-owned shim that injects the audience before handing off to the
400
+ # # engine's /authorize
401
+ # authorization_endpoint: ->(ctx) { "#{ctx[:origin]}/oauth/authorize" },
402
+ # # deliberately narrower than everything the server can mint
403
+ # scopes_supported: %w[mcp mcp:read],
404
+ # # MUST mirror your dynamic-registration policy: advertising
405
+ # # client_secret_* while DCR only accepts public clients makes
406
+ # # spec-following clients register in a way DCR then rejects
407
+ # token_endpoint_auth_methods_supported: %w[none],
408
+ # # omit a member entirely
409
+ # grant_types_supported: nil
410
+ # }
411
+ field :discovery_metadata_overrides, type: :hash, default: -> { {} }
412
+
303
413
  # Callable resolving the polymorphic owner assigned to clients created via
304
414
  # Dynamic Client Registration (the `owner` association on ClientApplication
305
415
  # is required). Example: `-> { Organization.default }`.
@@ -445,5 +555,12 @@ StandardId::ConfigSchema.define do
445
555
  # Dynamic client registration (RFC 7591) — throttle the open registration
446
556
  # endpoint by IP so an enabled deployment can't be flooded with client rows.
447
557
  field :dynamic_registration_per_ip, type: :integer, default: 10 # per hour
558
+
559
+ # Token introspection (RFC 7662) — throttle by IP so the endpoint cannot be
560
+ # used to bulk-classify stolen tokens. Note the limit renders the ordinary
561
+ # `{"active": false}` / 200 rather than 429: a 429 would distinguish
562
+ # "throttled" from "token invalid" and turn the limiter into a token-validity
563
+ # oracle.
564
+ field :introspection_per_ip, type: :integer, default: 30 # per 15 minutes
448
565
  end
449
566
  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.