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