mcp 1.6.1 → 1.8.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.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MCP
6
+ class Server
7
+ module OAuth
8
+ # Stateless builders for RFC 6750 Bearer challenges and the Rack error responses that carry them.
9
+ # `Authenticator` uses these for every rejection; they are also public so custom integrations can emit
10
+ # spec-shaped 401/403 responses without pulling in the middleware.
11
+ #
12
+ # The header output is the exact counterpart of `MCP::Client::OAuth::Discovery.parse_www_authenticate`:
13
+ # everything built here parses back into the same parameters on the client side.
14
+ # https://www.rfc-editor.org/rfc/rfc6750#section-3
15
+ module Challenge
16
+ CONTENT_TYPE_JSON = { "content-type" => "application/json" }.freeze
17
+ private_constant :CONTENT_TYPE_JSON
18
+
19
+ class << self
20
+ # Returns a `WWW-Authenticate` header value such as
21
+ # `Bearer error="invalid_token", error_description="...", scope="a b", resource_metadata="https://..."`.
22
+ # Parameters are quoted-string encoded per RFC 7235; nil parameters are omitted.
23
+ def build(error: nil, error_description: nil, scope: nil, resource_metadata: nil)
24
+ parameters = []
25
+ parameters << %(error="#{quote(error)}") if error
26
+ parameters << %(error_description="#{quote(error_description)}") if error_description
27
+ parameters << %(scope="#{quote(scope)}") if scope
28
+ parameters << %(resource_metadata="#{quote(resource_metadata)}") if resource_metadata
29
+
30
+ parameters.empty? ? "Bearer" : "Bearer #{parameters.join(", ")}"
31
+ end
32
+
33
+ # 401 with `error="invalid_token"`: the presented token is expired, revoked, or otherwise invalid.
34
+ # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
35
+ def invalid_token_response(error_description: nil, scope: nil, resource_metadata: nil)
36
+ challenge_response(
37
+ 401,
38
+ error: "invalid_token",
39
+ error_description: error_description,
40
+ scope: scope,
41
+ resource_metadata: resource_metadata,
42
+ )
43
+ end
44
+
45
+ # 401 without an error code: RFC 6750 Section 3.1 says the challenge answering a request that
46
+ # carried no authentication information at all should not include an error code or other error information.
47
+ # A request that tried another scheme counts as one (the same section).
48
+ def missing_token_response(scope: nil, resource_metadata: nil)
49
+ challenge_response(401, scope: scope, resource_metadata: resource_metadata)
50
+ end
51
+
52
+ # 403 with `error="insufficient_scope"`: the token is valid but lacks a scope the current operation requires.
53
+ # Clients treat this as a step-up signal and re-authorize with the scopes advertised in `scope`.
54
+ def insufficient_scope_response(error_description: nil, scope: nil, resource_metadata: nil)
55
+ challenge_response(
56
+ 403,
57
+ error: "insufficient_scope",
58
+ error_description: error_description,
59
+ scope: scope,
60
+ resource_metadata: resource_metadata,
61
+ )
62
+ end
63
+
64
+ # 400 with `error="invalid_request"`: the request itself is malformed (e.g. a `Bearer` header without exactly one token).
65
+ def invalid_request_response(error_description: nil, resource_metadata: nil)
66
+ challenge_response(
67
+ 400,
68
+ error: "invalid_request",
69
+ error_description: error_description,
70
+ resource_metadata: resource_metadata,
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def challenge_response(status, error: nil, error_description: nil, scope: nil, resource_metadata: nil)
77
+ # The description reaches the header (quoted below) and the JSON body alike; normalized once here
78
+ # so a custom verifier's message with invalid bytes cannot make `to_json` raise either.
79
+ error_description = utf8(error_description) if error_description
80
+ www_authenticate = build(
81
+ error: error,
82
+ error_description: error_description,
83
+ scope: scope,
84
+ resource_metadata: resource_metadata,
85
+ )
86
+ return [status, { "www-authenticate" => www_authenticate }, []] if error.nil?
87
+
88
+ body = { error: error }
89
+ body[:error_description] = error_description if error_description
90
+
91
+ [status, CONTENT_TYPE_JSON.merge("www-authenticate" => www_authenticate), [body.to_json]]
92
+ end
93
+
94
+ # Encodes a parameter value as an RFC 7230 quoted-string interior: `\` and `"` become quoted-pairs.
95
+ # Control characters are replaced with spaces because they cannot appear in a header value at all;
96
+ # leaving CR/LF in would let attacker-influenced text (e.g. an error message quoting a bad token)
97
+ # split the response header, and other control characters make strict servers reject the response outright.
98
+ # Invalid byte sequences are replaced first: a custom verifier's message need not be valid UTF-8,
99
+ # and `gsub` raising on it would escape the rescue that builds the challenge, turning the 401 into a 500.
100
+ def quote(value)
101
+ utf8(value).gsub(/[[:cntrl:]]/, " ").gsub(/[\\"]/) { |character| "\\#{character}" }
102
+ end
103
+
104
+ # Normalizes a value to valid UTF-8. `scrub` alone only repairs strings tagged UTF-8:
105
+ # Rack hands header values to a custom verifier as ASCII-8BIT, where every byte counts as valid,
106
+ # so a message assembled from them would still make `to_json` raise.
107
+ def utf8(value)
108
+ value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ module OAuth
6
+ # Error hierarchy for the OAuth 2.1 resource-server role. Each error carries the RFC 6750 Section 3.1
7
+ # registered error code that `Challenge` places in the `WWW-Authenticate` response header.
8
+ # https://www.rfc-editor.org/rfc/rfc6750#section-3.1
9
+ class Error < StandardError
10
+ attr_reader :error_code
11
+
12
+ # `error_code` defaults to the code a token rejection carries, so a custom verifier can subclass this
13
+ # and `raise MyError, "..."` without knowing about the keyword. `Authenticator#challenge_response`
14
+ # answers any such error with 401 `invalid_token`, which is what the default names; without a default,
15
+ # the missing keyword would raise `ArgumentError` inside `verify` and surface as HTTP 500 instead.
16
+ def initialize(message = nil, error_code: "invalid_token")
17
+ super(message)
18
+ @error_code = error_code
19
+ end
20
+ end
21
+
22
+ # The request is malformed (e.g. an `Authorization` header that does not carry a Bearer token). Maps to HTTP 400.
23
+ class InvalidRequestError < Error
24
+ def initialize(message = "The request is malformed")
25
+ super(message, error_code: "invalid_request")
26
+ end
27
+ end
28
+
29
+ # The access token is missing, expired, revoked, or otherwise invalid.
30
+ # Maps to HTTP 401. `TokenVerifier` implementations raise this to reject a token.
31
+ class InvalidTokenError < Error
32
+ def initialize(message = "The access token is invalid")
33
+ super(message, error_code: "invalid_token")
34
+ end
35
+ end
36
+
37
+ # The request carried no credentials at all. A subclass of `InvalidTokenError` so existing rescue clauses keep working,
38
+ # but distinguished because RFC 6750 Section 3.1 says the challenge answering a credential-less request should not include
39
+ # an error code.
40
+ class MissingTokenError < InvalidTokenError
41
+ def initialize(message = "Missing Authorization header")
42
+ super
43
+ end
44
+ end
45
+
46
+ # The token is valid but lacks a scope the resource requires. Maps to HTTP 403, whose `WWW-Authenticate` challenge signals
47
+ # step-up authorization (the scope challenge handling of the MCP authorization specification) to the client.
48
+ class InsufficientScopeError < Error
49
+ attr_reader :required_scopes
50
+
51
+ def initialize(message = "The request requires higher privileges", required_scopes: [])
52
+ super(message, error_code: "insufficient_scope")
53
+ @required_scopes = required_scopes
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "mcp/client/oauth/discovery"
7
+ require_relative "token_verifier"
8
+
9
+ module MCP
10
+ class Server
11
+ module OAuth
12
+ # Verifies access tokens by asking the authorization server via OAuth 2.0 Token Introspection (RFC 7662).
13
+ # Works with opaque tokens and needs no extra dependencies.
14
+ #
15
+ # verifier = MCP::Server::OAuth::IntrospectionVerifier.new(
16
+ # introspection_endpoint: "https://as.example.com/oauth/introspect",
17
+ # client_id: "mcp-resource-server",
18
+ # client_secret: ENV["INTROSPECTION_CLIENT_SECRET"],
19
+ # resource_metadata: metadata, # the ProtectedResourceMetadata this server publishes; its `resource` is the expected `aud`
20
+ # )
21
+ #
22
+ # Every `verify` call hits the endpoint; responses are intentionally not cached so token revocation takes
23
+ # effect within the authorization server's own latency, not ours.
24
+ #
25
+ # `audience` is required: the introspection response's `aud` (or `resource`) must cover it,
26
+ # which is the RFC 8707 audience check the MCP authorization specification demands of every resource server.
27
+ # It keeps a token issued for another resource from being replayed here. An authorization server that cannot
28
+ # audience-bind its tokens needs a custom verifier, and the deployment should understand what it is giving up.
29
+ class IntrospectionVerifier < TokenVerifier
30
+ # Raised when the introspection endpoint is unreachable or misbehaves. Deliberately not an `OAuth::Error`:
31
+ # an unavailable authorization server is an internal failure (HTTP 500), not a problem with the client's token.
32
+ class IntrospectionError < StandardError; end
33
+
34
+ # Named after the RFC 7591 `token_endpoint_auth_method` values, the same vocabulary the client side uses.
35
+ CLIENT_AUTH_METHODS = [:client_secret_basic, :client_secret_post, :none].freeze
36
+ private_constant :CLIENT_AUTH_METHODS
37
+
38
+ def initialize(
39
+ introspection_endpoint:,
40
+ resource_metadata:,
41
+ client_id: nil,
42
+ client_secret: nil,
43
+ client_auth_method: :client_secret_basic,
44
+ open_timeout: 5,
45
+ read_timeout: 5
46
+ )
47
+ super()
48
+
49
+ unless Client::OAuth::Discovery.secure_url?(introspection_endpoint)
50
+ raise ArgumentError, "introspection_endpoint must use https (http is allowed only on loopback): #{introspection_endpoint.inspect}"
51
+ end
52
+
53
+ # The RFC 8707 audience check keeps tokens issued for other resources from being replayed against this server;
54
+ # the expected value is the `resource` of the document this server publishes.
55
+ audience = resource_from(resource_metadata)
56
+ require_expected_claim!(:audience, audience)
57
+ require_positive_number!(:open_timeout, open_timeout)
58
+ require_positive_number!(:read_timeout, read_timeout)
59
+ unless CLIENT_AUTH_METHODS.include?(client_auth_method)
60
+ raise ArgumentError, "client_auth_method must be one of #{CLIENT_AUTH_METHODS.join(", ")}"
61
+ end
62
+ if client_auth_method != :none && client_id.nil?
63
+ raise ArgumentError, "client_id is required for client_auth_method #{client_auth_method.inspect}"
64
+ end
65
+
66
+ @introspection_endpoint = introspection_endpoint
67
+ @client_id = client_id
68
+ @client_secret = client_secret
69
+ @client_auth_method = client_auth_method
70
+ @audience = audience
71
+ @open_timeout = open_timeout
72
+ @read_timeout = read_timeout
73
+ end
74
+
75
+ def verify(token)
76
+ claims = introspect(token)
77
+
78
+ raise InvalidTokenError, "Token is not active" unless claims["active"] == true
79
+
80
+ validate_audience!(claims)
81
+
82
+ access_token = access_token_from_claims(token, claims, resource: @audience)
83
+ # An active-but-expired introspection response would be an authorization server bug, but expiry is this verifier's contractual duty,
84
+ # so it is enforced here rather than trusted.
85
+ raise InvalidTokenError, "Token expired" if access_token.expired?
86
+
87
+ access_token
88
+ end
89
+
90
+ private
91
+
92
+ def introspect(token)
93
+ uri = URI.parse(@introspection_endpoint)
94
+ request = Net::HTTP::Post.new(uri.request_uri)
95
+ request["Accept"] = "application/json"
96
+
97
+ form = { "token" => token }
98
+ case @client_auth_method
99
+ when :client_secret_basic
100
+ request["Authorization"] = "Basic #{basic_credentials}"
101
+ when :client_secret_post
102
+ form["client_id"] = @client_id
103
+ form["client_secret"] = @client_secret.to_s
104
+ end
105
+ request.set_form_data(form)
106
+
107
+ body = "".dup
108
+
109
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
110
+ http.request(request) do |response|
111
+ unless response.is_a?(Net::HTTPOK)
112
+ raise IntrospectionError, "Introspection endpoint responded with status #{response.code}"
113
+ end
114
+
115
+ response.read_body do |chunk|
116
+ body << chunk
117
+ if body.bytesize > MAX_UPSTREAM_RESPONSE_BYTES
118
+ raise IntrospectionError, "Introspection response exceeded #{MAX_UPSTREAM_RESPONSE_BYTES} bytes"
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ parsed = JSON.parse(body)
125
+ unless parsed.is_a?(Hash)
126
+ raise IntrospectionError, "Introspection endpoint returned a non-object JSON document"
127
+ end
128
+
129
+ parsed
130
+ rescue JSON::ParserError
131
+ raise IntrospectionError, "Introspection endpoint returned invalid JSON"
132
+ end
133
+
134
+ # RFC 6749 Section 2.3.1: the client id and secret are form-urlencoded before being joined and base64-encoded,
135
+ # so a `:` or another reserved character in either survives the round trip; `Net::HTTP#basic_auth` skips that step.
136
+ def basic_credentials
137
+ credentials = "#{URI.encode_www_form_component(@client_id)}:#{URI.encode_www_form_component(@client_secret.to_s)}"
138
+
139
+ [credentials].pack("m0")
140
+ end
141
+
142
+ def validate_audience!(claims)
143
+ audience_values = Array(claims["aud"]) + Array(claims["resource"])
144
+ return if audience_values.include?(@audience)
145
+
146
+ raise InvalidTokenError, "Invalid audience"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "mcp/client/oauth/discovery"
7
+ require_relative "token_verifier"
8
+
9
+ # This file is autoloaded only when `JWTVerifier` is referenced, so the `jwt` dependency does not affect users of other verifiers.
10
+ begin
11
+ require "jwt"
12
+ rescue LoadError
13
+ raise LoadError, "The 'jwt' gem is required to use MCP::Server::OAuth::JWTVerifier. Add it to your Gemfile: gem 'jwt'"
14
+ end
15
+
16
+ module MCP
17
+ class Server
18
+ module OAuth
19
+ # Verifies JWT access tokens locally: signature (via a JWKS endpoint, a static JWKS document, or a single key), `iss`, `aud`, `exp`, and `nbf`.
20
+ #
21
+ #
22
+ # verifier = MCP::Server::OAuth::JWTVerifier.new(
23
+ # resource_metadata: metadata, # the ProtectedResourceMetadata this server publishes
24
+ # jwks_uri: "https://as.example.com/.well-known/jwks.json",
25
+ # )
26
+ #
27
+ # `aud` is checked against the document's `resource`, the canonical resource URL spec-conformant authorization servers
28
+ # put into `aud` via the RFC 8707 `resource` parameter, and `iss` against the document's authorization server, of which
29
+ # there must be exactly one: this verifier holds one key set, so it verifies the tokens of one issuer.
30
+ #
31
+ # `alg: none` is refused whatever `algorithms:` says, and HMAC and asymmetric algorithms cannot share one allowlist, which defeats HS256 key-confusion attacks:
32
+ # a token symmetrically signed with the public key bytes never meets an HMAC verifier holding that key. The default allowlist contains only asymmetric algorithms.
33
+ # Pass `algorithms: ["HS256"]` together with `key: shared_secret` only when the authorization server genuinely issues HMAC-signed tokens, and understand
34
+ # that anyone holding the secret can mint tokens.
35
+ class JWTVerifier < TokenVerifier
36
+ # Raised when the JWKS endpoint cannot be fetched or parsed. Deliberately not an `OAuth::Error`:
37
+ # an unreachable key set is an internal failure (HTTP 500), not a problem with the client's token.
38
+ class JWKSFetchError < StandardError; end
39
+
40
+ DEFAULT_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"].freeze
41
+
42
+ HMAC_ALGORITHM_PATTERN = /\AHS\d+\z/i.freeze
43
+ private_constant :HMAC_ALGORITHM_PATTERN
44
+
45
+ # Minimum seconds between JWKS refetches triggered by an unknown `kid`, so a stream of forged tokens cannot hammer the JWKS endpoint.
46
+ JWKS_REFETCH_COOLDOWN = 30
47
+
48
+ # The RFC 7519 NumericDate claims the jwt gem compares with the clock under the `verify_expiration` and
49
+ # `verify_not_before` options passed in `decode`; `iat` joins the list only if its verification is ever enabled.
50
+ NUMERIC_DATE_CLAIMS = ["exp", "nbf"].freeze
51
+ private_constant :NUMERIC_DATE_CLAIMS
52
+
53
+ def initialize(resource_metadata:, jwks_uri: nil, jwks: nil, key: nil, algorithms: DEFAULT_ALGORITHMS, leeway: 0, jwks_cache_ttl: 300, jwks_max_stale: 3600, open_timeout: 5, read_timeout: 5)
54
+ super()
55
+
56
+ issuer = issuer_from(resource_metadata)
57
+ audience = resource_from(resource_metadata)
58
+ require_expected_claim!(:issuer, issuer)
59
+ require_expected_claim!(:audience, audience)
60
+ require_non_negative_number!(:leeway, leeway)
61
+ require_non_negative_number!(:jwks_cache_ttl, jwks_cache_ttl)
62
+ require_non_negative_number!(:jwks_max_stale, jwks_max_stale)
63
+ require_positive_number!(:open_timeout, open_timeout)
64
+ require_positive_number!(:read_timeout, read_timeout)
65
+
66
+ key_sources = [jwks_uri, jwks, key].compact
67
+ raise ArgumentError, "exactly one of jwks_uri, jwks, or key is required" unless key_sources.size == 1
68
+
69
+ if jwks_uri && !Client::OAuth::Discovery.secure_url?(jwks_uri)
70
+ raise ArgumentError, "jwks_uri must use https (http is allowed only on loopback): #{jwks_uri.inspect}"
71
+ end
72
+
73
+ @issuer = issuer
74
+ @audience = audience
75
+ @jwks_uri = jwks_uri
76
+ @static_jwks = jwks && deep_symbolize(jwks)
77
+ @key = key
78
+ @algorithms = validate_algorithms!(algorithms, key)
79
+ @leeway = leeway
80
+ @jwks_cache_ttl = jwks_cache_ttl
81
+ @jwks_max_stale = jwks_max_stale
82
+ @open_timeout = open_timeout
83
+ @read_timeout = read_timeout
84
+ @cached_jwks = nil
85
+ @jwks_fetched_at = nil
86
+ @kid_refetch_at = nil
87
+ @refetch_failed_at = nil
88
+ @state_mutex = Mutex.new
89
+ @fetch_mutex = Mutex.new
90
+ end
91
+
92
+ def verify(token)
93
+ validate_time_claims!(token)
94
+ claims, _header = decode(token)
95
+
96
+ access_token_from_claims(token, claims, resource: @audience)
97
+ rescue JWT::ExpiredSignature
98
+ raise InvalidTokenError, "Token expired"
99
+ rescue JWT::ImmatureSignature
100
+ raise InvalidTokenError, "Token not yet valid"
101
+ rescue JWT::InvalidIssuerError
102
+ raise InvalidTokenError, "Invalid issuer"
103
+ rescue JWT::InvalidAudError
104
+ raise InvalidTokenError, "Invalid audience"
105
+ rescue JWT::DecodeError
106
+ # Covers malformed tokens, signature mismatches, disallowed algorithms (including `alg: none`), missing required claims,
107
+ # and unknown key IDs. The generic message is deliberate: it goes into the `WWW-Authenticate` header, and detailing why
108
+ # verification failed would only help an attacker probe.
109
+ raise InvalidTokenError, "Invalid token"
110
+ end
111
+
112
+ private
113
+
114
+ def decode(token)
115
+ options = {
116
+ algorithms: @algorithms,
117
+ iss: @issuer,
118
+ verify_iss: true,
119
+ aud: @audience,
120
+ verify_aud: true,
121
+ verify_expiration: true,
122
+ verify_not_before: true,
123
+ # `verify_expiration` alone lets a token without `exp` through, and a token that never expires cannot be aged out.
124
+ # Spec-conformant access tokens always carry `exp` (RFC 9068 requires it).
125
+ required_claims: ["exp"],
126
+ leeway: @leeway,
127
+ }
128
+
129
+ if @key
130
+ JWT.decode(token, @key, true, options)
131
+ elsif @static_jwks
132
+ JWT.decode(token, nil, true, options.merge(jwks: @static_jwks))
133
+ else
134
+ JWT.decode(token, nil, true, options.merge(jwks: jwk_loader))
135
+ end
136
+ end
137
+
138
+ # The jwt gem calls `to_i` on `exp` and `nbf` while verifying, so a claim of the wrong type raises inside the gem
139
+ # instead of failing the token. The unverified payload is inspected first: a claim that is neither a finite number
140
+ # nor a digit string makes the token invalid, the same answer `expiry_from_claims` gives an introspection response.
141
+ def validate_time_claims!(token)
142
+ payload, = JWT.decode(token, nil, false)
143
+ raise InvalidTokenError, "Invalid token" unless payload.is_a?(Hash)
144
+
145
+ NUMERIC_DATE_CLAIMS.each do |name|
146
+ value = payload[name]
147
+ next if value.nil? || (value.is_a?(Numeric) && value.finite?) || (value.is_a?(String) && value.match?(/\A\d+\z/))
148
+
149
+ raise InvalidTokenError, "Malformed #{name} claim"
150
+ end
151
+ end
152
+
153
+ # `none` is never acceptable, and HMAC must not share an allowlist with asymmetric algorithms: an attacker who picks `alg`
154
+ # would otherwise sign with the public key bytes as the HMAC secret. A String key can only be an HMAC secret,
155
+ # so it is refused for asymmetric algorithms at construction rather than failing every verification later, and a `JWT::JWK`
156
+ # is refused outright because the decode path would reject every token signed by it.
157
+ def validate_algorithms!(algorithms, key)
158
+ if key.is_a?(JWT::JWK::KeyBase)
159
+ raise ArgumentError, "key must be an OpenSSL::PKey or an HMAC secret String; pass a JWK through jwks: { keys: [jwk.export] }"
160
+ end
161
+
162
+ list = Array(algorithms).map(&:to_s)
163
+ raise ArgumentError, "algorithms must name at least one signature algorithm" if list.empty?
164
+
165
+ if list.any? { |algorithm| algorithm.casecmp?("none") }
166
+ raise ArgumentError, "algorithms must not include none: unsigned tokens would be accepted"
167
+ end
168
+
169
+ hmac, asymmetric = list.partition { |algorithm| algorithm.match?(HMAC_ALGORITHM_PATTERN) }
170
+
171
+ if hmac.any? && asymmetric.any?
172
+ raise ArgumentError, "algorithms must not mix HMAC (#{hmac.join(", ")}) with asymmetric algorithms (#{asymmetric.join(", ")})"
173
+ end
174
+ if key.is_a?(String) && hmac.empty?
175
+ raise ArgumentError, "key must be an OpenSSL::PKey for asymmetric algorithms; a String key is an HMAC secret and needs an HS* allowlist"
176
+ end
177
+
178
+ list
179
+ end
180
+
181
+ # Loader for the `jwt` gem's `jwks:` option. The gem invokes it once per decode and again with `kid_not_found: true` when
182
+ # the token's `kid` is not in the returned set, which is the signal that the authorization server may have rotated its keys.
183
+ def jwk_loader
184
+ lambda do |options|
185
+ load_jwks(options)
186
+ end
187
+ end
188
+
189
+ # Refreshes the JWKS cache without performing network I/O under a lock other verifications wait on: only cache-state reads
190
+ # and swaps are synchronized, one thread fetches at a time, and the remaining threads keep verifying against the previous key
191
+ # set in the meantime. A failed refresh also falls back to that set, so a flaky JWKS endpoint degrades to slightly stale keys
192
+ # instead of taking verification down, but only for `jwks_max_stale` seconds past the TTL: beyond that the failure surfaces,
193
+ # so a key set the authorization server retired cannot stay trusted indefinitely. A failed unknown-`kid` refetch starts
194
+ # the cooldown all the same, or the endpoint would be retried on every such token while it is down.
195
+ def load_jwks(options)
196
+ # The key set and its fetch time are read as one snapshot and judged together: a refresh another thread completes
197
+ # in the meantime must not lend its fetch time to the set this thread is holding.
198
+ cached, fetched_at, refetch = @state_mutex.synchronize { [@cached_jwks, @jwks_fetched_at, refetch_required?(options)] }
199
+ unless refetch
200
+ # A refresh that failed moments ago is not retried yet, and keys past the stale bound are not served meanwhile.
201
+ raise JWKSFetchError, "JWKS endpoint could not be fetched" if cached && stale_beyond_bound?(fetched_at)
202
+
203
+ return cached
204
+ end
205
+
206
+ if @fetch_mutex.try_lock
207
+ begin
208
+ fresh = fetch_jwks
209
+ @state_mutex.synchronize do
210
+ @cached_jwks = fresh
211
+ @jwks_fetched_at = monotonic_now
212
+ @refetch_failed_at = nil
213
+ @kid_refetch_at = monotonic_now if options[:kid_not_found]
214
+ end
215
+
216
+ fresh
217
+ rescue JWKSFetchError
218
+ @state_mutex.synchronize do
219
+ @refetch_failed_at = monotonic_now
220
+ @kid_refetch_at = monotonic_now if options[:kid_not_found]
221
+ end
222
+ raise if cached.nil? || stale_beyond_bound?(fetched_at)
223
+
224
+ cached
225
+ ensure
226
+ @fetch_mutex.unlock
227
+ end
228
+ elsif cached && !stale_beyond_bound?(fetched_at)
229
+ cached
230
+ else
231
+ # Cold start, or a cached set past its stale bound, while another thread performs the fetch: wait for that fetch
232
+ # instead of failing the request with an empty or outdated key set. A fetch time that moved means the refresh succeeded,
233
+ # and its set is served the way the fetching thread serves it; an unchanged one means the refresh failed,
234
+ # and the set it left behind is judged by the bound.
235
+ @fetch_mutex.synchronize {}
236
+ fetched, refreshed_at = @state_mutex.synchronize { [@cached_jwks, @jwks_fetched_at] }
237
+ if fetched.nil? || (refreshed_at == fetched_at && stale_beyond_bound?(refreshed_at))
238
+ raise JWKSFetchError, "JWKS endpoint could not be fetched"
239
+ end
240
+
241
+ fetched
242
+ end
243
+ end
244
+
245
+ # An unknown `kid` refetches immediately (the usual key-rotation case) but at most once per cooldown window, so forged tokens with
246
+ # random `kid`s cannot turn every request into a JWKS fetch. Outside of a `kid` miss, the cache is refreshed only when the TTL lapses.
247
+ def refetch_required?(options)
248
+ return true if @cached_jwks.nil?
249
+
250
+ if options[:kid_not_found]
251
+ return @kid_refetch_at.nil? || monotonic_now - @kid_refetch_at >= JWKS_REFETCH_COOLDOWN
252
+ end
253
+
254
+ # A refresh that just failed is not retried on every request while the endpoint stays down.
255
+ return false if @refetch_failed_at && monotonic_now - @refetch_failed_at < JWKS_REFETCH_COOLDOWN
256
+
257
+ monotonic_now - @jwks_fetched_at >= @jwks_cache_ttl
258
+ end
259
+
260
+ # Whether a key set fetched at `fetched_at` has outlived its TTL by more than `jwks_max_stale`, the point past which
261
+ # a refresh failure is no longer bridged with the stale keys.
262
+ def stale_beyond_bound?(fetched_at)
263
+ monotonic_now - fetched_at > @jwks_cache_ttl + @jwks_max_stale
264
+ end
265
+
266
+ def fetch_jwks
267
+ uri = URI.parse(@jwks_uri)
268
+ request = Net::HTTP::Get.new(uri.request_uri, { "Accept" => "application/json" })
269
+ body = "".dup
270
+
271
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
272
+ http.request(request) do |response|
273
+ unless response.is_a?(Net::HTTPOK)
274
+ raise JWKSFetchError, "JWKS endpoint responded with status #{response.code}"
275
+ end
276
+
277
+ response.read_body do |chunk|
278
+ body << chunk
279
+ if body.bytesize > MAX_UPSTREAM_RESPONSE_BYTES
280
+ raise JWKSFetchError, "JWKS response exceeded #{MAX_UPSTREAM_RESPONSE_BYTES} bytes"
281
+ end
282
+ end
283
+ end
284
+ end
285
+
286
+ parsed = JSON.parse(body, symbolize_names: true)
287
+ raise JWKSFetchError, "JWKS endpoint returned a non-object JSON document" unless parsed.is_a?(Hash)
288
+
289
+ # A document without a `keys` array would replace a good cache with one that verifies nothing.
290
+ raise JWKSFetchError, "JWKS endpoint returned a document without a keys array" unless parsed[:keys].is_a?(Array)
291
+
292
+ # So would a set with a member the jwt gem cannot turn into a key: the gem refuses the whole set for a malformed member
293
+ # (jwt 3.3.0 and later skip a member of an unsupported type instead, see below), and unlike an unknown `kid` that refusal
294
+ # never triggers a refetch, so the document counts as a failed refresh instead of replacing the cache.
295
+ unless parsed[:keys].all? { |key| key.is_a?(Hash) }
296
+ raise JWKSFetchError, "JWKS endpoint returned a key that is not a JSON object"
297
+ end
298
+
299
+ key_set = begin
300
+ JWT::JWK::Set.new(parsed)
301
+ rescue JWT::JWKError => e
302
+ raise JWKSFetchError, "JWKS endpoint returned a key set that cannot be loaded: #{e.message}"
303
+ end
304
+
305
+ # jwt 3.3.0 and later skip a member whose key type they do not support instead of refusing the set (RFC 7517 Section 5.1),
306
+ # so a document whose every member is skipped loads as a set that verifies nothing; it counts as a failed refresh
307
+ # the same way an unloadable set does.
308
+ raise JWKSFetchError, "JWKS endpoint returned a key set with no loadable keys" if key_set.keys.empty?
309
+
310
+ parsed
311
+ rescue JSON::ParserError
312
+ raise JWKSFetchError, "JWKS endpoint returned invalid JSON"
313
+ rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError, Net::HTTPBadResponse, Net::ProtocolError => e
314
+ # A connection-level failure is the same event as an HTTP error from the caller's point of view: the key set could not be refreshed,
315
+ # and `load_jwks` decides whether the cached one still bridges the gap.
316
+ raise JWKSFetchError, "JWKS endpoint could not be fetched: #{e.class}"
317
+ end
318
+
319
+ def monotonic_now
320
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
321
+ end
322
+
323
+ # The `jwt` gem's JWKS handling expects symbol keys; user-supplied static documents commonly arrive as parsed JSON with string keys.
324
+ def deep_symbolize(jwks)
325
+ JSON.parse(JSON.generate(jwks), symbolize_names: true)
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end