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
|
@@ -52,6 +52,30 @@ module StandardId
|
|
|
52
52
|
SUPPORTED_ALGORITHMS[algorithm] || raise(ArgumentError, "Unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.keys.join(', ')}")
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Whether decode REQUIRES a matching `iss`. `nil` (the default) follows the
|
|
56
|
+
# issuer, which is the historic behaviour: verification on exactly when an
|
|
57
|
+
# issuer is configured.
|
|
58
|
+
#
|
|
59
|
+
# The override exists so an app can START MINTING an `iss` without yet
|
|
60
|
+
# REQUIRING one. Coupled, adopting an issuer is a flag day — every token
|
|
61
|
+
# already in flight was minted without the claim, so enabling the issuer
|
|
62
|
+
# rejects all of them at once, refresh tokens included. Decoupled, the app
|
|
63
|
+
# sets `verify_issuer = false`, waits out `refresh_token_lifetime`, and then
|
|
64
|
+
# drops the override with no window in which valid tokens are refused.
|
|
65
|
+
def self.verify_issuer?
|
|
66
|
+
configured = StandardId.config.verify_issuer
|
|
67
|
+
return StandardId.config.issuer.present? if configured.nil?
|
|
68
|
+
|
|
69
|
+
if configured && StandardId.config.issuer.blank?
|
|
70
|
+
raise StandardId::ConfigurationError,
|
|
71
|
+
"verify_issuer is true but no issuer is configured — decode would " \
|
|
72
|
+
"verify against nil and accept any token. Set config.issuer, or " \
|
|
73
|
+
"leave verify_issuer nil to follow it."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
configured
|
|
77
|
+
end
|
|
78
|
+
|
|
55
79
|
def self.asymmetric?
|
|
56
80
|
algorithm_config[:type] == :asymmetric
|
|
57
81
|
end
|
|
@@ -152,7 +176,7 @@ module StandardId
|
|
|
152
176
|
def self.decode(token, allowed_audiences: nil)
|
|
153
177
|
options = { algorithms: [algorithm] }
|
|
154
178
|
|
|
155
|
-
if StandardId.config.issuer.present?
|
|
179
|
+
if StandardId.config.issuer.present? && verify_issuer?
|
|
156
180
|
options[:iss] = StandardId.config.issuer
|
|
157
181
|
options[:verify_iss] = true
|
|
158
182
|
end
|
|
@@ -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,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "action_dispatch/routing/mapper"
|
|
2
|
+
|
|
3
|
+
module StandardId
|
|
4
|
+
# Route-mapper extensions, available inside `Rails.application.routes.draw`.
|
|
5
|
+
module Routing
|
|
6
|
+
# Documents this helper can draw, mapped to their path and controller.
|
|
7
|
+
WELL_KNOWN_DOCUMENTS = {
|
|
8
|
+
oauth_authorization_server: {
|
|
9
|
+
path: "oauth-authorization-server",
|
|
10
|
+
to: "standard_id/api/well_known/oauth_authorization_server#show"
|
|
11
|
+
},
|
|
12
|
+
openid_configuration: {
|
|
13
|
+
path: "openid-configuration",
|
|
14
|
+
to: "standard_id/api/well_known/openid_configuration#show"
|
|
15
|
+
},
|
|
16
|
+
jwks: {
|
|
17
|
+
path: "jwks.json",
|
|
18
|
+
to: "standard_id/api/well_known/jwks#show"
|
|
19
|
+
}
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Draw StandardId's discovery documents at the ORIGIN ROOT.
|
|
23
|
+
#
|
|
24
|
+
# # config/routes.rb
|
|
25
|
+
# Rails.application.routes.draw do
|
|
26
|
+
# standard_id_well_known_routes at: "/api/v1"
|
|
27
|
+
# namespace :api do
|
|
28
|
+
# mount StandardId::ApiEngine, at: "/", as: :standard_id_api
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# ## Why the gem cannot just route these itself
|
|
33
|
+
#
|
|
34
|
+
# The engine's own `.well-known` routes live INSIDE its mount, so an app
|
|
35
|
+
# mounting ApiEngine at `/api/v1` serves them at
|
|
36
|
+
# `/api/v1/.well-known/openid-configuration`. But RFC 8615 clients probe the
|
|
37
|
+
# **origin root**, which is outside any engine mount — a mapper helper in the
|
|
38
|
+
# host's routes file is the only place these can be drawn.
|
|
39
|
+
#
|
|
40
|
+
# ## The path-inserted form, and why it is not optional
|
|
41
|
+
#
|
|
42
|
+
# For each document this draws BOTH:
|
|
43
|
+
#
|
|
44
|
+
# /.well-known/oauth-authorization-server
|
|
45
|
+
# /.well-known/oauth-authorization-server/api/v1 <- RFC 8414 §3.1
|
|
46
|
+
#
|
|
47
|
+
# The second is the spec's form for an issuer that carries a path: the
|
|
48
|
+
# well-known segment is INSERTED BEFORE the issuer's path rather than appended
|
|
49
|
+
# to it. It looks redundant and it is not. It is the URL Claude Code actually
|
|
50
|
+
# requests in production against a path-carrying issuer; without it the client
|
|
51
|
+
# 404s, falls back to issuer-relative defaults (hitting the engine's real
|
|
52
|
+
# `/authorize` instead of a host audience shim), and every subsequent
|
|
53
|
+
# authenticated request 401s. That failure is remote from its cause and hard
|
|
54
|
+
# to diagnose, which is why the route is drawn for you rather than documented
|
|
55
|
+
# as an extra step.
|
|
56
|
+
#
|
|
57
|
+
# Both forms serve the same document. The mount path is stamped into the
|
|
58
|
+
# route defaults so DiscoveryResolver can build endpoint URLs off it — a root
|
|
59
|
+
# route has no SCRIPT_NAME to read it from.
|
|
60
|
+
#
|
|
61
|
+
# @param at [String] the path ApiEngine is mounted at ("/" or "" for root).
|
|
62
|
+
# Used both to build the path-inserted routes and to resolve endpoint URLs.
|
|
63
|
+
# @param only [Array<Symbol>, Symbol, nil] restrict to these documents.
|
|
64
|
+
# Keys: :oauth_authorization_server, :openid_configuration, :jwks. Apps
|
|
65
|
+
# that keep a host-owned metadata controller but want the gem's JWKS at the
|
|
66
|
+
# root pass `only: :jwks`.
|
|
67
|
+
# @param except [Array<Symbol>, Symbol, nil] the inverse of `only`.
|
|
68
|
+
# @param extra_paths [Array<String>] additional path-inserted suffixes beyond
|
|
69
|
+
# `at:`. For serving the same document under a second issuer path (e.g. a
|
|
70
|
+
# protected resource at `/mcp`) without a second route block.
|
|
71
|
+
# @param path_inserted [Boolean] set false to draw only the bare root forms.
|
|
72
|
+
# Defaults to true; turning it off is what reintroduces the Claude Code
|
|
73
|
+
# failure above, so it exists only for apps that route those themselves.
|
|
74
|
+
def standard_id_well_known_routes(at:, only: nil, except: nil, extra_paths: [], path_inserted: true)
|
|
75
|
+
mount_path = StandardId::Oauth::DiscoveryResolver.normalize_path(at)
|
|
76
|
+
documents = StandardId::Routing.selected_documents(only: only, except: except)
|
|
77
|
+
|
|
78
|
+
suffixes = path_inserted ? ([mount_path] + Array(extra_paths)).map { |p| StandardId::Oauth::DiscoveryResolver.normalize_path(p) } : []
|
|
79
|
+
suffixes = suffixes.reject(&:empty?).uniq
|
|
80
|
+
|
|
81
|
+
defaults = { StandardId::Oauth::DiscoveryResolver::MOUNT_PATH_PARAM => mount_path }
|
|
82
|
+
|
|
83
|
+
scope ".well-known" do
|
|
84
|
+
documents.each do |name, config|
|
|
85
|
+
get config[:path], to: config[:to], as: :"standard_id_root_#{name}", defaults: defaults
|
|
86
|
+
|
|
87
|
+
# jwks.json is a concrete file path, not a metadata document whose
|
|
88
|
+
# location RFC 8414 §3.1 relocates — a path-inserted variant of it
|
|
89
|
+
# would advertise a URL no spec describes.
|
|
90
|
+
next if name == :jwks
|
|
91
|
+
|
|
92
|
+
suffixes.each_with_index do |suffix, index|
|
|
93
|
+
get "#{config[:path]}#{suffix}",
|
|
94
|
+
to: config[:to],
|
|
95
|
+
as: :"standard_id_root_#{name}_at_#{index}",
|
|
96
|
+
defaults: defaults
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Hash] the WELL_KNOWN_DOCUMENTS entries `only:`/`except:` select
|
|
103
|
+
def self.selected_documents(only: nil, except: nil)
|
|
104
|
+
if only.present? && except.present?
|
|
105
|
+
raise ArgumentError, "standard_id_well_known_routes accepts `only:` or `except:`, not both"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
keys = WELL_KNOWN_DOCUMENTS.keys
|
|
109
|
+
requested = Array(only).map(&:to_sym) + Array(except).map(&:to_sym)
|
|
110
|
+
unknown = requested - keys
|
|
111
|
+
if unknown.any?
|
|
112
|
+
raise ArgumentError,
|
|
113
|
+
"Unknown StandardId well-known document(s): #{unknown.join(', ')}. Valid: #{keys.join(', ')}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
selected = if only.present?
|
|
117
|
+
Array(only).map(&:to_sym)
|
|
118
|
+
elsif except.present?
|
|
119
|
+
keys - Array(except).map(&:to_sym)
|
|
120
|
+
else
|
|
121
|
+
keys
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
WELL_KNOWN_DOCUMENTS.slice(*selected)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Included directly rather than through a load hook: ActionDispatch has no
|
|
130
|
+
# `:action_dispatch_routing_mapper` hook, and this file is required from
|
|
131
|
+
# lib/standard_id.rb during Bundler.require — long before any routes file is
|
|
132
|
+
# evaluated — so the mapper method is defined by the time a host calls it.
|
|
133
|
+
ActionDispatch::Routing::Mapper.include(StandardId::Routing)
|
data/lib/standard_id/version.rb
CHANGED
data/lib/standard_id.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "standard_id/api_engine"
|
|
|
6
6
|
require "standard_id/config_schema"
|
|
7
7
|
require "standard_id/config/schema"
|
|
8
8
|
require "standard_id/scope_config"
|
|
9
|
+
require "standard_id/association_strict_loading"
|
|
9
10
|
require "standard_id/errors"
|
|
10
11
|
require "standard_id/events"
|
|
11
12
|
require "standard_id/events/subscribers/base"
|
|
@@ -44,7 +45,9 @@ require "standard_id/oauth/subflows/base"
|
|
|
44
45
|
require "standard_id/oauth/subflows/traditional_code_grant"
|
|
45
46
|
require "standard_id/oauth/subflows/social_login_grant"
|
|
46
47
|
require "standard_id/oauth/passwordless_otp_flow"
|
|
48
|
+
require "standard_id/oauth/discovery_resolver"
|
|
47
49
|
require "standard_id/oauth/discovery_document"
|
|
50
|
+
require "standard_id/routing"
|
|
48
51
|
require "standard_id/oauth/consent_payload"
|
|
49
52
|
require "standard_id/oauth/client_registration"
|
|
50
53
|
require "standard_id/passwordless/base_strategy"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_id
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.34.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -133,6 +133,7 @@ files:
|
|
|
133
133
|
- app/controllers/standard_id/api/base_controller.rb
|
|
134
134
|
- app/controllers/standard_id/api/oauth/base_controller.rb
|
|
135
135
|
- app/controllers/standard_id/api/oauth/callback/providers_controller.rb
|
|
136
|
+
- app/controllers/standard_id/api/oauth/introspections_controller.rb
|
|
136
137
|
- app/controllers/standard_id/api/oauth/registrations_controller.rb
|
|
137
138
|
- app/controllers/standard_id/api/oauth/revocations_controller.rb
|
|
138
139
|
- app/controllers/standard_id/api/oauth/tokens_controller.rb
|
|
@@ -238,6 +239,7 @@ files:
|
|
|
238
239
|
- lib/standard_id/api/session_manager.rb
|
|
239
240
|
- lib/standard_id/api/token_manager.rb
|
|
240
241
|
- lib/standard_id/api_engine.rb
|
|
242
|
+
- lib/standard_id/association_strict_loading.rb
|
|
241
243
|
- lib/standard_id/authorization_bypass.rb
|
|
242
244
|
- lib/standard_id/bearer_token_extraction.rb
|
|
243
245
|
- lib/standard_id/config/callable_validator.rb
|
|
@@ -267,6 +269,7 @@ files:
|
|
|
267
269
|
- lib/standard_id/oauth/client_registration.rb
|
|
268
270
|
- lib/standard_id/oauth/consent_payload.rb
|
|
269
271
|
- lib/standard_id/oauth/discovery_document.rb
|
|
272
|
+
- lib/standard_id/oauth/discovery_resolver.rb
|
|
270
273
|
- lib/standard_id/oauth/implicit_authorization_flow.rb
|
|
271
274
|
- lib/standard_id/oauth/oauth_session_persistence.rb
|
|
272
275
|
- lib/standard_id/oauth/password_flow.rb
|
|
@@ -287,6 +290,7 @@ files:
|
|
|
287
290
|
- lib/standard_id/provider_registry.rb
|
|
288
291
|
- lib/standard_id/providers/base.rb
|
|
289
292
|
- lib/standard_id/rate_limit_store.rb
|
|
293
|
+
- lib/standard_id/routing.rb
|
|
290
294
|
- lib/standard_id/scope_config.rb
|
|
291
295
|
- lib/standard_id/session_type_resolver.rb
|
|
292
296
|
- lib/standard_id/testing.rb
|