mcp 1.7.0 → 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 +94 -17
- data/lib/mcp/client/oauth/flow.rb +68 -21
- data/lib/mcp/client/oauth/provider.rb +11 -0
- data/lib/mcp/client/stdio.rb +23 -4
- 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 +488 -120
- data/lib/mcp/server.rb +55 -28
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "access_token"
|
|
4
|
+
require_relative "challenge"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module MCP
|
|
8
|
+
class Server
|
|
9
|
+
module OAuth
|
|
10
|
+
# The verification core shared by `Middleware` and the streamable HTTP transport's built-in enforcement:
|
|
11
|
+
# extracts the bearer token from a Rack env, runs it through the verifier, checks scopes, and maps each failure
|
|
12
|
+
# to the RFC 6750 challenge response it deserves.
|
|
13
|
+
#
|
|
14
|
+
# - `token_verifier` - anything responding to `verify(token)`; see `TokenVerifier` for the contract.
|
|
15
|
+
# Built-ins: `JWTVerifier`, `IntrospectionVerifier`.
|
|
16
|
+
# - `required_scopes` - scopes the token must include, all of them; a shortfall produces 403 `insufficient_scope` (step-up).
|
|
17
|
+
# - `resource_metadata` - a `ProtectedResourceMetadata`; its well-known URL and `scopes_supported` feed the challenges.
|
|
18
|
+
# Pass `resource_metadata_url:` instead to point at a document served elsewhere.
|
|
19
|
+
# - `scope_matcher` - optional callable `(required_scope, granted_scopes) -> Boolean` for deployments
|
|
20
|
+
# whose scopes form a hierarchy; the MCP authorization specification requires a broader scope to satisfy
|
|
21
|
+
# the narrower scopes it implies, which exact membership (the default) cannot express. The matcher also rides
|
|
22
|
+
# on the returned `AccessToken`, so `require_scopes!` and `scope?` inside handlers apply the same semantics.
|
|
23
|
+
class Authenticator
|
|
24
|
+
# RFC 6750 Section 2.1: the scheme and the token are separated by spaces (`1*SP`), never by other whitespace.
|
|
25
|
+
BEARER_PATTERN = /\ABearer +(?<token>\S+)\z/i.freeze
|
|
26
|
+
# Whether the header names the Bearer scheme at all, well-formed or not. `Bearer` followed by anything other
|
|
27
|
+
# than a space names no Bearer credentials, the way the Python and TypeScript servers read it.
|
|
28
|
+
BEARER_SCHEME_PATTERN = /\ABearer(?: |\z)/i.freeze
|
|
29
|
+
private_constant :BEARER_PATTERN, :BEARER_SCHEME_PATTERN
|
|
30
|
+
|
|
31
|
+
# Upper bound on the presented token, applied before any verification so oversized garbage neither reaches
|
|
32
|
+
# cryptographic parsing nor gets forwarded to an introspection endpoint. Real-world access tokens stay far below this;
|
|
33
|
+
# web servers cap the whole header section around 16 KiB.
|
|
34
|
+
MAX_TOKEN_BYTES = 8192
|
|
35
|
+
|
|
36
|
+
def initialize(token_verifier:, required_scopes: [], resource_metadata: nil, resource_metadata_url: nil, scope_matcher: nil)
|
|
37
|
+
@verifier = token_verifier
|
|
38
|
+
@required_scopes = Array(required_scopes)
|
|
39
|
+
@resource_metadata = resource_metadata
|
|
40
|
+
@resource_metadata_url = resource_metadata_url || resource_metadata&.well_known_url
|
|
41
|
+
@scope_matcher = scope_matcher
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param env [Hash] the Rack env
|
|
45
|
+
# @return [AccessToken]
|
|
46
|
+
# @raise [MissingTokenError, InvalidRequestError, InvalidTokenError, InsufficientScopeError]
|
|
47
|
+
def authenticate(env)
|
|
48
|
+
token = extract_bearer_token(env)
|
|
49
|
+
|
|
50
|
+
access_token = @verifier.verify(token)
|
|
51
|
+
raise InvalidTokenError if access_token.nil?
|
|
52
|
+
|
|
53
|
+
access_token = attach_scope_matcher(access_token)
|
|
54
|
+
check_scopes!(access_token)
|
|
55
|
+
access_token
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Maps an `OAuth::Error` raised by `authenticate` (or by a handler's `require_scopes!`) to the Rack response carrying
|
|
59
|
+
# the matching challenge. An `Error` outside the four built-in classes, such as one a custom verifier defines,
|
|
60
|
+
# is a token rejection and answers 401 like an invalid token; anything else is a programming error.
|
|
61
|
+
def challenge_response(error)
|
|
62
|
+
case error
|
|
63
|
+
when MissingTokenError
|
|
64
|
+
Challenge.missing_token_response(scope: scope_hint, resource_metadata: @resource_metadata_url)
|
|
65
|
+
when InvalidRequestError
|
|
66
|
+
Challenge.invalid_request_response(error_description: error.message, resource_metadata: @resource_metadata_url)
|
|
67
|
+
when InsufficientScopeError
|
|
68
|
+
Challenge.insufficient_scope_response(
|
|
69
|
+
error_description: error.message,
|
|
70
|
+
scope: scope_hint(error.required_scopes),
|
|
71
|
+
resource_metadata: @resource_metadata_url,
|
|
72
|
+
)
|
|
73
|
+
when Error
|
|
74
|
+
Challenge.invalid_token_response(
|
|
75
|
+
error_description: error.message,
|
|
76
|
+
scope: scope_hint,
|
|
77
|
+
resource_metadata: @resource_metadata_url,
|
|
78
|
+
)
|
|
79
|
+
else
|
|
80
|
+
raise ArgumentError, "unexpected error class: #{error.class}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def extract_bearer_token(env)
|
|
87
|
+
header = env["HTTP_AUTHORIZATION"]
|
|
88
|
+
raise MissingTokenError if header.nil? || header.empty?
|
|
89
|
+
|
|
90
|
+
# Matched as bytes: a Rack server hands the header over as ASCII-8BIT, but a test request or a framework can tag it UTF-8,
|
|
91
|
+
# and a regexp raises on an invalid byte sequence in a UTF-8 string. As bytes, such a token simply fails verification.
|
|
92
|
+
bytes = header.b
|
|
93
|
+
|
|
94
|
+
# A header naming another scheme carries no Bearer credentials: RFC 6750 Section 3.1 counts a request that
|
|
95
|
+
# "attempted using an unsupported authentication method" among those lacking authentication information,
|
|
96
|
+
# which get the bare challenge, and the Python and TypeScript servers answer it 401 as well.
|
|
97
|
+
# A header that names Bearer but is malformed is a bad request (`invalid_request` in the same section).
|
|
98
|
+
raise MissingTokenError unless BEARER_SCHEME_PATTERN.match?(bytes)
|
|
99
|
+
|
|
100
|
+
match = BEARER_PATTERN.match(bytes)
|
|
101
|
+
raise InvalidRequestError, "Authorization header must carry a single Bearer token" if match.nil?
|
|
102
|
+
|
|
103
|
+
token = match[:token]
|
|
104
|
+
raise InvalidTokenError, "Token exceeds the maximum accepted length" if token.bytesize > MAX_TOKEN_BYTES
|
|
105
|
+
|
|
106
|
+
token
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def check_scopes!(access_token)
|
|
110
|
+
missing_scopes = @required_scopes.reject { |scope| scope_satisfied?(scope, access_token) }
|
|
111
|
+
return if missing_scopes.empty?
|
|
112
|
+
|
|
113
|
+
raise InsufficientScopeError.new(
|
|
114
|
+
"Token is missing required scopes: #{missing_scopes.join(", ")}",
|
|
115
|
+
required_scopes: @required_scopes,
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A verifier result that is not an `AccessToken` (a duck-typed object from a custom verifier) keeps its own `scope?`;
|
|
120
|
+
# the endpoint gate below still applies the matcher to it directly.
|
|
121
|
+
def attach_scope_matcher(access_token)
|
|
122
|
+
return access_token if @scope_matcher.nil? || !access_token.respond_to?(:with_scope_matcher)
|
|
123
|
+
|
|
124
|
+
access_token.with_scope_matcher(@scope_matcher)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A verifier result without a `scopes` reader (a duck-typed object offering only `scope?`) keeps its own judgement,
|
|
128
|
+
# since the matcher needs the granted list to reason about a hierarchy.
|
|
129
|
+
def scope_satisfied?(scope, access_token)
|
|
130
|
+
return access_token.scope?(scope) if @scope_matcher.nil? || !access_token.respond_to?(:scopes)
|
|
131
|
+
|
|
132
|
+
@scope_matcher.call(scope, access_token.scopes)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The scope hint the spec recommends including in challenges: the scopes of the failing operation when known,
|
|
136
|
+
# the scopes this authenticator enforces otherwise, or the resource's advertised scopes as a fallback.
|
|
137
|
+
# `offline_access` is never advertised: refresh token issuance is not a resource requirement per
|
|
138
|
+
# the MCP authorization specification.
|
|
139
|
+
def scope_hint(operation_scopes = [])
|
|
140
|
+
scopes = operation_scopes
|
|
141
|
+
scopes = @required_scopes if scopes.empty?
|
|
142
|
+
scopes = Array(@resource_metadata&.scopes_supported) if scopes.empty?
|
|
143
|
+
scopes -= ["offline_access"]
|
|
144
|
+
|
|
145
|
+
scopes.empty? ? nil : scopes.join(" ")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -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
|