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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/mcp/client/http.rb +105 -18
- data/lib/mcp/client/oauth/flow.rb +276 -23
- data/lib/mcp/client/oauth/in_memory_storage.rb +74 -1
- data/lib/mcp/client/oauth/provider.rb +77 -6
- data/lib/mcp/client/stdio.rb +23 -4
- data/lib/mcp/icon.rb +61 -0
- data/lib/mcp/prompt.rb +1 -1
- data/lib/mcp/resource.rb +2 -2
- data/lib/mcp/resource_template.rb +2 -2
- data/lib/mcp/server/oauth/access_token.rb +89 -0
- data/lib/mcp/server/oauth/authenticator.rb +150 -0
- data/lib/mcp/server/oauth/challenge.rb +114 -0
- data/lib/mcp/server/oauth/errors.rb +58 -0
- data/lib/mcp/server/oauth/introspection_verifier.rb +151 -0
- data/lib/mcp/server/oauth/jwt_verifier.rb +330 -0
- data/lib/mcp/server/oauth/middleware.rb +60 -0
- data/lib/mcp/server/oauth/protected_resource_metadata.rb +138 -0
- data/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb +86 -0
- data/lib/mcp/server/oauth/token_verifier.rb +135 -0
- data/lib/mcp/server/oauth.rb +29 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +712 -160
- data/lib/mcp/server.rb +63 -31
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
|
@@ -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
|