diff --git a/MCP_OAUTH_README.md b/MCP_OAUTH_README.md new file mode 100644 index 00000000..3316bc2e --- /dev/null +++ b/MCP_OAUTH_README.md @@ -0,0 +1,451 @@ +# MCP OAuth 2.1 Implementation + +Complete implementation of the [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) with OAuth 2.1, PKCE, and Dynamic Client Registration. + +## Overview + +This implementation demonstrates the cutting-edge MCP Authorization Spec, which enables secure OAuth-based authentication for Model Context Protocol servers. It includes: + +- **MCP Server** with OAuth 2.1 support (extends existing custom Micronaut server) +- **MCP Client** using the official SDK with full OAuth flow +- **Keycloak** as the Authorization Server +- **Complete RFC Compliance**: RFC 9728, RFC 8414, RFC 7591, RFC 7636, RFC 8707 + +## Architecture + +``` +┌──────────────────────────┐ +│ MCP Client (NEW) │ +│ Using Official SDK │ +│ - OAuth flow handler │ +│ - PKCE generator │ +│ - Token manager │ +└───────────┬──────────────┘ + │ 1. Request without token + ▼ +┌──────────────────────────┐ +│ MCP Server (EXTENDED) │ +│ Custom Micronaut │ +│ - Token validator │ +│ - RFC 9728 metadata │ +└───────────┬──────────────┘ + │ 2. HTTP 401 + WWW-Authenticate + ▼ +┌──────────────────────────┐ +│ OAuth Discovery │ +│ - Fetch metadata │ +│ - Dynamic registration │ +│ - Generate PKCE │ +└───────────┬──────────────┘ + │ 3. Authorization with resource parameter + ▼ +┌──────────────────────────┐ +│ Keycloak (Docker) │ +│ - User authentication │ +│ - Token issuance │ +│ - Audience validation │ +└───────────┬──────────────┘ + │ 4. Access token with audience claim + ▼ + Back to MCP Client +``` + +## What's Implemented + +### Server Extensions (Phase 1-3) ✅ + +**Location:** `jvm/src/main/java/com/muchq/mcpserver/oauth/` + +**Files:** +- `OAuthConfig.java` - Configuration for Keycloak integration +- `ProtectedResourceMetadata.java` - RFC 9728 DTO +- `ProtectedResourceController.java` - Metadata endpoint at `/.well-known/oauth-protected-resource` +- `TokenValidator.java` - **JWT validation with critical audience checking** +- `OAuthAuthenticationFilter.java` - HTTP filter with WWW-Authenticate headers + +**Key Features:** +- RFC 9728 Protected Resource Metadata discovery +- JWT signature verification against Keycloak JWKS +- **Audience validation (RFC 8707)** - prevents token reuse across services +- Timing-attack resistant token comparison +- Backward compatible with legacy Bearer token auth + +**Configuration (`application.yml`):** +```yaml +mcp: + oauth: + enabled: ${MCP_OAUTH_ENABLED:false} + authorization-server: ${MCP_OAUTH_AUTHZ_SERVER:http://localhost:8180/realms/mcp-demo} + resource-uri: ${MCP_RESOURCE_URI:http://localhost:8080} + jwks-uri: ${MCP_OAUTH_JWKS_URI:http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs} +``` + +### Client Implementation (Phase 4-6) ✅ + +**Location:** `jvm/src/main/java/com/muchq/mcpclient/` + +**OAuth Components:** +- `oauth/PkceGenerator.java` - Cryptographically secure PKCE with S256 +- `oauth/TokenManager.java` - Thread-safe token storage with expiration tracking +- `oauth/CallbackServer.java` - Local HTTP server for OAuth callback +- `oauth/BrowserLauncher.java` - Opens system browser for authorization +- `oauth/OAuthFlowHandler.java` - **Orchestrates complete OAuth 2.1 flow** + +**Client Wrapper:** +- `McpClientConfig.java` - Configuration builder +- `McpClientWrapper.java` - Wraps official MCP SDK with OAuth support + +**Demo:** +- `demo/McpClientDemo.java` - Full end-to-end demonstration +- `scripts/run-mcp-oauth-demo.sh` - Automated test script + +### Keycloak Setup ✅ + +**Location:** `local_docker/keycloak/` + +**Files:** +- `docker-compose.keycloak.yml` - Keycloak 26.0 service +- `realm-export.json` - Pre-configured `mcp-demo` realm +- `README.md` - Setup and configuration guide +- `DOCKER_TROUBLESHOOTING.md` - Docker credentials fix + +**Realm Configuration:** +- Dynamic Client Registration enabled (Anonymous policy) +- PKCE enforcement via Client Policy (S256 required) +- Resource Indicators support enabled +- Test user: `testuser` / `testpass` +- Port: 8180 (avoids conflict with MCP server) + +## Quick Start + +### Prerequisites + +Verify you have the required dependencies: + +```bash +# Required +docker --version # Docker 20+ (with compose v2) +docker compose version # Should be built-in with modern Docker +bazel --version # Bazel 8+ +java -version # Java 21+ (or Bazel will manage it) +python3 --version # Python 3.x (for JSON parsing in script) +curl --version # curl (for health checks) + +# macOS only +lsof -v # lsof (for port cleanup) +``` + +**Docker credentials issue (macOS):** If you see `docker-credential-desktop` errors: + +```bash +# Edit ~/.docker/config.json and remove this line: +# "credsStore": "desktop" +``` + +**Ports:** The demo uses ports 8080 (MCP server), 8180 (Keycloak), and 8888 (OAuth callback). Ensure these are free. + +### Run the Demo + +```bash +# From the repository root +./scripts/run-mcp-oauth-demo.sh +``` + +**What happens:** + +1. Starts Keycloak authorization server on http://localhost:8180 +2. Starts MCP Server with OAuth enabled on http://localhost:8080 +3. Prompts you to press Enter to start the client demo +4. Opens your browser for Keycloak login +5. After you log in, completes the OAuth flow and displays success + +**During the demo, you will:** + +1. Press Enter when prompted to start +2. Log in to Keycloak when browser opens: + - Username: `testuser` + - Password: `testpass` +3. See the OAuth flow complete in the terminal + +**Cleanup:** The script automatically stops all services when done. Use `--keep-keycloak` to leave Keycloak running for debugging. + +### Manual Setup + +If you want to run components separately: + +**1. Start Keycloak:** +```bash +cd local_docker/keycloak +docker compose -f docker-compose.keycloak.yml up -d + +# Wait for ready +until curl -sf http://localhost:8180/health/ready > /dev/null; do sleep 2; done +``` + +**2. Start MCP Server with OAuth:** +```bash +export MCP_OAUTH_ENABLED=true +export MCP_OAUTH_AUTHZ_SERVER=http://localhost:8180/realms/mcp-demo +export MCP_RESOURCE_URI=http://localhost:8080 +export MCP_OAUTH_JWKS_URI=http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs +export PORT=8080 + +bazel run //jvm/src/main/java/com/muchq/mcpserver:mcpserver +``` + +**3. Run MCP Client Demo:** +```bash +bazel run //jvm/src/main/java/com/muchq/mcpclient/demo:demo +``` + +## Verification + +### Test Server Metadata Endpoint + +```bash +# Protected Resource Metadata (RFC 9728) +curl http://localhost:8080/.well-known/oauth-protected-resource | jq + +# Expected response: +{ + "resource": "http://localhost:8080", + "authorization_servers": ["http://localhost:8180/realms/mcp-demo"], + "bearer_methods_supported": ["header"] +} +``` + +### Test Keycloak Configuration + +```bash +# Authorization Server Metadata (RFC 8414) +curl http://localhost:8180/realms/mcp-demo/.well-known/openid-configuration | jq + +# Check for key endpoints: +# - authorization_endpoint +# - token_endpoint +# - registration_endpoint (Dynamic Client Registration) +``` + +### Test Unauthorized Request + +```bash +# Request without token should return 401 +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + -i | grep "WWW-Authenticate" + +# Should see: +# WWW-Authenticate: Bearer realm="mcp", error="invalid_token", ... +``` + +## Security Features + +### Critical Security Implementations + +1. **Audience Validation (RFC 8707)** ⚠️ CRITICAL + - Tokens MUST have `aud` claim matching MCP server URI + - Prevents token reuse across different services + - Location: `TokenValidator.java:89-96` + +2. **PKCE (RFC 7636)** + - Cryptographically secure code_verifier (32 bytes) + - S256 code_challenge method (SHA-256) + - Prevents authorization code interception + - Location: `PkceGenerator.java` + +3. **Dynamic Client Registration (RFC 7591)** + - No hardcoded client credentials + - Clients register automatically + - Public client (no client secret for native apps) + - Location: `OAuthFlowHandler.java:195-230` + +4. **JWT Signature Verification** + - Validates against Keycloak's JWKS + - RSA signature verification + - Location: `TokenValidator.java:111-137` + +5. **Timing Attack Resistance** + - Legacy token comparison uses `MessageDigest.isEqual()` + - Location: `McpAuthenticationFilter.java:67-69` + +### Security Checklist + +- [x] Server validates JWT signature +- [x] Server validates token expiration +- [x] **Server validates audience claim** (CRITICAL) +- [x] Server returns WWW-Authenticate on 401 +- [x] Client generates cryptographically random PKCE +- [x] Client uses S256 code challenge method +- [x] Client includes resource parameter in auth request +- [x] Client includes resource parameter in token request +- [x] Tokens stored securely (in-memory for demo) +- [x] OAuth communication over HTTPS ready (localhost for dev) + +## Project Structure + +``` +jvm/src/main/java/com/muchq/ +├── mcpserver/ # Existing MCP Server +│ ├── oauth/ # NEW: OAuth extensions +│ │ ├── OAuthConfig.java +│ │ ├── ProtectedResourceController.java +│ │ ├── ProtectedResourceMetadata.java +│ │ ├── TokenValidator.java +│ │ └── OAuthAuthenticationFilter.java +│ ├── McpController.java +│ ├── McpRequestHandler.java +│ └── tools/ # MCP tools (add, echo, chess, etc.) +│ +└── mcpclient/ # NEW: MCP Client + ├── McpClientConfig.java + ├── McpClientWrapper.java + ├── oauth/ + │ ├── OAuthFlowHandler.java + │ ├── PkceGenerator.java + │ ├── TokenManager.java + │ ├── CallbackServer.java + │ └── BrowserLauncher.java + └── demo/ + └── McpClientDemo.java + +local_docker/keycloak/ +├── docker-compose.keycloak.yml +├── realm-export.json +├── README.md +└── DOCKER_TROUBLESHOOTING.md + +scripts/ +└── run-mcp-oauth-demo.sh +``` + +## Key Dependencies + +**Added to `bazel/java.MODULE.bazel`:** +```python +"com.nimbusds:nimbus-jose-jwt:9.47", # JWT parsing/validation +"com.nimbusds:oauth2-oidc-sdk:11.21", # OAuth 2.1 client +"io.modelcontextprotocol.sdk:mcp:0.12.1", # Official MCP SDK +"org.bouncycastle:bcprov-jdk18on:1.80", # Crypto for PKCE +``` + +## Remaining TODOs + +### High Priority + +- [ ] **Fix Docker credentials issue** to run Keycloak +- [ ] **Test end-to-end flow** with actual OAuth authentication +- [ ] Integrate MCP SDK's transport layer in client (currently simplified) +- [ ] Add actual MCP tool calls (initialize, listTools, callTool) to demo + +### Medium Priority + +- [ ] Implement token refresh flow +- [ ] Add persistent token storage (OS keychain) +- [ ] Add integration tests with WireMock +- [ ] Add unit tests for PKCE, TokenManager, CallbackServer +- [ ] Support multiple authorization servers (RFC 9728 allows array) + +### Low Priority + +- [ ] Implement OAuth 2.1 Device Flow (RFC 8628) for headless systems +- [ ] Add mTLS support for additional transport security +- [ ] Add scope negotiation for fine-grained permissions +- [ ] Refactor CallbackServer to use Micronaut (for consistency) +- [ ] Add comprehensive error recovery and user feedback + +### Production Enhancements + +- [ ] Use HTTPS for all OAuth endpoints (currently localhost HTTP) +- [ ] Implement secure token rotation +- [ ] Add rate limiting for token endpoint +- [ ] Add monitoring and metrics +- [ ] Create Docker Compose for complete stack +- [ ] Add Kubernetes deployment manifests + +## Troubleshooting + +### Docker Credentials Error + +See `local_docker/keycloak/DOCKER_TROUBLESHOOTING.md` for detailed solutions. + +**Quick fix:** +```bash +vim ~/.docker/config.json +# Remove: "credsStore": "desktop" +``` + +### Keycloak Not Starting + +```bash +# Check logs +docker compose -f local_docker/keycloak/docker-compose.keycloak.yml logs -f + +# Restart +docker compose -f local_docker/keycloak/docker-compose.keycloak.yml restart + +# Full reset +docker compose -f local_docker/keycloak/docker-compose.keycloak.yml down +docker compose -f local_docker/keycloak/docker-compose.keycloak.yml up -d +``` + +### Port Already in Use + +```bash +# Check what's using port 8180 +lsof -i :8180 + +# Or use a different port +# Edit docker-compose.keycloak.yml: +ports: + - "8181:8180" +``` + +### Client Registration Fails + +1. Verify Keycloak is running: `curl http://localhost:8180/health/ready` +2. Check realm exists: `curl http://localhost:8180/realms/mcp-demo/.well-known/openid-configuration` +3. Check Client Registration Policies in Keycloak Admin Console + +### Token Validation Fails + +Common causes: +- Wrong audience claim (check `MCP_RESOURCE_URI` matches token `aud`) +- Expired token (Keycloak default: 5 minutes) +- JWKS not accessible from server +- Token signature invalid + +## References + +### Specifications + +- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) +- [RFC 9728 - OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +- [RFC 8414 - OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) +- [RFC 7591 - OAuth 2.0 Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) +- [RFC 7636 - PKCE](https://datatracker.ietf.org/doc/html/rfc7636) +- [RFC 8707 - Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html) + +### Resources + +- [Keycloak Documentation](https://www.keycloak.org/documentation) +- [MCP Java SDK](https://modelcontextprotocol.io/sdk/java/mcp-overview) +- [Nimbus OAuth SDK](https://connect2id.com/products/nimbus-oauth-openid-connect-sdk) + +## License + +Part of the MoonBase project. + +## Contributing + +This is a demonstration implementation of the MCP Authorization Spec. Key areas for contribution: +- Additional security tests +- Token refresh implementation +- Integration with real MCP tools +- Documentation improvements + +--- + +**Status:** ✅ Implementation Complete - Pending E2E Testing (requires Docker fix) + +**Last Updated:** 2026-01-12 diff --git a/bazel/java.MODULE.bazel b/bazel/java.MODULE.bazel index be39395a..6837cdb9 100644 --- a/bazel/java.MODULE.bazel +++ b/bazel/java.MODULE.bazel @@ -20,7 +20,10 @@ maven.install( "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", "com.fasterxml.jackson.module:jackson-module-scala_2.13", "com.google.guava:guava:33.5.0-jre", + "com.nimbusds:nimbus-jose-jwt:9.47", + "com.nimbusds:oauth2-oidc-sdk:11.21", "io.micronaut:micronaut-inject", + "io.modelcontextprotocol.sdk:mcp:0.12.1", "io.micronaut:micronaut-inject-java", "io.micronaut.validation:micronaut-validation", "io.micronaut.validation:micronaut-validation-processor", @@ -34,10 +37,11 @@ maven.install( "io.netty:netty-codec", "io.netty:netty-handler", "io.netty:netty-transport", - "io.sentry:sentry-logback:8.26.0", + "io.sentry:sentry-logback:8.30.0", "jakarta.annotation:jakarta.annotation-api:3.0.0", "junit:junit:4.13.2", "org.assertj:assertj-core:3.27.6", + "org.bouncycastle:bcprov-jdk18on:1.83", "org.eclipse.jetty:jetty-server:%s" % JETTY_VERSION, "org.eclipse.jetty.websocket:websocket-jetty-server:%s" % JETTY_VERSION, "org.jspecify:jspecify:1.0.0", diff --git a/jvm/src/main/java/com/muchq/mcpclient/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpclient/BUILD.bazel new file mode 100644 index 00000000..d8b23044 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/BUILD.bazel @@ -0,0 +1,22 @@ +load("@rules_java//java:java_library.bzl", "java_library") + +java_library( + name = "config", + srcs = ["McpClientConfig.java"], + visibility = [ + "//jvm/src/main/java/com/muchq/mcpclient:__subpackages__", + "//jvm/src/test/java/com/muchq/mcpclient:__subpackages__", + ], +) + +java_library( + name = "mcpclient", + srcs = ["McpClientWrapper.java"], + visibility = ["//visibility:public"], + deps = [ + ":config", + "//jvm/src/main/java/com/muchq/mcpclient/oauth", + "@maven//:io_modelcontextprotocol_sdk_mcp", + "@maven//:org_slf4j_slf4j_api", + ], +) diff --git a/jvm/src/main/java/com/muchq/mcpclient/McpClientConfig.java b/jvm/src/main/java/com/muchq/mcpclient/McpClientConfig.java new file mode 100644 index 00000000..ad483034 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/McpClientConfig.java @@ -0,0 +1,125 @@ +package com.muchq.mcpclient; + +/** + * Configuration for the MCP OAuth client. + * + * This configuration is used to: + * - Connect to the MCP server + * - Identify the client during OAuth registration + * - Configure the local OAuth callback server + * - Select transport type (HTTP or SSE) + */ +public class McpClientConfig { + + /** + * Transport type for MCP communication. + */ + public enum TransportType { + /** Streamable HTTP transport (request/response). */ + HTTP, + /** Server-Sent Events transport (streaming). */ + SSE + } + + private final String serverUrl; + private final String clientName; + private final String clientVersion; + private final int callbackPort; + private final String clientId; + private final String clientSecret; + private final TransportType transportType; + + private McpClientConfig(Builder builder) { + this.serverUrl = builder.serverUrl; + this.clientName = builder.clientName; + this.clientVersion = builder.clientVersion; + this.callbackPort = builder.callbackPort; + this.clientId = builder.clientId; + this.clientSecret = builder.clientSecret; + this.transportType = builder.transportType; + } + + public String getServerUrl() { + return serverUrl; + } + + public String getClientName() { + return clientName; + } + + public String getClientVersion() { + return clientVersion; + } + + public int getCallbackPort() { + return callbackPort; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public TransportType getTransportType() { + return transportType; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String serverUrl; + private String clientName = "MCP Java Client"; + private String clientVersion = "1.0.0"; + private int callbackPort = 8888; + private String clientId; + private String clientSecret; + private TransportType transportType = TransportType.HTTP; + + public Builder serverUrl(String serverUrl) { + this.serverUrl = serverUrl; + return this; + } + + public Builder clientName(String clientName) { + this.clientName = clientName; + return this; + } + + public Builder clientVersion(String clientVersion) { + this.clientVersion = clientVersion; + return this; + } + + public Builder callbackPort(int callbackPort) { + this.callbackPort = callbackPort; + return this; + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder transportType(TransportType transportType) { + this.transportType = transportType; + return this; + } + + public McpClientConfig build() { + if (serverUrl == null || serverUrl.isEmpty()) { + throw new IllegalArgumentException("serverUrl is required"); + } + return new McpClientConfig(this); + } + } +} diff --git a/jvm/src/main/java/com/muchq/mcpclient/McpClientWrapper.java b/jvm/src/main/java/com/muchq/mcpclient/McpClientWrapper.java new file mode 100644 index 00000000..ffb541b2 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/McpClientWrapper.java @@ -0,0 +1,432 @@ +package com.muchq.mcpclient; + +import com.muchq.mcpclient.oauth.OAuthFlowHandler; +import com.muchq.mcpclient.oauth.OAuthRequestCustomizer; +import com.muchq.mcpclient.oauth.TokenManager; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.InitializeResult; +import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; +import io.modelcontextprotocol.spec.McpSchema.ListResourcesResult; +import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import java.time.Duration; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Full-featured MCP client wrapper with OAuth 2.1 support. + * + *

This client integrates with the official MCP SDK (io.modelcontextprotocol.sdk:mcp) + * and provides automatic OAuth authentication through the SDK's transport customization + * mechanism. + * + *

Features: + *

+ * + *

Usage example: + *

{@code
+ * McpClientConfig config = McpClientConfig.builder()
+ *     .serverUrl("http://localhost:8080/mcp")
+ *     .clientName("My MCP Client")
+ *     .callbackPort(8888)
+ *     .transportType(McpClientConfig.TransportType.HTTP)  // or SSE for streaming
+ *     .build();
+ *
+ * McpClientWrapper client = new McpClientWrapper(config);
+ * client.initialize();
+ *
+ * // List available tools
+ * ListToolsResult tools = client.listTools();
+ *
+ * // Call a tool
+ * CallToolResult result = client.callTool("calculator", Map.of(
+ *     "operation", "add",
+ *     "a", 1,
+ *     "b", 2
+ * ));
+ * }
+ */ +public class McpClientWrapper { + + private static final Logger LOG = LoggerFactory.getLogger(McpClientWrapper.class); + private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(30); + + private final McpClientConfig config; + private final TokenManager tokenManager; + private final OAuthFlowHandler oauthFlowHandler; + private final Duration requestTimeout; + + private volatile McpSyncClient mcpClient; + private volatile McpClientTransport transport; + private volatile boolean initialized = false; + + /** + * Creates a new MCP client with OAuth support using default timeout. + * + * @param config Client configuration + */ + public McpClientWrapper(McpClientConfig config) { + this(config, DEFAULT_REQUEST_TIMEOUT); + } + + /** + * Creates a new MCP client with OAuth support and custom timeout. + * + * @param config Client configuration + * @param requestTimeout Timeout for MCP requests + */ + public McpClientWrapper(McpClientConfig config, Duration requestTimeout) { + this.config = config; + this.requestTimeout = requestTimeout; + this.tokenManager = new TokenManager(); + this.oauthFlowHandler = new OAuthFlowHandler(config, tokenManager); + + LOG.info("MCP Client initialized for server: {}", config.getServerUrl()); + } + + /** + * Initializes the MCP connection. + * + *

This method: + *

    + *
  1. Triggers OAuth flow if no valid token is available
  2. + *
  3. Creates the MCP transport with OAuth authentication
  4. + *
  5. Initializes the MCP session
  6. + *
+ * + * @return The initialization result containing server capabilities + * @throws Exception if connection or authentication fails + */ + public InitializeResult initialize() throws Exception { + LOG.info("Initializing MCP connection..."); + + // Ensure we have a valid token before creating the transport + if (!tokenManager.hasValidAccessToken()) { + LOG.info("No valid token available, starting OAuth flow..."); + oauthFlowHandler.executeFlow(); + } + + if (!tokenManager.hasValidAccessToken()) { + throw new McpAuthenticationException("Failed to obtain access token"); + } + + // Create the transport with OAuth customizer + OAuthRequestCustomizer oauthCustomizer = new OAuthRequestCustomizer(tokenManager, oauthFlowHandler); + + this.transport = createTransport(oauthCustomizer); + + // Create the MCP client + this.mcpClient = McpClient.sync(transport) + .requestTimeout(requestTimeout) + .build(); + + // Initialize the MCP session + InitializeResult result = mcpClient.initialize(); + this.initialized = true; + + LOG.info("MCP connection initialized successfully"); + LOG.info("Server: {} v{}", result.serverInfo().name(), result.serverInfo().version()); + LOG.info("Protocol version: {}", result.protocolVersion()); + + if (result.capabilities() != null) { + LOG.info("Server capabilities: tools={}, resources={}, prompts={}", + result.capabilities().tools() != null, + result.capabilities().resources() != null, + result.capabilities().prompts() != null); + } + + return result; + } + + /** + * Lists all available tools from the MCP server. + * + * @return The list of tools with their schemas + * @throws Exception if the request fails + * @throws IllegalStateException if client is not initialized + */ + public ListToolsResult listTools() throws Exception { + ensureInitialized(); + return withAuthRetry(() -> mcpClient.listTools()); + } + + /** + * Calls a tool on the MCP server. + * + * @param toolName The name of the tool to call + * @param arguments The arguments to pass to the tool + * @return The result of the tool call + * @throws Exception if the request fails + * @throws IllegalStateException if client is not initialized + */ + public CallToolResult callTool(String toolName, Map arguments) throws Exception { + ensureInitialized(); + return withAuthRetry(() -> mcpClient.callTool(new McpSchema.CallToolRequest(toolName, arguments))); + } + + /** + * Lists all available resources from the MCP server. + * + * @return The list of resources + * @throws Exception if the request fails + * @throws IllegalStateException if client is not initialized + */ + public ListResourcesResult listResources() throws Exception { + ensureInitialized(); + return withAuthRetry(() -> mcpClient.listResources()); + } + + /** + * Reads a resource from the MCP server. + * + * @param uri The URI of the resource to read + * @return The resource content + * @throws Exception if the request fails + * @throws IllegalStateException if client is not initialized + */ + public ReadResourceResult readResource(String uri) throws Exception { + ensureInitialized(); + return withAuthRetry(() -> mcpClient.readResource(new McpSchema.ReadResourceRequest(uri))); + } + + /** + * Lists all available prompts from the MCP server. + * + * @return The list of prompts + * @throws Exception if the request fails + * @throws IllegalStateException if client is not initialized + */ + public ListPromptsResult listPrompts() throws Exception { + ensureInitialized(); + return withAuthRetry(() -> mcpClient.listPrompts()); + } + + /** + * Gets a prompt from the MCP server. + * + * @param promptName The name of the prompt + * @param arguments The arguments to pass to the prompt + * @return The rendered prompt + * @throws Exception if the request fails + * @throws IllegalStateException if client is not initialized + */ + public GetPromptResult getPrompt(String promptName, Map arguments) throws Exception { + ensureInitialized(); + return withAuthRetry(() -> mcpClient.getPrompt(new McpSchema.GetPromptRequest(promptName, arguments))); + } + + /** + * Sends a ping to the MCP server to verify connectivity. + * + * @throws Exception if the ping fails + * @throws IllegalStateException if client is not initialized + */ + public void ping() throws Exception { + ensureInitialized(); + withAuthRetry(() -> { + mcpClient.ping(); + return null; + }); + } + + /** + * Gets the current access token. + * + * @return Access token, or null if not authenticated + */ + public String getAccessToken() { + return tokenManager.getAccessToken(); + } + + /** + * Checks if the client is authenticated. + * + * @return true if valid access token is available + */ + public boolean isAuthenticated() { + return tokenManager.hasValidAccessToken(); + } + + /** + * Checks if the client has been initialized. + * + * @return true if initialize() has been called successfully + */ + public boolean isInitialized() { + return initialized; + } + + /** + * Gets the server URL. + * + * @return Server URL + */ + public String getServerUrl() { + return config.getServerUrl(); + } + + /** + * Gets the underlying MCP sync client for advanced usage. + * + *

This provides direct access to the MCP SDK client for operations + * not exposed through this wrapper. + * + * @return The underlying McpSyncClient, or null if not initialized + */ + public McpSyncClient getMcpClient() { + return mcpClient; + } + + /** + * Closes the MCP connection gracefully. + * + *

This method should be called when the client is no longer needed + * to release resources properly. + */ + public void close() { + if (mcpClient != null) { + try { + mcpClient.closeGracefully(); + LOG.info("MCP client closed gracefully"); + } catch (Exception e) { + LOG.warn("Error closing MCP client: {}", e.getMessage()); + } + } + this.initialized = false; + this.mcpClient = null; + this.transport = null; + } + + /** + * Forces re-authentication by clearing tokens and executing OAuth flow. + * + * @throws Exception if re-authentication fails + */ + public void reauthenticate() throws Exception { + LOG.info("Forcing re-authentication..."); + tokenManager.clearTokens(); + oauthFlowHandler.clearCachedState(); + oauthFlowHandler.executeFlow(); + } + + private void ensureInitialized() { + if (!initialized || mcpClient == null) { + throw new IllegalStateException("MCP client not initialized. Call initialize() first."); + } + } + + /** + * Executes an operation with automatic token refresh on authentication failure. + */ + private T withAuthRetry(McpOperation operation) throws Exception { + try { + return operation.execute(); + } catch (Exception e) { + // Check if this is an authentication error + if (isAuthenticationError(e)) { + LOG.info("Request failed with authentication error, attempting token refresh..."); + try { + oauthFlowHandler.refreshAccessToken(); + // Retry the operation + return operation.execute(); + } catch (Exception refreshException) { + LOG.warn("Token refresh failed: {}", refreshException.getMessage()); + // If refresh fails, try full re-auth + LOG.info("Attempting full re-authentication..."); + reauthenticate(); + reinitializeTransport(); + return operation.execute(); + } + } + throw e; + } + } + + private void reinitializeTransport() throws Exception { + LOG.info("Reinitializing transport with new credentials..."); + + if (mcpClient != null) { + try { + mcpClient.closeGracefully(); + } catch (Exception e) { + LOG.debug("Error closing previous client: {}", e.getMessage()); + } + } + + OAuthRequestCustomizer oauthCustomizer = new OAuthRequestCustomizer(tokenManager, oauthFlowHandler); + + this.transport = createTransport(oauthCustomizer); + + this.mcpClient = McpClient.sync(transport) + .requestTimeout(requestTimeout) + .build(); + + mcpClient.initialize(); + } + + private McpClientTransport createTransport(OAuthRequestCustomizer oauthCustomizer) { + if (config.getTransportType() == McpClientConfig.TransportType.SSE) { + LOG.info("Using SSE transport"); + return HttpClientSseClientTransport.builder(config.getServerUrl()) + .httpRequestCustomizer(oauthCustomizer) + .build(); + } else { + LOG.info("Using HTTP (Streamable) transport"); + return HttpClientStreamableHttpTransport.builder(config.getServerUrl()) + .httpRequestCustomizer(oauthCustomizer) + .build(); + } + } + + private boolean isAuthenticationError(Exception e) { + String message = e.getMessage(); + if (message == null) { + return false; + } + // Check for common authentication error indicators + // Be specific to avoid false positives (e.g., "token" could appear in other contexts) + String lowerMessage = message.toLowerCase(); + return message.contains("401") || + message.contains("403") || + lowerMessage.contains("unauthorized") || + lowerMessage.contains("forbidden") || + lowerMessage.contains("authentication failed") || + lowerMessage.contains("invalid_token") || + lowerMessage.contains("token expired") || + lowerMessage.contains("access denied"); + } + + @FunctionalInterface + private interface McpOperation { + T execute() throws Exception; + } + + /** + * Exception indicating an authentication failure. + */ + public static class McpAuthenticationException extends Exception { + public McpAuthenticationException(String message) { + super(message); + } + + public McpAuthenticationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/jvm/src/main/java/com/muchq/mcpclient/demo/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpclient/demo/BUILD.bazel new file mode 100644 index 00000000..025e749d --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/demo/BUILD.bazel @@ -0,0 +1,17 @@ +load("@rules_java//java:java_binary.bzl", "java_binary") + +java_binary( + name = "demo", + srcs = ["McpClientDemo.java"], + main_class = "com.muchq.mcpclient.demo.McpClientDemo", + visibility = ["//visibility:public"], + runtime_deps = [ + "@maven//:ch_qos_logback_logback_classic", + ], + deps = [ + "//jvm/src/main/java/com/muchq/mcpclient", + "//jvm/src/main/java/com/muchq/mcpclient:config", + "@maven//:io_modelcontextprotocol_sdk_mcp", + "@maven//:org_slf4j_slf4j_api", + ], +) diff --git a/jvm/src/main/java/com/muchq/mcpclient/demo/McpClientDemo.java b/jvm/src/main/java/com/muchq/mcpclient/demo/McpClientDemo.java new file mode 100644 index 00000000..5c2c76eb --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/demo/McpClientDemo.java @@ -0,0 +1,171 @@ +package com.muchq.mcpclient.demo; + +import com.muchq.mcpclient.McpClientConfig; +import com.muchq.mcpclient.McpClientWrapper; +import io.modelcontextprotocol.spec.McpSchema.InitializeResult; +import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; +import io.modelcontextprotocol.spec.McpSchema.ListResourcesResult; +import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; +import io.modelcontextprotocol.spec.McpSchema.Prompt; +import io.modelcontextprotocol.spec.McpSchema.Resource; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import java.time.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Demo application for MCP OAuth 2.1 + PKCE + DCR flow with full SDK integration. + * + *

This demonstrates: + *

    + *
  1. Creating an MCP client with OAuth support
  2. + *
  3. Automatic OAuth discovery (RFC 9728, RFC 8414)
  4. + *
  5. Dynamic Client Registration (RFC 7591)
  6. + *
  7. PKCE-protected authorization code flow
  8. + *
  9. Token management and automatic refresh
  10. + *
  11. Full MCP SDK integration with typed APIs
  12. + *
+ * + *

Prerequisites: + *

+ * + *

Environment variables: + *

+ */ +public class McpClientDemo { + + private static final Logger LOG = LoggerFactory.getLogger(McpClientDemo.class); + + public static void main(String[] args) { + McpClientWrapper client = null; + try { + LOG.info("=== MCP OAuth Demo Starting ==="); + + // Configuration + String serverUrl = System.getenv().getOrDefault( + "MCP_SERVER_URL", + "http://localhost:8080/mcp" + ); + String clientName = System.getenv().getOrDefault( + "MCP_CLIENT_NAME", + "MCP Demo Client" + ); + String clientId = System.getenv("MCP_CLIENT_ID"); + String clientSecret = System.getenv("MCP_CLIENT_SECRET"); + int callbackPort = Integer.parseInt( + System.getenv().getOrDefault("CALLBACK_PORT", "8888") + ); + + LOG.info("Server URL: {}", serverUrl); + LOG.info("Client Name: {}", clientName); + LOG.info("Callback Port: {}", callbackPort); + if (clientId != null && !clientId.isEmpty()) { + LOG.info("Client ID override: {}", clientId); + } + + // Create client configuration + McpClientConfig config = McpClientConfig.builder() + .serverUrl(serverUrl) + .clientName(clientName) + .clientId(clientId) + .clientSecret(clientSecret) + .callbackPort(callbackPort) + .build(); + + // Create MCP client with custom timeout + client = new McpClientWrapper(config, Duration.ofSeconds(30)); + + // Initialize connection (triggers OAuth flow if needed) + LOG.info("\n=== Step 1: Initializing MCP Connection ==="); + InitializeResult initResult = client.initialize(); + LOG.info("Server: {} v{}", initResult.serverInfo().name(), initResult.serverInfo().version()); + LOG.info("Protocol version: {}", initResult.protocolVersion()); + + // Check authentication status + LOG.info("\n=== Step 2: Checking Authentication ==="); + if (client.isAuthenticated()) { + LOG.info("Client is authenticated!"); + String token = client.getAccessToken(); + LOG.info("Access token available: {}...", token.substring(0, Math.min(20, token.length()))); + } else { + LOG.error("Client is NOT authenticated!"); + System.exit(1); + } + + // List available tools + LOG.info("\n=== Step 3: Listing Available Tools ==="); + ListToolsResult toolsResult = client.listTools(); + if (toolsResult.tools() != null && !toolsResult.tools().isEmpty()) { + LOG.info("Found {} tools:", toolsResult.tools().size()); + for (Tool tool : toolsResult.tools()) { + LOG.info(" - {}: {}", tool.name(), tool.description()); + } + } else { + LOG.info("No tools available from server"); + } + + // List available resources (if supported) + LOG.info("\n=== Step 4: Listing Available Resources ==="); + if (initResult.capabilities() != null && initResult.capabilities().resources() != null) { + ListResourcesResult resourcesResult = client.listResources(); + if (resourcesResult.resources() != null && !resourcesResult.resources().isEmpty()) { + LOG.info("Found {} resources:", resourcesResult.resources().size()); + for (Resource resource : resourcesResult.resources()) { + LOG.info(" - {}: {}", resource.uri(), resource.name()); + } + } else { + LOG.info("No resources available from server"); + } + } else { + LOG.info("Server does not support resources capability - skipping"); + } + + // List available prompts (if supported) + LOG.info("\n=== Step 5: Listing Available Prompts ==="); + if (initResult.capabilities() != null && initResult.capabilities().prompts() != null) { + ListPromptsResult promptsResult = client.listPrompts(); + if (promptsResult.prompts() != null && !promptsResult.prompts().isEmpty()) { + LOG.info("Found {} prompts:", promptsResult.prompts().size()); + for (Prompt prompt : promptsResult.prompts()) { + LOG.info(" - {}: {}", prompt.name(), prompt.description()); + } + } else { + LOG.info("No prompts available from server"); + } + } else { + LOG.info("Server does not support prompts capability - skipping"); + } + + LOG.info("\n=== Demo Completed Successfully! ==="); + LOG.info("The OAuth 2.1 + PKCE + DCR flow with full MCP SDK integration worked correctly."); + LOG.info("Key accomplishments:"); + LOG.info(" 1. Discovered authorization server via RFC 9728"); + LOG.info(" 2. Fetched authorization server metadata via RFC 8414"); + LOG.info(" 3. Dynamically registered client via RFC 7591"); + LOG.info(" 4. Generated PKCE parameters (S256)"); + LOG.info(" 5. Opened browser for user authentication"); + LOG.info(" 6. Received authorization code via callback"); + LOG.info(" 7. Exchanged code for access token (with resource parameter)"); + LOG.info(" 8. Successfully used MCP SDK with typed APIs"); + LOG.info("\nYou can now use this pattern to make authenticated MCP requests!"); + + } catch (Exception e) { + LOG.error("Demo failed with error", e); + System.exit(1); + } finally { + // Clean up resources + if (client != null) { + client.close(); + } + } + } +} diff --git a/jvm/src/main/java/com/muchq/mcpclient/docs/DCR_IN_MCP.md b/jvm/src/main/java/com/muchq/mcpclient/docs/DCR_IN_MCP.md new file mode 100644 index 00000000..5c7ee580 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/docs/DCR_IN_MCP.md @@ -0,0 +1,128 @@ +# Dynamic Client Registration in MCP + +This document explains when and why to use OAuth 2.0 Dynamic Client Registration (DCR) with Model Context Protocol (MCP) servers. + +## What is DCR? + +Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) allows OAuth clients to register themselves with an authorization server programmatically, rather than requiring manual pre-registration. + +## When DCR Makes Sense + +| Context | Why DCR Works | +|---------|---------------| +| **Developer tools/IDEs** | Claude Code, VS Code extensions, etc. can self-register without IT creating OAuth clients for each developer | +| **Desktop/CLI apps** | Each installation gets unique credentials; no shared client secrets baked into distributed binaries | +| **Multi-tenant platforms** | Client apps connecting to many different organizations' MCP servers can register with each tenant's auth server | +| **Self-service AI tooling** | Teams can deploy MCP-enabled tools without filing tickets to get OAuth clients provisioned | +| **Open ecosystems** | Public MCP servers that want to allow any compatible client to connect | + +## When to Use Pre-registered Clients Instead + +| Context | Why Skip DCR | +|---------|--------------| +| **High-security environments** | All clients must be vetted before access | +| **Production backends** | Server-to-server MCP calls where clients are known and fixed | +| **Audit requirements** | Need explicit control over which clients exist | +| **Rate limiting by client** | Want to assign quotas to specific known clients | + +## Open Ecosystem Pattern + +The most interesting DCR use case is building **public MCP servers that any compatible client can connect to**, similar to how public REST APIs work with OAuth. + +### Example: Public MCP Server + +``` +https://api.example.com/mcp +├── tools/ +│ ├── weather_forecast +│ ├── stock_quotes +│ └── flight_search +``` + +### The Flow + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Any MCP │ │ Your Auth │ │ Your MCP │ +│ Client │ │ Server │ │ Server │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + │ 1. GET /.well-known/oauth-protected-resource │ + │──────────────────────────────────────────────>│ + │ │ │ + │ 2. Discover auth server │ + │<──────────────────────────────────────────────│ + │ │ │ + │ 3. POST /register (DCR) │ + │──────────────────────>│ │ + │ │ │ + │ 4. client_id assigned │ │ + │<──────────────────────│ │ + │ │ │ + │ 5. User authenticates │ │ + │──────────────────────>│ │ + │ │ │ + │ 6. Access token │ │ + │<──────────────────────│ │ + │ │ │ + │ 7. Call tools with token │ + │──────────────────────────────────────────────>│ +``` + +### What DCR Enables + +| Without DCR | With DCR | +|-------------|----------| +| "Email us to request API access" | Client self-registers instantly | +| Manual client_id provisioning | Automated client_id assignment | +| You maintain client registry | Auth server handles it | +| Friction for new integrations | Zero-friction onboarding | + +### The Key Insight + +DCR separates two concerns: + +1. **Client identity** - "What software is making requests?" (handled by DCR) +2. **User identity** - "Who authorized this access?" (handled by OAuth login) + +In an open ecosystem, you don't gate on *which client* is connecting. You gate on: +- Is the **user** authenticated? +- Does the **user** have permission to use these tools? +- Is the **user** within rate limits? + +### Real-World Analogues + +| Service | Pattern | +|---------|---------| +| Twitter API | Any app can register, users authorize access to their account | +| GitHub OAuth Apps | Any developer can create an app, users grant repo access | +| Google APIs | Register app in console, users consent to scopes | + +MCP + DCR enables the same pattern for AI tool ecosystems. + +## Implementation + +This MCP client supports both patterns: + +```java +// DCR (automatic registration) +McpClientConfig config = McpClientConfig.builder() + .serverUrl("https://api.example.com/mcp") + .clientName("My AI Tool") + .build(); + +// Pre-registered (skip DCR) +McpClientConfig config = McpClientConfig.builder() + .serverUrl("https://api.example.com/mcp") + .clientId("known-client-id") + .clientSecret("secret") + .build(); +``` + +## Standards References + +- [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) - OAuth 2.0 Dynamic Client Registration +- [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) - PKCE (Proof Key for Code Exchange) +- [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) - OAuth 2.0 Authorization Server Metadata +- [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) - Resource Indicators for OAuth 2.0 +- [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) - OAuth 2.0 Protected Resource Metadata diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpclient/oauth/BUILD.bazel new file mode 100644 index 00000000..b0d94dcd --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_java//java:java_library.bzl", "java_library") + +java_library( + name = "oauth", + srcs = glob(["*.java"]), + visibility = [ + "//jvm/src/main/java/com/muchq/mcpclient:__pkg__", + "//jvm/src/test/java/com/muchq/mcpclient/oauth:__pkg__", + ], + deps = [ + "//jvm/src/main/java/com/muchq/mcpclient:config", + "@maven//:com_fasterxml_jackson_core_jackson_annotations", + "@maven//:com_fasterxml_jackson_core_jackson_databind", + "@maven//:com_nimbusds_nimbus_jose_jwt", + "@maven//:com_nimbusds_oauth2_oidc_sdk", + "@maven//:io_modelcontextprotocol_sdk_mcp", + "@maven//:org_slf4j_slf4j_api", + ], +) diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/BrowserLauncher.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/BrowserLauncher.java new file mode 100644 index 00000000..3887a440 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/BrowserLauncher.java @@ -0,0 +1,56 @@ +package com.muchq.mcpclient.oauth; + +import java.awt.Desktop; +import java.io.IOException; +import java.net.URI; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility for opening the system web browser. + * + * Used to launch the OAuth authorization URL in the user's default browser. + * + * Fallback: If Desktop.browse() is not supported (headless systems), + * the URL is printed to console for manual opening. + */ +public class BrowserLauncher { + + private static final Logger LOG = LoggerFactory.getLogger(BrowserLauncher.class); + + /** + * Opens the specified URL in the system's default web browser. + * + * @param url The URL to open + * @throws IOException if the browser cannot be launched + */ + public static void open(String url) throws IOException { + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("URL is required"); + } + + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + LOG.info("Opening browser: {}", url); + Desktop.getDesktop().browse(URI.create(url)); + } else { + // Fallback for headless systems + LOG.warn("Desktop.browse() not supported. Please open this URL manually:"); + System.out.println("\n" + "=".repeat(80)); + System.out.println("Please open this URL in your browser to authorize:"); + System.out.println(url); + System.out.println("=".repeat(80) + "\n"); + } + } catch (Exception e) { + LOG.error("Failed to open browser", e); + + // Fallback: print URL for manual opening + System.err.println("\nFailed to open browser automatically."); + System.err.println("Please open this URL manually:"); + System.err.println(url); + System.err.println(); + + throw new IOException("Failed to open browser: " + e.getMessage(), e); + } + } +} diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/CallbackServer.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/CallbackServer.java new file mode 100644 index 00000000..9f8c9478 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/CallbackServer.java @@ -0,0 +1,315 @@ +package com.muchq.mcpclient.oauth; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Temporary HTTP server for OAuth callback. + * + * This server: + * 1. Listens on localhost for the OAuth redirect + * 2. Parses the authorization code from the query string + * 3. Returns a success/error HTML page to the browser + * 4. Provides the authorization code to the OAuth flow + * + * Security: Only accepts connections on localhost to prevent external access. + */ +public class CallbackServer { + + private static final Logger LOG = LoggerFactory.getLogger(CallbackServer.class); + + private final HttpServer server; + private final CompletableFuture responseFuture; + private final int port; + private final String expectedState; + + /** + * Creates a new callback server listening on the specified port. + * + * @param port Port to listen on (typically 8888) + * @throws IOException if the server cannot be started + */ + public CallbackServer(int port, String expectedState) throws IOException { + this.port = port; + this.expectedState = expectedState; + this.responseFuture = new CompletableFuture<>(); + + // Create server bound to localhost only + this.server = HttpServer.create(new InetSocketAddress("localhost", port), 0); + + // Register callback endpoint + server.createContext("/callback", this::handleCallback); + + LOG.info("OAuth callback server created on port {}", port); + } + + /** + * Starts the callback server. + */ + public void start() { + server.start(); + LOG.info("OAuth callback server started: http://localhost:{}/callback", port); + } + + /** + * Stops the callback server. + */ + public void stop() { + server.stop(0); + LOG.info("OAuth callback server stopped"); + } + + /** + * Waits for the OAuth authorization response. + * + * @param timeout Maximum time to wait + * @param unit Time unit for timeout + * @return Authorization code if successful + * @throws TimeoutException if timeout is reached + * @throws IOException if authorization failed or was denied + * @throws InterruptedException if interrupted while waiting + */ + public String waitForAuthorizationCode(long timeout, TimeUnit unit) + throws TimeoutException, IOException, InterruptedException { + try { + AuthorizationResponse response = responseFuture.get(timeout, unit); + + if (response.isSuccess()) { + return response.code(); + } else { + throw new IOException("Authorization failed: " + response.error()); + } + } catch (java.util.concurrent.ExecutionException e) { + throw new IOException("Authorization error", e.getCause()); + } + } + + /** + * Handles the OAuth callback request. + */ + private void handleCallback(HttpExchange exchange) throws IOException { + try { + URI requestUri = exchange.getRequestURI(); + Map queryParams = parseQueryString(requestUri.getQuery()); + + LOG.debug("Received OAuth callback: {}", queryParams.keySet()); + + if (expectedState != null) { + String receivedState = queryParams.get("state"); + if (receivedState == null || !expectedState.equals(receivedState)) { + LOG.warn("Invalid OAuth state: expected={}, received={}", expectedState, receivedState); + responseFuture.complete(AuthorizationResponse.error("invalid_state", "State mismatch")); + sendErrorResponse(exchange, "invalid_state", "State mismatch"); + return; + } + } + + if (queryParams.containsKey("code")) { + // Success - authorization code received + String code = queryParams.get("code"); + LOG.info("Authorization code received"); + + responseFuture.complete(AuthorizationResponse.success(code)); + try { + sendSuccessResponse(exchange); + } catch (IOException e) { + LOG.debug("Failed to write success response to browser", e); + } + + } else if (queryParams.containsKey("error")) { + // Error - authorization failed/denied + String error = queryParams.get("error"); + String errorDescription = queryParams.getOrDefault("error_description", "Unknown error"); + LOG.warn("Authorization failed: {} - {}", error, errorDescription); + + responseFuture.complete(AuthorizationResponse.error(error, errorDescription)); + try { + sendErrorResponse(exchange, error, errorDescription); + } catch (IOException e) { + LOG.debug("Failed to write error response to browser", e); + } + + } else { + // Invalid callback - missing both code and error + LOG.warn("Invalid OAuth callback: missing code and error parameters"); + responseFuture.completeExceptionally( + new IOException("Invalid OAuth callback: missing code and error") + ); + try { + sendErrorResponse(exchange, "invalid_request", "Missing authorization code"); + } catch (IOException e) { + LOG.debug("Failed to write error response to browser", e); + } + } + + } catch (Exception e) { + LOG.error("Error handling OAuth callback", e); + responseFuture.completeExceptionally(e); + try { + sendErrorResponse(exchange, "server_error", "Internal server error"); + } catch (IOException ioException) { + LOG.debug("Failed to write error response to browser", ioException); + } + } + } + + /** + * Parses query string into key-value map. + * Handles parameters with and without values (e.g., "foo=bar" and "foo"). + */ + private Map parseQueryString(String query) { + Map params = new HashMap<>(); + if (query == null || query.isEmpty()) { + return params; + } + + for (String param : query.split("&")) { + if (param.isEmpty()) { + continue; + } + String[] pair = param.split("=", 2); + String key = urlDecode(pair[0]); + String value = pair.length == 2 ? urlDecode(pair[1]) : ""; + params.put(key, value); + } + + return params; + } + + private String urlDecode(String value) { + try { + return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return value; + } + } + + /** + * Sends success HTML response to browser. + */ + private void sendSuccessResponse(HttpExchange exchange) throws IOException { + String html = """ + + + + Authorization Successful + + + +
+

Authorization Successful!

+

You can close this window and return to the application.

+
+ + + """; + + sendHtmlResponse(exchange, 200, html); + } + + /** + * Sends error HTML response to browser. + */ + private void sendErrorResponse(HttpExchange exchange, String error, String description) throws IOException { + String html = String.format(""" + + + + Authorization Failed + + + +
+

Authorization Failed

+

%s

+

%s

+
+ + + """, error, description); + + sendHtmlResponse(exchange, 200, html); + } + + /** + * Sends HTML response. + */ + private void sendHtmlResponse(HttpExchange exchange, int statusCode, String html) throws IOException { + byte[] bytes = html.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + /** + * Authorization response from OAuth callback. + */ + private record AuthorizationResponse(boolean success, String code, String error, String errorDescription) { + + static AuthorizationResponse success(String code) { + return new AuthorizationResponse(true, code, null, null); + } + + static AuthorizationResponse error(String error, String errorDescription) { + return new AuthorizationResponse(false, null, error, errorDescription); + } + + boolean isSuccess() { + return success; + } + } +} diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthFlowHandler.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthFlowHandler.java new file mode 100644 index 00000000..52af98d9 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthFlowHandler.java @@ -0,0 +1,509 @@ +package com.muchq.mcpclient.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.muchq.mcpclient.McpClientConfig; +import com.nimbusds.oauth2.sdk.*; +import com.nimbusds.oauth2.sdk.RefreshTokenGrant; +import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.ClientSecretPost; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.http.HTTPRequest; +import com.nimbusds.oauth2.sdk.http.HTTPResponse; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.id.State; +import com.nimbusds.oauth2.sdk.pkce.CodeChallenge; +import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod; +import com.nimbusds.oauth2.sdk.pkce.CodeVerifier; +import com.nimbusds.oauth2.sdk.token.AccessToken; +import com.nimbusds.oauth2.sdk.token.RefreshToken; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; +import com.nimbusds.openid.connect.sdk.rp.OIDCClientInformation; +import com.nimbusds.openid.connect.sdk.rp.OIDCClientInformationResponse; +import com.nimbusds.openid.connect.sdk.rp.OIDCClientMetadata; +import com.nimbusds.openid.connect.sdk.rp.OIDCClientRegistrationRequest; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles the complete OAuth 2.1 + PKCE + DCR flow for MCP authentication. + * + * Flow: + * 1. Fetch Protected Resource Metadata (RFC 9728) from MCP server + * 2. Fetch Authorization Server Metadata (RFC 8414) from Keycloak + * 3. Dynamically Register Client (RFC 7591) + * 4. Generate PKCE parameters + * 5. Build authorization URL with resource parameter (RFC 8707) + * 6. Open browser for user authentication + * 7. Wait for authorization code via callback server + * 8. Exchange code for tokens (with code_verifier + resource parameter) + * 9. Store tokens in TokenManager + */ +public class OAuthFlowHandler { + + private static final Logger LOG = LoggerFactory.getLogger(OAuthFlowHandler.class); + + private final McpClientConfig config; + private final TokenManager tokenManager; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + // Cached state for token refresh + private volatile String cachedTokenEndpoint; + private volatile ClientRegistration cachedClientRegistration; + private volatile String cachedResourceUri; + + public OAuthFlowHandler(McpClientConfig config, TokenManager tokenManager) { + this.config = config; + this.tokenManager = tokenManager; + this.httpClient = HttpClient.newHttpClient(); + this.objectMapper = new ObjectMapper(); + } + + /** + * Refreshes the access token using the stored refresh token. + * + * @throws Exception if token refresh fails or no refresh token is available + */ + public synchronized void refreshAccessToken() throws Exception { + String refreshToken = tokenManager.getRefreshToken(); + if (refreshToken == null) { + throw new IOException("No refresh token available for token refresh"); + } + + if (cachedTokenEndpoint == null || cachedClientRegistration == null) { + LOG.info("No cached OAuth metadata, performing full discovery..."); + // Re-discover OAuth endpoints + String resourceUri = extractResourceUri(config.getServerUrl()); + ProtectedResourceMetadata resourceMetadata = fetchProtectedResourceMetadata(resourceUri); + String authzServerUrl = resourceMetadata.authorization_servers().get(0); + AuthorizationServerMetadata authzMetadata = fetchAuthorizationServerMetadata(authzServerUrl); + + cachedTokenEndpoint = authzMetadata.token_endpoint(); + cachedResourceUri = resourceUri; + + String redirectUri = "http://localhost:" + config.getCallbackPort() + "/callback"; + cachedClientRegistration = resolveClientRegistration(authzMetadata, redirectUri); + } + + LOG.info("Refreshing access token..."); + + RefreshTokenGrant grant = new RefreshTokenGrant( + new com.nimbusds.oauth2.sdk.token.RefreshToken(refreshToken) + ); + + ClientAuthentication clientAuthentication = buildClientAuthentication(cachedClientRegistration); + List resources = List.of(URI.create(cachedResourceUri)); + + com.nimbusds.oauth2.sdk.TokenRequest tokenRequest; + if (clientAuthentication == null) { + tokenRequest = new com.nimbusds.oauth2.sdk.TokenRequest( + URI.create(cachedTokenEndpoint), + cachedClientRegistration.clientId(), + grant, + null, + resources, + null, + null + ); + } else { + tokenRequest = new com.nimbusds.oauth2.sdk.TokenRequest( + URI.create(cachedTokenEndpoint), + clientAuthentication, + grant, + null, + resources, + null + ); + } + + HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); + HTTPResponse httpResponse = httpRequest.send(); + + com.nimbusds.oauth2.sdk.TokenResponse tokenResponse = OIDCTokenResponseParser.parse(httpResponse); + + if (!tokenResponse.indicatesSuccess()) { + // Clear cached state on failure so next attempt does full flow + clearCachedState(); + throw new IOException("Token refresh failed: " + tokenResponse.toErrorResponse().getErrorObject()); + } + + OIDCTokenResponse successResponse = (OIDCTokenResponse) tokenResponse.toSuccessResponse(); + AccessToken accessToken = successResponse.getOIDCTokens().getAccessToken(); + com.nimbusds.oauth2.sdk.token.RefreshToken newRefreshToken = successResponse.getOIDCTokens().getRefreshToken(); + + long expiresIn = accessToken.getLifetime() != 0 ? accessToken.getLifetime() : 300L; + + tokenManager.storeTokens( + accessToken.getValue(), + newRefreshToken != null ? newRefreshToken.getValue() : refreshToken, + expiresIn + ); + + LOG.info("Access token refreshed successfully, expires in {} seconds", expiresIn); + } + + /** + * Clears cached OAuth state, forcing full re-discovery on next operation. + */ + public synchronized void clearCachedState() { + cachedTokenEndpoint = null; + cachedClientRegistration = null; + cachedResourceUri = null; + } + + /** + * Executes the complete OAuth 2.1 flow and stores tokens. + * + * @throws Exception if any step of the OAuth flow fails + */ + public void executeFlow() throws Exception { + LOG.info("Starting OAuth 2.1 + PKCE + DCR flow for MCP server: {}", config.getServerUrl()); + + // Step 1: Fetch Protected Resource Metadata (RFC 9728) + String resourceUri = extractResourceUri(config.getServerUrl()); + ProtectedResourceMetadata resourceMetadata = fetchProtectedResourceMetadata(resourceUri); + LOG.info("Protected Resource: {}", resourceMetadata.resource()); + + if (resourceMetadata.authorization_servers() == null || resourceMetadata.authorization_servers().isEmpty()) { + throw new IOException("Protected Resource Metadata missing authorization_servers"); + } + String authzServerUrl = resourceMetadata.authorization_servers().get(0); + LOG.info("Authorization Server: {}", authzServerUrl); + + // Step 2: Fetch Authorization Server Metadata (RFC 8414) + AuthorizationServerMetadata authzMetadata = fetchAuthorizationServerMetadata(authzServerUrl); + LOG.info("Authorization Endpoint: {}", authzMetadata.authorization_endpoint()); + LOG.info("Token Endpoint: {}", authzMetadata.token_endpoint()); + LOG.info("Registration Endpoint: {}", authzMetadata.registration_endpoint()); + + // Step 3: Dynamic Client Registration (RFC 7591) + String redirectUri = "http://localhost:" + config.getCallbackPort() + "/callback"; + ClientRegistration clientRegistration = resolveClientRegistration( + authzMetadata, + redirectUri + ); + LOG.info("Client ID: {}", clientRegistration.clientId().getValue()); + + // Cache OAuth state for token refresh + this.cachedTokenEndpoint = authzMetadata.token_endpoint(); + this.cachedClientRegistration = clientRegistration; + this.cachedResourceUri = resourceUri; + + // Step 4: Generate PKCE parameters (Nimbus SDK handles this) + CodeVerifier codeVerifier = new CodeVerifier(); + CodeChallenge codeChallenge = CodeChallenge.compute(CodeChallengeMethod.S256, codeVerifier); + + // Step 5: Build authorization request with resource parameter (RFC 8707) + State state = new State(); + AuthorizationRequest authzRequest = new AuthorizationRequest.Builder( + new ResponseType(ResponseType.Value.CODE), + clientRegistration.clientId() + ) + .endpointURI(URI.create(authzMetadata.authorization_endpoint())) + .redirectionURI(URI.create(redirectUri)) + .codeChallenge(codeChallenge, CodeChallengeMethod.S256) + .customParameter("resource", resourceUri) // RFC 8707 - CRITICAL! + .state(state) + .scope(new Scope("openid")) + .build(); + + String authzUrl = authzRequest.toURI().toString(); + LOG.info("Authorization URL: {}", authzUrl); + + // Step 6: Start local callback server + CallbackServer callbackServer = new CallbackServer(config.getCallbackPort(), state.getValue()); + callbackServer.start(); + + try { + // Step 7: Open browser for user authentication + LOG.info("Opening browser for user authentication..."); + BrowserLauncher.open(authzUrl); + + // Step 8: Wait for authorization code + LOG.info("Waiting for authorization code (timeout: 5 minutes)..."); + String authCode = callbackServer.waitForAuthorizationCode(5, TimeUnit.MINUTES); + LOG.info("Authorization code received"); + + // Step 9: Exchange code for tokens with resource parameter + OAuthTokenResponse tokens = exchangeCodeForTokens( + authzMetadata.token_endpoint(), + clientRegistration, + authCode, + redirectUri, + codeVerifier, + resourceUri // RFC 8707 - CRITICAL! + ); + + // Step 10: Store tokens + long expiresIn = tokens.access_token_expires_in() != null ? + tokens.access_token_expires_in() : 300L; // Default 5 minutes + + tokenManager.storeTokens( + tokens.access_token(), + tokens.refresh_token(), + expiresIn + ); + + LOG.info("OAuth flow completed successfully!"); + LOG.info("Access token expires in {} seconds", expiresIn); + + } finally { + // Always stop the callback server + callbackServer.stop(); + } + } + + /** + * Fetches Protected Resource Metadata (RFC 9728) from MCP server. + */ + private ProtectedResourceMetadata fetchProtectedResourceMetadata(String resourceUri) throws Exception { + String metadataUrl = resourceUri + "/.well-known/oauth-protected-resource"; + LOG.debug("Fetching Protected Resource Metadata: {}", metadataUrl); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(metadataUrl)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("Failed to fetch Protected Resource Metadata: HTTP " + response.statusCode()); + } + + return objectMapper.readValue(response.body(), ProtectedResourceMetadata.class); + } + + /** + * Fetches Authorization Server Metadata (RFC 8414) from Keycloak. + */ + private AuthorizationServerMetadata fetchAuthorizationServerMetadata(String authzServerUrl) throws Exception { + String metadataUrl = authzServerUrl + "/.well-known/openid-configuration"; + LOG.debug("Fetching Authorization Server Metadata: {}", metadataUrl); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(metadataUrl)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("Failed to fetch Authorization Server Metadata: HTTP " + response.statusCode()); + } + + return objectMapper.readValue(response.body(), AuthorizationServerMetadata.class); + } + + /** + * Registers a new OAuth client dynamically (RFC 7591). + */ + private ClientRegistration resolveClientRegistration( + AuthorizationServerMetadata authzMetadata, + String redirectUri + ) throws Exception { + String configuredClientId = config.getClientId(); + if (configuredClientId != null && !configuredClientId.isEmpty()) { + LOG.info("Using preconfigured client ID; skipping dynamic registration."); + Secret secret = null; + ClientAuthenticationMethod authMethod = ClientAuthenticationMethod.NONE; + if (config.getClientSecret() != null && !config.getClientSecret().isEmpty()) { + secret = new Secret(config.getClientSecret()); + authMethod = ClientAuthenticationMethod.CLIENT_SECRET_BASIC; + } + return new ClientRegistration(new ClientID(configuredClientId), secret, authMethod); + } + + if (authzMetadata.registration_endpoint() == null) { + throw new IOException("Authorization server does not advertise a registration endpoint."); + } + + return registerClient( + authzMetadata.registration_endpoint(), + config.getClientName(), + redirectUri + ); + } + + private ClientRegistration registerClient(String registrationEndpoint, String clientName, String redirectUri) + throws Exception { + LOG.info("Registering client dynamically: {}", clientName); + + OIDCClientMetadata metadata = new OIDCClientMetadata(); + metadata.setName(clientName); + metadata.setRedirectionURIs(Set.of(URI.create(redirectUri))); + metadata.setGrantTypes(Set.of(GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN)); + metadata.setResponseTypes(Set.of(new ResponseType(ResponseType.Value.CODE))); + metadata.setTokenEndpointAuthMethod(ClientAuthenticationMethod.NONE); + + OIDCClientRegistrationRequest regRequest = new OIDCClientRegistrationRequest( + URI.create(registrationEndpoint), + metadata, + null + ); + + HTTPRequest httpRequest = regRequest.toHTTPRequest(); + HTTPResponse httpResponse = httpRequest.send(); + + OIDCClientInformationResponse regResponse = OIDCClientInformationResponse.parse(httpResponse); + + if (!regResponse.indicatesSuccess()) { + throw new IOException("Client registration failed: " + regResponse.toErrorResponse().getErrorObject()); + } + + OIDCClientInformation clientInfo = + (OIDCClientInformation) regResponse.toSuccessResponse().getClientInformation(); + ClientAuthenticationMethod authMethod = clientInfo.getOIDCMetadata().getTokenEndpointAuthMethod(); + return new ClientRegistration(clientInfo.getID(), clientInfo.getSecret(), authMethod); + } + + /** + * Exchanges authorization code for access token (with PKCE + resource parameter). + */ + private OAuthTokenResponse exchangeCodeForTokens( + String tokenEndpoint, + ClientRegistration clientRegistration, + String authCode, + String redirectUri, + CodeVerifier codeVerifier, + String resourceUri + ) throws Exception { + LOG.info("Exchanging authorization code for tokens..."); + + // Build token request with PKCE + AuthorizationGrant grant = new AuthorizationCodeGrant( + new AuthorizationCode(authCode), + URI.create(redirectUri), + codeVerifier + ); + + ClientAuthentication clientAuthentication = buildClientAuthentication(clientRegistration); + + com.nimbusds.oauth2.sdk.TokenRequest tokenRequest; + List resources = List.of(URI.create(resourceUri)); // RFC 8707 resource parameter - CRITICAL! + + if (clientAuthentication == null) { + tokenRequest = new com.nimbusds.oauth2.sdk.TokenRequest( + URI.create(tokenEndpoint), + clientRegistration.clientId(), + grant, + null, + resources, + null, + null + ); + } else { + tokenRequest = new com.nimbusds.oauth2.sdk.TokenRequest( + URI.create(tokenEndpoint), + clientAuthentication, + grant, + null, + resources, + null + ); + } + + HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); + HTTPResponse httpResponse = httpRequest.send(); + + // Parse response + com.nimbusds.oauth2.sdk.TokenResponse tokenResponse = OIDCTokenResponseParser.parse(httpResponse); + + if (!tokenResponse.indicatesSuccess()) { + throw new IOException("Token exchange failed: " + tokenResponse.toErrorResponse().getErrorObject()); + } + + OIDCTokenResponse successResponse = (OIDCTokenResponse) tokenResponse.toSuccessResponse(); + AccessToken accessToken = successResponse.getOIDCTokens().getAccessToken(); + RefreshToken refreshToken = successResponse.getOIDCTokens().getRefreshToken(); + + return new OAuthTokenResponse( + accessToken.getValue(), + refreshToken != null ? refreshToken.getValue() : null, + accessToken.getLifetime() + ); + } + + /** + * Extracts resource URI from server URL. + * Removes path component to get base URL. + */ + private String extractResourceUri(String serverUrl) { + try { + URI uri = URI.create(serverUrl); + return uri.getScheme() + "://" + uri.getAuthority(); + } catch (Exception e) { + // Fallback: return as-is + return serverUrl; + } + } + + private ClientAuthentication buildClientAuthentication(ClientRegistration clientRegistration) throws Exception { + if (clientRegistration == null) { + return null; + } + + ClientAuthenticationMethod authMethod = clientRegistration.authMethod(); + Secret clientSecret = clientRegistration.clientSecret(); + + if (authMethod == null || ClientAuthenticationMethod.NONE.equals(authMethod)) { + return null; + } + + if (clientSecret == null) { + throw new IOException("Token endpoint requires client authentication, but no client secret was provided."); + } + + if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(authMethod)) { + return new ClientSecretBasic(clientRegistration.clientId(), clientSecret); + } + + if (ClientAuthenticationMethod.CLIENT_SECRET_POST.equals(authMethod)) { + return new ClientSecretPost(clientRegistration.clientId(), clientSecret); + } + + throw new IOException("Unsupported token endpoint auth method: " + authMethod.getValue()); + } + + // DTOs for metadata and tokens + + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) + public record ProtectedResourceMetadata( + String resource, + List authorization_servers, + List bearer_methods_supported + ) {} + + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) + public record AuthorizationServerMetadata( + String issuer, + String authorization_endpoint, + String token_endpoint, + String registration_endpoint, + String jwks_uri + ) {} + + public record OAuthTokenResponse( + String access_token, + String refresh_token, + Long access_token_expires_in + ) {} + + private record ClientRegistration( + ClientID clientId, + Secret clientSecret, + ClientAuthenticationMethod authMethod + ) {} +} diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthRequestCustomizer.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthRequestCustomizer.java new file mode 100644 index 00000000..9bb8bb17 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthRequestCustomizer.java @@ -0,0 +1,82 @@ +package com.muchq.mcpclient.oauth; + +import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; +import io.modelcontextprotocol.common.McpTransportContext; +import java.net.URI; +import java.net.http.HttpRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OAuth 2.1 request customizer for MCP SDK transports. + * + *

This customizer integrates with the MCP SDK's transport layer to inject + * OAuth Bearer tokens into outgoing HTTP requests. It works in conjunction + * with the TokenManager and OAuthFlowHandler to provide automatic authentication. + * + *

Features: + *

    + *
  • Injects Authorization header with Bearer token
  • + *
  • Triggers OAuth flow when no valid token is available
  • + *
  • Supports token refresh when tokens expire
  • + *
  • Thread-safe for concurrent requests
  • + *
+ */ +public class OAuthRequestCustomizer implements McpSyncHttpClientRequestCustomizer { + + private static final Logger LOG = LoggerFactory.getLogger(OAuthRequestCustomizer.class); + + private final TokenManager tokenManager; + private final OAuthFlowHandler oauthFlowHandler; + + /** + * Creates a new OAuth request customizer. + * + * @param tokenManager Manages OAuth tokens + * @param oauthFlowHandler Handles OAuth flow execution + */ + public OAuthRequestCustomizer(TokenManager tokenManager, OAuthFlowHandler oauthFlowHandler) { + this.tokenManager = tokenManager; + this.oauthFlowHandler = oauthFlowHandler; + } + + /** + * Customizes the HTTP request by adding the OAuth Bearer token. + * + *

If no valid token is available and this is not already an authentication + * request, this method will trigger the OAuth flow to obtain a new token. + * + * @param builder The HTTP request builder to customize + * @param method The MCP method being called (e.g., "tools/list") + * @param endpoint The target URI + * @param body The request body + * @param context The transport context + */ + @Override + public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body, + McpTransportContext context) { + String token = tokenManager.getAccessToken(); + + if (token == null) { + LOG.debug("No valid access token available for request to {}", endpoint); + // Check if we have a refresh token we can use + String refreshToken = tokenManager.getRefreshToken(); + if (refreshToken != null) { + LOG.info("Attempting to refresh access token..."); + try { + oauthFlowHandler.refreshAccessToken(); + token = tokenManager.getAccessToken(); + } catch (Exception e) { + LOG.warn("Token refresh failed, will need full OAuth flow: {}", e.getMessage()); + } + } + } + + if (token != null) { + LOG.debug("Adding Authorization header to request"); + builder.header("Authorization", "Bearer " + token); + } else { + LOG.warn("No access token available - request may fail with 401"); + } + } +} diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/TokenManager.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/TokenManager.java new file mode 100644 index 00000000..d03d7d40 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/TokenManager.java @@ -0,0 +1,104 @@ +package com.muchq.mcpclient.oauth; + +import java.time.Instant; + +/** + * Thread-safe manager for OAuth access and refresh tokens. + * + * Responsibilities: + * - Store access token, refresh token, and expiration time + * - Check token expiration before returning + * - Thread-safe access for concurrent requests + * + * Note: This is an in-memory implementation suitable for demo purposes. + * Production implementations should use secure storage (OS keychain, encrypted file, etc.) + */ +public class TokenManager { + + /** + * Safety buffer in seconds before token expiration. + * This prevents race conditions where a token expires between validation and use. + */ + private static final long EXPIRATION_BUFFER_SECONDS = 30; + + private volatile String accessToken; + private volatile String refreshToken; + private volatile Instant expiresAt; + + /** + * Stores OAuth tokens with expiration tracking. + * + * @param accessToken The JWT access token + * @param refreshToken The refresh token (optional, may be null) + * @param expiresInSeconds Number of seconds until access token expires + */ + public synchronized void storeTokens(String accessToken, String refreshToken, long expiresInSeconds) { + if (accessToken == null || accessToken.isEmpty()) { + throw new IllegalArgumentException("accessToken is required"); + } + if (expiresInSeconds <= 0) { + throw new IllegalArgumentException("expiresInSeconds must be positive, got: " + expiresInSeconds); + } + + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresAt = Instant.now().plusSeconds(expiresInSeconds); + } + + /** + * Retrieves the access token if it's still valid. + * Includes a safety buffer before actual expiration to prevent race conditions. + * + * @return Access token, or null if expired/missing + */ + public synchronized String getAccessToken() { + if (accessToken == null) { + return null; + } + + // Check if token is expired (with safety buffer) + Instant bufferExpiry = expiresAt.minusSeconds(EXPIRATION_BUFFER_SECONDS); + if (Instant.now().isAfter(bufferExpiry)) { + return null; + } + + return accessToken; + } + + /** + * Retrieves the refresh token. + * + * @return Refresh token, or null if not available + */ + public synchronized String getRefreshToken() { + return refreshToken; + } + + /** + * Checks if a valid access token is available. + * + * @return true if access token exists and is not expired + */ + public synchronized boolean hasValidAccessToken() { + return getAccessToken() != null; + } + + /** + * Clears all stored tokens. + * Useful when logging out or when authentication fails. + */ + public synchronized void clearTokens() { + this.accessToken = null; + this.refreshToken = null; + this.expiresAt = null; + } + + /** + * Gets the expiration time of the current access token. + * + * @return Expiration instant, or null if no token stored + */ + public synchronized Instant getExpiresAt() { + return expiresAt; + } +} diff --git a/jvm/src/main/java/com/muchq/mcpserver/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpserver/BUILD.bazel index a83d4a67..a07871a5 100644 --- a/jvm/src/main/java/com/muchq/mcpserver/BUILD.bazel +++ b/jvm/src/main/java/com/muchq/mcpserver/BUILD.bazel @@ -28,6 +28,7 @@ java_binary( "//jvm/src/main/java/com/muchq/http_client/jdk11", "//jvm/src/main/java/com/muchq/json", "//jvm/src/main/java/com/muchq/mcpserver/dtos", + "//jvm/src/main/java/com/muchq/mcpserver/oauth", "//jvm/src/main/java/com/muchq/mcpserver/tools", "@maven//:com_fasterxml_jackson_core_jackson_annotations", "@maven//:com_fasterxml_jackson_core_jackson_databind", diff --git a/jvm/src/main/java/com/muchq/mcpserver/McpAuthenticationFilter.java b/jvm/src/main/java/com/muchq/mcpserver/McpAuthenticationFilter.java index 6dcc282e..0fb4e46d 100644 --- a/jvm/src/main/java/com/muchq/mcpserver/McpAuthenticationFilter.java +++ b/jvm/src/main/java/com/muchq/mcpserver/McpAuthenticationFilter.java @@ -22,6 +22,7 @@ @Filter("/mcp/**") @Requires(property = "mcp.auth.token") +@Requires(property = "mcp.oauth.enabled", value = "false", defaultValue = "false") public class McpAuthenticationFilter implements HttpServerFilter { private static final Logger LOG = LoggerFactory.getLogger(McpAuthenticationFilter.class); diff --git a/jvm/src/main/java/com/muchq/mcpserver/McpController.java b/jvm/src/main/java/com/muchq/mcpserver/McpController.java index c57d52ed..98c94f1f 100644 --- a/jvm/src/main/java/com/muchq/mcpserver/McpController.java +++ b/jvm/src/main/java/com/muchq/mcpserver/McpController.java @@ -4,9 +4,11 @@ import com.muchq.mcpserver.dtos.JsonRpcRequest; import com.muchq.mcpserver.dtos.JsonRpcResponse; import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; import io.micronaut.http.annotation.Body; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Error; +import io.micronaut.http.annotation.Get; import io.micronaut.http.annotation.Post; import jakarta.inject.Inject; import org.slf4j.Logger; @@ -29,6 +31,11 @@ public JsonRpcResponse handleRequest(@Body JsonRpcRequest request) { return requestHandler.handleRequest(request); } + @Get + public HttpResponse noopGet() { + return HttpResponse.noContent(); + } + @Error(global = true) public JsonRpcResponse handleError(HttpRequest request, Throwable error) { LOG.error("Error processing MCP request", error); diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpserver/oauth/BUILD.bazel new file mode 100644 index 00000000..7fb12fdb --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/BUILD.bazel @@ -0,0 +1,26 @@ +load("@rules_java//java:defs.bzl", "java_library") + +java_library( + name = "oauth", + srcs = glob(["*.java"]), + plugins = [ + "//bazel/rules:micronaut_type_element_visitor_processor", + "//bazel/rules:micronaut_aggregating_type_element_visitor_processor", + "//bazel/rules:micronaut_bean_definition_inject_processor", + "//bazel/rules:micronaut_package_element_visitor_processor", + ], + visibility = ["//jvm/src/main/java/com/muchq/mcpserver:__pkg__"], + deps = [ + "//jvm/src/main/java/com/muchq/mcpserver/dtos", + "@maven//:com_fasterxml_jackson_core_jackson_annotations", + "@maven//:com_nimbusds_nimbus_jose_jwt", + "@maven//:io_micronaut_micronaut_context", + "@maven//:io_micronaut_micronaut_core", + "@maven//:io_micronaut_micronaut_http", + "@maven//:io_micronaut_micronaut_inject", + "@maven//:io_projectreactor_reactor_core", + "@maven//:jakarta_inject_jakarta_inject_api", + "@maven//:org_reactivestreams_reactive_streams", + "@maven//:org_slf4j_slf4j_api", + ], +) diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthAuthenticationFilter.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthAuthenticationFilter.java new file mode 100644 index 00000000..8149fbbf --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthAuthenticationFilter.java @@ -0,0 +1,121 @@ +package com.muchq.mcpserver.oauth; + +import com.muchq.mcpserver.dtos.JsonRpcError; +import com.muchq.mcpserver.dtos.JsonRpcResponse; +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import jakarta.inject.Inject; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * OAuth 2.1 authentication filter for MCP endpoints. + * + * This filter intercepts all requests to /mcp/** and validates JWT access tokens. + * When OAuth is disabled, the legacy McpAuthenticationFilter is used instead. + * + * Flow: + * 1. Extract Authorization header + * 2. Validate Bearer token format + * 3. Validate JWT signature, expiration, and audience + * 4. Return 401 with WWW-Authenticate header if validation fails + * 5. Allow request to proceed if valid + * + * WWW-Authenticate header format (RFC 6750): + * Bearer realm="mcp", error="invalid_token", resource="" + */ +@Filter("/mcp/**") +@Requires(property = "mcp.oauth.enabled", value = "true") +public class OAuthAuthenticationFilter implements HttpServerFilter { + + private static final Logger LOG = LoggerFactory.getLogger(OAuthAuthenticationFilter.class); + + private final TokenValidator tokenValidator; + private final OAuthConfig config; + + @Inject + public OAuthAuthenticationFilter(TokenValidator tokenValidator, OAuthConfig config) { + this.tokenValidator = tokenValidator; + this.config = config; + } + + @Override + public Publisher> doFilter( + HttpRequest request, + ServerFilterChain chain + ) { + LOG.debug("OAuth filter processing request: {} {}", request.getMethod(), request.getPath()); + + // Extract Authorization header + String authHeader = request.getHeaders().get("Authorization"); + + if (authHeader == null || authHeader.isEmpty()) { + LOG.warn("Missing Authorization header"); + return Mono.just(createUnauthorizedResponse("invalid_token", "Missing Authorization header")); + } + + // Validate Bearer token format + if (!authHeader.startsWith("Bearer ")) { + LOG.warn("Invalid Authorization header format: {}", authHeader.substring(0, Math.min(20, authHeader.length()))); + return Mono.just(createUnauthorizedResponse("invalid_token", "Authorization header must use Bearer scheme")); + } + + // Extract token (remove "Bearer " prefix) + String token = authHeader.substring(7); + + // Validate token + TokenValidator.ValidationResult result = tokenValidator.validate(token); + + if (!result.isValid()) { + LOG.warn("Token validation failed: {}", result.getErrorMessage()); + return Mono.just(createUnauthorizedResponse("invalid_token", result.getErrorMessage())); + } + + // Token is valid, allow request to proceed + LOG.debug("Token validated successfully for subject: {}", result.getSubject()); + return Mono.from(chain.proceed(request)); + } + + /** + * Creates a 401 Unauthorized response with WWW-Authenticate header. + * + * The WWW-Authenticate header follows RFC 6750 and RFC 9728: + * - realm: Identifies the protection space + * - error: OAuth error code + * - resource: URL to Protected Resource Metadata (RFC 9728) + * + * @param error OAuth error code (e.g., "invalid_token", "invalid_request") + * @param errorDescription Human-readable error description + * @return HTTP 401 response with JSON-RPC error body + */ + private MutableHttpResponse createUnauthorizedResponse(String error, String errorDescription) { + // Build WWW-Authenticate header per RFC 6750 + String resourceMetadataUrl = config.getResourceUri() + "/.well-known/oauth-protected-resource"; + String wwwAuthenticate = String.format( + "Bearer realm=\"mcp\", error=\"%s\", error_description=\"%s\", resource=\"%s\"", + error, + errorDescription, + resourceMetadataUrl + ); + + // Create JSON-RPC error response + JsonRpcResponse jsonRpcError = new JsonRpcResponse( + "2.0", + null, + null, + new JsonRpcError(-32000, "Unauthorized: " + errorDescription) + ); + + return HttpResponse.status(HttpStatus.UNAUTHORIZED) + .header("WWW-Authenticate", wwwAuthenticate) + .body(jsonRpcError); + } +} diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthConfig.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthConfig.java new file mode 100644 index 00000000..c3d8c3f3 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthConfig.java @@ -0,0 +1,88 @@ +package com.muchq.mcpserver.oauth; + +import io.micronaut.context.annotation.ConfigurationProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Configuration for OAuth 2.1 / OpenID Connect integration with Keycloak. + * + * These settings are used for: + * - Publishing Protected Resource Metadata (RFC 9728) + * - Validating JWT access tokens + * - Advertising the authorization server + */ +@ConfigurationProperties("mcp.oauth") +public class OAuthConfig { + + private static final Logger LOG = LoggerFactory.getLogger(OAuthConfig.class); + + /** + * Whether OAuth authentication is enabled. + * When false, the legacy Bearer token authentication (MCP_AUTH_TOKEN) is used. + * When true, JWT token validation with Keycloak is used. + */ + private boolean enabled = true; + + /** + * The authorization server URL (Keycloak realm). + * Example: http://localhost:8180/realms/mcp-demo + * + * This is advertised in the Protected Resource Metadata (RFC 9728) + * so clients can discover where to obtain tokens. + */ + private String authorizationServer = "http://localhost:8180/realms/mcp-demo"; + + /** + * The resource URI for this MCP server. + * This is the canonical identifier for this server in OAuth terms. + * Example: http://localhost:8080 + * + * CRITICAL: This MUST match the audience (aud) claim in JWT tokens. + * Tokens without this audience will be rejected (RFC 8707). + */ + private String resourceUri = "http://localhost:8080"; + + /** + * The JWKS (JSON Web Key Set) URI from Keycloak. + * Used to fetch public keys for validating JWT signatures. + * Example: http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs + */ + private String jwksUri = "http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getAuthorizationServer() { + LOG.debug("getAuthorizationServer() called, returning: {}", authorizationServer); + return authorizationServer; + } + + public void setAuthorizationServer(String authorizationServer) { + LOG.info("setAuthorizationServer() called with: {}", authorizationServer); + this.authorizationServer = authorizationServer; + } + + public String getResourceUri() { + LOG.debug("getResourceUri() called, returning: {}", resourceUri); + return resourceUri; + } + + public void setResourceUri(String resourceUri) { + LOG.info("setResourceUri() called with: {}", resourceUri); + this.resourceUri = resourceUri; + } + + public String getJwksUri() { + return jwksUri; + } + + public void setJwksUri(String jwksUri) { + this.jwksUri = jwksUri; + } +} diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceController.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceController.java new file mode 100644 index 00000000..8342b930 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceController.java @@ -0,0 +1,62 @@ +package com.muchq.mcpserver.oauth; + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Controller for serving RFC 9728 Protected Resource Metadata. + * + * This endpoint allows MCP clients to discover: + * 1. The canonical resource URI for this server + * 2. Which authorization server(s) can issue tokens for this resource + * 3. How to send bearer tokens (Authorization header) + * + * Flow: + * 1. Client makes unauthenticated MCP request + * 2. Server returns 401 with WWW-Authenticate header pointing to this endpoint + * 3. Client fetches this metadata to discover the authorization server + * 4. Client proceeds with OAuth flow using the discovered authorization server + * + * @see RFC 9728 + */ +@Controller("/.well-known") +public class ProtectedResourceController { + + private static final Logger LOG = LoggerFactory.getLogger(ProtectedResourceController.class); + + private final OAuthConfig config; + + public ProtectedResourceController(OAuthConfig config) { + this.config = config; + } + + /** + * Serves Protected Resource Metadata per RFC 9728. + * + * This endpoint is accessible without authentication and provides + * OAuth discovery information to clients. + * + * Example response: + * { + * "resource": "http://localhost:8080", + * "authorization_servers": ["http://localhost:8180/realms/mcp-demo"], + * "bearer_methods_supported": ["header"] + * } + * + * @return Protected Resource Metadata + */ + @Get("/oauth-protected-resource") + public ProtectedResourceMetadata getMetadata() { + LOG.debug("Serving Protected Resource Metadata: resource={}, authz_server={}", + config.getResourceUri(), config.getAuthorizationServer()); + + return new ProtectedResourceMetadata( + config.getResourceUri(), + List.of(config.getAuthorizationServer()), + List.of("header") + ); + } +} diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceMetadata.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceMetadata.java new file mode 100644 index 00000000..27a8ad85 --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceMetadata.java @@ -0,0 +1,34 @@ +package com.muchq.mcpserver.oauth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Introspected; +import java.util.List; + +/** + * RFC 9728 - OAuth 2.0 Protected Resource Metadata + * + * This metadata document allows clients to discover: + * 1. The resource identifier (canonical URI for this server) + * 2. The authorization server(s) that can issue tokens for this resource + * 3. Supported bearer token methods (header, query, body) + * + * Published at: GET /.well-known/oauth-protected-resource + * + * @param resource The resource identifier (RFC 8707) - the canonical URI for this MCP server + * @param authorizationServers List of authorization server URLs that can issue tokens for this resource + * @param bearerMethodsSupported List of methods for sending bearer tokens (typically ["header"]) + * + * @see RFC 9728 + */ +@Introspected +public record ProtectedResourceMetadata( + @JsonProperty("resource") + String resource, + + @JsonProperty("authorization_servers") + List authorizationServers, + + @JsonProperty("bearer_methods_supported") + List bearerMethodsSupported +) { +} diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/TokenValidator.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/TokenValidator.java new file mode 100644 index 00000000..d9d1c37d --- /dev/null +++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/TokenValidator.java @@ -0,0 +1,191 @@ +package com.muchq.mcpserver.oauth; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.micronaut.context.annotation.Requires; +import jakarta.inject.Singleton; +import java.io.IOException; +import java.net.URL; +import java.text.ParseException; +import java.util.Date; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates JWT access tokens from Keycloak. + * + * This validator performs three critical checks: + * 1. Signature verification against Keycloak's JWKS + * 2. Expiration check + * 3. Audience validation (RFC 8707) - CRITICAL for security + * + * The audience check ensures tokens issued for other services + * cannot be used to access this MCP server. + */ +@Singleton +@Requires(property = "mcp.oauth.enabled", value = "true") +public class TokenValidator { + + private static final Logger LOG = LoggerFactory.getLogger(TokenValidator.class); + + private final OAuthConfig config; + private final JWKSet jwkSet; + + public TokenValidator(OAuthConfig config) throws IOException, ParseException { + this.config = config; + + // Fetch JWKS from Keycloak + LOG.info("Fetching JWKS from: {}", config.getJwksUri()); + this.jwkSet = JWKSet.load(new URL(config.getJwksUri())); + LOG.info("Loaded {} keys from JWKS", jwkSet.getKeys().size()); + } + + /** + * Validates a JWT access token. + * + * @param bearerToken The JWT token (without "Bearer " prefix) + * @return ValidationResult indicating success or failure with error message + */ + public ValidationResult validate(String bearerToken) { + try { + // Parse JWT + SignedJWT jwt = SignedJWT.parse(bearerToken); + JWTClaimsSet claims = jwt.getJWTClaimsSet(); + + LOG.debug("Validating token: kid={}, sub={}, aud={}", + jwt.getHeader().getKeyID(), + claims.getSubject(), + claims.getAudience()); + + // 1. Verify signature + if (!verifySignature(jwt)) { + LOG.warn("Token signature verification failed"); + return ValidationResult.invalid("Invalid signature"); + } + + // 2. Check expiration + Date expiration = claims.getExpirationTime(); + if (expiration == null) { + LOG.warn("Token missing expiration claim"); + return ValidationResult.invalid("Missing expiration"); + } + + if (expiration.before(new Date())) { + LOG.warn("Token expired at: {}", expiration); + return ValidationResult.invalid("Token expired"); + } + + // 3. CRITICAL: Validate audience (RFC 8707) + // This prevents tokens issued for other services from being accepted + List audiences = claims.getAudience(); + if (audiences == null || audiences.isEmpty()) { + LOG.warn("Token missing audience claim"); + return ValidationResult.invalid("Missing audience"); + } + + String expectedAudience = config.getResourceUri(); + if (!audiences.contains(expectedAudience)) { + LOG.warn("Token audience mismatch. Expected: {}, Got: {}", + expectedAudience, audiences); + return ValidationResult.invalid("Invalid audience"); + } + + LOG.info("Token validated successfully for subject: {}", claims.getSubject()); + return ValidationResult.valid(claims.getSubject()); + + } catch (ParseException e) { + LOG.error("Failed to parse JWT token", e); + return ValidationResult.invalid("Malformed token"); + } catch (Exception e) { + LOG.error("Token validation failed", e); + return ValidationResult.invalid("Validation error: " + e.getMessage()); + } + } + + /** + * Verifies the JWT signature against Keycloak's public keys. + * + * @param jwt The signed JWT + * @return true if signature is valid, false otherwise + */ + private boolean verifySignature(SignedJWT jwt) { + try { + String keyId = jwt.getHeader().getKeyID(); + JWSAlgorithm algorithm = jwt.getHeader().getAlgorithm(); + + if (keyId == null) { + LOG.warn("Token missing 'kid' header"); + return false; + } + + // Find the matching key in JWKS + JWK jwk = jwkSet.getKeyByKeyId(keyId); + if (jwk == null) { + LOG.warn("No key found for kid: {}", keyId); + return false; + } + + // Only support RSA for now (Keycloak default) + if (!(jwk instanceof RSAKey)) { + LOG.warn("Unsupported key type: {}", jwk.getKeyType()); + return false; + } + + RSAKey rsaKey = (RSAKey) jwk; + JWSVerifier verifier = new RSASSAVerifier(rsaKey.toRSAPublicKey()); + + return jwt.verify(verifier); + + } catch (JOSEException e) { + LOG.error("Signature verification failed", e); + return false; + } + } + + /** + * Result of token validation. + * + * @param valid Whether the token is valid + * @param errorMessage Error message if invalid, null otherwise + * @param subject The subject (username) from the token if valid, null otherwise + */ + public static class ValidationResult { + private final boolean valid; + private final String errorMessage; + private final String subject; + + private ValidationResult(boolean valid, String errorMessage, String subject) { + this.valid = valid; + this.errorMessage = errorMessage; + this.subject = subject; + } + + public static ValidationResult valid(String subject) { + return new ValidationResult(true, null, subject); + } + + public static ValidationResult invalid(String errorMessage) { + return new ValidationResult(false, errorMessage, null); + } + + public boolean isValid() { + return valid; + } + + public String getErrorMessage() { + return errorMessage; + } + + public String getSubject() { + return subject; + } + } +} diff --git a/jvm/src/main/resources/BUILD.bazel b/jvm/src/main/resources/BUILD.bazel index 7f274f5a..f37ef26e 100644 --- a/jvm/src/main/resources/BUILD.bazel +++ b/jvm/src/main/resources/BUILD.bazel @@ -12,6 +12,9 @@ filegroup( filegroup( name = "micronaut_config", - srcs = ["application.yml"], + srcs = [ + "application.yml", + "application-dev.yml", + ], visibility = ["//visibility:public"], ) diff --git a/jvm/src/main/resources/application-dev.yml b/jvm/src/main/resources/application-dev.yml new file mode 100644 index 00000000..22d5fae1 --- /dev/null +++ b/jvm/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +mcp: + oauth: + enabled: true + authorization-server: http://localhost:8180/realms/mcp-demo + resource-uri: http://localhost:8080 + jwks-uri: http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs diff --git a/jvm/src/main/resources/application.yml b/jvm/src/main/resources/application.yml index d4068550..6331cd2c 100644 --- a/jvm/src/main/resources/application.yml +++ b/jvm/src/main/resources/application.yml @@ -7,3 +7,8 @@ micronaut: mcp: auth: token: ${MCP_AUTH_TOKEN:} + oauth: + enabled: ${MCP_OAUTH_ENABLED:true} + authorization-server: ${MCP_OAUTH_AUTHZ_SERVER:http://localhost:8180/realms/mcp-demo} + resource-uri: ${MCP_RESOURCE_URI:http://localhost:8080} + jwks-uri: ${MCP_OAUTH_JWKS_URI:http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs} diff --git a/jvm/src/test/java/com/muchq/mcpclient/BUILD.bazel b/jvm/src/test/java/com/muchq/mcpclient/BUILD.bazel new file mode 100644 index 00000000..237e2fca --- /dev/null +++ b/jvm/src/test/java/com/muchq/mcpclient/BUILD.bazel @@ -0,0 +1,12 @@ +load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite") + +java_test_suite( + name = "mcpclient", + size = "small", + srcs = ["McpClientConfigTest.java"], + deps = [ + "//jvm/src/main/java/com/muchq/mcpclient:config", + "@maven//:junit_junit", + "@maven//:org_assertj_assertj_core", + ], +) diff --git a/jvm/src/test/java/com/muchq/mcpclient/McpClientConfigTest.java b/jvm/src/test/java/com/muchq/mcpclient/McpClientConfigTest.java new file mode 100644 index 00000000..b8716ef4 --- /dev/null +++ b/jvm/src/test/java/com/muchq/mcpclient/McpClientConfigTest.java @@ -0,0 +1,171 @@ +package com.muchq.mcpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.muchq.mcpclient.McpClientConfig.TransportType; +import org.junit.Test; + +public class McpClientConfigTest { + + @Test + public void testBuilderWithRequiredFieldsOnly() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("http://localhost:8080/mcp") + .build(); + + assertThat(config.getServerUrl()).isEqualTo("http://localhost:8080/mcp"); + assertThat(config.getClientName()).isEqualTo("MCP Java Client"); // default + assertThat(config.getClientVersion()).isEqualTo("1.0.0"); // default + assertThat(config.getCallbackPort()).isEqualTo(8888); // default + assertThat(config.getClientId()).isNull(); + assertThat(config.getClientSecret()).isNull(); + assertThat(config.getTransportType()).isEqualTo(TransportType.HTTP); // default + } + + @Test + public void testBuilderWithAllFields() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("http://localhost:8080/mcp") + .clientName("My Client") + .clientVersion("2.0.0") + .callbackPort(9999) + .clientId("client-123") + .clientSecret("secret-456") + .transportType(TransportType.SSE) + .build(); + + assertThat(config.getServerUrl()).isEqualTo("http://localhost:8080/mcp"); + assertThat(config.getClientName()).isEqualTo("My Client"); + assertThat(config.getClientVersion()).isEqualTo("2.0.0"); + assertThat(config.getCallbackPort()).isEqualTo(9999); + assertThat(config.getClientId()).isEqualTo("client-123"); + assertThat(config.getClientSecret()).isEqualTo("secret-456"); + assertThat(config.getTransportType()).isEqualTo(TransportType.SSE); + } + + @Test + public void testBuilderRejectsNullServerUrl() { + assertThatThrownBy(() -> McpClientConfig.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serverUrl is required"); + } + + @Test + public void testBuilderRejectsEmptyServerUrl() { + assertThatThrownBy(() -> McpClientConfig.builder().serverUrl("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serverUrl is required"); + } + + @Test + public void testBuilderWithHttpsUrl() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("https://api.example.com/mcp") + .build(); + + assertThat(config.getServerUrl()).isEqualTo("https://api.example.com/mcp"); + } + + @Test + public void testBuilderMethodsReturnBuilder() { + McpClientConfig.Builder builder = McpClientConfig.builder(); + + // Verify fluent API - all methods return the builder + assertThat(builder.serverUrl("http://localhost")) + .isSameAs(builder); + assertThat(builder.clientName("name")) + .isSameAs(builder); + assertThat(builder.clientVersion("1.0")) + .isSameAs(builder); + assertThat(builder.callbackPort(8888)) + .isSameAs(builder); + assertThat(builder.clientId("id")) + .isSameAs(builder); + assertThat(builder.clientSecret("secret")) + .isSameAs(builder); + } + + @Test + public void testDefaultClientName() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("http://localhost") + .build(); + + assertThat(config.getClientName()).isEqualTo("MCP Java Client"); + } + + @Test + public void testDefaultClientVersion() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("http://localhost") + .build(); + + assertThat(config.getClientVersion()).isEqualTo("1.0.0"); + } + + @Test + public void testDefaultCallbackPort() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("http://localhost") + .build(); + + assertThat(config.getCallbackPort()).isEqualTo(8888); + } + + @Test + public void testClientIdAndSecretCanBeSetIndependently() { + // Test with only clientId (no secret) + McpClientConfig configWithIdOnly = McpClientConfig.builder() + .serverUrl("http://localhost") + .clientId("my-client") + .build(); + + assertThat(configWithIdOnly.getClientId()).isEqualTo("my-client"); + assertThat(configWithIdOnly.getClientSecret()).isNull(); + + // Test with only clientSecret (no id) + McpClientConfig configWithSecretOnly = McpClientConfig.builder() + .serverUrl("http://localhost") + .clientSecret("my-secret") + .build(); + + assertThat(configWithSecretOnly.getClientId()).isNull(); + assertThat(configWithSecretOnly.getClientSecret()).isEqualTo("my-secret"); + } + + @Test + public void testDefaultTransportTypeIsHttp() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("http://localhost") + .build(); + + assertThat(config.getTransportType()).isEqualTo(TransportType.HTTP); + } + + @Test + public void testTransportTypeCanBeSetToSse() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("http://localhost") + .transportType(TransportType.SSE) + .build(); + + assertThat(config.getTransportType()).isEqualTo(TransportType.SSE); + } + + @Test + public void testTransportTypeCanBeSetToHttp() { + McpClientConfig config = McpClientConfig.builder() + .serverUrl("http://localhost") + .transportType(TransportType.HTTP) + .build(); + + assertThat(config.getTransportType()).isEqualTo(TransportType.HTTP); + } + + @Test + public void testTransportTypeBuilderReturnsSameBuilder() { + McpClientConfig.Builder builder = McpClientConfig.builder(); + assertThat(builder.transportType(TransportType.SSE)).isSameAs(builder); + } +} diff --git a/jvm/src/test/java/com/muchq/mcpclient/oauth/BUILD.bazel b/jvm/src/test/java/com/muchq/mcpclient/oauth/BUILD.bazel new file mode 100644 index 00000000..aca29912 --- /dev/null +++ b/jvm/src/test/java/com/muchq/mcpclient/oauth/BUILD.bazel @@ -0,0 +1,14 @@ +load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite") + +java_test_suite( + name = "oauth", + size = "small", + srcs = [ + "TokenManagerTest.java", + ], + deps = [ + "//jvm/src/main/java/com/muchq/mcpclient/oauth", + "@maven//:junit_junit", + "@maven//:org_assertj_assertj_core", + ], +) diff --git a/jvm/src/test/java/com/muchq/mcpclient/oauth/TokenManagerTest.java b/jvm/src/test/java/com/muchq/mcpclient/oauth/TokenManagerTest.java new file mode 100644 index 00000000..bbb7e858 --- /dev/null +++ b/jvm/src/test/java/com/muchq/mcpclient/oauth/TokenManagerTest.java @@ -0,0 +1,139 @@ +package com.muchq.mcpclient.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.Test; + +public class TokenManagerTest { + + @Test + public void testStoreAndRetrieveTokens() { + TokenManager manager = new TokenManager(); + + manager.storeTokens("access123", "refresh456", 300); + + assertThat(manager.getAccessToken()).isEqualTo("access123"); + assertThat(manager.getRefreshToken()).isEqualTo("refresh456"); + assertThat(manager.hasValidAccessToken()).isTrue(); + } + + @Test + public void testStoreTokensWithNullRefreshToken() { + TokenManager manager = new TokenManager(); + + manager.storeTokens("access123", null, 300); + + assertThat(manager.getAccessToken()).isEqualTo("access123"); + assertThat(manager.getRefreshToken()).isNull(); + assertThat(manager.hasValidAccessToken()).isTrue(); + } + + @Test + public void testGetAccessTokenReturnsNullWhenNotSet() { + TokenManager manager = new TokenManager(); + + assertThat(manager.getAccessToken()).isNull(); + assertThat(manager.hasValidAccessToken()).isFalse(); + } + + @Test + public void testGetAccessTokenReturnsNullWhenExpired() { + TokenManager manager = new TokenManager(); + + // Store token that expires in 1 second (but buffer is 30 seconds, so it's already "expired") + manager.storeTokens("access123", "refresh456", 1); + + // Token should be considered expired due to 30-second buffer + assertThat(manager.getAccessToken()).isNull(); + assertThat(manager.hasValidAccessToken()).isFalse(); + } + + @Test + public void testGetAccessTokenWithSufficientTime() { + TokenManager manager = new TokenManager(); + + // Store token that expires in 60 seconds (more than 30-second buffer) + manager.storeTokens("access123", "refresh456", 60); + + assertThat(manager.getAccessToken()).isEqualTo("access123"); + assertThat(manager.hasValidAccessToken()).isTrue(); + } + + @Test + public void testClearTokens() { + TokenManager manager = new TokenManager(); + manager.storeTokens("access123", "refresh456", 300); + + manager.clearTokens(); + + assertThat(manager.getAccessToken()).isNull(); + assertThat(manager.getRefreshToken()).isNull(); + assertThat(manager.hasValidAccessToken()).isFalse(); + assertThat(manager.getExpiresAt()).isNull(); + } + + @Test + public void testStoreTokensRejectsNullAccessToken() { + TokenManager manager = new TokenManager(); + + assertThatThrownBy(() -> manager.storeTokens(null, "refresh", 300)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("accessToken is required"); + } + + @Test + public void testStoreTokensRejectsEmptyAccessToken() { + TokenManager manager = new TokenManager(); + + assertThatThrownBy(() -> manager.storeTokens("", "refresh", 300)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("accessToken is required"); + } + + @Test + public void testStoreTokensRejectsZeroExpiration() { + TokenManager manager = new TokenManager(); + + assertThatThrownBy(() -> manager.storeTokens("access", "refresh", 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expiresInSeconds must be positive"); + } + + @Test + public void testStoreTokensRejectsNegativeExpiration() { + TokenManager manager = new TokenManager(); + + assertThatThrownBy(() -> manager.storeTokens("access", "refresh", -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expiresInSeconds must be positive"); + } + + @Test + public void testGetExpiresAtReturnsNullInitially() { + TokenManager manager = new TokenManager(); + + assertThat(manager.getExpiresAt()).isNull(); + } + + @Test + public void testGetExpiresAtReturnsValueAfterStore() { + TokenManager manager = new TokenManager(); + + manager.storeTokens("access123", "refresh456", 300); + + assertThat(manager.getExpiresAt()).isNotNull(); + } + + @Test + public void testRefreshTokenPersistsAfterAccessTokenExpires() { + TokenManager manager = new TokenManager(); + + // Store token with very short expiration + manager.storeTokens("access123", "refresh456", 1); + + // Access token should be null (expired due to buffer), but refresh token should persist + assertThat(manager.getAccessToken()).isNull(); + assertThat(manager.getRefreshToken()).isEqualTo("refresh456"); + } +} diff --git a/local_docker/keycloak/DOCKER_TROUBLESHOOTING.md b/local_docker/keycloak/DOCKER_TROUBLESHOOTING.md new file mode 100644 index 00000000..13031379 --- /dev/null +++ b/local_docker/keycloak/DOCKER_TROUBLESHOOTING.md @@ -0,0 +1,96 @@ +# Docker Credentials Troubleshooting + +## Issue + +When trying to pull the Keycloak image, you may encounter: + +``` +error getting credentials - err: exec: "docker-credential-desktop": executable file not found in $PATH +``` + +## Cause + +This occurs when Docker Desktop's credential helper is configured but not available in PATH, or Docker Desktop isn't properly installed/running. + +## Solutions + +### Option 1: Fix Docker Credential Helper (Recommended) + +1. **Check if Docker Desktop is running:** + ```bash + open -a Docker # macOS + ``` + +2. **Verify Docker credential helper path:** + ```bash + which docker-credential-desktop + # Should output: /Applications/Docker.app/Contents/Resources/bin/docker-credential-desktop + ``` + +3. **Add to PATH if missing:** + ```bash + export PATH="/Applications/Docker.app/Contents/Resources/bin:$PATH" + # Add to ~/.zshrc or ~/.bashrc for persistence + ``` + +### Option 2: Disable Credential Helper Temporarily + +1. **Edit Docker config:** + ```bash + vim ~/.docker/config.json + ``` + +2. **Remove or comment out `credsStore`:** + ```json + { + "auths": {}, + // "credsStore": "desktop" <- Comment or remove this line + } + ``` + +3. **Try pulling again:** + ```bash + docker pull quay.io/keycloak/keycloak:26.0 + ``` + +### Option 3: Use Alternative Keycloak Image + +If `quay.io` is the issue, try Docker Hub: + +1. **Update `docker-compose.keycloak.yml`:** + ```yaml + services: + keycloak: + image: keycloak/keycloak:26.0 # Docker Hub instead of quay.io + ``` + +### Option 4: Build from Dockerfile + +If pulling images continues to fail, create a minimal Dockerfile: + +```dockerfile +FROM eclipse-temurin:21-jre-jammy +WORKDIR /opt/keycloak +# Download Keycloak release manually +``` + +## Verification + +After applying a fix, verify Docker works: + +```bash +# Test pulling a small image +docker pull hello-world + +# Test Keycloak pull +docker pull quay.io/keycloak/keycloak:26.0 + +# Start Keycloak +cd local_docker/keycloak +docker compose -f docker-compose.keycloak.yml up -d +``` + +## References + +- [Docker Credential Helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers) +- [Docker Desktop Installation](https://docs.docker.com/desktop/install/mac-install/) diff --git a/local_docker/keycloak/README.md b/local_docker/keycloak/README.md new file mode 100644 index 00000000..03546c73 --- /dev/null +++ b/local_docker/keycloak/README.md @@ -0,0 +1,215 @@ +# Keycloak Setup for MCP OAuth Demo + +This directory contains Keycloak configuration for demonstrating the MCP Authorization Spec with OAuth 2.1 + PKCE + Dynamic Client Registration. + +## Quick Start + +```bash +# Start Keycloak +docker compose -f docker-compose.keycloak.yml up -d + +# Wait for Keycloak to be ready (about 30 seconds) +until curl -sf http://localhost:8180/health/ready > /dev/null; do sleep 2; done + +# Verify it's running +curl http://localhost:8180/realms/mcp-demo/.well-known/openid-configuration | jq + +# Stop Keycloak +docker compose -f docker-compose.keycloak.yml down +``` + +## Configuration + +### Realm: `mcp-demo` + +- **Port:** `8180` (avoids conflict with MCP server on `8080`) +- **Admin Console:** http://localhost:8180/admin +- **Admin Credentials:** `admin` / `admin` + +### Test User + +- **Username:** `testuser` +- **Password:** `testpass` +- **Email:** testuser@example.com + +### OAuth 2.1 Features Enabled + +1. **Dynamic Client Registration (RFC 7591)** + - Anonymous registration enabled via Client Registration Policies + - Clients can self-register at `/realms/mcp-demo/clients-registrations/openid-connect` + +2. **PKCE Enforcement (RFC 7636)** + - Client Policy: `mcp-oauth-policy` + - Client Profile: `mcp-oauth-profile` + - PKCE is automatically enforced for all dynamically registered clients + - Only S256 code challenge method is supported + +3. **Resource Indicators (RFC 8707)** + - Supported via custom `resource` parameter in authorization/token requests + - Tokens will include `aud` claim based on resource parameter + +4. **Short-Lived Tokens** + - Access Token Lifespan: 5 minutes (300 seconds) + - Refresh Token: Enabled for token renewal + +## Key Endpoints + +### Well-Known Endpoints + +```bash +# OpenID Configuration +http://localhost:8180/realms/mcp-demo/.well-known/openid-configuration + +# JWKS (for token validation) +http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs +``` + +### OAuth Endpoints + +```bash +# Authorization Endpoint +http://localhost:8180/realms/mcp-demo/protocol/openid-connect/auth + +# Token Endpoint +http://localhost:8180/realms/mcp-demo/protocol/openid-connect/token + +# Dynamic Client Registration +http://localhost:8180/realms/mcp-demo/clients-registrations/openid-connect +``` + +## Testing Dynamic Client Registration + +```bash +# Register a new client +curl -X POST http://localhost:8180/realms/mcp-demo/clients-registrations/openid-connect \ + -H "Content-Type: application/json" \ + -d '{ + "client_name": "Test MCP Client", + "redirect_uris": ["http://localhost:8888/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + }' | jq + +# Response will include client_id and client_secret (if applicable) +``` + +## Verifying PKCE Enforcement + +The realm is configured to automatically enforce PKCE for all dynamically registered clients: + +```bash +# Authorization request (MUST include code_challenge) +http://localhost:8180/realms/mcp-demo/protocol/openid-connect/auth?\ + response_type=code&\ + client_id=&\ + redirect_uri=http://localhost:8888/callback&\ + code_challenge=&\ + code_challenge_method=S256 + +# Token exchange (MUST include code_verifier) +POST http://localhost:8180/realms/mcp-demo/protocol/openid-connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code& +code=& +redirect_uri=http://localhost:8888/callback& +client_id=& +code_verifier= +``` + +## Client Policies Configuration + +The realm includes pre-configured client policies for OAuth 2.1 compliance: + +### Policy: `mcp-oauth-policy` + +- **Applies To:** Clients registered via anonymous registration +- **Enforces:** `mcp-oauth-profile` + +### Profile: `mcp-oauth-profile` + +- **Executors:** + - `pkce-enforcer`: Automatically requires PKCE for authorization code flow + - `secure-client-authn-executor`: Restricts client authentication methods + +## Troubleshooting + +### Port Already in Use + +If port 8180 is already in use, modify `docker-compose.keycloak.yml`: + +```yaml +ports: + - "8181:8180" # Change host port to 8181 +``` + +Then update MCP server configuration: + +```bash +export MCP_OAUTH_AUTHZ_SERVER=http://localhost:8181/realms/mcp-demo +``` + +### Keycloak Not Starting + +Check logs: + +```bash +docker compose -f docker-compose.keycloak.yml logs -f +``` + +### Realm Not Imported + +If the realm doesn't exist after startup, manually import it: + +1. Open Admin Console: http://localhost:8180/admin +2. Login with `admin` / `admin` +3. Click "Create Realm" +4. Click "Browse" and select `realm-export.json` +5. Click "Create" + +### Dynamic Registration Failing + +Verify client registration policies are active: + +1. Admin Console → Realm Settings → Client Registration +2. Check "Anonymous" access type is enabled +3. Admin Console → Realm Settings → Client Policies +4. Verify `mcp-oauth-policy` is enabled and active + +## Integration with MCP Server + +The MCP server should be configured to use this Keycloak instance: + +```bash +export MCP_OAUTH_ENABLED=true +export MCP_OAUTH_AUTHZ_SERVER=http://localhost:8180/realms/mcp-demo +export MCP_RESOURCE_URI=http://localhost:8080 +export MCP_OAUTH_JWKS_URI=http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs +``` + +The MCP server will: +- Advertise Keycloak as its authorization server in RFC 9728 metadata +- Validate JWT tokens against Keycloak's JWKS +- Verify token audience matches `http://localhost:8080` + +## Security Notes + +- This is a **development-only** configuration +- SSL is disabled (`sslRequired: none`) for localhost testing +- In production: + - Enable HTTPS + - Use proper SSL certificates + - Configure secure redirect URIs (no localhost) + - Enable brute force protection + - Configure proper session timeouts + - Use external database (not in-memory) + +## References + +- [Keycloak Documentation](https://www.keycloak.org/documentation) +- [OAuth 2.1 Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13) +- [RFC 7591 - Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) +- [RFC 7636 - PKCE](https://datatracker.ietf.org/doc/html/rfc7636) +- [RFC 8707 - Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html) +- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) diff --git a/local_docker/keycloak/docker-compose.keycloak.yml b/local_docker/keycloak/docker-compose.keycloak.yml new file mode 100644 index 00000000..8e2583a7 --- /dev/null +++ b/local_docker/keycloak/docker-compose.keycloak.yml @@ -0,0 +1,29 @@ +services: + keycloak: + image: quay.io/keycloak/keycloak:26.0 + command: + - start-dev + - --import-realm + environment: + KC_DB: dev-mem + KC_HTTP_PORT: 8180 + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + KC_FEATURES: dynamic-scopes,client-secret-rotation + ports: + - "8180:8180" + volumes: + - ./realm-export.json:/opt/keycloak/data/import/realm.json:ro + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8180/health/ready"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - mcp-oauth + +networks: + mcp-oauth: + driver: bridge diff --git a/local_docker/keycloak/realm-export.json b/local_docker/keycloak/realm-export.json new file mode 100644 index 00000000..622c9bb6 --- /dev/null +++ b/local_docker/keycloak/realm-export.json @@ -0,0 +1,752 @@ +{ + "realm": "mcp-demo", + "id": "e4d28f06-91c5-4f42-b493-7ed851d56171", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "defaultSignatureAlgorithm": "RS256", + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "attributes": { + "clientPolicies": "{\"policies\":[{\"name\":\"mcp-oauth-policy\",\"description\":\"Enforce PKCE for dynamically registered clients\",\"enabled\":true,\"conditions\":[{\"condition\":\"client-updater-source\",\"configuration\":{\"client-source\":\"anonymous\"}}],\"profiles\":[\"mcp-oauth-profile\"]}]}", + "clientProfiles": "{\"profiles\":[{\"name\":\"mcp-oauth-profile\",\"description\":\"OAuth 2.1 profile for MCP clients with PKCE\",\"executors\":[{\"executor\":\"pkce-enforcer\",\"configuration\":{\"auto-configure\":true}},{\"executor\":\"secure-client-authn-executor\",\"configuration\":{\"client-authns\":[\"none\",\"client-secret-basic\",\"client-secret-post\",\"client-secret-jwt\",\"private-key-jwt\"]}}]}]}", + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaInterval": "5", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "frontendUrl": "", + "acr.loa.map": "{}" + }, + "users": [ + { + "username": "testuser", + "enabled": true, + "totp": false, + "emailVerified": true, + "email": "testuser@example.com", + "firstName": "Test", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "testpass", + "temporary": false + } + ], + "requiredActions": [], + "realmRoles": [ + "default-roles-mcp-demo" + ], + "clientRoles": {}, + "groups": [] + } + ], + "roles": { + "realm": [ + { + "name": "default-roles-mcp-demo", + "description": "Default roles for realm", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ] + }, + "clientRole": false, + "containerId": "mcp-demo" + }, + { + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "mcp-demo" + }, + { + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "mcp-demo" + } + ] + }, + "groups": [], + "defaultRole": { + "name": "default-roles-mcp-demo", + "description": "Default roles for realm", + "composite": true, + "clientRole": false, + "containerId": "mcp-demo" + }, + "requiredCredentials": [ + "password" + ], + "scopeMappings": [], + "clientScopeMappings": {}, + "clients": [], + "clientScopes": [ + { + "name": "mcp-access", + "description": "Scope for MCP resource access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Access to MCP resources" + }, + "protocolMappers": [] + }, + { + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + } + ] + }, + { + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt", + "mcp-access" + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "71d75ed5-4691-4675-9cec-2a3719692d34", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-address-mapper", + "oidc-full-name-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "95ce271e-fc63-4807-b5e9-8d9924708bf0", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "config": {} + }, + { + "id": "89e896bd-9bc7-41f4-9397-2e1ef0477e23", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "config": {} + }, + { + "id": "c4980afb-9684-4030-9dc7-b7ef59be6e29", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "9fff2c2c-8df2-4325-9134-e1ae41e84885", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "e16de4fd-3e5b-499d-828e-5a27c33c4579", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "8df6190a-b46e-43a0-8124-73e8107dac67", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-address-mapper", + "saml-role-list-mapper" + ] + } + } + ] + } +} diff --git a/maven_install.json b/maven_install.json index ea9c54e1..cc657117 100755 --- a/maven_install.json +++ b/maven_install.json @@ -1,7 +1,7 @@ { "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", - "__INPUT_ARTIFACTS_HASH": 749049213, - "__RESOLVED_ARTIFACTS_HASH": -1516269127, + "__INPUT_ARTIFACTS_HASH": -707983187, + "__RESOLVED_ARTIFACTS_HASH": 758269122, "conflict_resolution": { "com.fasterxml.jackson.core:jackson-annotations": "com.fasterxml.jackson.core:jackson-annotations:2.19.2", "com.fasterxml.jackson.core:jackson-core": "com.fasterxml.jackson.core:jackson-core:2.19.2", @@ -13,6 +13,7 @@ "com.google.errorprone:error_prone_annotations:2.5.1": "com.google.errorprone:error_prone_annotations:2.41.0", "com.google.guava:guava:32.0.1-jre": "com.google.guava:guava:33.5.0-jre", "com.google.j2objc:j2objc-annotations:2.8": "com.google.j2objc:j2objc-annotations:3.1", + "com.nimbusds:nimbus-jose-jwt:9.47": "com.nimbusds:nimbus-jose-jwt:10.0.1", "io.micronaut.jaxrs:micronaut-jaxrs-processor": "io.micronaut.jaxrs:micronaut-jaxrs-processor:4.10.0", "io.micronaut.jaxrs:micronaut-jaxrs-server": "io.micronaut.jaxrs:micronaut-jaxrs-server:4.10.0", "io.micronaut.validation:micronaut-validation": "io.micronaut.validation:micronaut-validation:4.12.0", @@ -41,6 +42,12 @@ }, "version": "1.5.6" }, + "com.ethlo.time:itu": { + "shasums": { + "jar": "23d3ba84095d489a595240f89045085ea5066fb6fc1dc091258d577df9d74abc" + }, + "version": "1.10.3" + }, "com.fasterxml.jackson.core:jackson-annotations": { "shasums": { "jar": "e516743a316dcf83c572ffc9cb6e8c5e8c134880c8c5155b02f7b34e9c5dc3cf" @@ -59,6 +66,12 @@ }, "version": "2.19.2" }, + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": { + "shasums": { + "jar": "80a213e7d998244922ab7bcf0938ea03eb7868e88e2d05a8407c6228c885a37e" + }, + "version": "2.19.2" + }, "com.fasterxml.jackson.datatype:jackson-datatype-guava": { "shasums": { "jar": "0f400e0c47bc1806051c87c2629f644c90479a44a052cb004ff842eae7d10bab" @@ -95,6 +108,12 @@ }, "version": "3.27.0" }, + "com.github.stephenc.jcip:jcip-annotations": { + "shasums": { + "jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323" + }, + "version": "1.0-1" + }, "com.google.code.findbugs:jsr305": { "shasums": { "jar": "766ad2a0783f2687962c8ad74ceecc38a28b9f72a2d085ee438b7813e928d0c7" @@ -137,6 +156,36 @@ }, "version": "3.1" }, + "com.networknt:json-schema-validator": { + "shasums": { + "jar": "2c71e970dc1b67499b99efaf65982696e98ced0028548d43bf4eba06edd8834f" + }, + "version": "1.5.7" + }, + "com.nimbusds:content-type": { + "shasums": { + "jar": "60349793e006fba96b532cb0c21e10e969fe0db8d87f91c3b9eaf82ba2998895" + }, + "version": "2.3" + }, + "com.nimbusds:lang-tag": { + "shasums": { + "jar": "e8c1c594e2425bdbea2d860de55c69b69fc5d59454452449a0f0913c2a5b8a31" + }, + "version": "1.7" + }, + "com.nimbusds:nimbus-jose-jwt": { + "shasums": { + "jar": "f28dbd9ab128324f05050d76b78469d3a9cd83e0319aabc68d1c276e3923e13a" + }, + "version": "10.0.1" + }, + "com.nimbusds:oauth2-oidc-sdk": { + "shasums": { + "jar": "4a42c7e51e1788a3951ff925692f05a6b0088d50f6fc7ad4c4a2338dd0491fb4" + }, + "version": "11.21" + }, "com.thoughtworks.paranamer:paranamer": { "shasums": { "jar": "a9df136f2e926b37a838a5b4e2227343c3a755d15724b3a8350b4aea4b158945" @@ -329,6 +378,12 @@ }, "version": "4.10.12" }, + "io.modelcontextprotocol.sdk:mcp": { + "shasums": { + "jar": "13a5f7879ad792bb4e77a2687c61eb350373f746ce7324a19b8f7778d2739aab" + }, + "version": "0.12.1" + }, "io.netty:netty-buffer": { "shasums": { "jar": "d013a96acea4889bc01ee03738d53c2e4a05e3faec14c25a92b4224c77d7a408" @@ -427,15 +482,15 @@ }, "io.sentry:sentry": { "shasums": { - "jar": "955e066ac6b70aa6419442490e04725e6730261f44875c4f1fb62439fd1baace" + "jar": "f841a0d5e1500b130f38cbab803e897cebe3f639e4ed2f0cd1ae98872b36e0ae" }, - "version": "8.26.0" + "version": "8.30.0" }, "io.sentry:sentry-logback": { "shasums": { - "jar": "3f97447c1d3cf64f38e2ea1ab685f1bc731643f8cdda4e4bcbfe7c38fbbbd888" + "jar": "e7f056a54b3c90724d91b3558c539894e81dd4eaf9ca04a4b4b295168e4a437c" }, - "version": "8.26.0" + "version": "8.30.0" }, "jakarta.annotation:jakarta.annotation-api": { "shasums": { @@ -473,12 +528,30 @@ }, "version": "1.17.7" }, + "net.minidev:accessors-smart": { + "shasums": { + "jar": "2796ae857d0c7be4bc3580daa4d3828d555212355f4c83d38dd0af0742b3c812" + }, + "version": "2.5.1" + }, + "net.minidev:json-smart": { + "shasums": { + "jar": "86c0c189581b79b57b0719f443a724e9f628ffbb9eef645cf79194f5973a1001" + }, + "version": "2.5.1" + }, "org.assertj:assertj-core": { "shasums": { "jar": "b27872b049abc88e23c853ce5917945dd57d43bd31175d852e3941d2a6fd0231" }, "version": "3.27.6" }, + "org.bouncycastle:bcprov-jdk18on": { + "shasums": { + "jar": "82cf3a2af766c3bc874f6d36b9f20a8b99a8f09762dc776e8a227a45d8daaafb" + }, + "version": "1.83" + }, "org.checkerframework:checker-qual": { "shasums": { "jar": "13b3b6c9261d35886a297adae438d6606581acc1bf7c901402dd9963f2b6a690" @@ -634,6 +707,12 @@ "jar": "7b751d952061954d5abfed7181c1f645d336091b679891591d63329c622eb832" }, "version": "2.0.17" + }, + "org.yaml:snakeyaml": { + "shasums": { + "jar": "ef779af5d29a9dde8cc70ce0341f5c6f7735e23edff9685ceaa9d35359b7bb7f" + }, + "version": "2.4" } }, "dependencies": { @@ -645,6 +724,11 @@ "com.fasterxml.jackson.core:jackson-annotations", "com.fasterxml.jackson.core:jackson-core" ], + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": [ + "com.fasterxml.jackson.core:jackson-core", + "com.fasterxml.jackson.core:jackson-databind", + "org.yaml:snakeyaml" + ], "com.fasterxml.jackson.datatype:jackson-datatype-guava": [ "com.fasterxml.jackson.core:jackson-annotations", "com.fasterxml.jackson.core:jackson-core", @@ -678,6 +762,19 @@ "com.google.j2objc:j2objc-annotations", "org.jspecify:jspecify" ], + "com.networknt:json-schema-validator": [ + "com.ethlo.time:itu", + "com.fasterxml.jackson.core:jackson-databind", + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", + "org.slf4j:slf4j-api" + ], + "com.nimbusds:oauth2-oidc-sdk": [ + "com.github.stephenc.jcip:jcip-annotations", + "com.nimbusds:content-type", + "com.nimbusds:lang-tag", + "com.nimbusds:nimbus-jose-jwt", + "net.minidev:json-smart" + ], "io.micronaut.jaxrs:micronaut-jaxrs-common": [ "io.micronaut:micronaut-http", "io.micronaut:micronaut-inject", @@ -870,6 +967,12 @@ "io.projectreactor:reactor-core", "org.slf4j:slf4j-api" ], + "io.modelcontextprotocol.sdk:mcp": [ + "com.fasterxml.jackson.core:jackson-databind", + "com.networknt:json-schema-validator", + "io.projectreactor:reactor-core", + "org.slf4j:slf4j-api" + ], "io.netty:netty-buffer": [ "io.netty:netty-common" ], @@ -966,6 +1069,12 @@ "junit:junit": [ "org.hamcrest:hamcrest-core" ], + "net.minidev:accessors-smart": [ + "org.ow2.asm:asm" + ], + "net.minidev:json-smart": [ + "net.minidev:accessors-smart" + ], "org.assertj:assertj-core": [ "net.bytebuddy:byte-buddy" ], @@ -1121,6 +1230,14 @@ "ch.qos.logback.core.testUtil", "ch.qos.logback.core.util" ], + "com.ethlo.time:itu": [ + "com.ethlo.time", + "com.ethlo.time.internal", + "com.ethlo.time.internal.fixed", + "com.ethlo.time.internal.token", + "com.ethlo.time.internal.util", + "com.ethlo.time.token" + ], "com.fasterxml.jackson.core:jackson-annotations": [ "com.fasterxml.jackson.annotation" ], @@ -1165,6 +1282,11 @@ "com.fasterxml.jackson.databind.util", "com.fasterxml.jackson.databind.util.internal" ], + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": [ + "com.fasterxml.jackson.dataformat.yaml", + "com.fasterxml.jackson.dataformat.yaml.snakeyaml.error", + "com.fasterxml.jackson.dataformat.yaml.util" + ], "com.fasterxml.jackson.datatype:jackson-datatype-guava": [ "com.fasterxml.jackson.datatype.guava", "com.fasterxml.jackson.datatype.guava.deser", @@ -1258,6 +1380,9 @@ "com.github.javaparser.symbolsolver.resolution.typesolvers", "com.github.javaparser.symbolsolver.utils" ], + "com.github.stephenc.jcip:jcip-annotations": [ + "net.jcip.annotations" + ], "com.google.code.findbugs:jsr305": [ "javax.annotation", "javax.annotation.concurrent", @@ -1304,6 +1429,115 @@ "com.google.j2objc:j2objc-annotations": [ "com.google.j2objc.annotations" ], + "com.networknt:json-schema-validator": [ + "com.networknt.org.apache.commons.validator.routines", + "com.networknt.schema", + "com.networknt.schema.annotation", + "com.networknt.schema.format", + "com.networknt.schema.i18n", + "com.networknt.schema.oas", + "com.networknt.schema.output", + "com.networknt.schema.regex", + "com.networknt.schema.resource", + "com.networknt.schema.result", + "com.networknt.schema.serialization", + "com.networknt.schema.serialization.node", + "com.networknt.schema.utils", + "com.networknt.schema.walk" + ], + "com.nimbusds:content-type": [ + "com.nimbusds.common.contenttype" + ], + "com.nimbusds:lang-tag": [ + "com.nimbusds.langtag" + ], + "com.nimbusds:nimbus-jose-jwt": [ + "com.nimbusds.jose", + "com.nimbusds.jose.crypto", + "com.nimbusds.jose.crypto.bc", + "com.nimbusds.jose.crypto.factories", + "com.nimbusds.jose.crypto.impl", + "com.nimbusds.jose.crypto.opts", + "com.nimbusds.jose.crypto.utils", + "com.nimbusds.jose.jca", + "com.nimbusds.jose.jwk", + "com.nimbusds.jose.jwk.gen", + "com.nimbusds.jose.jwk.source", + "com.nimbusds.jose.mint", + "com.nimbusds.jose.proc", + "com.nimbusds.jose.produce", + "com.nimbusds.jose.shaded.gson", + "com.nimbusds.jose.shaded.gson.annotations", + "com.nimbusds.jose.shaded.gson.internal", + "com.nimbusds.jose.shaded.gson.internal.bind", + "com.nimbusds.jose.shaded.gson.internal.bind.util", + "com.nimbusds.jose.shaded.gson.internal.reflect", + "com.nimbusds.jose.shaded.gson.internal.sql", + "com.nimbusds.jose.shaded.gson.reflect", + "com.nimbusds.jose.shaded.gson.stream", + "com.nimbusds.jose.shaded.jcip", + "com.nimbusds.jose.util", + "com.nimbusds.jose.util.cache", + "com.nimbusds.jose.util.events", + "com.nimbusds.jose.util.health", + "com.nimbusds.jwt", + "com.nimbusds.jwt.proc", + "com.nimbusds.jwt.util" + ], + "com.nimbusds:oauth2-oidc-sdk": [ + "com.nimbusds.oauth2.sdk", + "com.nimbusds.oauth2.sdk.as", + "com.nimbusds.oauth2.sdk.assertions", + "com.nimbusds.oauth2.sdk.assertions.jwt", + "com.nimbusds.oauth2.sdk.assertions.saml2", + "com.nimbusds.oauth2.sdk.auth", + "com.nimbusds.oauth2.sdk.auth.verifier", + "com.nimbusds.oauth2.sdk.ciba", + "com.nimbusds.oauth2.sdk.client", + "com.nimbusds.oauth2.sdk.cnf", + "com.nimbusds.oauth2.sdk.device", + "com.nimbusds.oauth2.sdk.dpop", + "com.nimbusds.oauth2.sdk.dpop.verifiers", + "com.nimbusds.oauth2.sdk.http", + "com.nimbusds.oauth2.sdk.id", + "com.nimbusds.oauth2.sdk.jarm", + "com.nimbusds.oauth2.sdk.jose", + "com.nimbusds.oauth2.sdk.pkce", + "com.nimbusds.oauth2.sdk.rar", + "com.nimbusds.oauth2.sdk.token", + "com.nimbusds.oauth2.sdk.tokenexchange", + "com.nimbusds.oauth2.sdk.util", + "com.nimbusds.oauth2.sdk.util.date", + "com.nimbusds.oauth2.sdk.util.singleuse", + "com.nimbusds.oauth2.sdk.util.tls", + "com.nimbusds.openid.connect.sdk", + "com.nimbusds.openid.connect.sdk.assurance", + "com.nimbusds.openid.connect.sdk.assurance.claims", + "com.nimbusds.openid.connect.sdk.assurance.evidences", + "com.nimbusds.openid.connect.sdk.assurance.evidences.attachment", + "com.nimbusds.openid.connect.sdk.assurance.request", + "com.nimbusds.openid.connect.sdk.claims", + "com.nimbusds.openid.connect.sdk.federation", + "com.nimbusds.openid.connect.sdk.federation.api", + "com.nimbusds.openid.connect.sdk.federation.config", + "com.nimbusds.openid.connect.sdk.federation.entities", + "com.nimbusds.openid.connect.sdk.federation.policy", + "com.nimbusds.openid.connect.sdk.federation.policy.language", + "com.nimbusds.openid.connect.sdk.federation.policy.operations", + "com.nimbusds.openid.connect.sdk.federation.registration", + "com.nimbusds.openid.connect.sdk.federation.trust", + "com.nimbusds.openid.connect.sdk.federation.trust.constraints", + "com.nimbusds.openid.connect.sdk.federation.trust.marks", + "com.nimbusds.openid.connect.sdk.federation.utils", + "com.nimbusds.openid.connect.sdk.id", + "com.nimbusds.openid.connect.sdk.nativesso", + "com.nimbusds.openid.connect.sdk.op", + "com.nimbusds.openid.connect.sdk.rp", + "com.nimbusds.openid.connect.sdk.rp.statement", + "com.nimbusds.openid.connect.sdk.token", + "com.nimbusds.openid.connect.sdk.validators", + "com.nimbusds.secevent.sdk.claims" + ], "com.thoughtworks.paranamer:paranamer": [ "com.thoughtworks.paranamer" ], @@ -1664,6 +1898,16 @@ "io.micronaut.websocket.exceptions", "io.micronaut.websocket.interceptor" ], + "io.modelcontextprotocol.sdk:mcp": [ + "io.modelcontextprotocol.client", + "io.modelcontextprotocol.client.transport", + "io.modelcontextprotocol.client.transport.customizer", + "io.modelcontextprotocol.common", + "io.modelcontextprotocol.server", + "io.modelcontextprotocol.server.transport", + "io.modelcontextprotocol.spec", + "io.modelcontextprotocol.util" + ], "io.netty:netty-buffer": [ "io.netty.buffer", "io.netty.buffer.search" @@ -1787,6 +2031,7 @@ "io.sentry.internal.modules", "io.sentry.internal.viewhierarchy", "io.sentry.logger", + "io.sentry.metrics", "io.sentry.opentelemetry", "io.sentry.profilemeasurements", "io.sentry.profiling", @@ -1907,6 +2152,17 @@ "net.bytebuddy.utility.privilege", "net.bytebuddy.utility.visitor" ], + "net.minidev:accessors-smart": [ + "net.minidev.asm", + "net.minidev.asm.ex" + ], + "net.minidev:json-smart": [ + "net.minidev.json", + "net.minidev.json.annotate", + "net.minidev.json.parser", + "net.minidev.json.reader", + "net.minidev.json.writer" + ], "org.assertj:assertj-core": [ "org.assertj.core.annotation", "org.assertj.core.annotations", @@ -1938,6 +2194,190 @@ "org.assertj.core.util.introspection", "org.assertj.core.util.xml" ], + "org.bouncycastle:bcprov-jdk18on": [ + "org.bouncycastle", + "org.bouncycastle.asn1", + "org.bouncycastle.asn1.anssi", + "org.bouncycastle.asn1.bc", + "org.bouncycastle.asn1.cryptopro", + "org.bouncycastle.asn1.gm", + "org.bouncycastle.asn1.nist", + "org.bouncycastle.asn1.ocsp", + "org.bouncycastle.asn1.pkcs", + "org.bouncycastle.asn1.sec", + "org.bouncycastle.asn1.teletrust", + "org.bouncycastle.asn1.ua", + "org.bouncycastle.asn1.util", + "org.bouncycastle.asn1.x500", + "org.bouncycastle.asn1.x500.style", + "org.bouncycastle.asn1.x509", + "org.bouncycastle.asn1.x509.qualified", + "org.bouncycastle.asn1.x509.sigi", + "org.bouncycastle.asn1.x9", + "org.bouncycastle.crypto", + "org.bouncycastle.crypto.agreement", + "org.bouncycastle.crypto.agreement.ecjpake", + "org.bouncycastle.crypto.agreement.jpake", + "org.bouncycastle.crypto.agreement.kdf", + "org.bouncycastle.crypto.agreement.srp", + "org.bouncycastle.crypto.commitments", + "org.bouncycastle.crypto.constraints", + "org.bouncycastle.crypto.digests", + "org.bouncycastle.crypto.ec", + "org.bouncycastle.crypto.encodings", + "org.bouncycastle.crypto.engines", + "org.bouncycastle.crypto.examples", + "org.bouncycastle.crypto.fpe", + "org.bouncycastle.crypto.generators", + "org.bouncycastle.crypto.hpke", + "org.bouncycastle.crypto.io", + "org.bouncycastle.crypto.kems", + "org.bouncycastle.crypto.macs", + "org.bouncycastle.crypto.modes", + "org.bouncycastle.crypto.modes.gcm", + "org.bouncycastle.crypto.modes.kgcm", + "org.bouncycastle.crypto.paddings", + "org.bouncycastle.crypto.params", + "org.bouncycastle.crypto.parsers", + "org.bouncycastle.crypto.prng", + "org.bouncycastle.crypto.prng.drbg", + "org.bouncycastle.crypto.signers", + "org.bouncycastle.crypto.threshold", + "org.bouncycastle.crypto.tls", + "org.bouncycastle.crypto.util", + "org.bouncycastle.i18n", + "org.bouncycastle.i18n.filter", + "org.bouncycastle.iana", + "org.bouncycastle.internal.asn1.bsi", + "org.bouncycastle.internal.asn1.cms", + "org.bouncycastle.internal.asn1.cryptlib", + "org.bouncycastle.internal.asn1.eac", + "org.bouncycastle.internal.asn1.edec", + "org.bouncycastle.internal.asn1.gnu", + "org.bouncycastle.internal.asn1.iana", + "org.bouncycastle.internal.asn1.isara", + "org.bouncycastle.internal.asn1.isismtt", + "org.bouncycastle.internal.asn1.iso", + "org.bouncycastle.internal.asn1.kisa", + "org.bouncycastle.internal.asn1.microsoft", + "org.bouncycastle.internal.asn1.misc", + "org.bouncycastle.internal.asn1.nsri", + "org.bouncycastle.internal.asn1.ntt", + "org.bouncycastle.internal.asn1.oiw", + "org.bouncycastle.internal.asn1.rosstandart", + "org.bouncycastle.jcajce", + "org.bouncycastle.jcajce.interfaces", + "org.bouncycastle.jcajce.io", + "org.bouncycastle.jcajce.provider.asymmetric", + "org.bouncycastle.jcajce.provider.asymmetric.compositesignatures", + "org.bouncycastle.jcajce.provider.asymmetric.dh", + "org.bouncycastle.jcajce.provider.asymmetric.dsa", + "org.bouncycastle.jcajce.provider.asymmetric.dstu", + "org.bouncycastle.jcajce.provider.asymmetric.ec", + "org.bouncycastle.jcajce.provider.asymmetric.ecgost", + "org.bouncycastle.jcajce.provider.asymmetric.ecgost12", + "org.bouncycastle.jcajce.provider.asymmetric.edec", + "org.bouncycastle.jcajce.provider.asymmetric.elgamal", + "org.bouncycastle.jcajce.provider.asymmetric.gost", + "org.bouncycastle.jcajce.provider.asymmetric.ies", + "org.bouncycastle.jcajce.provider.asymmetric.mldsa", + "org.bouncycastle.jcajce.provider.asymmetric.mlkem", + "org.bouncycastle.jcajce.provider.asymmetric.rsa", + "org.bouncycastle.jcajce.provider.asymmetric.slhdsa", + "org.bouncycastle.jcajce.provider.asymmetric.util", + "org.bouncycastle.jcajce.provider.asymmetric.x509", + "org.bouncycastle.jcajce.provider.config", + "org.bouncycastle.jcajce.provider.digest", + "org.bouncycastle.jcajce.provider.drbg", + "org.bouncycastle.jcajce.provider.kdf", + "org.bouncycastle.jcajce.provider.kdf.hkdf", + "org.bouncycastle.jcajce.provider.kdf.pbepbkdf2", + "org.bouncycastle.jcajce.provider.kdf.scrypt", + "org.bouncycastle.jcajce.provider.keystore", + "org.bouncycastle.jcajce.provider.keystore.bc", + "org.bouncycastle.jcajce.provider.keystore.bcfks", + "org.bouncycastle.jcajce.provider.keystore.pkcs12", + "org.bouncycastle.jcajce.provider.keystore.util", + "org.bouncycastle.jcajce.provider.symmetric", + "org.bouncycastle.jcajce.provider.symmetric.util", + "org.bouncycastle.jcajce.provider.util", + "org.bouncycastle.jcajce.spec", + "org.bouncycastle.jcajce.util", + "org.bouncycastle.jce", + "org.bouncycastle.jce.exception", + "org.bouncycastle.jce.interfaces", + "org.bouncycastle.jce.netscape", + "org.bouncycastle.jce.provider", + "org.bouncycastle.jce.spec", + "org.bouncycastle.math", + "org.bouncycastle.math.ec", + "org.bouncycastle.math.ec.custom.djb", + "org.bouncycastle.math.ec.custom.gm", + "org.bouncycastle.math.ec.custom.sec", + "org.bouncycastle.math.ec.endo", + "org.bouncycastle.math.ec.rfc7748", + "org.bouncycastle.math.ec.rfc8032", + "org.bouncycastle.math.ec.tools", + "org.bouncycastle.math.field", + "org.bouncycastle.math.raw", + "org.bouncycastle.pqc.asn1", + "org.bouncycastle.pqc.crypto", + "org.bouncycastle.pqc.crypto.bike", + "org.bouncycastle.pqc.crypto.cmce", + "org.bouncycastle.pqc.crypto.crystals.dilithium", + "org.bouncycastle.pqc.crypto.falcon", + "org.bouncycastle.pqc.crypto.frodo", + "org.bouncycastle.pqc.crypto.hqc", + "org.bouncycastle.pqc.crypto.lms", + "org.bouncycastle.pqc.crypto.mayo", + "org.bouncycastle.pqc.crypto.mldsa", + "org.bouncycastle.pqc.crypto.mlkem", + "org.bouncycastle.pqc.crypto.newhope", + "org.bouncycastle.pqc.crypto.ntru", + "org.bouncycastle.pqc.crypto.ntruprime", + "org.bouncycastle.pqc.crypto.picnic", + "org.bouncycastle.pqc.crypto.rainbow", + "org.bouncycastle.pqc.crypto.saber", + "org.bouncycastle.pqc.crypto.slhdsa", + "org.bouncycastle.pqc.crypto.snova", + "org.bouncycastle.pqc.crypto.sphincs", + "org.bouncycastle.pqc.crypto.sphincsplus", + "org.bouncycastle.pqc.crypto.util", + "org.bouncycastle.pqc.crypto.xmss", + "org.bouncycastle.pqc.crypto.xwing", + "org.bouncycastle.pqc.jcajce.interfaces", + "org.bouncycastle.pqc.jcajce.provider", + "org.bouncycastle.pqc.jcajce.provider.bike", + "org.bouncycastle.pqc.jcajce.provider.cmce", + "org.bouncycastle.pqc.jcajce.provider.dilithium", + "org.bouncycastle.pqc.jcajce.provider.falcon", + "org.bouncycastle.pqc.jcajce.provider.frodo", + "org.bouncycastle.pqc.jcajce.provider.hqc", + "org.bouncycastle.pqc.jcajce.provider.kyber", + "org.bouncycastle.pqc.jcajce.provider.lms", + "org.bouncycastle.pqc.jcajce.provider.mayo", + "org.bouncycastle.pqc.jcajce.provider.newhope", + "org.bouncycastle.pqc.jcajce.provider.ntru", + "org.bouncycastle.pqc.jcajce.provider.ntruprime", + "org.bouncycastle.pqc.jcajce.provider.picnic", + "org.bouncycastle.pqc.jcajce.provider.saber", + "org.bouncycastle.pqc.jcajce.provider.snova", + "org.bouncycastle.pqc.jcajce.provider.sphincs", + "org.bouncycastle.pqc.jcajce.provider.sphincsplus", + "org.bouncycastle.pqc.jcajce.provider.util", + "org.bouncycastle.pqc.jcajce.provider.xmss", + "org.bouncycastle.pqc.jcajce.spec", + "org.bouncycastle.pqc.math.ntru", + "org.bouncycastle.pqc.math.ntru.parameters", + "org.bouncycastle.util", + "org.bouncycastle.util.encoders", + "org.bouncycastle.util.io", + "org.bouncycastle.util.io.pem", + "org.bouncycastle.util.test", + "org.bouncycastle.x509", + "org.bouncycastle.x509.extension", + "org.bouncycastle.x509.util" + ], "org.checkerframework:checker-qual": [ "org.checkerframework.checker.builder.qual", "org.checkerframework.checker.calledmethods.qual", @@ -2119,21 +2559,48 @@ "org.slf4j.event", "org.slf4j.helpers", "org.slf4j.spi" + ], + "org.yaml:snakeyaml": [ + "org.yaml.snakeyaml", + "org.yaml.snakeyaml.comments", + "org.yaml.snakeyaml.composer", + "org.yaml.snakeyaml.constructor", + "org.yaml.snakeyaml.emitter", + "org.yaml.snakeyaml.env", + "org.yaml.snakeyaml.error", + "org.yaml.snakeyaml.events", + "org.yaml.snakeyaml.extensions.compactnotation", + "org.yaml.snakeyaml.external.com.google.gdata.util.common.base", + "org.yaml.snakeyaml.inspector", + "org.yaml.snakeyaml.internal", + "org.yaml.snakeyaml.introspector", + "org.yaml.snakeyaml.nodes", + "org.yaml.snakeyaml.parser", + "org.yaml.snakeyaml.reader", + "org.yaml.snakeyaml.representer", + "org.yaml.snakeyaml.resolver", + "org.yaml.snakeyaml.scanner", + "org.yaml.snakeyaml.serializer", + "org.yaml.snakeyaml.tokens", + "org.yaml.snakeyaml.util" ] }, "repositories": { "https://repo1.maven.org/maven2/": [ "ch.qos.logback:logback-classic", "ch.qos.logback:logback-core", + "com.ethlo.time:itu", "com.fasterxml.jackson.core:jackson-annotations", "com.fasterxml.jackson.core:jackson-core", "com.fasterxml.jackson.core:jackson-databind", + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", "com.fasterxml.jackson.datatype:jackson-datatype-guava", "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", "com.fasterxml.jackson.module:jackson-module-scala_2.13", "com.github.javaparser:javaparser-core", "com.github.javaparser:javaparser-symbol-solver-core", + "com.github.stephenc.jcip:jcip-annotations", "com.google.code.findbugs:jsr305", "com.google.code.gson:gson", "com.google.errorprone:error_prone_annotations", @@ -2141,6 +2608,11 @@ "com.google.guava:guava", "com.google.guava:listenablefuture", "com.google.j2objc:j2objc-annotations", + "com.networknt:json-schema-validator", + "com.nimbusds:content-type", + "com.nimbusds:lang-tag", + "com.nimbusds:nimbus-jose-jwt", + "com.nimbusds:oauth2-oidc-sdk", "com.thoughtworks.paranamer:paranamer", "io.micronaut.jaxrs:micronaut-jaxrs-common", "io.micronaut.jaxrs:micronaut-jaxrs-processor", @@ -2173,6 +2645,7 @@ "io.micronaut:micronaut-router", "io.micronaut:micronaut-runtime", "io.micronaut:micronaut-websocket", + "io.modelcontextprotocol.sdk:mcp", "io.netty:netty-buffer", "io.netty:netty-codec", "io.netty:netty-codec-base", @@ -2197,7 +2670,10 @@ "jakarta.ws.rs:jakarta.ws.rs-api", "junit:junit", "net.bytebuddy:byte-buddy", + "net.minidev:accessors-smart", + "net.minidev:json-smart", "org.assertj:assertj-core", + "org.bouncycastle:bcprov-jdk18on", "org.checkerframework:checker-qual", "org.eclipse.jetty.toolchain:jetty-jakarta-servlet-api", "org.eclipse.jetty.websocket:websocket-core-common", @@ -2223,7 +2699,8 @@ "org.ow2.asm:asm-util", "org.reactivestreams:reactive-streams", "org.scala-lang:scala-library", - "org.slf4j:slf4j-api" + "org.slf4j:slf4j-api", + "org.yaml:snakeyaml" ] }, "services": { @@ -2245,6 +2722,14 @@ "com.fasterxml.jackson.databind.ObjectMapper" ] }, + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": { + "com.fasterxml.jackson.core.JsonFactory": [ + "com.fasterxml.jackson.dataformat.yaml.YAMLFactory" + ], + "com.fasterxml.jackson.core.ObjectCodec": [ + "com.fasterxml.jackson.dataformat.yaml.YAMLMapper" + ] + }, "com.fasterxml.jackson.datatype:jackson-datatype-guava": { "com.fasterxml.jackson.databind.Module": [ "com.fasterxml.jackson.datatype.guava.GuavaModule" @@ -2530,6 +3015,12 @@ "reactor.core.scheduler.ReactorBlockHoundIntegration" ] }, + "org.bouncycastle:bcprov-jdk18on": { + "java.security.Provider": [ + "org.bouncycastle.jce.provider.BouncyCastleProvider", + "org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider" + ] + }, "org.eclipse.jetty.websocket:websocket-core-common": { "org.eclipse.jetty.websocket.core.Extension": [ "org.eclipse.jetty.websocket.core.internal.FragmentExtension", diff --git a/scripts/run-mcp-oauth-demo.sh b/scripts/run-mcp-oauth-demo.sh new file mode 100755 index 00000000..9258d9fc --- /dev/null +++ b/scripts/run-mcp-oauth-demo.sh @@ -0,0 +1,230 @@ +#!/bin/bash +set -e + +echo "=== MCP OAuth 2.1 Demo Setup ===" +echo "" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +KEYCLOAK_PORT=8180 +MCP_SERVER_PORT=8080 +CALLBACK_PORT=8888 +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +KEYCLOAK_DIR="$ROOT_DIR/local_docker/keycloak" +STARTED_KEYCLOAK=false +KEYCLOAK_BASE_URL="http://localhost:$KEYCLOAK_PORT" +MCP_PID="" +KEEP_KEYCLOAK="${1:-}" + +# Cleanup function for signal handling +cleanup() { + echo "" + echo "=== Cleanup ===" + echo -e "${YELLOW}→${NC} Stopping MCP Server..." + # Kill bazel wrapper if we have the PID + if [ -n "$MCP_PID" ]; then + kill $MCP_PID 2>/dev/null || true + fi + # Also kill any process on the MCP port (bazel run spawns child processes) + local port_pid + port_pid=$(lsof -i :$MCP_SERVER_PORT -t 2>/dev/null || true) + if [ -n "$port_pid" ]; then + kill $port_pid 2>/dev/null || true + fi + + if [ "$KEEP_KEYCLOAK" != "--keep-keycloak" ]; then + if [ "$STARTED_KEYCLOAK" = true ]; then + echo -e "${YELLOW}→${NC} Stopping Keycloak..." + cd "$KEYCLOAK_DIR" + docker compose -f docker-compose.keycloak.yml down + cd "$ROOT_DIR" + else + echo -e "${YELLOW}→${NC} Keycloak was already running; leaving it up." + fi + fi +} + +# Trap signals to ensure cleanup runs +trap cleanup EXIT INT TERM + +echo "Configuration:" +echo " Keycloak: http://localhost:$KEYCLOAK_PORT" +echo " MCP Server: http://localhost:$MCP_SERVER_PORT" +echo " OAuth Callback: http://localhost:$CALLBACK_PORT/callback" +echo "" + +# Check if Keycloak is already running +if curl -sf http://localhost:$KEYCLOAK_PORT/health/ready > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC} Keycloak is already running" +else + echo -e "${YELLOW}→${NC} Starting Keycloak..." + cd "$KEYCLOAK_DIR" + docker compose -f docker-compose.keycloak.yml up -d + STARTED_KEYCLOAK=true + + echo -e "${YELLOW}→${NC} Waiting for Keycloak to be ready (this may take 30-60 seconds)..." + RETRY_COUNT=0 + MAX_RETRIES=60 + until curl -sf http://localhost:$KEYCLOAK_PORT/realms/mcp-demo/.well-known/openid-configuration > /dev/null 2>&1; do + sleep 2 + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo -e "${RED}✗${NC} Keycloak failed to start within timeout" + echo "Checking logs..." + docker compose -f docker-compose.keycloak.yml logs --tail 20 + exit 1 + fi + echo -n "." + done + echo "" + echo -e "${GREEN}✓${NC} Keycloak is ready" + cd "$ROOT_DIR" +fi + +# Verify Keycloak realm +echo -e "${YELLOW}→${NC} Verifying Keycloak realm configuration..." +if curl -sf http://localhost:$KEYCLOAK_PORT/realms/mcp-demo/.well-known/openid-configuration > /dev/null; then + echo -e "${GREEN}✓${NC} Keycloak realm 'mcp-demo' is configured" +else + echo -e "${RED}✗${NC} Keycloak realm 'mcp-demo' not found" + echo "Please check local_docker/keycloak/realm-export.json" + exit 1 +fi + +# Ensure demo client exists only when explicitly requested +if [ -z "${MCP_CLIENT_ID:-}" ] && [ "${MCP_FORCE_ADMIN_CLIENT:-}" = "1" ]; then + echo -e "${YELLOW}→${NC} Ensuring demo client exists in Keycloak..." + TOKEN_RESPONSE=$(curl -sS \ + -d "client_id=admin-cli" \ + -d "grant_type=password" \ + -d "username=admin" \ + -d "password=admin" \ + "$KEYCLOAK_BASE_URL/realms/master/protocol/openid-connect/token") + + if [ -z "$TOKEN_RESPONSE" ]; then + echo -e "${RED}✗${NC} Failed to obtain Keycloak admin token (empty response)" + exit 1 + fi + + ADMIN_TOKEN=$(python3 -c 'import json,sys; +data = sys.stdin.read().strip(); +print(json.loads(data).get("access_token","") if data else "")' <<< "$TOKEN_RESPONSE") + + if [ -z "$ADMIN_TOKEN" ]; then + echo -e "${RED}✗${NC} Failed to obtain Keycloak admin token" + echo "$TOKEN_RESPONSE" + exit 1 + fi + + CLIENT_ID="mcp-demo-client" + CLIENT_INTERNAL_ID=$(curl -sf \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + "$KEYCLOAK_BASE_URL/admin/realms/mcp-demo/clients?clientId=$CLIENT_ID" | \ + python3 -c 'import json,sys; +data = json.load(sys.stdin); +print(data[0].get("id","") if isinstance(data,list) and data else "")' + ) + + if [ -z "$CLIENT_INTERNAL_ID" ]; then + REDIRECT_URI="http://localhost:$CALLBACK_PORT/callback" + CREATE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"$CLIENT_ID\", + \"name\": \"MCP Demo Client\", + \"publicClient\": true, + \"standardFlowEnabled\": true, + \"directAccessGrantsEnabled\": false, + \"serviceAccountsEnabled\": false, + \"redirectUris\": [\"$REDIRECT_URI\"], + \"webOrigins\": [\"+\"] +}" \ + "$KEYCLOAK_BASE_URL/admin/realms/mcp-demo/clients") + + if [ "$CREATE_STATUS" != "201" ]; then + echo -e "${RED}✗${NC} Failed to create demo client (HTTP $CREATE_STATUS)" + exit 1 + fi + echo -e "${GREEN}✓${NC} Created demo client '$CLIENT_ID'" + else + echo -e "${GREEN}✓${NC} Demo client '$CLIENT_ID' already exists" + fi + + export MCP_CLIENT_ID="$CLIENT_ID" +elif [ -n "${MCP_CLIENT_ID:-}" ]; then + echo -e "${YELLOW}→${NC} Using preconfigured MCP client ID: ${MCP_CLIENT_ID}" +fi + +# Start MCP Server with OAuth +echo -e "${YELLOW}→${NC} Starting MCP Server with OAuth enabled..." +export MICRONAUT_ENVIRONMENTS=dev +export PORT=$MCP_SERVER_PORT + +bazel run //jvm/src/main/java/com/muchq/mcpserver:mcpserver > /tmp/mcp-server.log 2>&1 & +MCP_PID=$! + +echo -e "${YELLOW}→${NC} Waiting for MCP Server to start..." +sleep 5 + +# Check if MCP server is running +if ! ps -p $MCP_PID > /dev/null; then + echo -e "${RED}✗${NC} MCP Server failed to start" + cat /tmp/mcp-server.log + exit 1 +fi + +# Verify Protected Resource Metadata endpoint +if curl -sf http://localhost:$MCP_SERVER_PORT/.well-known/oauth-protected-resource > /dev/null; then + echo -e "${GREEN}✓${NC} MCP Server is ready and serving OAuth metadata" +else + echo -e "${RED}✗${NC} MCP Server not responding to OAuth metadata endpoint" + kill $MCP_PID 2>/dev/null || true + exit 1 +fi + +echo "" +echo "=== Running MCP Client Demo ===" +echo "" +echo "The demo will:" +echo " 1. Discover the authorization server from MCP server" +echo " 2. Fetch authorization server metadata from Keycloak" +echo " 3. Dynamically register as an OAuth client" +echo " 4. Open your browser for authentication" +echo " 5. Wait for you to log in (use testuser/testpass)" +echo " 6. Exchange authorization code for access token" +echo " 7. Display success message" +echo "" +echo -e "${YELLOW}Press Enter to start the demo${NC}" +read + +# Run the demo +bazel run //jvm/src/main/java/com/muchq/mcpclient/demo:demo + +DEMO_EXIT_CODE=$? + +# Cleanup is handled by the EXIT trap + +if [ $DEMO_EXIT_CODE -eq 0 ]; then + echo "" + echo -e "${GREEN}=== Demo Completed Successfully! ===${NC}" + echo "" + echo "What just happened:" + echo " ✓ Client discovered OAuth endpoints (RFC 9728, RFC 8414)" + echo " ✓ Client dynamically registered (RFC 7591)" + echo " ✓ PKCE protected the authorization code exchange" + echo " ✓ Resource parameter ensured token audience validation (RFC 8707)" + echo " ✓ MCP server validated the JWT token" + echo "" + echo "This demonstrates a complete implementation of the MCP Authorization Spec!" +else + echo "" + echo -e "${RED}=== Demo Failed ===${NC}" + echo "Check the logs above for error details" + exit 1 +fi