From 939ed32d2bf985f08ba8be31cb121c5a727df8b8 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 5 Nov 2026 11:07:00 -0800 Subject: [PATCH] feat: add opt-in OAuth authentication for MCP --- docs/mcp.md | 51 ++- docs/mcp/oauth_smoke.py | 207 ++++++++++ docs/mcp/test_oauth_smoke.py | 175 ++++++++ pom.xml | 4 + .../jsonapi/api/request/RequestContext.java | 9 +- .../api/request/token/FixedTokenResolver.java | 4 +- .../request/token/HeaderTokenResolver.java | 4 +- .../request/token/PrincipalTokenResolver.java | 10 +- .../token/RequestAuthTokenResolver.java | 11 +- .../HeaderBasedAuthenticationMechanism.java | 102 ++++- .../api/security/McpOAuthMetadata.java | 135 ++++++ .../ProtectedResourceMetadataResource.java | 27 ++ .../challenge/impl/ErrorChallengeSender.java | 17 +- .../HeaderBasedSecurityConfiguration.java | 42 +- .../mcp/McpEndpointConfigSourceFactory.java | 53 ++- .../sgv2/jsonapi/api/v1/mcp/McpResource.java | 5 + .../sgv2/jsonapi/config/AuthConfig.java | 10 + .../sgv2/jsonapi/config/McpAuthConfig.java | 16 + src/main/resources/application.yaml | 4 +- .../token/PrincipalTokenResolverTest.java | 32 ++ ...eaderBasedAuthenticationMechanismTest.java | 251 ++++++++++++ .../api/security/McpOAuthMetadataTest.java | 215 ++++++++++ .../impl/ErrorChallengeSenderTest.java | 64 +++ .../HeaderBasedSecurityConfigurationTest.java | 75 ++++ .../McpEndpointConfigSourceFactoryTest.java | 70 ++++ .../api/v1/mcp/McpOAuthCustomPathTest.java | 31 ++ .../api/v1/mcp/McpOAuthDisabledTest.java | 60 +++ .../v1/mcp/McpOAuthFeatureDisabledTest.java | 42 ++ .../sgv2/jsonapi/api/v1/mcp/McpOAuthTest.java | 387 ++++++++++++++++++ .../api/v1/mcp/McpOAuthTestSupport.java | 131 ++++++ .../jsonapi/api/v1/mcp/McpResourceTest.java | 2 + .../api/v1/mcp/McpSetupErrorsTest.java | 2 + src/test/resources/mcp-oauth-private-key.pem | 28 ++ src/test/resources/mcp-oauth-public-key.pem | 9 + 34 files changed, 2236 insertions(+), 49 deletions(-) create mode 100644 docs/mcp/oauth_smoke.py create mode 100644 docs/mcp/test_oauth_smoke.py create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/security/McpOAuthMetadata.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/security/ProtectedResourceMetadataResource.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/config/McpAuthConfig.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/request/token/PrincipalTokenResolverTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanismTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/security/McpOAuthMetadataTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/security/challenge/impl/ErrorChallengeSenderTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/security/configuration/HeaderBasedSecurityConfigurationTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthCustomPathTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthDisabledTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthFeatureDisabledTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthTestSupport.java create mode 100644 src/test/resources/mcp-oauth-private-key.pem create mode 100644 src/test/resources/mcp-oauth-public-key.pem diff --git a/docs/mcp.md b/docs/mcp.md index a8aae2cc05..102fa568b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,13 +4,60 @@ The Data API exposes commands as MCP tools over Streamable HTTP. Use `tools/list ## Endpoint and requests -The default endpoint is `/v1/mcp`. It follows the HTTP and MCP root paths described in the [configuration guide](../CONFIGURATION.md#mcp-logging). Keep authentication paths aligned when changing the endpoint. +The default endpoint is `/v1/mcp`. It follows the HTTP and MCP root paths described in the [configuration guide](../CONFIGURATION.md#mcp-logging). The server derives the MCP authentication paths from that configuration. Tool execution is disabled by default. Enable it with `stargate.feature.flags.mcp=true`, or send `Feature-Flag-mcp` with value `true` when the server has no configured override. This flag does not grant database permissions. Current tests send protocol version `2026-07-28` and call tools without initialization or a session ID. Each request carries its own protocol metadata. [McpHttpTestSupport](../src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java) defines the tested headers and JSON-RPC envelope. -The current implementation uses the existing Data API `Token` header. A missing token returns HTTP 401 before tool execution. OAuth connection and migration instructions remain separate work in [#2643](https://github.com/stargate/data-api/issues/2643). These tests do not establish compatibility with every MCP client or protocol revision. +The existing Data API `Token` and `X-Cassandra-Token` headers remain supported on ordinary REST endpoints when header authentication is enabled. MCP retains that behavior until OAuth mode is enabled. Missing credentials return HTTP 401 before tool execution. These tests do not establish compatibility with every MCP client or protocol revision. + +## OAuth authentication + +OAuth mode is disabled by default. Enabling it makes the MCP transport accept only Bearer access tokens and leaves ordinary REST authentication unchanged. Configure the public MCP origin, authorization server, and JWT trust settings. For the default endpoint: + +```properties +stargate.auth.bearer.enabled=true +stargate.feature.flags.mcp=true +stargate.mcp.auth.public-base-url=https://api.example.com +stargate.mcp.auth.server-url=https://auth.example.com +mp.jwt.verify.issuer=https://auth.example.com +mp.jwt.verify.publickey.location=https://auth.example.com/.well-known/jwks.json +mp.jwt.verify.audiences=https://api.example.com/v1/mcp +``` + +Use the issuer and verification key published by your authorization server. A local public-key file can replace the JWKS URL. Bearer authentication requires an explicit issuer, verification key, and audience at startup. With MCP enabled, the audience must include the public MCP resource URL. + +The public base URL contains the scheme, host, and optional port. The server derives the MCP resource path from its HTTP and MCP root configuration. For example, HTTP root `/service` and MCP root `/tools` require audience `https://api.example.com/service/tools`. OAuth mode requires a dedicated MCP path beneath the HTTP root. Root-only configurations are rejected at startup because the current framework does not keep those transport routes within application authentication. Public discovery requires MCP to be enabled in server configuration. Its metadata is available without credentials at `/.well-known/oauth-protected-resource`, under the HTTP root when configured, and is absent when Bearer authentication or MCP is disabled. + +Unauthenticated MCP requests receive HTTP 401 with an absolute `WWW-Authenticate: Bearer resource_metadata="..."` URL. Send `Authorization: Bearer ` on every subsequent HTTP request, including initialization for older protocols and catalog requests. The validator checks the token before tool execution. Legacy MCP headers and invalid Bearer credentials cannot fall back to legacy authentication in OAuth mode. When OAuth mode is disabled, legacy MCP authentication keeps its existing precedence. An MCP access token alone does not authenticate an ordinary REST request. + +REST and MCP resolve the same authenticated credential. A JWT's encoded token is retained separately from its subject or client ID. Tool names, arguments, and result formats do not change. Optional `stargate.mcp.auth.scopes-supported` values describe scopes supported by the deployment; advertising them does not grant database permissions. + +This endpoint is an OAuth protected resource, not an authorization server. Browser login, client registration, organization/role selection, and token issuance remain outside Data API. The backend must support the agreed JWT credential contract. Successful initialization or tool discovery does not prove authorized database execution. + +Local verification used DSE 6.9.21's native OIDC authentication alongside internal password authentication. It required an OIDC issuer, the MCP resource audience, `allow_token_via_password_authentication=true`, and `token_username=token`. DSE mapped an issuer-signed claim to a restricted database role. The issuer derived that claim from validated organization membership and role selection. Internal password authentication alone cannot validate these JWT credentials. Each deployment needs matching issuer, audience, and database permission configuration. + +The compatibility tests inspect the HTTP-to-command boundary with a processor test double. Validate real permitted and denied database calls against the deployment's backend as well. Migration work remains tracked in [#2643](https://github.com/stargate/data-api/issues/2643). + +### Reusable OAuth smoke test + +With OAuth and MCP enabled, use an existing collection and a known document to test the database path. Run from the repository root with Python 3.10 or later. + +```sh +python3 docs/mcp/oauth_smoke.py \ + --endpoint https://api.example.com/v1/mcp \ + --keyspace demo_readonly --collection messages \ + --filter '{"id":"welcome"}' \ + --expect-document '{"id":"welcome","value":"hello"}' \ + --denied-keyspace demo_other_org +``` + +The [script](mcp/oauth_smoke.py) prompts for an access token without echoing it. Automation can supply `MCP_ACCESS_TOKEN` through the environment. Obtain the token from the deployment's authorization server for the exact MCP resource audience. The script consumes that token and does not perform browser login or token issuance. + +It checks the unauthenticated HTTP 401 challenge, public resource metadata, authenticated tool catalog, and exact `findOne` document. It prints the available tool names and exits nonzero on HTTP, protocol, application, or document mismatches. The optional `--denied-keyspace` repeats the read against another existing keyspace and requires `UNAUTHORIZED_ACCESS`. No write tools are called. The script uses the stateless protocol version described above and does not test older session-based clients. + +Run the script's focused tests with `python3 -B -m unittest discover -s docs/mcp -p 'test_*.py' -v`. ## Results and errors diff --git a/docs/mcp/oauth_smoke.py b/docs/mcp/oauth_smoke.py new file mode 100644 index 0000000000..8244d88665 --- /dev/null +++ b/docs/mcp/oauth_smoke.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Check a Data API OAuth resource and a database read with an existing token.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import getpass +import ipaddress +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +import warnings + +PROTOCOL = "2026-07-28" +MAX_BODY = 2 * 1024 * 1024 + + +class SmokeFailure(ValueError): + pass + + +def require(condition, message): + if not condition: + raise SmokeFailure(message) + + +def origin(url): + try: + parts = urllib.parse.urlsplit(url) + host, port = parts.hostname, parts.port + try: + loopback = ipaddress.ip_address(host or "").is_loopback + except ValueError: + loopback = host == "localhost" + require(host and (parts.scheme == "https" or parts.scheme == "http" and loopback) + and parts.username is None and parts.password is None + and not parts.query and not parts.fragment, + "URLs must use HTTPS or loopback HTTP without credentials, queries, or fragments") + return parts.scheme, host, port or (443 if parts.scheme == "https" else 80) + except ValueError as error: + if isinstance(error, SmokeFailure): + raise + raise SmokeFailure("Invalid URL") from None + + +def json_object(raw): + try: + value = json.loads(raw) + except (ValueError, UnicodeDecodeError): + raise SmokeFailure("Expected a JSON object") from None + require(isinstance(value, dict), "Expected a JSON object") + return value + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args): + return None + + +@dataclass +class Response: + status: int + headers: object + body: bytes + + def json(self): + return json_object(self.body) + + +class Client: + def __init__(self, endpoint): + origin(endpoint) + self.endpoint = endpoint + self.sequence = 0 + self.opener = urllib.request.build_opener(NoRedirect()) + + def request(self, url, data=None, headers=None): + origin(url) + request = urllib.request.Request(url, data=data, headers=headers or {}) + try: + response = self.opener.open(request, timeout=20) + except urllib.error.HTTPError as error: + response = error + with response: + body = response.read(MAX_BODY + 1) + require(len(body) <= MAX_BODY, "Response exceeds size limit") + return Response(response.code, response.headers, body) + + def rpc(self, method, params, token=None): + self.sequence += 1 + payload = {"jsonrpc": "2.0", "id": self.sequence, "method": method, + "params": {**params, "_meta": { + "io.modelcontextprotocol/protocolVersion": PROTOCOL, + "io.modelcontextprotocol/clientCapabilities": {}}}} + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": PROTOCOL, "Mcp-Method": method} + if method == "tools/call": + headers["Mcp-Name"] = params["name"] + if token: + headers["Authorization"] = "Bearer " + token + return self.request(self.endpoint, json.dumps(payload).encode(), headers) + + def result(self, method, params, token): + response = self.rpc(method, params, token) + require(response.status == 200, f"{method} returned HTTP {response.status}") + body = response.json() + require(body.get("jsonrpc") == "2.0" and body.get("id") == self.sequence, + "JSON-RPC response did not match its request") + require("error" not in body and isinstance(body.get("result"), dict), + "MCP returned a protocol error or no result") + return body["result"] + + +def smoke_test(endpoint, token, keyspace, collection, filter_value, expected_document, + denied_keyspace=None): + require(isinstance(token, str) and bool(token) and not any(c.isspace() for c in token), + "A nonempty access token without whitespace is required") + require(isinstance(filter_value, dict) and isinstance(expected_document, dict), + "The filter and expected document must be JSON objects") + client = Client(endpoint) + challenge = client.rpc("tools/list", {}) + require(challenge.status == 401, f"Unauthenticated MCP returned HTTP {challenge.status}, expected 401") + header = challenge.headers.get("WWW-Authenticate", "") + metadata = re.search(r'\bresource_metadata="([^"]+)"', header) + require(header.lower().startswith("bearer ") and metadata is not None, + "The 401 challenge is missing Bearer resource metadata") + metadata_url = metadata.group(1) + require(origin(metadata_url) == origin(endpoint), "Resource metadata must use the MCP origin") + response = client.request(metadata_url) + require(response.status == 200, f"Resource metadata returned HTTP {response.status}") + document = response.json() + servers = document.get("authorization_servers") + require(document.get("resource") == endpoint and isinstance(servers, list) and bool(servers) + and all(isinstance(server, str) for server in servers) + and isinstance(document.get("bearer_methods_supported"), list) + and "header" in document["bearer_methods_supported"], "Resource metadata is inconsistent") + for server in servers: + origin(server) + print("PASS Unauthenticated challenge and protected-resource metadata") + + catalog = client.result("tools/list", {}, token) + tools = catalog.get("tools") + require(isinstance(tools, list) and bool(tools) + and all(isinstance(tool, dict) and isinstance(tool.get("name"), str) + and bool(tool["name"]) for tool in tools), "Invalid tool catalog") + names = [tool["name"] for tool in tools] + require(len(names) == len(set(names)) and "findOne" in names and not catalog.get("nextCursor"), + "Expected a complete, unique catalog containing findOne") + print(f"PASS Authenticated catalog ({len(names)} tools): " + + json.dumps(names).replace(token, "[REDACTED]")) + + arguments = {"keyspace": keyspace, "collection": collection, "filter": filter_value} + result = client.result("tools/call", {"name": "findOne", "arguments": arguments}, token) + content = result.get("structuredContent") + require(result.get("isError", False) is False and isinstance(content, dict) + and not content.get("errors") and content.get("document") == expected_document, + "Database read did not return the expected document without application errors") + print("PASS Database read returned the exact expected document") + if denied_keyspace is not None: + result = client.result("tools/call", {"name": "findOne", "arguments": { + **arguments, "keyspace": denied_keyspace}}, token) + content = result.get("structuredContent") + require(result.get("isError") is True and isinstance(content, dict) + and isinstance(content.get("errors"), list) and len(content["errors"]) == 1 + and isinstance(content["errors"][0], dict) + and content["errors"][0].get("errorCode") == "UNAUTHORIZED_ACCESS", + "Denied read did not return UNAUTHORIZED_ACCESS") + print("PASS Other keyspace read was denied with UNAUTHORIZED_ACCESS") + return names + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("endpoint", "keyspace", "collection"): + parser.add_argument("--" + name, required=True) + + def object_argument(value): + try: + return json_object(value) + except SmokeFailure as error: + raise argparse.ArgumentTypeError(str(error)) from None + + parser.add_argument("--filter", required=True, type=object_argument, dest="filter_value") + parser.add_argument("--expect-document", required=True, type=object_argument, dest="expected_document") + parser.add_argument("--denied-keyspace") + args = parser.parse_args() + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", getpass.GetPassWarning) + token = os.environ.get("MCP_ACCESS_TOKEN") or getpass.getpass("OAuth access token: ") + smoke_test(token=token, **vars(args)) + except SmokeFailure as error: + print("FAIL " + str(error), file=sys.stderr) + return 1 + except (OSError, ValueError, EOFError, getpass.GetPassWarning): + print("FAIL Request or token input failed", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/mcp/test_oauth_smoke.py b/docs/mcp/test_oauth_smoke.py new file mode 100644 index 0000000000..68f766860a --- /dev/null +++ b/docs/mcp/test_oauth_smoke.py @@ -0,0 +1,175 @@ +"""Run with python3 -B -m unittest discover -s docs/mcp -p 'test_*.py'.""" + +from contextlib import redirect_stderr, redirect_stdout +from http.server import BaseHTTPRequestHandler, HTTPServer +from io import StringIO +import json +import os +import sys +import threading +import unittest +from unittest.mock import patch +import warnings + +import oauth_smoke as smoke + + +class OAuthSmokeTest(unittest.TestCase): + def setUp(self): + self.requests = [] + self.mutation = lambda method, body: body + self.redirect = False + owner = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def send(self, status, body, **headers): + raw = json.dumps(body).encode() + self.send_response(status) + for key, value in headers.items(): + self.send_header(key.replace("_", "-"), value) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + owner.requests.append((self.path, dict(self.headers), None)) + body = {"resource": owner.endpoint, "authorization_servers": [owner.base], + "bearer_methods_supported": ["header"]} + self.send(200, owner.mutation("metadata", body)) + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + owner.requests.append((self.path, dict(self.headers), body)) + if "Authorization" not in self.headers: + self.send(401, {}, WWW_Authenticate=f'Bearer resource_metadata="{owner.base}/metadata"') + return + if owner.redirect: + self.send(302, {}, Location=owner.base + "/redirected") + return + if body["method"] == "tools/list": + result = {"tools": [{"name": "findOne"}, {"name": "findKeyspaces"}]} + elif body["params"]["arguments"]["keyspace"] == "denied": + result = {"isError": True, "structuredContent": { + "errors": [{"errorCode": "UNAUTHORIZED_ACCESS"}]}} + else: + result = {"isError": False, "structuredContent": {"document": {"id": "hello"}}} + response = {"jsonrpc": "2.0", "id": body["id"], "result": result} + self.send(200, owner.mutation(body["method"], response)) + + self.server = HTTPServer(("127.0.0.1", 0), Handler) + self.base = "http://127.0.0.1:" + str(self.server.server_port) + self.endpoint = self.base + "/v1/mcp" + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.addCleanup(self.server.server_close) + self.addCleanup(self.thread.join) + self.addCleanup(self.server.shutdown) + + def run_smoke(self, **overrides): + arguments = {"endpoint": self.endpoint, "token": "test-token", "keyspace": "allowed", + "collection": "messages", "filter_value": {"id": "hello"}, + "expected_document": {"id": "hello"}} + with redirect_stdout(StringIO()): + return smoke.smoke_test(**{**arguments, **overrides}) + + def test_read_and_denied_read_use_bearer_only_on_mcp(self): + self.assertEqual(self.run_smoke(denied_keyspace="denied"), ["findOne", "findKeyspaces"]) + self.assertEqual(len(self.requests), 5) + for path, headers, body in self.requests: + self.assertNotIn("Token", headers) + self.assertNotIn("X-Cassandra-Token", headers) + if body is None or body["id"] == 1: + self.assertNotIn("Authorization", headers) + else: + self.assertEqual(headers["Authorization"], "Bearer test-token") + self.assertEqual(path, "/v1/mcp") + self.assertEqual(headers["Mcp-Protocol-Version"], smoke.PROTOCOL) + self.assertEqual(body["params"]["_meta"], { + "io.modelcontextprotocol/protocolVersion": smoke.PROTOCOL, + "io.modelcontextprotocol/clientCapabilities": {}}) + self.assertEqual(self.requests[-1][2]["params"]["arguments"], { + "keyspace": "denied", "collection": "messages", "filter": {"id": "hello"}}) + + def test_wrong_document_fails(self): + with self.assertRaisesRegex(smoke.SmokeFailure, "expected document"): + self.run_smoke(expected_document={"id": "different"}) + + def test_setup_error_does_not_count_as_permission_denial(self): + def mutation(method, body): + if method == "tools/call" and body["result"].get("isError"): + body["result"]["structuredContent"]["errors"][0]["errorCode"] = "UNAUTHENTICATED_REQUEST" + return body + self.mutation = mutation + with self.assertRaisesRegex(smoke.SmokeFailure, "UNAUTHORIZED_ACCESS"): + self.run_smoke(denied_keyspace="denied") + + def test_application_error_cannot_pass_with_matching_document(self): + def mutation(method, body): + if method == "tools/call": + body["result"]["structuredContent"]["errors"] = [{"errorCode": "INVALID_QUERY"}] + return body + self.mutation = mutation + with self.assertRaisesRegex(smoke.SmokeFailure, "application errors"): + self.run_smoke() + + def test_rpc_id_must_match(self): + def mutation(method, body): + if method == "tools/list": + body["id"] += 1 + return body + self.mutation = mutation + with self.assertRaisesRegex(smoke.SmokeFailure, "did not match"): + self.run_smoke() + + def test_resource_must_match_endpoint(self): + def mutation(method, body): + if method == "metadata": + body["resource"] += "/other" + return body + self.mutation = mutation + with self.assertRaisesRegex(smoke.SmokeFailure, "metadata is inconsistent"): + self.run_smoke() + self.assertEqual(len(self.requests), 2) + + def test_authenticated_redirect_is_not_followed(self): + self.redirect = True + with self.assertRaisesRegex(smoke.SmokeFailure, "HTTP 302"): + self.run_smoke() + self.assertEqual([path for path, _, _ in self.requests], ["/v1/mcp", "/metadata", "/v1/mcp"]) + + def test_cross_origin_metadata_is_not_requested(self): + with patch.object(smoke.Client, "rpc", return_value=smoke.Response(401, { + "WWW-Authenticate": 'Bearer resource_metadata="https://other.example/metadata"'}, b"")): + with self.assertRaisesRegex(smoke.SmokeFailure, "MCP origin"): + self.run_smoke() + self.assertEqual(self.requests, []) + + def test_unsafe_urls_fail_before_network(self): + for endpoint in ("http://api.example/v1/mcp", "https://user:pass@api.example/v1/mcp", + "https://api.example/v1/mcp?token=secret", "https://api.example/#fragment"): + with self.subTest(endpoint=endpoint), self.assertRaises(smoke.SmokeFailure): + self.run_smoke(endpoint=endpoint) + self.assertEqual(self.requests, []) + + def test_prompt_fails_closed_when_hidden_input_is_unavailable(self): + arguments = ["oauth_smoke.py", "--endpoint", self.endpoint, "--keyspace", "allowed", + "--collection", "messages", "--filter", '{}', "--expect-document", '{}'] + def unavailable_prompt(*args): + warnings.warn("secret input must not be echoed", smoke.getpass.GetPassWarning) + self.fail("The prompt continued after its hidden-input warning") + output, error = StringIO(), StringIO() + with patch.object(sys, "argv", arguments), patch.dict(os.environ, {"MCP_ACCESS_TOKEN": ""}), \ + patch.object(smoke.getpass, "getpass", side_effect=unavailable_prompt), \ + patch.object(smoke, "smoke_test") as run, redirect_stdout(output), redirect_stderr(error): + self.assertEqual(smoke.main(), 1) + run.assert_not_called() + self.assertEqual(output.getvalue(), "") + self.assertEqual(error.getvalue(), "FAIL Request or token input failed\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/pom.xml b/pom.xml index 9660c4c763..9ce97ad61a 100644 --- a/pom.xml +++ b/pom.xml @@ -130,6 +130,10 @@ io.quarkus quarkus-security + + io.quarkus + quarkus-smallrye-jwt + io.quarkus quarkus-grpc diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/request/RequestContext.java b/src/main/java/io/stargate/sgv2/jsonapi/api/request/RequestContext.java index e0c770d6b4..a4535803eb 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/request/RequestContext.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/request/RequestContext.java @@ -82,16 +82,14 @@ public RequestContext( RoutingContext routingContext, SecurityIdentity securityIdentity, Instance tenantResolver, + Instance tokenResolver, Instance embeddingCredentialsResolver) { this.httpHeaders = new HttpHeaderAccess(routingContext.request().headers()); - // Token from SecurityIdentity principal (same as PrincipalTokenResolver logic) this.authToken = normalizeOptionalString( - securityIdentity.getPrincipal() != null - ? securityIdentity.getPrincipal().getName() - : null); + tokenResolver.get().resolve(routingContext, securityIdentity.getPrincipal())); this.requestId = getOrCreateRequestId(routingContext); this.userAgent = new UserAgent(httpHeaders.getHeader(HttpHeaders.USER_AGENT)); this.tenant = tenantResolver.get().resolve(routingContext, null); @@ -116,7 +114,8 @@ public RequestContext( this.httpHeaders = new HttpHeaderAccess(routingContext.request().headers()); this.authToken = - normalizeOptionalString(tokenResolver.get().resolve(routingContext, securityContext)); + normalizeOptionalString( + tokenResolver.get().resolve(routingContext, securityContext.getUserPrincipal())); this.requestId = generateRequestId(); this.userAgent = new UserAgent(httpHeaders.getHeader(HttpHeaders.USER_AGENT)); this.tenant = tenantResolver.get().resolve(routingContext, securityContext); diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/FixedTokenResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/FixedTokenResolver.java index 9dbed81aba..720f4511d5 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/FixedTokenResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/FixedTokenResolver.java @@ -18,7 +18,7 @@ package io.stargate.sgv2.jsonapi.api.request.token; import io.vertx.ext.web.RoutingContext; -import jakarta.ws.rs.core.SecurityContext; +import java.security.Principal; /** The {@link RequestAuthTokenResolver} that uses a fixed token supplied by the configuration. */ public class FixedTokenResolver implements RequestAuthTokenResolver { @@ -31,7 +31,7 @@ public FixedTokenResolver(String fixedToken) { /** {@inheritDoc} */ @Override - public String resolve(RoutingContext context, SecurityContext securityContext) { + public String resolve(RoutingContext context, Principal principal) { return fixedToken; } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/HeaderTokenResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/HeaderTokenResolver.java index 99b285c2cb..2940ffa57e 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/HeaderTokenResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/HeaderTokenResolver.java @@ -18,7 +18,7 @@ package io.stargate.sgv2.jsonapi.api.request.token; import io.vertx.ext.web.RoutingContext; -import jakarta.ws.rs.core.SecurityContext; +import java.security.Principal; /** The {@link RequestAuthTokenResolver} that resolves a token from the HTTP header. */ public class HeaderTokenResolver implements RequestAuthTokenResolver { @@ -32,7 +32,7 @@ public HeaderTokenResolver(String headerName) { /** {@inheritDoc} */ @Override - public String resolve(RoutingContext context, SecurityContext securityContext) { + public String resolve(RoutingContext context, Principal principal) { return context.request().getHeader(headerName); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/PrincipalTokenResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/PrincipalTokenResolver.java index e6112812f1..1f303c1219 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/PrincipalTokenResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/PrincipalTokenResolver.java @@ -18,16 +18,18 @@ package io.stargate.sgv2.jsonapi.api.request.token; import io.vertx.ext.web.RoutingContext; -import jakarta.ws.rs.core.SecurityContext; import java.security.Principal; +import org.eclipse.microprofile.jwt.JsonWebToken; -/** {@link RequestAuthTokenResolver} that reads the token from the {@link Principal#getName()}. */ +/** Resolves validated JWT credentials or the legacy token stored as the principal name. */ public class PrincipalTokenResolver implements RequestAuthTokenResolver { /** {@inheritDoc} */ @Override - public String resolve(RoutingContext context, SecurityContext securityContext) { - var principal = securityContext.getUserPrincipal(); + public String resolve(RoutingContext context, Principal principal) { + if (principal instanceof JsonWebToken jwt) { + return jwt.getRawToken(); + } return principal != null ? principal.getName() : null; } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/RequestAuthTokenResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/RequestAuthTokenResolver.java index 082639d4d9..ea60fa3da4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/RequestAuthTokenResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/request/token/RequestAuthTokenResolver.java @@ -18,23 +18,24 @@ package io.stargate.sgv2.jsonapi.api.request.token; import io.vertx.ext.web.RoutingContext; -import jakarta.ws.rs.core.SecurityContext; +import java.security.Principal; /** * Resolver of the Cassandra token. * *

The implementation can use any information from the {@link RoutingContext} or {@link - * SecurityContext} to obtain the token. + * Principal} to obtain the token. */ @FunctionalInterface public interface RequestAuthTokenResolver { /** - * Returns a Cassandra token given a {@link RoutingContext} and a {@link SecurityContext}. + * Returns a Cassandra token given a {@link RoutingContext} and its authenticated {@link + * Principal}. * * @param context the routing context - * @param securityContext the security context + * @param principal the authenticated principal * @return The Cassandra token. If empty, indicates that no token should be used. */ - String resolve(RoutingContext context, SecurityContext securityContext); + String resolve(RoutingContext context, Principal principal); } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanism.java b/src/main/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanism.java index 8d90af5118..392935f3e1 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanism.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanism.java @@ -20,25 +20,28 @@ import static io.stargate.sgv2.jsonapi.config.constants.HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME; import static io.stargate.sgv2.jsonapi.config.constants.HttpConstants.DEPRECATED_AUTHENTICATION_TOKEN_HEADER_NAME; +import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.smallrye.jwt.runtime.auth.JsonWebTokenCredential; import io.quarkus.vertx.http.runtime.security.ChallengeData; import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport; import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; import io.smallrye.mutiny.Uni; import io.stargate.sgv2.jsonapi.api.security.challenge.impl.ErrorChallengeSender; +import io.stargate.sgv2.jsonapi.config.AuthConfig; import io.vertx.ext.web.RoutingContext; import jakarta.enterprise.inject.Instance; +import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; -import java.util.Collections; import java.util.Optional; import java.util.Set; /** - * Implementation of the {@link HttpAuthenticationMechanism} that authenticates on a header. If a - * target header exists, request is considered as authenticated. + * Coordinates OAuth-only MCP requests and the existing Data API token headers. * * @see HeaderIdentityProvider */ @@ -47,19 +50,75 @@ public class HeaderBasedAuthenticationMechanism implements HttpAuthenticationMec /** The name of the header to be used for the authentication. */ private final String headerName; + private final boolean headerEnabled; + private final boolean bearerEnabled; + private final McpOAuthMetadata metadata; + /** Customize the challenge. */ private final Instance customChallengeSender; public HeaderBasedAuthenticationMechanism( - String headerName, Instance customChallengeSender) { - this.headerName = headerName; + AuthConfig config, + Instance customChallengeSender, + McpOAuthMetadata metadata) { + this.headerName = config.headerBased().headerName(); + this.headerEnabled = config.headerBased().enabled(); + this.bearerEnabled = config.bearer().enabled(); this.customChallengeSender = customChallengeSender; + this.metadata = metadata; } - /** Validate Header existence */ @Override public Uni authenticate( RoutingContext context, IdentityProviderManager identityProviderManager) { + return authenticateCredentials(context, identityProviderManager) + .onFailure(AuthenticationFailedException.class) + .invoke( + failure -> + // The MCP transport writes authentication failures without requesting a challenge. + metadata + .challenge(context) + .ifPresent( + challenge -> + context.response().putHeader(HttpHeaders.WWW_AUTHENTICATE, challenge))); + } + + private Uni authenticateCredentials( + RoutingContext context, IdentityProviderManager identityProviderManager) { + String authorization = context.request().getHeader(HttpHeaders.AUTHORIZATION); + if (bearerEnabled && metadata.isMcpRequest(context)) { + context.put(HttpAuthenticationMechanism.class.getName(), this); + if (authorization == null + || !authorization.regionMatches(true, 0, "Bearer ", 0, 7) + || authorization.substring(7).isBlank()) { + return Uni.createFrom().failure(new AuthenticationFailedException()); + } + var request = + new TokenAuthenticationRequest( + new JsonWebTokenCredential(authorization.substring(7).trim())); + HttpSecurityUtils.setRoutingContextAttribute(request, context); + return identityProviderManager.authenticate(request); + } + + if (headerEnabled) { + String headerValue = legacyHeader(context); + if (headerValue != null) { + context.put(HttpAuthenticationMechanism.class.getName(), this); + var request = new HeaderAuthenticationRequest(headerName, headerValue); + HttpSecurityUtils.setRoutingContextAttribute(request, context); + return identityProviderManager.authenticate(request); + } + } + + // Do not let the lower-priority JWT mechanism enable Bearer authentication implicitly. + if (authorization != null) { + context.put(HttpAuthenticationMechanism.class.getName(), this); + return Uni.createFrom().failure(new AuthenticationFailedException()); + } + return Uni.createFrom().optional(Optional.empty()); + } + + private String legacyHeader(RoutingContext context) { // Data API Authentication header key: Token String headerValue = context.request().getHeader(headerName); // Data API Authentication header key: token @@ -70,21 +129,30 @@ public Uni authenticate( if (null == headerValue) { headerValue = context.request().getHeader(DEPRECATED_AUTHENTICATION_TOKEN_HEADER_NAME); } - if (null != headerValue) { - HeaderAuthenticationRequest request = - new HeaderAuthenticationRequest(headerName, headerValue); - HttpSecurityUtils.setRoutingContextAttribute(request, context); - return identityProviderManager.authenticate(request); - } - // No suitable header has been found in this requests - return Uni.createFrom().optional(Optional.empty()); + return headerValue; + } + + @Override + public int getPriority() { + // Keep the feature flag ahead of the built-in JWT mechanism. + return 3000; } /** {@inheritDoc} */ @Override public Uni getChallenge(RoutingContext context) { - return Uni.createFrom() - .item(new ChallengeData(Response.Status.UNAUTHORIZED.getStatusCode(), null, null)); + var challenge = + metadata + .challenge(context) + .map( + value -> + new ChallengeData( + Response.Status.UNAUTHORIZED.getStatusCode(), + HttpHeaders.WWW_AUTHENTICATE, + value)) + .orElseGet( + () -> new ChallengeData(Response.Status.UNAUTHORIZED.getStatusCode(), null, null)); + return Uni.createFrom().item(challenge); } /** @@ -104,7 +172,7 @@ public Uni sendChallenge(RoutingContext context) { /** {@inheritDoc} */ @Override public Set> getCredentialTypes() { - return Collections.singleton(HeaderAuthenticationRequest.class); + return Set.of(HeaderAuthenticationRequest.class, TokenAuthenticationRequest.class); } /** {@inheritDoc} */ diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/security/McpOAuthMetadata.java b/src/main/java/io/stargate/sgv2/jsonapi/api/security/McpOAuthMetadata.java new file mode 100644 index 0000000000..9c6d7973c3 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/security/McpOAuthMetadata.java @@ -0,0 +1,135 @@ +package io.stargate.sgv2.jsonapi.api.security; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.runtime.Startup; +import io.smallrye.jwt.auth.principal.JWTAuthContextInfo; +import io.stargate.sgv2.jsonapi.api.v1.mcp.McpEndpointConfigSourceFactory; +import io.stargate.sgv2.jsonapi.config.AuthConfig; +import io.stargate.sgv2.jsonapi.config.McpAuthConfig; +import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; +import io.stargate.sgv2.jsonapi.config.feature.ApiFeatures; +import io.stargate.sgv2.jsonapi.config.feature.FeaturesConfig; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.net.URI; +import java.util.List; +import java.util.Optional; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** Keeps discovery and authentication challenges on the same configured public resource. */ +@Startup +@ApplicationScoped +public class McpOAuthMetadata { + private final McpEndpointConfigSourceFactory.TransportPaths transportPaths; + private final Optional document; + private final Optional challenge; + + @Inject + public McpOAuthMetadata( + AuthConfig authConfig, + FeaturesConfig featuresConfig, + McpAuthConfig config, + @ConfigProperty(name = McpEndpointConfigSourceFactory.ENDPOINT_PATH) String endpointPath, + @ConfigProperty(name = McpEndpointConfigSourceFactory.METADATA_PATH) String metadataPath, + JWTAuthContextInfo jwtConfig) { + this.transportPaths = McpEndpointConfigSourceFactory.transportPaths(endpointPath); + boolean bearerEnabled = authConfig.bearer().enabled(); + String httpRoot = + metadataPath.substring( + 0, metadataPath.length() - ProtectedResourceMetadataResource.PATH.length()); + if (bearerEnabled + && (endpointPath.equals(httpRoot.isEmpty() ? "/" : httpRoot) + || !endpointPath.startsWith(httpRoot + "/"))) { + // SDK 2.0.3 registers root endpoints on a different router from their auth permissions. + throw new IllegalArgumentException( + "OAuth requires quarkus.mcp.server.http.root-path to use a dedicated path below the HTTP root"); + } + if (!bearerEnabled + || !ApiFeatures.fromConfigAndRequest(featuresConfig, null) + .isFeatureEnabled(ApiFeature.MCP)) { + document = Optional.empty(); + challenge = Optional.empty(); + return; + } + + URI publicBase = + config + .publicBaseUrl() + .orElseThrow( + () -> + new IllegalArgumentException("stargate.mcp.auth.public-base-url is required")); + validateUrl(publicBase, "stargate.mcp.auth.public-base-url", true); + URI authorizationServer = + config + .serverUrl() + .orElseThrow( + () -> new IllegalArgumentException("stargate.mcp.auth.server-url is required")); + validateUrl(authorizationServer, "stargate.mcp.auth.server-url", false); + URI resource = publicBase.resolve(endpointPath); + if (jwtConfig.getExpectedAudience() == null + || !jwtConfig.getExpectedAudience().contains(resource.toString())) { + throw new IllegalArgumentException( + "mp.jwt.verify.audiences must include the configured public MCP resource"); + } + document = + Optional.of( + new ProtectedResourceMetadata( + resource, + List.of(authorizationServer), + List.of("header"), + List.copyOf(config.scopesSupported().orElseGet(List::of)))); + challenge = + Optional.of( + "Bearer resource_metadata=\"" + publicBase.resolve(metadataPath).toString() + "\""); + } + + public Optional document() { + return document; + } + + public boolean isMcpRequest(RoutingContext context) { + return transportPaths.matches(context.normalizedPath()); + } + + public Optional challenge(RoutingContext context) { + if (challenge.isEmpty()) { + return Optional.empty(); + } + return isMcpRequest(context) ? challenge : Optional.empty(); + } + + private static void validateUrl(URI uri, String property, boolean originOnly) { + String scheme = uri.getScheme(); + String host = uri.getHost(); + boolean loopback = + "localhost".equalsIgnoreCase(host) + || "127.0.0.1".equals(host) + || "::1".equals(host) + || "[::1]".equals(host); + boolean secure = + "https".equalsIgnoreCase(scheme) || ("http".equalsIgnoreCase(scheme) && loopback); + if (!secure + || host == null + || uri.getUserInfo() != null + || uri.getRawQuery() != null + || uri.getRawFragment() != null + || (originOnly + && uri.getRawPath() != null + && !uri.getRawPath().isEmpty() + && !uri.getRawPath().equals("/"))) { + throw new IllegalArgumentException( + property + + " must be an HTTPS URL or loopback HTTP URL" + + (originOnly ? " without a path" : "")); + } + } + + public record ProtectedResourceMetadata( + URI resource, + @JsonProperty("authorization_servers") List authorizationServers, + @JsonProperty("bearer_methods_supported") List bearerMethodsSupported, + @JsonProperty("scopes_supported") @JsonInclude(JsonInclude.Include.NON_EMPTY) + List scopesSupported) {} +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/security/ProtectedResourceMetadataResource.java b/src/main/java/io/stargate/sgv2/jsonapi/api/security/ProtectedResourceMetadataResource.java new file mode 100644 index 0000000000..3861a8ce31 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/security/ProtectedResourceMetadataResource.java @@ -0,0 +1,27 @@ +package io.stargate.sgv2.jsonapi.api.security; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +/** Publishes the authorization server for the configured MCP resource. */ +@Path(ProtectedResourceMetadataResource.PATH) +public class ProtectedResourceMetadataResource { + public static final String PATH = "/.well-known/oauth-protected-resource"; + + private final McpOAuthMetadata metadata; + + @Inject + public ProtectedResourceMetadataResource(McpOAuthMetadata metadata) { + this.metadata = metadata; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public McpOAuthMetadata.ProtectedResourceMetadata get() { + return metadata.document().orElseThrow(NotFoundException::new); + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/security/challenge/impl/ErrorChallengeSender.java b/src/main/java/io/stargate/sgv2/jsonapi/api/security/challenge/impl/ErrorChallengeSender.java index 76475a3ae3..c5e471ac74 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/security/challenge/impl/ErrorChallengeSender.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/security/challenge/impl/ErrorChallengeSender.java @@ -32,24 +32,35 @@ public class ErrorChallengeSender /** Object mapper for custom response. */ private final ObjectMapper objectMapper; - /** Result is always constant */ - private final CommandResult commandResult; + private final CommandResult missingTokenResult; + private final CommandResult invalidTokenResult; @Inject public ErrorChallengeSender(ObjectMapper objectMapper) { this.objectMapper = objectMapper; - commandResult = + missingTokenResult = CommandResult.statusOnlyBuilder(RequestTracing.NO_OP) .addThrowable( APISecurityException.Code.MISSING_AUTHENTICATION_TOKEN.get( "authHeader", HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME)) .build(); + invalidTokenResult = + CommandResult.statusOnlyBuilder(RequestTracing.NO_OP) + .addThrowable(APISecurityException.Code.UNAUTHENTICATED_REQUEST.get()) + .build(); } /** {@inheritDoc} */ @Override public Uni apply(RoutingContext context, ChallengeData challengeData) { + var commandResult = + context.request().getHeader(HttpHeaders.AUTHORIZATION) == null + ? missingTokenResult + : invalidTokenResult; + challengeData + .getHeaders() + .forEach((name, value) -> context.response().headers().set(name, value)); try { // try to serialize String response = objectMapper.writeValueAsString(commandResult); diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/security/configuration/HeaderBasedSecurityConfiguration.java b/src/main/java/io/stargate/sgv2/jsonapi/api/security/configuration/HeaderBasedSecurityConfiguration.java index b72c75a02e..8c240a5a23 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/security/configuration/HeaderBasedSecurityConfiguration.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/security/configuration/HeaderBasedSecurityConfiguration.java @@ -18,27 +18,61 @@ package io.stargate.sgv2.jsonapi.api.security.configuration; import io.quarkus.arc.lookup.LookupIfProperty; +import io.quarkus.runtime.Startup; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.smallrye.jwt.auth.principal.JWTAuthContextInfo; import io.stargate.sgv2.jsonapi.api.security.HeaderAuthenticationRequest; import io.stargate.sgv2.jsonapi.api.security.HeaderBasedAuthenticationMechanism; import io.stargate.sgv2.jsonapi.api.security.HeaderIdentityProvider; +import io.stargate.sgv2.jsonapi.api.security.McpOAuthMetadata; import io.stargate.sgv2.jsonapi.api.security.challenge.impl.ErrorChallengeSender; import io.stargate.sgv2.jsonapi.config.AuthConfig; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; +import java.util.ArrayList; /** The configuration for the header based security. */ public class HeaderBasedSecurityConfiguration { @Produces @ApplicationScoped - @LookupIfProperty(name = "stargate.auth.header-based.enabled", stringValue = "true") + @Startup HttpAuthenticationMechanism httpAuthenticationMechanism( - AuthConfig config, Instance customChallengeSender) { - String headerName = config.headerBased().headerName(); - return new HeaderBasedAuthenticationMechanism(headerName, customChallengeSender); + AuthConfig config, + Instance customChallengeSender, + McpOAuthMetadata metadata, + JWTAuthContextInfo jwtConfig) { + if (config.bearer().enabled()) { + validateBearerConfiguration(jwtConfig); + } + return new HeaderBasedAuthenticationMechanism(config, customChallengeSender, metadata); + } + + static void validateBearerConfiguration(JWTAuthContextInfo config) { + var missing = new ArrayList(); + if (!isConfigured(config.getIssuedBy())) { + missing.add("mp.jwt.verify.issuer"); + } + if (!isConfigured(config.getPublicKeyLocation()) + && !isConfigured(config.getPublicKeyContent()) + && config.getPublicVerificationKey() == null) { + missing.add("mp.jwt.verify.publickey.location or mp.jwt.verify.publickey"); + } + if (config.getExpectedAudience() == null + || config.getExpectedAudience().stream() + .noneMatch(HeaderBasedSecurityConfiguration::isConfigured)) { + missing.add("mp.jwt.verify.audiences"); + } + if (!missing.isEmpty()) { + throw new IllegalStateException( + "Bearer authentication requires " + String.join(", ", missing)); + } + } + + private static boolean isConfigured(String value) { + return value != null && !value.isBlank(); } @Produces diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpEndpointConfigSourceFactory.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpEndpointConfigSourceFactory.java index fcb0d65fb2..6612dc54e8 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpEndpointConfigSourceFactory.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpEndpointConfigSourceFactory.java @@ -4,16 +4,18 @@ import io.smallrye.config.ConfigSourceContext; import io.smallrye.config.ConfigSourceFactory; import io.smallrye.config.PropertiesConfigSource; +import io.stargate.sgv2.jsonapi.api.security.ProtectedResourceMetadataResource; import java.net.URI; import java.util.List; import java.util.Map; import java.util.regex.Pattern; import org.eclipse.microprofile.config.spi.ConfigSource; -/** Shares the normalized MCP endpoint with request logging and Quarkus access logging. */ +/** Shares normalized MCP paths with request logging and HTTP authentication. */ @StaticInitSafe public class McpEndpointConfigSourceFactory implements ConfigSourceFactory { - static final String ENDPOINT_PATH = "stargate.jsonapi.mcp.endpoint-path"; + public static final String ENDPOINT_PATH = "stargate.jsonapi.mcp.endpoint-path"; + public static final String METADATA_PATH = "stargate.jsonapi.mcp.metadata-path"; @Override public Iterable getConfigSources(ConfigSourceContext context) { @@ -37,6 +39,11 @@ public Iterable getConfigSources(ConfigSourceContext context) { } String excludePattern = Pattern.quote(endpointPath) + (endpointPath.equals("/") ? ".*" : "(/.*)?"); + String metadataPath = + URI.create(httpRoot) + .resolve(ProtectedResourceMetadataResource.PATH.substring(1)) + .normalize() + .getPath(); // A config source is needed because Quarkus creates its access logger before CDI startup. // The default source ordinal leaves explicit operator exclusions at their normal precedence. return List.of( @@ -44,8 +51,48 @@ public Iterable getConfigSources(ConfigSourceContext context) { Map.of( ENDPOINT_PATH, endpointPath, + METADATA_PATH, + metadataPath, "quarkus.http.access-log.exclude-pattern", - excludePattern), + excludePattern, + "quarkus.http.auth.permission.mcp.paths", + transportPaths(endpointPath).permissionPaths(), + "quarkus.http.auth.permission.mcp.policy", + "authenticated", + "quarkus.http.auth.permission.mcp-metadata.paths", + metadataPath, + "quarkus.http.auth.permission.mcp-metadata.policy", + "permit"), "MCP endpoint")); } + + /** Mirrors the streamable HTTP, SSE, and message routes registered by MCP SDK 2.0.3. */ + public static TransportPaths transportPaths(String endpointPath) { + String childRoot = endpointPath.equals("/") ? "" : endpointPath; + return new TransportPaths(endpointPath, childRoot + "/sse", childRoot + "/messages/"); + } + + public record TransportPaths(String endpoint, String sse, String messagesPrefix) { + public boolean matches(String normalizedPath) { + return endpoint.equals(normalizedPath) + || (endpoint + "/").equals(normalizedPath) + || sse.equals(normalizedPath) + || (sse + "/").equals(normalizedPath) + || normalizedPath.startsWith(messagesPrefix); + } + + public String permissionPaths() { + return String.join( + ",", + List.of( + endpoint, + endpoint.equals("/") ? endpoint : endpoint + "/", + sse, + sse + "/", + messagesPrefix + "*") + .stream() + .distinct() + .toList()); + } + } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResource.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResource.java index 29eb50ea2a..c87bda8071 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResource.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResource.java @@ -18,6 +18,7 @@ import io.stargate.sgv2.jsonapi.api.request.EmbeddingCredentialsResolver; import io.stargate.sgv2.jsonapi.api.request.RequestContext; import io.stargate.sgv2.jsonapi.api.request.tenant.RequestTenantResolver; +import io.stargate.sgv2.jsonapi.api.request.token.RequestAuthTokenResolver; import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; import io.stargate.sgv2.jsonapi.exception.APIException; import io.stargate.sgv2.jsonapi.exception.SchemaException; @@ -65,6 +66,7 @@ public class McpResource { private final Provider routingContextProvider; private final Provider securityIdentityProvider; private final Instance tenantResolver; + private final Instance tokenResolver; private final Instance embeddingCredentialsResolver; @Inject @@ -81,6 +83,7 @@ public McpResource( Provider routingContextProvider, Provider securityIdentityProvider, Instance tenantResolver, + Instance tokenResolver, Instance embeddingCredentialsResolver) { // TODO: these vars are needed to replicate what we do in GeneralResource, @@ -94,6 +97,7 @@ public McpResource( this.routingContextProvider = routingContextProvider; this.securityIdentityProvider = securityIdentityProvider; this.tenantResolver = tenantResolver; + this.tokenResolver = tokenResolver; this.embeddingCredentialsResolver = embeddingCredentialsResolver; this.contextBuilderSupplier = @@ -340,6 +344,7 @@ private RequestContext createRequestContext() { routingContextProvider.get(), securityIdentityProvider.get(), tenantResolver, + tokenResolver, embeddingCredentialsResolver); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/config/AuthConfig.java b/src/main/java/io/stargate/sgv2/jsonapi/config/AuthConfig.java index 9cdcfa9fd1..21b52a2327 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/config/AuthConfig.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/config/AuthConfig.java @@ -38,6 +38,16 @@ public interface AuthConfig { @Valid HeaderBasedAuthConfig headerBased(); + /** Configuration for verified Bearer authentication. */ + @Valid + BearerAuthConfig bearer(); + + interface BearerAuthConfig { + + @WithDefault("false") + boolean enabled(); + } + /** Configuration for the header based authentication. */ interface HeaderBasedAuthConfig { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/config/McpAuthConfig.java b/src/main/java/io/stargate/sgv2/jsonapi/config/McpAuthConfig.java new file mode 100644 index 0000000000..abf9e1e71d --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/config/McpAuthConfig.java @@ -0,0 +1,16 @@ +package io.stargate.sgv2.jsonapi.config; + +import io.smallrye.config.ConfigMapping; +import java.net.URI; +import java.util.List; +import java.util.Optional; + +/** Public authorization discovery for the MCP endpoint. */ +@ConfigMapping(prefix = "stargate.mcp.auth") +public interface McpAuthConfig { + Optional serverUrl(); + + Optional publicBaseUrl(); + + Optional> scopesSupported(); +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0c733d09b6..1dad77ea3c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -89,7 +89,7 @@ quarkus: pattern: "%h %l %t \"%r\" %s %b" # McpEndpointConfigSourceFactory excludes the effective MCP path from access logs. - # every /v1 path is authenticated by default + # every v1 path under the HTTP root is authenticated by default # adapt if changing the authentication mechanism auth: # disable basic auth completely @@ -97,7 +97,7 @@ quarkus: proactive: false permission: default: - paths: /v1/* + paths: v1/* policy: authenticated limits: diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/request/token/PrincipalTokenResolverTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/request/token/PrincipalTokenResolverTest.java new file mode 100644 index 0000000000..32f2a86bb0 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/request/token/PrincipalTokenResolverTest.java @@ -0,0 +1,32 @@ +package io.stargate.sgv2.jsonapi.api.request.token; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.Test; + +class PrincipalTokenResolverTest { + + private final PrincipalTokenResolver resolver = new PrincipalTokenResolver(); + + @Test + void preservesRawJwtInsteadOfItsSubject() { + var principal = mock(JsonWebToken.class); + when(principal.getName()).thenReturn("client-id"); + when(principal.getRawToken()).thenReturn("signed-access-token"); + + assertThat(resolver.resolve(null, principal)).isEqualTo("signed-access-token"); + } + + @Test + void preservesLegacyPrincipalCredentials() { + assertThat(resolver.resolve(null, () -> "legacy-token")).isEqualTo("legacy-token"); + } + + @Test + void anonymousPrincipalHasNoCredential() { + assertThat(resolver.resolve(null, null)).isNull(); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanismTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanismTest.java new file mode 100644 index 0000000000..eddac77b1b --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanismTest.java @@ -0,0 +1,251 @@ +package io.stargate.sgv2.jsonapi.api.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.smallrye.mutiny.Uni; +import io.stargate.sgv2.jsonapi.api.security.challenge.impl.ErrorChallengeSender; +import io.stargate.sgv2.jsonapi.config.AuthConfig; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.inject.Instance; +import jakarta.ws.rs.core.HttpHeaders; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +class HeaderBasedAuthenticationMechanismTest { + + @Test + void bearerCredentialsReachTheJwtProviderBeforeLegacyHeaders() { + var manager = mock(IdentityProviderManager.class); + var identity = mock(SecurityIdentity.class); + when(manager.authenticate(any())).thenReturn(Uni.createFrom().item(identity)); + var mechanism = mechanism(true, true); + var context = context(Map.of("Authorization", "Bearer encoded-jwt", "Token", "legacy-token")); + + assertThat(mechanism.authenticate(context, manager).await().indefinitely()).isSameAs(identity); + + var request = ArgumentCaptor.forClass(AuthenticationRequest.class); + verify(manager).authenticate(request.capture()); + assertThat(request.getValue()).isInstanceOf(TokenAuthenticationRequest.class); + assertThat(((TokenAuthenticationRequest) request.getValue()).getToken().getToken()) + .isEqualTo("encoded-jwt"); + verify(context).put(HttpAuthenticationMechanism.class.getName(), mechanism); + } + + @ParameterizedTest + @ValueSource(strings = {"Bearer", "Bearer ", "Bearer\tencoded-jwt", "Basic encoded"}) + void malformedAuthorizationCannotFallBackToALegacyHeader(String authorization) { + var manager = mock(IdentityProviderManager.class); + var context = context(Map.of("Authorization", authorization, "Token", "legacy-token")); + var mechanism = mechanism(true, true); + + assertThatThrownBy(() -> mechanism.authenticate(context, manager).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class); + verify(context).put(HttpAuthenticationMechanism.class.getName(), mechanism); + verifyNoInteractions(manager); + } + + @Test + void providerFailureCannotFallBackToALegacyHeader() { + var manager = mock(IdentityProviderManager.class); + when(manager.authenticate(any())) + .thenReturn(Uni.createFrom().failure(new AuthenticationFailedException())); + var context = context(Map.of("Authorization", "Bearer invalid-jwt", "Token", "legacy-token")); + var mechanism = mechanism(true, true); + + assertThatThrownBy(() -> mechanism.authenticate(context, manager).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class); + verify(context).put(HttpAuthenticationMechanism.class.getName(), mechanism); + var request = ArgumentCaptor.forClass(AuthenticationRequest.class); + verify(manager).authenticate(request.capture()); + assertThat(request.getValue()).isInstanceOf(TokenAuthenticationRequest.class); + } + + @Test + void rejectedBearerIncludesTheChallengeBeforeTheMcpTransportWritesItsError() { + var manager = mock(IdentityProviderManager.class); + when(manager.authenticate(any())) + .thenReturn(Uni.createFrom().failure(new AuthenticationFailedException())); + var context = context(Map.of("Authorization", "Bearer invalid-jwt")); + var response = mock(HttpServerResponse.class); + when(context.response()).thenReturn(response); + var metadata = mock(McpOAuthMetadata.class); + String challenge = + "Bearer resource_metadata=\"https://data.example.test/.well-known/oauth-protected-resource\""; + when(metadata.isMcpRequest(context)).thenReturn(true); + when(metadata.challenge(context)).thenReturn(Optional.of(challenge)); + var mechanism = + new HeaderBasedAuthenticationMechanism(authConfig(true, true), instance(), metadata); + + assertThatThrownBy(() -> mechanism.authenticate(context, manager).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class); + verify(response).putHeader(HttpHeaders.WWW_AUTHENTICATE, challenge); + } + + @Test + void disabledBearerAuthenticationPreservesLegacyHeaderPrecedence() { + var manager = mock(IdentityProviderManager.class); + var identity = mock(SecurityIdentity.class); + when(manager.authenticate(any())).thenReturn(Uni.createFrom().item(identity)); + var context = context(Map.of("Authorization", "Bearer invalid-jwt", "Token", "legacy-token")); + + assertThat(mechanism(false, true).authenticate(context, manager).await().indefinitely()) + .isSameAs(identity); + var request = ArgumentCaptor.forClass(AuthenticationRequest.class); + verify(manager).authenticate(request.capture()); + assertThat(request.getValue()).isInstanceOf(HeaderAuthenticationRequest.class); + } + + @Test + void disabledBearerAuthenticationStopsTheNativeJwtMechanism() { + var manager = mock(IdentityProviderManager.class); + var context = context(Map.of("Authorization", "Bearer encoded-jwt")); + + assertThatThrownBy( + () -> mechanism(false, true).authenticate(context, manager).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class); + verifyNoInteractions(manager); + } + + @ParameterizedTest + @ValueSource(strings = {"Token", "token", "X-Cassandra-Token"}) + void enabledMcpOAuthRejectsLegacyCredentials(String header) { + var manager = mock(IdentityProviderManager.class); + var context = context(Map.of(header, "legacy-token")); + + assertThatThrownBy( + () -> mechanism(true, true).authenticate(context, manager).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class); + verifyNoInteractions(manager); + } + + @Test + void enabledMcpOAuthRequiresBearerCredentials() { + var manager = mock(IdentityProviderManager.class); + + assertThatThrownBy( + () -> + mechanism(true, true) + .authenticate(context(Map.of()), manager) + .await() + .indefinitely()) + .isInstanceOf(AuthenticationFailedException.class); + verifyNoInteractions(manager); + } + + @Test + void enabledMcpOAuthPreservesLegacyHeaderPrecedenceOnRestPaths() { + var manager = mock(IdentityProviderManager.class); + var identity = mock(SecurityIdentity.class); + when(manager.authenticate(any())).thenReturn(Uni.createFrom().item(identity)); + var context = context(Map.of("Authorization", "Bearer synthetic-jwt", "Token", "legacy-token")); + + assertThat(mechanism(true, true, false).authenticate(context, manager).await().indefinitely()) + .isSameAs(identity); + var request = ArgumentCaptor.forClass(AuthenticationRequest.class); + verify(manager).authenticate(request.capture()); + assertThat(request.getValue()).isInstanceOf(HeaderAuthenticationRequest.class); + } + + @Test + void enabledMcpOAuthDoesNotEnableBearerAuthenticationOnRestPaths() { + var manager = mock(IdentityProviderManager.class); + var context = context(Map.of("Authorization", "Bearer encoded-jwt")); + + assertThatThrownBy( + () -> + mechanism(true, true, false).authenticate(context, manager).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class); + verifyNoInteractions(manager); + } + + @Test + void disabledLegacyHeadersDoNotAuthenticate() { + var manager = mock(IdentityProviderManager.class); + + assertThat( + mechanism(true, false, false) + .authenticate(context(Map.of("Token", "legacy-token")), manager) + .await() + .indefinitely()) + .isNull(); + verifyNoInteractions(manager); + } + + @Test + void coordinatesTheChallengeAheadOfTheNativeJwtMechanism() { + var mechanism = mechanism(true, true); + assertThat(mechanism.getPriority()).isGreaterThan(1000); + assertThat(mechanism.getCredentialTypes()) + .contains(TokenAuthenticationRequest.class, HeaderAuthenticationRequest.class); + } + + @Test + void challengeUsesTheConfiguredMetadata() { + var config = authConfig(true, true); + var metadata = mock(McpOAuthMetadata.class); + var context = context(Map.of()); + var challenge = + "Bearer resource_metadata=\"https://data.example.test/.well-known/oauth-protected-resource\""; + when(metadata.challenge(context)).thenReturn(Optional.of(challenge)); + var mechanism = new HeaderBasedAuthenticationMechanism(config, instance(), metadata); + + var result = mechanism.getChallenge(context).await().indefinitely(); + assertThat(result.status).isEqualTo(401); + assertThat(result.getHeaders()).containsEntry(HttpHeaders.WWW_AUTHENTICATE, challenge); + } + + private static HeaderBasedAuthenticationMechanism mechanism(boolean bearer, boolean legacy) { + return mechanism(bearer, legacy, true); + } + + private static HeaderBasedAuthenticationMechanism mechanism( + boolean bearer, boolean legacy, boolean mcpRequest) { + var metadata = mock(McpOAuthMetadata.class); + when(metadata.isMcpRequest(any())).thenReturn(mcpRequest); + when(metadata.challenge(any())).thenReturn(Optional.empty()); + return new HeaderBasedAuthenticationMechanism(authConfig(bearer, legacy), instance(), metadata); + } + + private static AuthConfig authConfig(boolean bearer, boolean legacy) { + var config = mock(AuthConfig.class); + var headerConfig = mock(AuthConfig.HeaderBasedAuthConfig.class); + var bearerConfig = mock(AuthConfig.BearerAuthConfig.class); + when(config.headerBased()).thenReturn(headerConfig); + when(headerConfig.enabled()).thenReturn(legacy); + when(headerConfig.headerName()).thenReturn("Token"); + when(config.bearer()).thenReturn(bearerConfig); + when(bearerConfig.enabled()).thenReturn(bearer); + return config; + } + + private static RoutingContext context(Map headers) { + var request = mock(HttpServerRequest.class); + when(request.getHeader(any(String.class))).thenAnswer(call -> headers.get(call.getArgument(0))); + var context = mock(RoutingContext.class); + when(context.request()).thenReturn(request); + return context; + } + + @SuppressWarnings("unchecked") + private static Instance instance() { + return mock(Instance.class); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/security/McpOAuthMetadataTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/security/McpOAuthMetadataTest.java new file mode 100644 index 0000000000..600ffa2ec6 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/security/McpOAuthMetadataTest.java @@ -0,0 +1,215 @@ +package io.stargate.sgv2.jsonapi.api.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.smallrye.jwt.auth.principal.JWTAuthContextInfo; +import io.stargate.sgv2.jsonapi.config.AuthConfig; +import io.stargate.sgv2.jsonapi.config.McpAuthConfig; +import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; +import io.stargate.sgv2.jsonapi.config.feature.FeaturesConfig; +import io.vertx.ext.web.RoutingContext; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class McpOAuthMetadataTest { + private static final String PUBLIC_BASE = "https://data.example.test"; + private static final String AUTH_SERVER = "https://auth.example.test/oauth"; + private static final String ENDPOINT = "/v1/mcp"; + private static final String METADATA = ProtectedResourceMetadataResource.PATH; + + @Test + void serializesConfiguredResourceAndOmitsUnconfiguredScopes() throws Exception { + var metadata = + metadata(config(PUBLIC_BASE), ENDPOINT, METADATA, true, true, PUBLIC_BASE + ENDPOINT); + var mapper = new ObjectMapper(); + JsonNode actual = mapper.valueToTree(metadata.document().orElseThrow()); + assertThat(actual) + .isEqualTo( + mapper.readTree( + """ + { + "resource": "https://data.example.test/v1/mcp", + "authorization_servers": ["https://auth.example.test/oauth"], + "bearer_methods_supported": ["header"] + } + """)); + } + + @Test + void publishesOnlyConfiguredScopes() { + var config = config(PUBLIC_BASE); + when(config.scopesSupported()).thenReturn(Optional.of(List.of("db:read", "db:write"))); + assertThat( + metadata(config, ENDPOINT, METADATA, true, true, PUBLIC_BASE + ENDPOINT) + .document() + .orElseThrow() + .scopesSupported()) + .containsExactly("db:read", "db:write"); + } + + @Test + void sharesTheEffectiveEndpointAndHttpRootWithChallenges() { + var metadata = + metadata( + config(PUBLIC_BASE), + "/service/tools", + "/service" + METADATA, + true, + true, + PUBLIC_BASE + "/service/tools"); + assertThat(metadata.document().orElseThrow().resource()) + .isEqualTo(URI.create(PUBLIC_BASE + "/service/tools")); + assertThat(metadata.challenge(request("/service/tools"))) + .contains("Bearer resource_metadata=\"" + PUBLIC_BASE + "/service" + METADATA + "\""); + assertThat(metadata.challenge(request("/service/tools/sse"))).isPresent(); + assertThat(metadata.challenge(request("/service/tools/messages/session-id"))).isPresent(); + assertThat(metadata.challenge(request("/service/tools/collections"))).isEmpty(); + assertThat(metadata.challenge(request("/service/toolsmith"))).isEmpty(); + assertThat(metadata.challenge(request("/v1/collections"))).isEmpty(); + } + + @Test + void disabledFeaturesDoNotRequireOrAdvertiseAuthorizationConfiguration() { + var missing = mock(McpAuthConfig.class); + for (boolean bearerEnabled : List.of(false, true)) { + var metadata = metadata(missing, ENDPOINT, METADATA, bearerEnabled, false); + assertThat(metadata.document()).isEmpty(); + assertThat(metadata.challenge(mock(RoutingContext.class))).isEmpty(); + } + assertThat(metadata(missing, ENDPOINT, METADATA, false, true).document()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"/", "/service"}) + void oauthRejectsEndpointsAtTheHttpRootEvenWhenMcpIsDisabled(String endpoint) { + String metadataPath = (endpoint.equals("/") ? "" : endpoint) + METADATA; + var missing = mock(McpAuthConfig.class); + for (boolean mcpEnabled : List.of(false, true)) { + assertThatIllegalArgumentException() + .isThrownBy(() -> metadata(missing, endpoint, metadataPath, true, mcpEnabled)) + .withMessageContaining("quarkus.mcp.server.http.root-path") + .withMessageContaining("dedicated path below the HTTP root"); + assertThat(metadata(missing, endpoint, metadataPath, false, mcpEnabled).document()).isEmpty(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"/other/tools", "/service-sibling/tools"}) + void oauthRejectsEndpointsOutsideTheHttpRoot(String endpoint) { + var missing = mock(McpAuthConfig.class); + for (boolean mcpEnabled : List.of(false, true)) { + assertThatIllegalArgumentException() + .isThrownBy(() -> metadata(missing, endpoint, "/service" + METADATA, true, mcpEnabled)) + .withMessageContaining("below the HTTP root"); + } + assertThat(metadata(missing, endpoint, "/service" + METADATA, false, true).document()) + .isEmpty(); + } + + @Test + void identifiesTransportPathsEvenWhenDiscoveryIsDisabled() { + var metadata = metadata(mock(McpAuthConfig.class), ENDPOINT, METADATA, false, false); + assertThat(metadata.document()).isEmpty(); + assertThat(metadata.isMcpRequest(request(ENDPOINT))).isTrue(); + assertThat(metadata.isMcpRequest(request("/v1/collections"))).isFalse(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "http://data.example.test", + "https://data.example.test/path", + "https://user:data@data.example.test", + "https://data.example.test?secret=value", + "https://data.example.test#fragment", + "/relative" + }) + void rejectsUntrustedPublicOriginsWithoutEchoingTheirValues(String publicBase) { + assertThatIllegalArgumentException() + .isThrownBy(() -> metadata(config(publicBase), ENDPOINT, METADATA, true, true)) + .withMessageContaining("stargate.mcp.auth.public-base-url") + .withMessageNotContaining(publicBase); + } + + @ParameterizedTest + @ValueSource(strings = {"http://localhost:8083", "http://127.0.0.1:8083", "http://[::1]:8083"}) + void acceptsLoopbackHttpForLocalDevelopment(String publicBase) { + assertThat( + metadata(config(publicBase), ENDPOINT, METADATA, true, true, publicBase + ENDPOINT) + .document() + .orElseThrow() + .resource()) + .isEqualTo(URI.create(publicBase + ENDPOINT)); + } + + @Test + void requiresDiscoveryAndAudienceConfigurationWhenEnabled() { + var missingBase = config(PUBLIC_BASE); + when(missingBase.publicBaseUrl()).thenReturn(Optional.empty()); + assertThatIllegalArgumentException() + .isThrownBy(() -> metadata(missingBase, ENDPOINT, METADATA, true, true)) + .withMessageContaining("stargate.mcp.auth.public-base-url is required"); + var missingServer = config(PUBLIC_BASE); + when(missingServer.serverUrl()).thenReturn(Optional.empty()); + assertThatIllegalArgumentException() + .isThrownBy(() -> metadata(missingServer, ENDPOINT, METADATA, true, true)) + .withMessageContaining("stargate.mcp.auth.server-url is required"); + assertThatIllegalArgumentException() + .isThrownBy(() -> metadata(config(PUBLIC_BASE), ENDPOINT, METADATA, true, true)) + .withMessageContaining("mp.jwt.verify.audiences"); + assertThatIllegalArgumentException() + .isThrownBy( + () -> + metadata( + config(PUBLIC_BASE), + ENDPOINT, + METADATA, + true, + true, + "https://other.example.test")) + .withMessageContaining("mp.jwt.verify.audiences"); + } + + private McpAuthConfig config(String publicBase) { + var config = mock(McpAuthConfig.class); + when(config.publicBaseUrl()).thenReturn(Optional.of(URI.create(publicBase))); + when(config.serverUrl()).thenReturn(Optional.of(URI.create(AUTH_SERVER))); + when(config.scopesSupported()).thenReturn(Optional.empty()); + return config; + } + + private McpOAuthMetadata metadata( + McpAuthConfig config, + String endpoint, + String metadataPath, + boolean bearerEnabled, + boolean mcpEnabled, + String... audiences) { + var auth = mock(AuthConfig.class); + var bearer = mock(AuthConfig.BearerAuthConfig.class); + when(auth.bearer()).thenReturn(bearer); + when(bearer.enabled()).thenReturn(bearerEnabled); + var features = mock(FeaturesConfig.class); + when(features.flags()).thenReturn(Map.of(ApiFeature.MCP, Boolean.toString(mcpEnabled))); + var jwtConfig = new JWTAuthContextInfo(); + jwtConfig.setExpectedAudience(Set.of(audiences)); + return new McpOAuthMetadata(auth, features, config, endpoint, metadataPath, jwtConfig); + } + + private RoutingContext request(String path) { + var context = mock(RoutingContext.class); + when(context.normalizedPath()).thenReturn(path); + return context; + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/security/challenge/impl/ErrorChallengeSenderTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/security/challenge/impl/ErrorChallengeSenderTest.java new file mode 100644 index 0000000000..c668e07c5f --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/security/challenge/impl/ErrorChallengeSenderTest.java @@ -0,0 +1,64 @@ +package io.stargate.sgv2.jsonapi.api.security.challenge.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import jakarta.ws.rs.core.HttpHeaders; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ErrorChallengeSenderTest { + @Test + void rejectedBearerReportsInvalidCredentialsAndPreservesTheChallenge() throws Exception { + String body = sendChallenge("Bearer rejected-credential"); + + assertThat(new ObjectMapper().readTree(body).at("/errors/0/errorCode").asText()) + .isEqualTo("UNAUTHENTICATED_REQUEST"); + assertThat(body).contains("invalid token").doesNotContain("rejected-credential", "missing"); + } + + @Test + void missingCredentialsKeepTheExistingMissingTokenResponse() throws Exception { + String body = sendChallenge(null); + + assertThat(new ObjectMapper().readTree(body).at("/errors/0/errorCode").asText()) + .isEqualTo("MISSING_AUTHENTICATION_TOKEN"); + assertThat(body).contains("Token"); + } + + private static String sendChallenge(String authorization) { + var request = mock(HttpServerRequest.class); + when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(authorization); + var response = mock(HttpServerResponse.class); + var headers = MultiMap.caseInsensitiveMultiMap(); + when(response.headers()).thenReturn(headers); + when(response.write(anyString())).thenReturn(Future.succeededFuture()); + var context = mock(RoutingContext.class); + when(context.request()).thenReturn(request); + when(context.response()).thenReturn(response); + String challenge = "Bearer resource_metadata=\"https://data.example.test/metadata\""; + + assertThat( + new ErrorChallengeSender(new ObjectMapper()) + .apply(context, new ChallengeData(401, HttpHeaders.WWW_AUTHENTICATE, challenge)) + .await() + .indefinitely()) + .isTrue(); + + assertThat(headers.get(HttpHeaders.WWW_AUTHENTICATE)).isEqualTo(challenge); + verify(response).setStatusCode(401); + var body = ArgumentCaptor.forClass(String.class); + verify(response).write(body.capture()); + return body.getValue(); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/security/configuration/HeaderBasedSecurityConfigurationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/security/configuration/HeaderBasedSecurityConfigurationTest.java new file mode 100644 index 0000000000..ab23b7ac1c --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/security/configuration/HeaderBasedSecurityConfigurationTest.java @@ -0,0 +1,75 @@ +package io.stargate.sgv2.jsonapi.api.security.configuration; + +import static io.stargate.sgv2.jsonapi.api.security.configuration.HeaderBasedSecurityConfiguration.validateBearerConfiguration; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.smallrye.jwt.auth.principal.JWTAuthContextInfo; +import io.smallrye.jwt.config.JWTAuthContextInfoProvider; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class HeaderBasedSecurityConfigurationTest { + + @Test + void verifiedBearerRequiresExplicitIssuerKeyAndAudience() { + assertThatCode(() -> validateBearerConfiguration(jwtConfig())).doesNotThrowAnyException(); + } + + @Test + void inlinePublicKeysAreSupported() { + var config = jwtConfig(); + config.setPublicKeyLocation(null); + config.setPublicKeyContent("inline-test-public-key"); + assertThatCode(() -> validateBearerConfiguration(config)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "mp.jwt.verify.issuer", + "mp.jwt.verify.publickey.location", + "mp.jwt.verify.audiences" + }) + void missingRequiredVerificationConfigurationFailsClosed(String missing) { + var config = jwtConfig(); + switch (missing) { + case "mp.jwt.verify.issuer" -> config.setIssuedBy(null); + case "mp.jwt.verify.publickey.location" -> config.setPublicKeyLocation(null); + case "mp.jwt.verify.audiences" -> config.setExpectedAudience(null); + default -> throw new IllegalArgumentException(missing); + } + assertThatThrownBy(() -> validateBearerConfiguration(config)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(missing); + } + + @Test + void emptyAudienceListFailsClosed() { + var config = jwtConfig(); + config.setExpectedAudience(Set.of("", " ")); + assertThatThrownBy(() -> validateBearerConfiguration(config)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("mp.jwt.verify.audiences"); + } + + @Test + void disabledIssuerVerificationFailsClosed() { + var config = + JWTAuthContextInfoProvider.createWithKeyLocation("https://auth.example.test/jwks", "NONE") + .getContextInfo(); + assertThatThrownBy(() -> validateBearerConfiguration(config)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("mp.jwt.verify.issuer"); + } + + private static JWTAuthContextInfo jwtConfig() { + var config = new JWTAuthContextInfo(); + config.setIssuedBy("https://auth.example.test"); + config.setPublicKeyLocation("test-public-key.pem"); + config.setExpectedAudience(Set.of("https://data.example.test/v1/mcp")); + return config; + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpEndpointConfigSourceFactoryTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpEndpointConfigSourceFactoryTest.java index 601b0c0726..c358e709a8 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpEndpointConfigSourceFactoryTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpEndpointConfigSourceFactoryTest.java @@ -5,6 +5,7 @@ import io.smallrye.config.PropertiesConfigSource; import io.smallrye.config.SmallRyeConfig; import io.smallrye.config.SmallRyeConfigBuilder; +import java.util.List; import java.util.Map; import java.util.regex.Pattern; import org.junit.jupiter.api.Test; @@ -32,6 +33,17 @@ void excludesOnlyTheNormalizedEndpointAndItsDescendants( "quarkus.mcp.server.http.root-path", mcpRoot)); assertThat(config.getValue(McpEndpointConfigSourceFactory.ENDPOINT_PATH, String.class)) .isEqualTo(expectedEndpoint); + assertThat(config.getValue("quarkus.http.auth.permission.mcp.paths", String.class)) + .isEqualTo( + String.join( + ",", + expectedEndpoint, + expectedEndpoint + "/", + expectedEndpoint + "/sse", + expectedEndpoint + "/sse/", + expectedEndpoint + "/messages/*")); + assertThat(config.getValue("quarkus.http.auth.permission.mcp.policy", String.class)) + .isEqualTo("authenticated"); var excluded = Pattern.compile(config.getValue("quarkus.http.access-log.exclude-pattern", String.class)); assertThat(excluded.matcher(expectedEndpoint).matches()).isTrue(); @@ -47,6 +59,64 @@ void rootEndpointExcludesAllDescendants() { Pattern.compile(config.getValue("quarkus.http.access-log.exclude-pattern", String.class)); assertThat(excluded.matcher("/").matches()).isTrue(); assertThat(excluded.matcher("/messages").matches()).isTrue(); + assertThat(config.getValue("quarkus.http.auth.permission.mcp.paths", String.class)) + .isEqualTo("/,/sse,/sse/,/messages/*"); + } + + @ParameterizedTest + @CsvSource({ + "/, /, /, ''", + "/service, /, /service, /service", + "/, /v1/mcp, /v1/mcp, /v1/mcp", + "/service, /tools, /service/tools, /service/tools" + }) + void protectsOnlySdkTransportRoutes( + String httpRoot, String mcpRoot, String endpoint, String childRoot) { + var config = + config( + Map.of( + "quarkus.http.root-path", httpRoot, + "quarkus.mcp.server.http.root-path", mcpRoot)); + var paths = McpEndpointConfigSourceFactory.transportPaths(endpoint); + assertThat(config.getValue("quarkus.http.auth.permission.mcp.paths", String.class)) + .isEqualTo(paths.permissionPaths()) + .doesNotContain(endpoint.equals("/") ? "/*," : endpoint + "/*"); + assertThat(paths.matches(endpoint)).isTrue(); + assertThat(paths.matches(endpoint.equals("/") ? "/" : endpoint + "/")).isTrue(); + assertThat(paths.matches(childRoot + "/sse")).isTrue(); + assertThat(paths.matches(childRoot + "/sse/")).isTrue(); + assertThat(paths.matches(childRoot + "/messages/session-id")).isTrue(); + for (String path : + List.of( + "/v1/collections", + "/stargate/health", + childRoot + "/v1/collections", + childRoot + "/stargate/health", + childRoot + "/collections", + childRoot + "/sse-other", + childRoot + "/messages-other/session-id", + endpoint + "-other", + childRoot + "/unknown")) { + assertThat(paths.matches(path)).as(path).isFalse(); + } + } + + @Test + void metadataRemainsPublicUnderTheConfiguredHttpRoot() { + var config = + config( + Map.of( + "quarkus.http.root-path", + "/service", + "quarkus.mcp.server.http.root-path", + "/tools")); + String path = "/service/.well-known/oauth-protected-resource"; + assertThat(config.getValue(McpEndpointConfigSourceFactory.METADATA_PATH, String.class)) + .isEqualTo(path); + assertThat(config.getValue("quarkus.http.auth.permission.mcp-metadata.paths", String.class)) + .isEqualTo(path); + assertThat(config.getValue("quarkus.http.auth.permission.mcp-metadata.policy", String.class)) + .isEqualTo("permit"); } @Test diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthCustomPathTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthCustomPathTest.java new file mode 100644 index 0000000000..58a053fad1 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthCustomPathTest.java @@ -0,0 +1,31 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import java.util.HashMap; +import java.util.Map; + +@QuarkusTest +@TestProfile(McpOAuthCustomPathTest.CustomPathProfile.class) +class McpOAuthCustomPathTest extends McpOAuthTest { + @Override + protected String expectedResource() { + return McpOAuthTestSupport.PUBLIC_BASE + "/service/tools"; + } + + @Override + protected String expectedMetadataUrl() { + return McpOAuthTestSupport.PUBLIC_BASE + "/service" + McpOAuthTestSupport.METADATA_PATH; + } + + public static class CustomPathProfile extends OAuthProfile { + @Override + public Map getConfigOverrides() { + var overrides = new HashMap<>(super.getConfigOverrides()); + overrides.put("quarkus.http.root-path", "/service"); + overrides.put("quarkus.mcp.server.http.root-path", "/tools"); + overrides.put("mp.jwt.verify.audiences", McpOAuthTestSupport.PUBLIC_BASE + "/service/tools"); + return overrides; + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthDisabledTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthDisabledTest.java new file mode 100644 index 0000000000..f33bbcf7d6 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthDisabledTest.java @@ -0,0 +1,60 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertResponseComplete; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.request; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.rpcPayload; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@QuarkusTest +@TestProfile(McpOAuthDisabledTest.DisabledProfile.class) +class McpOAuthDisabledTest { + @Test + void disabledBearerAuthenticationDoesNotAdvertiseOAuthMetadata() { + given().get(McpOAuthTestSupport.METADATA_PATH).then().statusCode(404); + } + + @Test + void aValidBearerTokenDoesNotEnableAuthenticationWithoutOptingIn() { + String credential = + McpOAuthTestSupport.token( + McpOAuthTestSupport.FIRST_SUBJECT, + McpOAuthTestSupport.FIRST_ORG, + "read-role", + McpOAuthTestSupport.PUBLIC_BASE + "/v1/mcp"); + request("tools/list") + .header("Authorization", "Bearer " + credential) + .body(rpcPayload(1, "tools/list", Map.of())) + .post("/v1/mcp") + .then() + .statusCode(401); + } + + @ParameterizedTest + @ValueSource(strings = {"Token", "X-Cassandra-Token"}) + void legacyMcpAuthenticationRemainsUsableWhenOAuthIsDisabled(String header) { + var response = + request("tools/list") + .header(header, "legacy-credential") + .body(rpcPayload(1, "tools/list", Map.of())) + .post("/v1/mcp"); + + assertResponseComplete(response, 1); + } + + public static class DisabledProfile extends McpOAuthTest.OAuthProfile { + @Override + public Map getConfigOverrides() { + var overrides = new HashMap<>(super.getConfigOverrides()); + overrides.put("stargate.auth.bearer.enabled", "false"); + return overrides; + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthFeatureDisabledTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthFeatureDisabledTest.java new file mode 100644 index 0000000000..c16af3f3a1 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthFeatureDisabledTest.java @@ -0,0 +1,42 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.request; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.rpcPayload; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@QuarkusTest +@TestProfile(McpOAuthFeatureDisabledTest.DisabledProfile.class) +class McpOAuthFeatureDisabledTest { + @Test + void disabledMcpFeatureDoesNotAdvertiseOAuthMetadata() { + given().get(McpOAuthTestSupport.METADATA_PATH).then().statusCode(404); + } + + @ParameterizedTest + @ValueSource(strings = {"Token", "X-Cassandra-Token"}) + void disabledMetadataDoesNotReenableLegacyMcpAuthentication(String header) { + request("tools/list") + .header(header, "legacy-credential") + .body(rpcPayload(1, "tools/list", Map.of())) + .post("/v1/mcp") + .then() + .statusCode(401); + } + + public static class DisabledProfile extends McpOAuthTest.OAuthProfile { + @Override + public Map getConfigOverrides() { + var overrides = new HashMap<>(super.getConfigOverrides()); + overrides.put("stargate.feature.flags.mcp", "false"); + return overrides; + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthTest.java new file mode 100644 index 0000000000..82136cda26 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthTest.java @@ -0,0 +1,387 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static io.restassured.RestAssured.config; +import static io.restassured.RestAssured.given; +import static io.restassured.config.HttpClientConfig.httpClientConfig; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.CURRENT_PROTOCOL_VERSION; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertResponseComplete; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertRpcError; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.request; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.rpcPayload; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOAuthTestSupport.AUTH_SERVER; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOAuthTestSupport.FIRST_ORG; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOAuthTestSupport.FIRST_SUBJECT; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOAuthTestSupport.METADATA_PATH; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOAuthTestSupport.PUBLIC_BASE; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOAuthTestSupport.SECOND_ORG; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOAuthTestSupport.SECOND_SUBJECT; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOAuthTestSupport.token; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.quarkiverse.mcp.server.JsonRpcErrorCodes; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import io.smallrye.mutiny.Uni; +import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; +import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; +import io.stargate.sgv2.jsonapi.api.model.command.CommandStatus; +import io.stargate.sgv2.jsonapi.api.request.RequestContext; +import io.stargate.sgv2.jsonapi.service.processor.CommandProcessor; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +@QuarkusTest +@TestProfile(McpOAuthTest.OAuthProfile.class) +class McpOAuthTest { + private static final int UNSUPPORTED_PROTOCOL_VERSION = -32022; + + @InjectMock CommandProcessor commandProcessor; + + @Test + void discoveryIsPublicAndUsesTheConfiguredResource() { + var metadata = given().get(METADATA_PATH).then().statusCode(200).extract().jsonPath(); + + assertThat(metadata.getString("resource")).isEqualTo(expectedResource()); + assertThat(metadata.getList("authorization_servers", String.class)) + .containsExactly(AUTH_SERVER); + assertThat(metadata.getList("bearer_methods_supported", String.class)).contains("header"); + } + + @Test + void unauthenticatedRequestsAdvertiseAbsoluteMetadataLocation() { + var response = catalogRequest(1).post(mcpPath()); + + assertChallenge(response); + verifyNoInteractions(commandProcessor); + } + + @Test + void validJwtListsToolsWithoutAStoredSession() { + String credential = firstToken(); + for (int id = 1; id <= 2; id++) { + var response = + catalogRequest(id).header("Authorization", "Bearer " + credential).post(mcpPath()); + assertResponseComplete(response, id); + assertThat(response.header("Mcp-Session-Id")).isNull(); + assertThat(response.jsonPath().getList("result.tools.name", String.class)) + .contains("findKeyspaces", "findEmbeddingProviders", "findOne", "insertOne"); + } + } + + @Test + void validJwtDoesNotAdmitAnOlderProtocol() { + // OAuth admits the caller; it does not widen the supported protocol revisions. + var response = + given() + .contentType("application/json") + .accept("application/json, text/event-stream") + .header("Authorization", "Bearer " + firstToken()) + .header("MCP-Protocol-Version", "2025-11-25") + .header("Mcp-Method", "initialize") + .body( + Map.of( + "jsonrpc", + "2.0", + "id", + 1, + "method", + "initialize", + "params", + Map.of( + "protocolVersion", "2025-11-25", + "capabilities", Map.of(), + "clientInfo", Map.of("name", "oauth-compatibility-test", "version", "1")))) + .post(mcpPath()); + + var error = assertRpcError(response, 400, UNSUPPORTED_PROTOCOL_VERSION); + assertThat(error.getJsonObject("data").getString("requested")).isEqualTo("2025-11-25"); + assertThat(error.getJsonObject("data").getJsonArray("supported").getList()) + .containsExactly(CURRENT_PROTOCOL_VERSION); + assertThat(response.header("Mcp-Session-Id")).isNull(); + verifyNoInteractions(commandProcessor); + } + + @ParameterizedTest + @EnumSource(McpOAuthTestSupport.InvalidToken.class) + void invalidBearerTokensCannotReachTheTool(McpOAuthTestSupport.InvalidToken kind) { + var response = + providerRequest(1) + .header( + "Authorization", + "Bearer " + McpOAuthTestSupport.invalidToken(kind, expectedResource())) + .post(mcpPath()); + + assertChallenge(response); + verifyNoInteractions(commandProcessor); + } + + @Test + void invalidBearerDoesNotFallBackToAnExistingLegacyToken() { + var response = + providerRequest(1) + .header("Authorization", "Bearer malformed-access-token") + .header("Token", "legacy-credential") + .post(mcpPath()); + + assertChallenge(response); + assertThat(response.jsonPath().getInt("error.code")) + .isEqualTo(JsonRpcErrorCodes.SECURITY_ERROR); + assertThat(response.asString()) + .doesNotContain( + "MISSING_AUTHENTICATION_TOKEN", "legacy-credential", "malformed-access-token"); + verifyNoInteractions(commandProcessor); + } + + @ParameterizedTest + @ValueSource(strings = {"Token", "X-Cassandra-Token"}) + void legacyTokenHeadersCannotAuthenticateOAuthOnlyMcp(String header) { + var response = catalogRequest(1).header(header, "legacy-credential").post(mcpPath()); + + assertChallenge(response); + verifyNoInteractions(commandProcessor); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"Token", "X-Cassandra-Token"}) + void commandContextReceivesTheVerifiedTokenRatherThanItsSubjectOrLegacyHeaders(String header) { + String credential = firstToken(); + var observed = new AtomicReference(); + when(commandProcessor.processCommand(any(), any())) + .thenAnswer( + invocation -> { + CommandContext context = invocation.getArgument(0); + observed.set(context.requestContext().authToken()); + return Uni.createFrom() + .item(CommandResult.statusOnlyBuilder(context.requestTracing()).build()); + }); + + var request = providerRequest(1).header("Authorization", "Bearer " + credential); + if (header != null) { + request.header(header, "legacy-credential"); + } + var response = request.post(mcpPath()); + + assertResponseComplete(response, 1); + assertThat(observed.get()) + .isEqualTo(credential) + .isNotEqualTo(FIRST_SUBJECT) + .isNotEqualTo("legacy-credential"); + assertThat(response.asString()).doesNotContain(credential, "legacy-credential"); + } + + @ParameterizedTest + @ValueSource(strings = {"Token", "X-Cassandra-Token"}) + void legacyCredentialsCannotOpenTheSseTransport(String header) { + var response = + given() + // Bound the read if a regression incorrectly admits this streaming request. + .config(config().httpClient(httpClientConfig().setParam("http.socket.timeout", 5000))) + .header(header, "legacy-credential") + .accept("text/event-stream") + .get(mcpPath() + "/sse"); + + assertChallenge(response); + verifyNoInteractions(commandProcessor); + } + + @ParameterizedTest + @ValueSource(strings = {"Token", "X-Cassandra-Token"}) + void legacyCredentialsCannotPostToTheMessageTransport(String header) { + var response = + given() + .header(header, "legacy-credential") + .contentType("application/json") + .body(rpcPayload(1, "tools/list", Map.of())) + .post(mcpPath() + "/messages/synthetic-session-id"); + + assertChallenge(response); + verifyNoInteractions(commandProcessor); + } + + @ParameterizedTest + @ValueSource(strings = {"Token", "X-Cassandra-Token"}) + void ordinaryRestCallsRetainLegacyAuthentication(String header) { + var observed = new AtomicReference(); + when(commandProcessor.processCommand(any(), any())) + .thenAnswer( + invocation -> { + CommandContext context = invocation.getArgument(0); + observed.set(context.requestContext().authToken()); + return Uni.createFrom() + .item(CommandResult.statusOnlyBuilder(context.requestTracing()).build()); + }); + var response = + given() + .contentType("application/json") + .header(header, "legacy-credential") + .body(Map.of("findEmbeddingProviders", Map.of())) + .post("/v1"); + + assertThat(response.statusCode()).as(response.asString()).isEqualTo(200); + assertThat(observed.get()).isEqualTo("legacy-credential"); + assertThat(response.asString()).doesNotContain("legacy-credential"); + } + + @Test + void ordinaryRestCallsDoNotAcceptBearerOnlyAuthentication() { + var response = + given() + .contentType("application/json") + .header("Authorization", "Bearer " + firstToken()) + .body(Map.of("findEmbeddingProviders", Map.of())) + .post("/v1"); + + assertThat(response.statusCode()).as(response.asString()).isEqualTo(401); + verifyNoInteractions(commandProcessor); + } + + @Test + void overlappingJwtRequestsKeepSeparateCredentialsWhenCompletedInReverseOrder() throws Exception { + String firstToken = firstToken(); + String secondToken = token(SECOND_SUBJECT, SECOND_ORG, "write-role", expectedResource()); + var firstCompletion = new CompletableFuture(); + var secondCompletion = new CompletableFuture(); + var completions = Map.of(firstToken, firstCompletion, secondToken, secondCompletion); + var started = new LinkedBlockingQueue(); + when(commandProcessor.processCommand(any(), any())) + .thenAnswer( + invocation -> { + CommandContext context = invocation.getArgument(0); + return Uni.createFrom() + .emitter( + emitter -> { + started.add(context.requestContext()); + var completion = completions.get(context.requestContext().authToken()); + if (completion == null) { + emitter.fail( + new IllegalStateException("Expected the encoded access token")); + } else { + completion.thenAccept(emitter::complete); + } + }) + .map( + value -> + CommandResult.statusOnlyBuilder(context.requestTracing()) + .addStatus(CommandStatus.OK, value) + .build()); + }); + + try (var clients = Executors.newFixedThreadPool(2); + var worker = Executors.newSingleThreadExecutor()) { + worker.submit(() -> {}).get(); + try { + var firstResponse = + clients.submit( + () -> + providerRequest(1) + .header("Authorization", "Bearer " + firstToken) + .post(mcpPath())); + var firstContext = started.poll(10, TimeUnit.SECONDS); + assertThat(firstContext).isNotNull(); + var secondResponse = + clients.submit( + () -> + providerRequest(2) + .header("Authorization", "Bearer " + secondToken) + .post(mcpPath())); + var secondContext = started.poll(10, TimeUnit.SECONDS); + assertThat(secondContext).isNotNull(); + + assertThat(firstContext).isNotSameAs(secondContext); + assertThat(firstContext.authToken()).isEqualTo(firstToken); + assertThat(secondContext.authToken()).isEqualTo(secondToken); + assertThat(firstContext.requestId()).isNotEqualTo(secondContext.requestId()); + worker.submit(() -> secondCompletion.complete(2)).get(10, TimeUnit.SECONDS); + assertThat( + assertResponseComplete(secondResponse.get(10, TimeUnit.SECONDS), 2) + .getJsonObject("_meta") + .getJsonObject("status") + .getInteger("ok")) + .isEqualTo(2); + assertThat(firstResponse.isDone()).isFalse(); + worker.submit(() -> firstCompletion.complete(1)).get(10, TimeUnit.SECONDS); + assertThat( + assertResponseComplete(firstResponse.get(10, TimeUnit.SECONDS), 1) + .getJsonObject("_meta") + .getJsonObject("status") + .getInteger("ok")) + .isEqualTo(1); + } finally { + firstCompletion.complete(1); + secondCompletion.complete(2); + } + } + } + + protected String expectedResource() { + return PUBLIC_BASE + "/v1/mcp"; + } + + protected String expectedMetadataUrl() { + return PUBLIC_BASE + METADATA_PATH; + } + + private String firstToken() { + return token(FIRST_SUBJECT, FIRST_ORG, "read-role", expectedResource()); + } + + private String mcpPath() { + return ConfigProvider.getConfig().getValue("quarkus.mcp.server.http.root-path", String.class); + } + + private RequestSpecification catalogRequest(int id) { + return request("tools/list").body(rpcPayload(id, "tools/list", Map.of())); + } + + private RequestSpecification providerRequest(int id) { + return request("tools/call") + .header("Mcp-Name", "findEmbeddingProviders") + .body(McpHttpTestSupport.toolPayload(id, "findEmbeddingProviders", Map.of())); + } + + private void assertChallenge(Response response) { + assertThat(response.statusCode()).as(response.asString()).isEqualTo(401); + assertThat(response.header("WWW-Authenticate")) + .as("HTTP headers: %s; body: %s", response.headers(), response.asString()) + .contains("Bearer", "resource_metadata=\"" + expectedMetadataUrl() + "\""); + } + + public static class OAuthProfile implements NoGlobalResourcesTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "stargate.feature.flags.mcp", + "true", + "stargate.auth.bearer.enabled", + "true", + "stargate.mcp.auth.server-url", + AUTH_SERVER, + "stargate.mcp.auth.public-base-url", + PUBLIC_BASE, + "mp.jwt.verify.issuer", + AUTH_SERVER, + "mp.jwt.verify.audiences", + PUBLIC_BASE + "/v1/mcp", + "mp.jwt.verify.publickey.location", + "mcp-oauth-public-key.pem"); + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthTestSupport.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthTestSupport.java new file mode 100644 index 0000000000..4b016d22c7 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOAuthTestSupport.java @@ -0,0 +1,131 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import io.vertx.core.json.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Objects; + +final class McpOAuthTestSupport { + static final String AUTH_SERVER = "https://auth.example.test"; + static final String PUBLIC_BASE = "https://data.example.test"; + static final String METADATA_PATH = "/.well-known/oauth-protected-resource"; + static final String FIRST_SUBJECT = "oauth-client-one"; + static final String SECOND_SUBJECT = "oauth-client-two"; + static final String FIRST_ORG = "11111111-1111-4111-8111-111111111111"; + static final String SECOND_ORG = "22222222-2222-4222-8222-222222222222"; + private static final Base64.Encoder BASE64 = Base64.getUrlEncoder().withoutPadding(); + + private McpOAuthTestSupport() {} + + static String token(String subject, String org, String role, String audience) { + return sign(claims(subject, org, role, audience), fixturePrivateKey()); + } + + static String invalidToken(InvalidToken kind, String audience) { + if (kind == InvalidToken.MALFORMED) { + return "malformed-access-token"; + } + if (kind == InvalidToken.EMPTY) { + return ""; + } + var claims = claims(FIRST_SUBJECT, FIRST_ORG, "read-role", audience); + switch (kind) { + case UNSIGNED -> { + return BASE64.encodeToString( + "{\"alg\":\"none\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8)) + + "." + + BASE64.encodeToString(claims.encode().getBytes(StandardCharsets.UTF_8)) + + "."; + } + case EXPIRED -> { + long now = Instant.now().getEpochSecond(); + claims.put("iat", now - 900).put("nbf", now - 900).put("exp", now - 300); + } + case WRONG_AUDIENCE -> claims.put("aud", List.of("https://another-resource.example.test")); + case MISSING_AUDIENCE -> claims.remove("aud"); + case MISSING_EXPIRATION -> claims.remove("exp"); + case WRONG_ISSUER -> claims.put("iss", "https://another-issuer.example.test"); + case NOT_YET_VALID -> claims.put("nbf", Instant.now().plusSeconds(300).getEpochSecond()); + case WRONG_SIGNATURE -> { + try { + var generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return sign(claims, generator.generateKeyPair().getPrivate()); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to generate a synthetic signing key", exception); + } + } + default -> throw new IllegalArgumentException("Unsupported invalid-token case: " + kind); + } + return sign(claims, fixturePrivateKey()); + } + + private static JsonObject claims(String subject, String org, String role, String audience) { + long now = Instant.now().getEpochSecond(); + return new JsonObject() + .put("iss", AUTH_SERVER) + .put("sub", subject) + .put("aud", List.of(audience)) + .put("iat", now - 60) + .put("nbf", now - 60) + .put("exp", now + 300) + .put("org", org) + .put("groups", List.of(role)); + } + + private static String sign(JsonObject claims, PrivateKey privateKey) { + var header = new JsonObject().put("alg", "RS256").put("typ", "JWT"); + String input = encode(header.encode()) + "." + encode(claims.encode()); + try { + var signer = Signature.getInstance("SHA256withRSA"); + signer.initSign(privateKey); + signer.update(input.getBytes(StandardCharsets.US_ASCII)); + return input + "." + BASE64.encodeToString(signer.sign()); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to sign a synthetic JWT", exception); + } + } + + private static String encode(String value) { + return BASE64.encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static PrivateKey fixturePrivateKey() { + // These checked-in synthetic keys are only used by authentication tests. + try (var stream = + Objects.requireNonNull( + McpOAuthTestSupport.class.getResourceAsStream("/mcp-oauth-private-key.pem"))) { + String pem = new String(stream.readAllBytes(), StandardCharsets.US_ASCII); + String contents = + pem.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + return KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(contents))); + } catch (IOException | GeneralSecurityException exception) { + throw new IllegalStateException("Unable to read the synthetic JWT signing key", exception); + } + } + + enum InvalidToken { + MALFORMED, + UNSIGNED, + EMPTY, + EXPIRED, + WRONG_AUDIENCE, + MISSING_AUDIENCE, + MISSING_EXPIRATION, + WRONG_ISSUER, + WRONG_SIGNATURE, + NOT_YET_VALID + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResourceTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResourceTest.java index e45043aa6a..e7dd5a3906 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResourceTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResourceTest.java @@ -27,6 +27,7 @@ import io.stargate.sgv2.jsonapi.api.request.EmbeddingCredentialsResolver; import io.stargate.sgv2.jsonapi.api.request.RequestContext; import io.stargate.sgv2.jsonapi.api.request.tenant.RequestTenantResolver; +import io.stargate.sgv2.jsonapi.api.request.token.PrincipalTokenResolver; import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; import io.stargate.sgv2.jsonapi.config.feature.ApiFeatures; import io.stargate.sgv2.jsonapi.metrics.JsonProcessingMetricsReporter; @@ -307,6 +308,7 @@ private McpResource mcpResource( routingContextProvider, securityIdentityProvider, tenantResolver, + instanceOf(new PrincipalTokenResolver()), embeddingCredentialsResolver); } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsTest.java index 636045ff94..1c22aee1ce 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsTest.java @@ -45,6 +45,7 @@ import io.stargate.sgv2.jsonapi.api.request.EmbeddingCredentialsResolver; import io.stargate.sgv2.jsonapi.api.request.RequestContext; import io.stargate.sgv2.jsonapi.api.request.tenant.RequestTenantResolver; +import io.stargate.sgv2.jsonapi.api.request.token.PrincipalTokenResolver; import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; import io.stargate.sgv2.jsonapi.config.feature.FeaturesConfig; import io.stargate.sgv2.jsonapi.exception.APIException; @@ -154,6 +155,7 @@ void setUp() { () -> routingContext, () -> securityIdentity, instanceOf(tenantResolver), + instanceOf(new PrincipalTokenResolver()), instanceOf(credentialsResolver)); } diff --git a/src/test/resources/mcp-oauth-private-key.pem b/src/test/resources/mcp-oauth-private-key.pem new file mode 100644 index 0000000000..311549b99e --- /dev/null +++ b/src/test/resources/mcp-oauth-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCJhhXz1GbDfaSz +KzyOlpxePcpL1QNmixIOej1JB3tNcy+2BlhYEOa6mL0I0vHz4/yKsDLk+S26NOds +kIfsEqHdD/hh3zGisfRhcng/MrGgJ+Zy/GLflVvLLpZYJ5IMtN+SRUEGTShqRVJE +3U1kby6slIEfJi+9TuOjuY+cN26pHqSc2VhZG9dzpR+mlkAISg3QcYilo7/XwEWW +NlnJiP5rRh4hkBJggC1NWhBQE/H2iDrC20GuhyP5MMRhZBvMuu4KxzPvQSt2bWCy +48hLotnynYL6Jc7ehAOWwJb0nCzLD0Q8FK6HcvvhY26JZYhX2fnh0fNAwrZwoQjh +IPWk3CXHAgMBAAECggEANPHxW6knUxX5JZ18t+yWENY7HszIfV8HOZmoJIiD2QKC +mNKCio0fNzF8fXww+FOKs544Sd0A1NJvUmWmXqWhuWjfmFaEVn2qdyXJ05LhvaX+ +27/9FNT8vRGXgiLh4JB2DmspKJLl/1vm22pT1FnVn02hFGvwLzLDAxJ6jorsyKDZ +/si1BLKt3eUb+9/Q5Ky3lZVn/KWvr+UXd2O6lR/GAwo8vhsQ1IHtzFLkmOHt19Kw +C51dRW8rk/RGny3065Aw079L/Ffs7Mq49zXml9H5WwFb/sfVYRQoszdVGu7jD9Gn +OLeca8qRfM2kMQqPJTKl4pA3s+xNy4OrV7sG4v9S2QKBgQC/v0DGZrtdv0bAQZ0x +e5Mr5/zJ1GNpt86eNvXid8z7p89ys2Gg9XS2iRvlcuErkqkf/IUIBrHsJ9BCbZp1 +/BaK07M9WDHkQkIrzuJNhBFngG1Q6tE6Ng9JbBuYoT6AdmJ7weGfIIvOIcg9FL4R +sK5taATRB/zAfQTR3hWBUWse5QKBgQC3m11B0/DxLlOo0Ba/QwvilbQNW+SZzfRR +j3RlFoRtoserWxvBGdjHwvJ/XUJKlo6I2SybQSDJhEmKc0JgfZV1Xg8BIrvc9IPJ +Kymrv4lmOFO847vTyfQTNMzyNVVFqfJoAMfvRl03oyUKSMOWRWxaG4/Y/q+Z7U3/ +uI6wmsR7OwKBgEFNsZHgiw9yhXYU4qRnocfiSoWyf+kRYJ0hurxWcMmEbXJzBVz8 +O7OvHoEWjvl4CYGLTwNcxFvZc2QsT96+7C57yx+pWdSTfFXPL+OWhxbo9lIIpxlc +L/ihiQSCZfb6WrArZ9v6CaM6fifPWKxNsBoywvEt9zbXXTzclhZk1EbBAoGAYemL +WOByO9utp3eYudRCUUnuI7WeedrX8Gqk8oeIhYLqIdpLObXT4et3MC6GfIo/ZoES +BZU8q9pM8XU3r4cpHdbT/5ILRvBqGuxWo41X81gUZ8W2U2H+NAPTS1Lfr2/KomXQ +OSx354Sr6wkUrF1HQ+Iw2tIuyNJA5x7/kPRn1SMCgYBtSLQAWKn3eX2GfgZmyzxq +K4TxgD894QQa4C3OsuzqbO4X3MFpwdhIOhUQ3saQFF7VBxEyKTK7IJgUDY3GIZ6w +D0chuAOwIDhvr/uT7nahLHdJCrlBM8k80XM9zVH0A81cj7HcCDOGoYjVXm9RWKxS +2vVEFeEiUyKMd7xzZru/+w== +-----END PRIVATE KEY----- diff --git a/src/test/resources/mcp-oauth-public-key.pem b/src/test/resources/mcp-oauth-public-key.pem new file mode 100644 index 0000000000..a1221471c4 --- /dev/null +++ b/src/test/resources/mcp-oauth-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiYYV89Rmw32ksys8jpac +Xj3KS9UDZosSDno9SQd7TXMvtgZYWBDmupi9CNLx8+P8irAy5PktujTnbJCH7BKh +3Q/4Yd8xorH0YXJ4PzKxoCfmcvxi35Vbyy6WWCeSDLTfkkVBBk0oakVSRN1NZG8u +rJSBHyYvvU7jo7mPnDduqR6knNlYWRvXc6UfppZACEoN0HGIpaO/18BFljZZyYj+ +a0YeIZASYIAtTVoQUBPx9og6wttBrocj+TDEYWQbzLruCscz70Erdm1gsuPIS6LZ +8p2C+iXO3oQDlsCW9Jwsyw9EPBSuh3L74WNuiWWIV9n54dHzQMK2cKEI4SD1pNwl +xwIDAQAB +-----END PUBLIC KEY-----