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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +291 -0
- data/README.md +14 -0
- data/app/controllers/standard_id/api/oauth/introspections_controller.rb +164 -0
- data/app/controllers/standard_id/api/oauth/revocations_controller.rb +12 -61
- data/app/controllers/standard_id/api/well_known/oauth_authorization_server_controller.rb +7 -4
- data/app/controllers/standard_id/api/well_known/openid_configuration_controller.rb +7 -4
- data/app/models/concerns/standard_id/account_associations.rb +18 -5
- data/app/models/standard_id/identifier.rb +4 -1
- data/app/models/standard_id/session.rb +116 -1
- data/config/routes/api.rb +5 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +122 -2
- data/lib/standard_id/association_strict_loading.rb +121 -0
- data/lib/standard_id/config/schema.rb +117 -0
- data/lib/standard_id/engine.rb +33 -0
- data/lib/standard_id/jwt_service.rb +25 -1
- data/lib/standard_id/oauth/discovery_document.rb +73 -12
- data/lib/standard_id/oauth/discovery_resolver.rb +158 -0
- data/lib/standard_id/provider_registry.rb +66 -13
- data/lib/standard_id/routing.rb +133 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +3 -0
- metadata +5 -1
|
@@ -3,11 +3,24 @@ module StandardId
|
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
5
|
included do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
317
|
-
#
|
|
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
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -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.
|