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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +344 -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 +171 -14
- 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 +115 -12
- data/lib/standard_id/engine.rb +33 -0
- 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
|
@@ -6,17 +6,20 @@ module StandardId
|
|
|
6
6
|
public_controller
|
|
7
7
|
|
|
8
8
|
def show
|
|
9
|
-
|
|
9
|
+
resolved = StandardId::Oauth::DiscoveryResolver.resolve(request: request)
|
|
10
10
|
|
|
11
|
-
unless issuer.present?
|
|
11
|
+
unless resolved[:issuer].present?
|
|
12
12
|
render json: { error: "Issuer not configured" }, status: :not_found
|
|
13
13
|
return
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
|
17
17
|
render json: StandardId::Oauth::DiscoveryDocument.build(
|
|
18
|
-
issuer,
|
|
19
|
-
|
|
18
|
+
resolved[:issuer],
|
|
19
|
+
endpoint_base: resolved[:endpoint_base],
|
|
20
|
+
registration_enabled: StandardId.config.oauth.dynamic_registration_enabled,
|
|
21
|
+
introspection_enabled: StandardId.config.oauth.introspection_enabled,
|
|
22
|
+
overrides: resolved[:overrides]
|
|
20
23
|
)
|
|
21
24
|
end
|
|
22
25
|
end
|
|
@@ -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
|
|
|
@@ -23,9 +26,10 @@ module StandardId
|
|
|
23
26
|
#
|
|
24
27
|
# `by_token` is NOT authentication on its own: it matches the SHA256
|
|
25
28
|
# `lookup_hash`, which exists only to find the candidate row from an
|
|
26
|
-
# indexed column. The credential is
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
+
# indexed column. The credential is `token_digest` (see #authenticate_token
|
|
30
|
+
# for the two schemes it may hold), and every consumer previously had to
|
|
31
|
+
# remember to verify it by hand — and to rescue BCrypt::Errors::InvalidHash.
|
|
32
|
+
# This is that step, done once, here.
|
|
29
33
|
#
|
|
30
34
|
# Honours the current scope, so callers keep their own filters:
|
|
31
35
|
#
|
|
@@ -43,24 +47,171 @@ module StandardId
|
|
|
43
47
|
session.authenticate_token(token) ? session : nil
|
|
44
48
|
end
|
|
45
49
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
+
# Keyed digest of an opaque session token — the default scheme.
|
|
51
|
+
#
|
|
52
|
+
# HMAC-SHA256 under `secret_key_base`, domain-separated from `lookup_hash`
|
|
53
|
+
# by both construction (HMAC vs plain SHA256) and an explicit versioned
|
|
54
|
+
# prefix, so the two stored values can never coincide even though both
|
|
55
|
+
# derive from the same token and secret.
|
|
56
|
+
#
|
|
57
|
+
# Why not BCrypt (which this was, and which `token_digest_cost` still
|
|
58
|
+
# selects): BCrypt's cost factor exists to make brute-force of a LOW-entropy
|
|
59
|
+
# secret expensive. Session tokens are `SecureRandom.urlsafe_base64(32)` —
|
|
60
|
+
# 256 bits — so guessing one is infeasible regardless of how fast the hash
|
|
61
|
+
# is, and the stretching bought nothing while costing a measured ~181 ms of
|
|
62
|
+
# CPU on EVERY authenticated request (cost 12). That is the same reasoning
|
|
63
|
+
# under which `RefreshToken` digests with plain SHA256 and `lookup_hash`
|
|
64
|
+
# with SHA256; this brings session tokens in line with both.
|
|
65
|
+
DIGEST_PREFIX = "standard_id.session.token_digest.v1:".freeze
|
|
66
|
+
|
|
67
|
+
def self.hmac_token_digest(token)
|
|
68
|
+
OpenSSL::HMAC.hexdigest(
|
|
69
|
+
"SHA256", Rails.configuration.secret_key_base, "#{DIGEST_PREFIX}#{token}"
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Timing-safe verification of `token` against this session's stored digest.
|
|
74
|
+
#
|
|
75
|
+
# Handles BOTH schemes, because digests written before the HMAC default —
|
|
76
|
+
# and any written since by an app that sets `token_digest_cost` — are
|
|
77
|
+
# BCrypt. A BCrypt digest is self-identifying by its `$2<x>$` prefix, so the
|
|
78
|
+
# scheme is read off the stored value rather than off configuration; that
|
|
79
|
+
# way a config change never strands existing sessions, and no rewrite of
|
|
80
|
+
# stored digests is needed. Both branches end in a constant-time compare, so
|
|
81
|
+
# the response time carries no information about how much of the digest
|
|
82
|
+
# matched.
|
|
50
83
|
#
|
|
51
84
|
# @return [Boolean] false for a blank or malformed digest — never raises.
|
|
52
85
|
def authenticate_token(token)
|
|
53
86
|
return false if token.blank? || token_digest.blank?
|
|
54
87
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
if token_digest.start_with?("$2")
|
|
89
|
+
stored = BCrypt::Password.new(token_digest)
|
|
90
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
91
|
+
stored.to_s,
|
|
92
|
+
BCrypt::Engine.hash_secret(token, stored.salt)
|
|
93
|
+
)
|
|
94
|
+
else
|
|
95
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
96
|
+
token_digest, self.class.hmac_token_digest(token)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
60
99
|
rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSalt
|
|
61
100
|
false
|
|
62
101
|
end
|
|
63
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
|
+
|
|
64
215
|
attr_reader :token
|
|
65
216
|
|
|
66
217
|
before_validation :generate_token, :generate_token_digest, :generate_lookup_hash, on: :create
|
|
@@ -94,11 +245,17 @@ module StandardId
|
|
|
94
245
|
@token ||= SecureRandom.urlsafe_base64(32)
|
|
95
246
|
end
|
|
96
247
|
|
|
248
|
+
# HMAC by default; BCrypt only when an app explicitly asks for it by setting
|
|
249
|
+
# `token_digest_cost`. That inverts the previous default (BCrypt at
|
|
250
|
+
# bcrypt-ruby's cost 12) — see .hmac_token_digest for why — while leaving
|
|
251
|
+
# the escape hatch meaning exactly what it says for anyone who set it
|
|
252
|
+
# deliberately. Existing rows are untouched either way: #authenticate_token
|
|
253
|
+
# reads the scheme off the stored digest, not off this config.
|
|
97
254
|
def generate_token_digest
|
|
98
255
|
configured_cost = StandardId.config.session.token_digest_cost
|
|
99
256
|
self.token_digest =
|
|
100
257
|
if configured_cost.nil?
|
|
101
|
-
|
|
258
|
+
self.class.hmac_token_digest(token)
|
|
102
259
|
else
|
|
103
260
|
cost = configured_cost.clamp(BCrypt::Engine::MIN_COST, BCrypt::Engine::MAX_COST)
|
|
104
261
|
BCrypt::Password.create(token, cost: cost)
|
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
|