omni_agent 0.1.10 → 0.1.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8a84d4b38e835bc00b73a88bc233932198498112e3837e33fdfdf3dbabab981
4
- data.tar.gz: 6d5cd7709dc61be01ae892a67732b08ee3e17a6d4af7d81f8640f0fd618010e8
3
+ metadata.gz: bc288d35e0b6c6e5e4ea763c36d05aa37ee95d20aeb2f4b1369e15a6ba22bc92
4
+ data.tar.gz: 428b4d96e1b1f7ad2bf1f7cd2c99893333c0eac59d2add9a94faf18198376baa
5
5
  SHA512:
6
- metadata.gz: b56c77174212b7a4ba2e45b2b3bb6e0f384631fcdd2bcbf30e2a32c8edb1617b239e8bdd482dbb259baf48746b20c38d29fd325685262337e38ab1e9669a5220
7
- data.tar.gz: 5fcb5179527eda96d3dc6726ad78c73b1bba4b3ef7c34825cbfec03c2437e7018647b43cf9f9d43791599efdc628530d5c6cfefcb6f431bd7548a97f7c9a8365
6
+ metadata.gz: 9c55eaf9a31782bb1c16c152d15627943cef09f5a179c6039590b412dba505196aae36ed325905992bd26df567c74553a83fec629c59062c2cfa5d4981a03bf4
7
+ data.tar.gz: 904fdc44a855a5b7cfe66817cd531cc86bd44d5a8f8bccd6b97c6bf3d313a4b4e4a7a2f18fb18305bf5ae5be4bca7424562c1e39f77ecbb12ee63bea4d00fcf3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.11](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.10...omni_agent/v0.1.11) (2026-10-15)
6
+
7
+
8
+ ### Features
9
+
10
+ * **mcp:** add OAuth 2.1 authentication per the MCP authorization spec ([160c3d4](https://github.com/ACR1209/omni_agent/commit/160c3d47fb70272c58162359296c795a03b52219))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **mcp:** stop blocking requests while fetching the JWKS ([5074eb2](https://github.com/ACR1209/omni_agent/commit/5074eb2d939b2c1d6c365b15482cc9024f361527))
16
+
5
17
  ## [0.1.10](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.9...omni_agent/v0.1.10) (2026-10-09)
6
18
 
7
19
 
data/README.md CHANGED
@@ -262,6 +262,10 @@ class SupportServer < OmniAgent::MCP::Server
262
262
 
263
263
  authenticate :bearer, tokens: -> { ENV.fetch("SUPPORT_MCP_TOKENS", "").split(",") }
264
264
  # or: authenticate { |request| User.find_by(api_token: request.bearer_token) }
265
+ # or OAuth 2.1 (MCP authorization spec), with tokens from your authorization server:
266
+ # authenticate :oauth, resource: "https://app.example.com/mcp/support",
267
+ # authorization_servers: "https://auth.example.com",
268
+ # jwks_uri: "https://auth.example.com/.well-known/jwks.json"
265
269
 
266
270
  authorize_tool { |tool_class, principal| !tool_class.tags.include?(:admin) }
267
271
  context { |principal, _request| { current_user: principal } }
@@ -101,6 +101,10 @@ module OmniAgent
101
101
 
102
102
  # Comma-separated list of accepted bearer tokens.
103
103
  authenticate :bearer, tokens: -> { ENV.fetch("#{token_env_var}", "").split(",") }
104
+ # Or OAuth 2.1 with tokens from your authorization server (add `gem "jwt"`):
105
+ # authenticate :oauth, resource: "https://app.example.com/mcp/...",
106
+ # authorization_servers: "https://auth.example.com",
107
+ # jwks_uri: "https://auth.example.com/.well-known/jwks.json"
104
108
 
105
109
  # Hide tools per principal:
106
110
  # authorize_tool { |tool_class, principal| true }
@@ -3,7 +3,8 @@ require "active_support/security_utils"
3
3
  module OmniAgent
4
4
  module MCP
5
5
  class Authenticator
6
- Result = Struct.new(:authenticated, :principal) do
6
+ # error and access_token are only set by `authenticate :oauth`.
7
+ Result = Struct.new(:authenticated, :principal, :error, :access_token) do
7
8
  def authenticated?
8
9
  authenticated == true
9
10
  end
@@ -21,7 +22,7 @@ module OmniAgent
21
22
  if authentication.nil?
22
23
  raise OmniAgent::MCPError,
23
24
  "#{@server_class.name} does not declare authentication. " \
24
- "Add `authenticate :bearer, tokens: ...`, `authenticate { |request| ... }`, " \
25
+ "Add `authenticate :bearer, tokens: ...`, `authenticate :oauth, ...`, `authenticate { |request| ... }`, " \
25
26
  "or explicitly opt out with `authenticate :none`."
26
27
  end
27
28
 
@@ -30,6 +31,8 @@ module OmniAgent
30
31
  success(nil)
31
32
  when :bearer
32
33
  authenticate_bearer(request, authentication[:tokens])
34
+ when :oauth
35
+ authentication[:resource_server].authenticate(request)
33
36
  when :custom
34
37
  principal = authentication[:block].call(request)
35
38
  principal ? success(principal) : failure
@@ -0,0 +1,60 @@
1
+ module OmniAgent
2
+ module MCP
3
+ module OAuth
4
+ # A validated access token and its claims (JWT payload or introspection
5
+ # response). It is the principal unless `authenticate :oauth` has a block.
6
+ class AccessToken
7
+ attr_reader :token, :claims
8
+
9
+ def initialize(token, claims)
10
+ @token = token
11
+ @claims = claims.transform_keys(&:to_s).freeze
12
+ end
13
+
14
+ def [](key)
15
+ claims[key.to_s]
16
+ end
17
+
18
+ def subject
19
+ claims["sub"]
20
+ end
21
+
22
+ def issuer
23
+ claims["iss"]
24
+ end
25
+
26
+ def client_id
27
+ claims["client_id"] || claims["azp"]
28
+ end
29
+
30
+ def audience
31
+ Array(claims["aud"]).map(&:to_s)
32
+ end
33
+
34
+ # RFC 9068 / RFC 7662 use a space-separated "scope"; some providers
35
+ # (Okta, Entra ID) use "scp", as a string or an array.
36
+ def scopes
37
+ raw = claims.key?("scope") ? claims["scope"] : claims["scp"]
38
+ raw.is_a?(Array) ? raw.map(&:to_s) : raw.to_s.split
39
+ end
40
+
41
+ def scope?(*names)
42
+ (names.flatten.map(&:to_s) - scopes).empty?
43
+ end
44
+
45
+ def expires_at
46
+ Time.at(claims["exp"]) if claims["exp"].is_a?(Numeric)
47
+ end
48
+
49
+ def to_s
50
+ subject.to_s
51
+ end
52
+
53
+ # Never print the raw token.
54
+ def inspect
55
+ "#<#{self.class.name} sub=#{subject.inspect} client_id=#{client_id.inspect} scopes=#{scopes.inspect}>"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,52 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "uri"
5
+
6
+ module OmniAgent
7
+ module MCP
8
+ module OAuth
9
+ # Minimal JSON client for the authorization server (JWKS, introspection).
10
+ module HTTPClient
11
+ OPEN_TIMEOUT = 5
12
+ READ_TIMEOUT = 5
13
+
14
+ NETWORK_ERRORS = [
15
+ IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, URI::InvalidURIError
16
+ ].freeze
17
+
18
+ module_function
19
+
20
+ def get_json(url)
21
+ uri = URI.parse(url.to_s)
22
+ request(uri, Net::HTTP::Get.new(uri))
23
+ end
24
+
25
+ def post_form_json(url, form, basic_auth: nil)
26
+ uri = URI.parse(url.to_s)
27
+ post = Net::HTTP::Post.new(uri)
28
+ post.set_form_data(form)
29
+ post.basic_auth(*basic_auth) if basic_auth
30
+ request(uri, post)
31
+ end
32
+
33
+ def request(uri, http_request)
34
+ http_request["accept"] = "application/json"
35
+
36
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
37
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
38
+ http.request(http_request)
39
+ end
40
+
41
+ unless response.is_a?(Net::HTTPSuccess)
42
+ raise VerificationError, "#{http_request.method} #{uri} returned HTTP #{response.code}"
43
+ end
44
+
45
+ JSON.parse(response.body.to_s)
46
+ rescue JSON::ParserError, *NETWORK_ERRORS => e
47
+ raise VerificationError, "#{http_request.method} #{uri} failed: #{e.class}: #{e.message}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,43 @@
1
+ require "uri"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ module OAuth
6
+ # Asks the authorization server whether a token is active (RFC 7662).
7
+ # Works with opaque tokens. Returns the introspection response, or nil
8
+ # when the token is not active.
9
+ class IntrospectionVerifier
10
+ def initialize(url:, client_id: nil, client_secret: nil)
11
+ raise ArgumentError, "introspection: requires url:" if url.nil?
12
+
13
+ @url = url
14
+ @client_id = client_id
15
+ @client_secret = client_secret
16
+ end
17
+
18
+ def call(token, _request = nil)
19
+ response = HTTPClient.post_form_json(
20
+ OAuth.resolve(@url),
21
+ { "token" => token, "token_type_hint" => "access_token" },
22
+ basic_auth: credentials
23
+ )
24
+ response.is_a?(Hash) && response["active"] == true ? response : nil
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class.name} url=#{@url.inspect}>"
29
+ end
30
+
31
+ private
32
+
33
+ # client_secret_basic: RFC 6749 section 2.3.1 form-encodes both values.
34
+ def credentials
35
+ client_id = OAuth.resolve(@client_id)
36
+ return if client_id.nil?
37
+
38
+ [ client_id, OAuth.resolve(@client_secret) ].map { |value| URI.encode_www_form_component(value.to_s) }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,122 @@
1
+ module OmniAgent
2
+ module MCP
3
+ module OAuth
4
+ # Verifies JWT access tokens (RFC 9068) with the authorization server's
5
+ # JSON Web Key Set. Returns the payload, or nil when the token is invalid.
6
+ # Issuer, audience and scopes are checked afterwards by ResourceServer.
7
+ class JWTVerifier
8
+ # Asymmetric algorithms only: a JWKS publishes public keys.
9
+ DEFAULT_ALGORITHMS = %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
10
+ CACHE_TTL = 300
11
+ # A token signed with an unknown `kid` refetches the key set (key
12
+ # rotation), but at most this often, so forged tokens cannot make every
13
+ # request call the authorization server. Also the wait after a failed fetch.
14
+ MIN_REFRESH_INTERVAL = 30
15
+
16
+ def self.require_jwt!
17
+ require "jwt"
18
+ rescue LoadError
19
+ raise OmniAgent::MissingDependencyError,
20
+ "The 'jwt' gem is required for `authenticate :oauth, jwks_uri: ...`. " \
21
+ "Please add `gem 'jwt'` to your Gemfile."
22
+ end
23
+
24
+ def initialize(jwks_uri, algorithms: DEFAULT_ALGORITHMS, leeway: 0)
25
+ self.class.require_jwt!
26
+
27
+ @jwks_uri = jwks_uri
28
+ @algorithms = Array(algorithms).map(&:to_s)
29
+ @leeway = leeway
30
+ # Held only while fetching, so at most one fetch runs at a time.
31
+ # Reading the cached keys never takes it.
32
+ @fetch_mutex = Mutex.new
33
+ @jwks = nil
34
+ @fetched_at = nil
35
+ @failed_at = nil
36
+ end
37
+
38
+ def call(token, _request = nil)
39
+ payload, _header = ::JWT.decode(
40
+ token, nil, true,
41
+ algorithms: @algorithms,
42
+ jwks: method(:jwks),
43
+ required_claims: %w[exp iss],
44
+ leeway: @leeway
45
+ )
46
+ payload
47
+ rescue ::JWT::DecodeError
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ # Key loader for JWT.decode. Called with `kid_not_found: true` when the
54
+ # token's `kid` is not in the cached set.
55
+ def jwks(options = {})
56
+ if @jwks.nil?
57
+ load_initial_jwks
58
+ elsif refresh_due?(options) && @fetch_mutex.try_lock
59
+ # Only the thread that got the lock refreshes; the others keep
60
+ # using the cached keys instead of waiting for it.
61
+ begin
62
+ fetch_jwks
63
+ ensure
64
+ @fetch_mutex.unlock
65
+ end
66
+ end
67
+
68
+ @jwks
69
+ end
70
+
71
+ # With no keys yet, concurrent requests wait for one shared fetch rather
72
+ # than each fetching in turn. After a failure they fail fast until
73
+ # MIN_REFRESH_INTERVAL has passed, so a down authorization server does
74
+ # not tie up every app thread on timeouts.
75
+ def load_initial_jwks
76
+ raise_unavailable if backing_off?
77
+
78
+ @fetch_mutex.synchronize do
79
+ next if @jwks
80
+ raise_unavailable if backing_off?
81
+
82
+ fetch_jwks
83
+ end
84
+
85
+ raise_unavailable if @jwks.nil?
86
+ end
87
+
88
+ def refresh_due?(options)
89
+ return false if backing_off?
90
+
91
+ age = monotonic_now - @fetched_at
92
+ age > CACHE_TTL || (options[:kid_not_found] && age > MIN_REFRESH_INTERVAL)
93
+ end
94
+
95
+ def fetch_jwks
96
+ keys = HTTPClient.get_json(OAuth.resolve(@jwks_uri))
97
+ # @fetched_at first: readers that see @jwks expect it to be set.
98
+ @fetched_at = monotonic_now
99
+ @jwks = keys
100
+ @failed_at = nil
101
+ rescue VerificationError => e
102
+ @failed_at = monotonic_now
103
+ fallback = @jwks ? "using cached keys" : "retrying in #{MIN_REFRESH_INTERVAL}s"
104
+ MCP.logger&.warn("[OmniAgent::MCP] Could not fetch JWKS, #{fallback}: #{e.message}")
105
+ end
106
+
107
+ def backing_off?
108
+ failed_at = @failed_at
109
+ !failed_at.nil? && monotonic_now - failed_at < MIN_REFRESH_INTERVAL
110
+ end
111
+
112
+ def raise_unavailable
113
+ raise VerificationError, "JWKS from #{OAuth.resolve(@jwks_uri)} is unavailable"
114
+ end
115
+
116
+ def monotonic_now
117
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,166 @@
1
+ require "uri"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ module OAuth
6
+ # Built by `authenticate :oauth, ...`. Validates bearer access tokens
7
+ # (signature or introspection, issuer, audience, expiry and scopes) and
8
+ # describes the server in Protected Resource Metadata (RFC 9728) so MCP
9
+ # clients can find the authorization server.
10
+ class ResourceServer
11
+ METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
12
+ # Seconds of clock skew tolerated when checking exp and nbf.
13
+ LEEWAY = 30
14
+
15
+ attr_reader :scopes
16
+
17
+ def initialize(resource:, authorization_servers:, scopes: [], scopes_supported: nil, audience: nil,
18
+ jwks_uri: nil, algorithms: JWTVerifier::DEFAULT_ALGORITHMS, introspection: nil,
19
+ verify_token: nil, &principal_block)
20
+ raise ArgumentError, "authenticate :oauth requires resource:" if resource.nil?
21
+ raise ArgumentError, "authenticate :oauth requires authorization_servers:" if Array(authorization_servers).empty?
22
+
23
+ validate_resource!(resource) unless resource.respond_to?(:call)
24
+
25
+ @resource = resource
26
+ @authorization_servers = authorization_servers
27
+ @scopes = Array(scopes).map(&:to_s).freeze
28
+ @scopes_supported = scopes_supported && Array(scopes_supported).map(&:to_s).freeze
29
+ @audience = audience
30
+ @principal_block = principal_block
31
+ @verifier = build_verifier(jwks_uri, algorithms, introspection, verify_token)
32
+ end
33
+
34
+ # Returns an Authenticator::Result. `error` is nil when no token was
35
+ # sent, :invalid_token or :insufficient_scope (RFC 6750 section 3.1).
36
+ def authenticate(request)
37
+ token = request.bearer_token
38
+ return failure(nil) if token.nil?
39
+
40
+ claims = verify(token, request)
41
+ return failure(:invalid_token) unless claims.is_a?(Hash)
42
+
43
+ access_token = AccessToken.new(token, claims)
44
+ return failure(:invalid_token) unless valid?(access_token)
45
+ return failure(:insufficient_scope) unless access_token.scope?(scopes)
46
+
47
+ principal = @principal_block ? @principal_block.call(access_token, request) : access_token
48
+ return failure(:invalid_token) unless principal
49
+
50
+ Authenticator::Result.new(true, principal, nil, access_token)
51
+ rescue VerificationError => e
52
+ MCP.logger&.error("[OmniAgent::MCP] OAuth token verification failed: #{e.message}")
53
+ failure(:invalid_token)
54
+ end
55
+
56
+ def metadata(resource_name: nil)
57
+ supported = @scopes_supported || scopes
58
+
59
+ {
60
+ resource: resource,
61
+ authorization_servers: authorization_servers,
62
+ scopes_supported: supported.empty? ? nil : supported,
63
+ bearer_methods_supported: [ "header" ],
64
+ resource_name: resource_name
65
+ }.compact
66
+ end
67
+
68
+ # Value of the WWW-Authenticate header for a 401 or 403 response.
69
+ def challenge(realm:, error: nil)
70
+ params = { realm: realm, resource_metadata: metadata_url }
71
+ params[:error] = error.to_s if error
72
+ params[:scope] = scopes.join(" ") unless scopes.empty?
73
+
74
+ "Bearer " + params.map { |key, value| %(#{key}="#{value}") }.join(", ")
75
+ end
76
+
77
+ # Served by RackApp under the MCP endpoint itself, so it works wherever
78
+ # the server is mounted.
79
+ def metadata_url
80
+ "#{resource.chomp('/')}#{METADATA_PATH}"
81
+ end
82
+
83
+ def resource
84
+ OAuth.resolve(@resource).to_s
85
+ end
86
+
87
+ def authorization_servers
88
+ Array(OAuth.resolve(@authorization_servers)).map(&:to_s)
89
+ end
90
+
91
+ private
92
+
93
+ def build_verifier(jwks_uri, algorithms, introspection, verify_token)
94
+ given = { jwks_uri: jwks_uri, introspection: introspection, verify_token: verify_token }.compact
95
+ unless given.size == 1
96
+ raise ArgumentError, "authenticate :oauth requires exactly one of jwks_uri:, introspection: or verify_token:"
97
+ end
98
+
99
+ if jwks_uri
100
+ JWTVerifier.new(jwks_uri, algorithms: algorithms, leeway: LEEWAY)
101
+ elsif introspection
102
+ IntrospectionVerifier.new(**introspection.transform_keys(&:to_sym))
103
+ else
104
+ raise ArgumentError, "verify_token: must respond to #call" unless verify_token.respond_to?(:call)
105
+
106
+ verify_token
107
+ end
108
+ end
109
+
110
+ def verify(token, request)
111
+ if @verifier.respond_to?(:arity) && @verifier.arity == 1
112
+ @verifier.call(token)
113
+ else
114
+ @verifier.call(token, request)
115
+ end
116
+ end
117
+
118
+ def valid?(access_token)
119
+ claims = access_token.claims
120
+
121
+ fresh?(claims) && trusted_issuer?(claims) && intended_audience?(access_token)
122
+ end
123
+
124
+ def fresh?(claims)
125
+ now = Time.now.to_i
126
+ return false if claims.key?("exp") && !(claims["exp"].is_a?(Numeric) && now < claims["exp"] + LEEWAY)
127
+ return false if claims.key?("nbf") && !(claims["nbf"].is_a?(Numeric) && now >= claims["nbf"] - LEEWAY)
128
+
129
+ true
130
+ end
131
+
132
+ # JWTs must carry `iss`; introspection responses and custom verifiers may omit it.
133
+ def trusted_issuer?(claims)
134
+ return true unless claims.key?("iss")
135
+
136
+ authorization_servers.map { |server| normalize(server) }.include?(normalize(claims["iss"]))
137
+ end
138
+
139
+ # RFC 8707: the token must have been issued for this server. Tokens
140
+ # meant for another resource are rejected, never passed through.
141
+ def intended_audience?(access_token)
142
+ expected = Array(OAuth.resolve(@audience) || resource).map { |value| normalize(value) }
143
+
144
+ access_token.audience.any? { |value| expected.include?(normalize(value)) }
145
+ end
146
+
147
+ def normalize(uri)
148
+ uri.to_s.chomp("/")
149
+ end
150
+
151
+ def validate_resource!(resource)
152
+ uri = URI.parse(resource.to_s)
153
+ return if uri.is_a?(URI::HTTP) && uri.host && uri.fragment.nil?
154
+
155
+ raise ArgumentError, "resource: must be the absolute http(s) URL of the MCP endpoint, without a fragment"
156
+ rescue URI::InvalidURIError
157
+ raise ArgumentError, "resource: #{resource.inspect} is not a valid URL"
158
+ end
159
+
160
+ def failure(error)
161
+ Authenticator::Result.new(false, nil, error, nil)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,18 @@
1
+ module OmniAgent
2
+ module MCP
3
+ # Resource server side of the MCP authorization spec (OAuth 2.1). Access
4
+ # tokens are issued by an external authorization server and validated here;
5
+ # see ResourceServer.
6
+ module OAuth
7
+ # The authorization server could not be reached or answered with something
8
+ # unusable. The request is rejected as unauthenticated.
9
+ class VerificationError < OmniAgent::MCPError; end
10
+
11
+ # Settings accept a value or a callable, so they can be read from ENV or
12
+ # credentials on every request.
13
+ def self.resolve(value)
14
+ value.respond_to?(:call) ? value.call : value
15
+ end
16
+ end
17
+ end
18
+ end
@@ -7,6 +7,11 @@ module OmniAgent
7
7
  class RackApp
8
8
  JSON_HEADERS = { "content-type" => "application/json" }.freeze
9
9
  PARSE_FAILED = Object.new.freeze
10
+ METADATA_CORS_HEADERS = {
11
+ "access-control-allow-origin" => "*",
12
+ "access-control-allow-methods" => "GET, OPTIONS",
13
+ "access-control-allow-headers" => "mcp-protocol-version"
14
+ }.freeze
10
15
 
11
16
  # Accepts the server class or its name. Passing a String defers the
12
17
  # constant lookup to request time, which keeps routes reload-safe.
@@ -23,11 +28,17 @@ module OmniAgent
23
28
  def call(env)
24
29
  server_class = MCP.resolve_server_class(@server)
25
30
  request = Request.new(env)
31
+ resource_server = server_class.configured_oauth_resource_server
32
+
33
+ # Public, so clients can discover the authorization server before they have a token.
34
+ if resource_server && request.path_info == OAuth::ResourceServer::METADATA_PATH
35
+ return protected_resource_metadata(server_class, resource_server, request)
36
+ end
26
37
 
27
38
  return forbidden unless origin_allowed?(request, server_class)
28
39
 
29
40
  auth = Authenticator.new(server_class).authenticate(request)
30
- return unauthorized(server_class) unless auth.authenticated?
41
+ return unauthorized(server_class, resource_server, auth) unless auth.authenticated?
31
42
 
32
43
  return method_not_allowed unless request.post?
33
44
 
@@ -41,7 +52,7 @@ module OmniAgent
41
52
  message = parse_json(body)
42
53
  return json_response(400, parse_error_response) if message.equal?(PARSE_FAILED)
43
54
 
44
- server = server_class.new(principal: auth.principal, request: request, transport: :http)
55
+ server = server_class.new(principal: auth.principal, access_token: auth.access_token, request: request, transport: :http)
45
56
  response = Handler.new(server).call(message)
46
57
  return [ 202, {}, [] ] if response.nil?
47
58
 
@@ -94,13 +105,26 @@ module OmniAgent
94
105
  http_error(403, "Origin not allowed")
95
106
  end
96
107
 
97
- def method_not_allowed
98
- [ 405, JSON_HEADERS.merge("allow" => "POST"), [ JSON.generate({ error: "Method not allowed" }) ] ]
108
+ def method_not_allowed(allow = "POST")
109
+ [ 405, JSON_HEADERS.merge("allow" => allow), [ JSON.generate({ error: "Method not allowed" }) ] ]
110
+ end
111
+
112
+ def unauthorized(server_class, resource_server, auth)
113
+ realm = server_class.server_name
114
+ challenge = resource_server ? resource_server.challenge(realm: realm, error: auth.error) : %(Bearer realm="#{realm}")
115
+ status, message = auth.error == :insufficient_scope ? [ 403, "Insufficient scope" ] : [ 401, "Unauthorized" ]
116
+
117
+ [ status, JSON_HEADERS.merge("www-authenticate" => challenge), [ JSON.generate({ error: message }) ] ]
99
118
  end
100
119
 
101
- def unauthorized(server_class)
102
- headers = JSON_HEADERS.merge("www-authenticate" => %(Bearer realm="#{server_class.server_name}"))
103
- [ 401, headers, [ JSON.generate({ error: "Unauthorized" }) ] ]
120
+ # RFC 9728 Protected Resource Metadata. Browser-based clients fetch it
121
+ # cross-origin, and it holds nothing secret, so any origin may read it.
122
+ def protected_resource_metadata(server_class, resource_server, request)
123
+ return [ 204, METADATA_CORS_HEADERS.dup, [] ] if request.options?
124
+ return method_not_allowed("GET") unless request.get? || request.head?
125
+
126
+ metadata = resource_server.metadata(resource_name: server_class.server_name)
127
+ [ 200, JSON_HEADERS.merge(METADATA_CORS_HEADERS), [ JSON.generate(metadata) ] ]
104
128
  end
105
129
  end
106
130
  end
@@ -50,8 +50,17 @@ module OmniAgent
50
50
  add_tool_declaration(type: :tool, tool_class: tool_class, name: as.to_s)
51
51
  end
52
52
 
53
- def authenticate(strategy = nil, tokens: nil, &block)
54
- @authentication = if block
53
+ def authenticate(strategy = nil, tokens: nil, **oauth_options, &block)
54
+ if strategy != :oauth && oauth_options.any?
55
+ raise ArgumentError, "unknown keywords for authenticate: #{oauth_options.keys.map(&:inspect).join(', ')}"
56
+ end
57
+
58
+ @authentication = if strategy == :oauth
59
+ raise ArgumentError, "authenticate :oauth does not take tokens:" unless tokens.nil?
60
+
61
+ # The block, if any, maps the validated access token to the principal.
62
+ { strategy: :oauth, resource_server: OAuth::ResourceServer.new(**oauth_options, &block) }
63
+ elsif block
55
64
  { strategy: :custom, block: block }
56
65
  elsif strategy == :none
57
66
  { strategy: :none }
@@ -60,7 +69,7 @@ module OmniAgent
60
69
 
61
70
  { strategy: :bearer, tokens: tokens }
62
71
  else
63
- raise ArgumentError, "authenticate expects :bearer (with tokens:), :none, or a block"
72
+ raise ArgumentError, "authenticate expects :bearer (with tokens:), :oauth, :none, or a block"
64
73
  end
65
74
  end
66
75
 
@@ -93,6 +102,11 @@ module OmniAgent
93
102
  def configured_stdio_principal_block; setting(:@stdio_principal_block); end
94
103
  def configured_allowed_origins; list_setting(:@allowed_origins); end
95
104
 
105
+ def configured_oauth_resource_server
106
+ authentication = configured_authentication
107
+ authentication[:resource_server] if authentication && authentication[:strategy] == :oauth
108
+ end
109
+
96
110
  # Ordered { "ToolName" => tool_class }: for each server in the
97
111
  # inheritance chain, its <Server>::Tools namespace first, then its
98
112
  # declared tools. Resolved on every call so lazily referenced tools
@@ -203,12 +217,14 @@ module OmniAgent
203
217
  end
204
218
  end
205
219
 
206
- attr_reader :principal, :request, :transport
220
+ # access_token is the OAuth::AccessToken for `authenticate :oauth`, nil otherwise.
221
+ attr_reader :principal, :request, :transport, :access_token
207
222
 
208
- def initialize(principal: nil, request: nil, transport: :http)
223
+ def initialize(principal: nil, request: nil, transport: :http, access_token: nil)
209
224
  @principal = principal
210
225
  @request = request
211
226
  @transport = transport
227
+ @access_token = access_token
212
228
  end
213
229
 
214
230
  def visible_tools
@@ -227,6 +243,7 @@ module OmniAgent
227
243
 
228
244
  def tool_context
229
245
  base = { mcp_principal: principal, mcp_request: request }
246
+ base[:mcp_access_token] = access_token if access_token
230
247
  context_block = self.class.configured_context_block
231
248
  return base unless context_block
232
249
 
@@ -1,3 +1,3 @@
1
1
  module OmniAgent
2
- VERSION = "0.1.10"
2
+ VERSION = "0.1.11"
3
3
  end
data/lib/omni_agent.rb CHANGED
@@ -3,7 +3,10 @@ require "omni_agent/engine" if defined?(Rails)
3
3
  require "zeitwerk"
4
4
 
5
5
  loader = Zeitwerk::Loader.for_gem
6
- loader.inflector.inflect("openai" => "OpenAI", "cli" => "CLI", "mcp" => "MCP")
6
+ loader.inflector.inflect(
7
+ "openai" => "OpenAI", "cli" => "CLI", "mcp" => "MCP",
8
+ "oauth" => "OAuth", "jwt_verifier" => "JWTVerifier", "http_client" => "HTTPClient"
9
+ )
7
10
  loader.ignore(File.expand_path("generators", __dir__))
8
11
  loader.setup
9
12
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omni_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.10
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - ACR1209
@@ -85,6 +85,12 @@ files:
85
85
  - lib/omni_agent/mcp.rb
86
86
  - lib/omni_agent/mcp/authenticator.rb
87
87
  - lib/omni_agent/mcp/handler.rb
88
+ - lib/omni_agent/mcp/oauth.rb
89
+ - lib/omni_agent/mcp/oauth/access_token.rb
90
+ - lib/omni_agent/mcp/oauth/http_client.rb
91
+ - lib/omni_agent/mcp/oauth/introspection_verifier.rb
92
+ - lib/omni_agent/mcp/oauth/jwt_verifier.rb
93
+ - lib/omni_agent/mcp/oauth/resource_server.rb
88
94
  - lib/omni_agent/mcp/rack_app.rb
89
95
  - lib/omni_agent/mcp/request.rb
90
96
  - lib/omni_agent/mcp/routing.rb