From 3ffad0b3445c30007dcce090a05805acf09d61c9 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:36:48 -0400 Subject: [PATCH 01/15] Revised blueprint to take Restlet foundation for MCP --- .../blueprints/mcp-server-authentication.md | 100 +++++++++--------- src/main/resources/wiki/Installation.md | 10 +- 2 files changed, 57 insertions(+), 53 deletions(-) diff --git a/src/main/resources/blueprints/mcp-server-authentication.md b/src/main/resources/blueprints/mcp-server-authentication.md index 74062e45..66788c8c 100644 --- a/src/main/resources/blueprints/mcp-server-authentication.md +++ b/src/main/resources/blueprints/mcp-server-authentication.md @@ -36,7 +36,7 @@ Authentication support for the MCP server adapter, adding two complementary authentication modes: -1. **`authentication` with existing types** (bearer, apikey, basic, digest) — Reuse the same `Authentication` union already defined for `ExposesRest` and `ExposesSkill`. The engine validates incoming `Authorization` headers on every HTTP request to the MCP endpoint before dispatching to `JettyStreamableHandler`. This covers the common case of a shared secret, API key, or static bearer token protecting the MCP server — identical to how the REST adapter works today. +1. **`authentication` with existing types** (bearer, apikey, basic, digest) — Reuse the same `Authentication` union already defined for `ExposesRest` and `ExposesSkill`. The engine validates incoming `Authorization` headers on every HTTP request to the MCP endpoint before dispatching to `McpServerResource`. This covers the common case of a shared secret, API key, or static bearer token protecting the MCP server — identical to how the REST adapter works today. 2. **`authentication` with new `oauth2` type** — A new `AuthOAuth2` schema definition that aligns with the [MCP 2025-11-25 Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). The MCP server acts as an OAuth 2.1 resource server: it validates bearer tokens issued by an external authorization server, serves Protected Resource Metadata (RFC 9728), and returns proper `WWW-Authenticate` challenges on `401`/`403`. This is the protocol-native authorization mode for MCP over Streamable HTTP. @@ -57,15 +57,15 @@ Authentication support for the MCP server adapter, adding two complementary auth | **Secure remote deployment** | MCP servers can be deployed on public endpoints with OAuth protection | Operations, InfoSec | | **Parity with REST adapter** | Same auth UX for all adapter types — configure once in YAML | Capability authors | | **Enterprise readiness** | Integration with corporate IdPs (Keycloak, Entra ID, Okta) via standard OAuth 2.1 | Enterprise teams | -| **Zero-code security** | Declarative authentication — no custom Jetty filters or middleware | Developers | +| **Zero-code security** | Declarative authentication — no custom filters or middleware | Developers | ### Key Design Decisions -1. **Two-tier authentication model**: Simple static credentials (bearer/apikey/basic/digest) reuse the existing `Authentication` union and `ServerAuthenticationFilter` pattern from the REST adapter. OAuth 2.1 adds a new `AuthOAuth2` type for protocol-native MCP authorization. +1. **Two-tier authentication model**: Simple static credentials (bearer/apikey/basic/digest) reuse the existing `Authentication` union and `ServerAuthenticationRestlet` pattern from the REST adapter. OAuth 2.1 adds a new `AuthOAuth2` type for protocol-native MCP authorization. -2. **Jetty Handler chain**: Authentication is implemented as a Jetty `Handler` wrapper that intercepts requests before `JettyStreamableHandler`, mirroring the REST adapter's filter-before-router pattern but using Jetty's handler model instead of Restlet's filter chain. +2. **Restlet filter chain**: Authentication is implemented as a Restlet `Restlet` wrapper that intercepts requests before the `Router` → `McpServerResource` chain, following the same filter-before-router pattern used by the REST and Skill adapters. -3. **Protected Resource Metadata is auto-served**: When `oauth2` authentication is configured, the engine auto-serves the `/.well-known/oauth-protected-resource` endpoint with metadata derived from the YAML configuration — no manual metadata file needed. +3. **Protected Resource Metadata is auto-served**: When `oauth2` authentication is configured, the engine auto-serves the `/.well-known/oauth-protected-resource` endpoint via additional routes on the `Router` — no manual metadata file needed. 4. **`WWW-Authenticate` challenges follow the spec**: On `401 Unauthorized`, the server includes the `resource_metadata` URL and optional `scope` in the `WWW-Authenticate: Bearer` header, as required by RFC 9728 and MCP 2025-11-25. @@ -165,8 +165,8 @@ ExposesMcp ├── resources[] └── prompts[] -Jetty HTTP Chain: - Server → ServerConnector → JettyStreamableHandler → ProtocolDispatcher +Restlet HTTP Chain: + Server → Router → McpServerResource → ProtocolDispatcher (no authentication) ``` @@ -185,12 +185,12 @@ ExposesMcp ├── resources[] └── prompts[] -Jetty HTTP Chain (with static auth): - Server → ServerConnector → McpAuthenticationHandler → JettyStreamableHandler +Restlet HTTP Chain (with static auth): + Server → ServerAuthenticationRestlet → Router → McpServerResource (same pattern as REST adapter's ServerAuthenticationRestlet wrapping Router) -Jetty HTTP Chain (with OAuth2 auth): - Server → ServerConnector → McpOAuth2Handler → JettyStreamableHandler +Restlet HTTP Chain (with OAuth2 auth): + Server → McpOAuth2Restlet → Router → McpServerResource (validates JWT, serves /.well-known/oauth-protected-resource) ``` @@ -214,15 +214,15 @@ ExposesRest ExposesMcp ExposesSki ├─ resources[] ├─ tools[] ├─ skills[] │ └─ operations[] ├─ resources[] │ └─ (Restlet filter chain) └─ prompts[] └─ (Restlet filter chain) - (Jetty handler chain) + (Restlet filter chain) Filter/Handler flow: REST: ChallengeAuthenticator ──→ Router ──→ ResourceRestlet ServerAuthenticationRestlet ──→ Router ──→ ResourceRestlet -MCP: McpAuthenticationHandler ──→ JettyStreamableHandler ──→ ProtocolDispatcher - McpOAuth2Handler ──→ JettyStreamableHandler ──→ ProtocolDispatcher +MCP: ServerAuthenticationRestlet ──→ Router ──→ McpServerResource ──→ ProtocolDispatcher + McpOAuth2Restlet ──→ Router ──→ McpServerResource ──→ ProtocolDispatcher ``` ### Conceptual mapping @@ -230,9 +230,9 @@ MCP: McpAuthenticationHandler ──→ JettyStreamableHandler ──→ Prot | Concept | REST Adapter | MCP Adapter (proposed) | |---------|-------------|------------------------| | Auth config location | `exposes[].authentication` | `exposes[].authentication` | -| Static credential validation | `ServerAuthenticationRestlet` | `McpAuthenticationHandler` | -| HTTP challenge (basic/digest) | Restlet `ChallengeAuthenticator` | Jetty `McpAuthenticationHandler` | -| OAuth2 resource server | Not supported | `McpOAuth2Handler` (new) | +| Static credential validation | `ServerAuthenticationRestlet` | `ServerAuthenticationRestlet` (reused) | +| HTTP challenge (basic/digest) | Restlet `ChallengeAuthenticator` | Restlet `ChallengeAuthenticator` (reused) | +| OAuth2 resource server | Not supported | `McpOAuth2Restlet` (new) | | Credential source | `binds` → environment vars | `binds` → environment vars | | Timing-safe comparison | `MessageDigest.isEqual()` | `MessageDigest.isEqual()` | | Transport applicability | HTTP only | HTTP only (skip for stdio) | @@ -577,45 +577,49 @@ capability: ### 9.1 Authentication Handler Insertion -`McpServerAdapter.initHttpTransport()` currently sets `JettyStreamableHandler` directly on the Jetty server. With authentication, the chain becomes: +`McpServerAdapter.initHttpTransport()` currently passes the `Router` directly to `initServer()`. With authentication, a `buildServerChain()` method wraps the router — identical to the REST adapter's pattern: ```java // Pseudocode — current -server.setHandler(new JettyStreamableHandler(this)); +Router router = new Router(context); +router.attachDefault(McpServerResource.class); +initServer(address, port, router); // Pseudocode — proposed -Handler mcpHandler = new JettyStreamableHandler(this); -if (spec.authentication() != null) { - mcpHandler = buildAuthHandler(spec.authentication(), mcpHandler); -} -server.setHandler(mcpHandler); +Router router = new Router(context); +router.attachDefault(McpServerResource.class); +Restlet chain = buildServerChain(serverSpec, router); +initServer(address, port, chain); ``` -Where `buildAuthHandler` returns: -- `McpAuthenticationHandler` for static types (bearer, apikey, basic, digest) -- `McpOAuth2Handler` for `oauth2` +Where `buildServerChain` returns: +- `ServerAuthenticationRestlet` wrapping the router for static types (bearer, apikey) +- Restlet `ChallengeAuthenticator` wrapping the router for basic/digest +- `McpOAuth2Restlet` wrapping the router for `oauth2` -Both wrap the downstream handler and intercept requests before they reach `JettyStreamableHandler`. +The static authentication path reuses `ServerAuthenticationRestlet` directly — no MCP-specific class needed. -### 9.2 Static Authentication Handler (`McpAuthenticationHandler`) +### 9.2 Static Authentication (reuses `ServerAuthenticationRestlet`) -A Jetty `Handler.Wrapper` that: +The existing `ServerAuthenticationRestlet` is reused directly — no MCP-specific authentication class is needed for static credentials. The Restlet sits before the `Router` in the chain: -1. Extracts credentials from the HTTP request (same logic as `ServerAuthenticationRestlet`) +1. Extracts credentials from the HTTP request (bearer token or API key) 2. Resolves `{{VARIABLE}}` templates from environment, restricted to `binds`-declared keys 3. Compares using `MessageDigest.isEqual()` (timing-safe) -4. On success: delegates to wrapped handler (`JettyStreamableHandler`) +4. On success: delegates to the next `Restlet` (the `Router` → `McpServerResource`) 5. On failure: returns `401 Unauthorized` with appropriate challenge header +For basic/digest authentication, the existing Restlet `ChallengeAuthenticator` is reused, again following the same pattern as the REST adapter. + ``` -Request → McpAuthenticationHandler - ├─ Valid credentials → JettyStreamableHandler → ProtocolDispatcher +Request → ServerAuthenticationRestlet + ├─ Valid credentials → Router → McpServerResource → ProtocolDispatcher └─ Invalid/missing → 401 Unauthorized ``` -### 9.3 OAuth 2.1 Handler (`McpOAuth2Handler`) +### 9.3 OAuth 2.1 Handler (`McpOAuth2Restlet`) -A Jetty `Handler.Wrapper` that implements the resource server side of MCP 2025-11-25 authorization: +A Restlet `Restlet` subclass that implements the resource server side of MCP 2025-11-25 authorization. It wraps the `Router` in the same position as `ServerAuthenticationRestlet`: **Initialization:** 1. Fetch AS metadata from `authorizationServerUrl` (try `.well-known/oauth-authorization-server` then `.well-known/openid-configuration`) @@ -625,12 +629,12 @@ A Jetty `Handler.Wrapper` that implements the resource server side of MCP 2025-1 **Request handling:** ``` -Request → McpOAuth2Handler +Request → McpOAuth2Restlet ├─ GET /.well-known/oauth-protected-resource → Return metadata JSON ├─ No Authorization header → 401 + WWW-Authenticate ├─ Invalid/expired token → 401 + WWW-Authenticate ├─ Insufficient scope → 403 + WWW-Authenticate (insufficient_scope) - └─ Valid token → JettyStreamableHandler → ProtocolDispatcher + └─ Valid token → Router → McpServerResource → ProtocolDispatcher ``` **Token validation (JWKS mode):** @@ -768,11 +772,11 @@ Additional cross-field validations: **Alternative considered**: A single `Authentication` union with all five types, and adapter-level validation rules rejecting `oauth2` on REST/Skill. Rejected because schema-level enforcement is stronger than rule-level enforcement, and because OAuth2 may eventually need different configuration for REST (e.g., token relay) vs. MCP (resource server). -### D2: Jetty Handler chain vs. Servlet Filter +### D2: Restlet filter chain — reuse `ServerAuthenticationRestlet` -**Decision**: Implement authentication as a Jetty `Handler.Wrapper`. +**Decision**: Reuse the existing `ServerAuthenticationRestlet` for static credentials and implement `McpOAuth2Restlet` as a new `Restlet` subclass for OAuth 2.1. -**Rationale**: The MCP adapter already uses Jetty's Handler model (not Servlets). `JettyStreamableHandler` extends `Handler.Abstract`. Wrapping it with another handler is the natural Jetty pattern and avoids introducing a Servlet context just for authentication. +**Rationale**: The MCP adapter now uses the Restlet framework for HTTP transport, the same as the REST and Skill adapters. `ServerAuthenticationRestlet` already implements bearer and API key validation with timing-safe comparison. Reusing it directly avoids code duplication and ensures consistent authentication behavior across all three adapter types. OAuth 2.1 requires MCP-specific logic (Protected Resource Metadata, JWT validation) that justifies a dedicated class. ### D3: No per-tool scope mapping @@ -807,10 +811,10 @@ Additional cross-field validations: **Bring MCP authentication to parity with REST adapter for the simplest cases.** 1. Add `authentication` property to `ExposesMcp` in JSON schema (use existing `Authentication` union first; `McpAuthentication` union comes in Phase 2) -2. Create `McpAuthenticationHandler` (Jetty `Handler.Wrapper`) — port logic from `ServerAuthenticationRestlet` to Jetty handler model -3. Wire into `McpServerAdapter.initHttpTransport()` — insert handler before `JettyStreamableHandler` +2. Add `buildServerChain()` to `McpServerAdapter` — reuse `ServerAuthenticationRestlet` and `ChallengeAuthenticator` from the REST adapter, wrapping the `Router` before `McpServerResource` +3. Wire into `McpServerAdapter.initHttpTransport()` — insert authentication restlet before the `Router` 4. Add Spectral rule `naftiko-mcp-auth-stdio-conflict` -5. Tests: unit tests for handler, integration test with bearer-protected MCP server +5. Tests: unit tests for chain wiring, integration test with bearer-protected MCP server ### Phase 2: OAuth 2.1 Resource Server @@ -818,7 +822,7 @@ Additional cross-field validations: 1. Add `AuthOAuth2` definition to JSON schema 2. Replace `Authentication` ref on `ExposesMcp` with `McpAuthentication` union -3. Create `McpOAuth2Handler`: +3. Create `McpOAuth2Restlet`: - AS metadata discovery (RFC 8414) - JWKS fetching and caching - JWT validation (signature, expiry, issuer, audience) @@ -831,7 +835,7 @@ Additional cross-field validations: **Extended OAuth features.** -1. Add introspection support to `McpOAuth2Handler` (RFC 7662) +1. Add introspection support to `McpOAuth2Restlet` (RFC 7662) 2. Implement `403` scope challenge responses 3. Origin header validation for DNS rebinding prevention 4. JWKS cache key rotation support (unknown-kid refresh) @@ -849,8 +853,8 @@ Additional cross-field validations: ### Engine -- `McpServerAdapter` with no `authentication` configured behaves exactly as it does today — no handler inserted, no overhead -- Existing `JettyStreamableHandler` is not modified — authentication handlers wrap it +- `McpServerAdapter` with no `authentication` configured behaves exactly as it does today — the `Router` is passed directly to `initServer()`, no overhead +- Existing `McpServerResource` and `ProtocolDispatcher` are not modified — authentication restlets wrap the `Router` ### Wire Protocol diff --git a/src/main/resources/wiki/Installation.md b/src/main/resources/wiki/Installation.md index 5e7f9d57..5c020031 100644 --- a/src/main/resources/wiki/Installation.md +++ b/src/main/resources/wiki/Installation.md @@ -1,6 +1,6 @@ -To use Naftiko Framework, you must install and then run its engine. +To use Naftiko Framework, you need to install and then run the Naftiko Engine, passing a Naftiko YAML file to it. A command-line interface is also provided. -## Docker usage +## Naftiko Engine ### Prerequisites * You need Docker or, if you are on macOS or Windows their Docker Desktop version. To do so, follow the official documentation: * [For Mac](https://docs.docker.com/desktop/setup/install/mac-install/) @@ -31,7 +31,7 @@ To use Naftiko Framework, you must install and then run its engine. * If your capability refers to some local hosts, be careful to not use 'localhost', but 'host.docker.internal' instead. This is because your capability will run into an isolated docker container, so 'localhost' will refer to the container and not your local machine.\ For example: ```bash - baseUri: "http://host.docker.internal:8080/api" + baseUri: "http://host.docker.internal:8080/api/" ``` * In the same way, if your capability expose a local host, be careful to not use 'localhost', but '0.0.0.0' instead. Else requests to localhost coming from outside of the container won't succeed.\ For example: @@ -53,8 +53,8 @@ To use Naftiko Framework, you must install and then run its engine. ``` Then you should be able to request your capability at http://localhost:8081 -## CLI tool -The Naftiko framework provides a CLI tool.\ +## Naftiko CLI +The Naftiko Framework also includes a CLI tool.\ The goal of this CLI is to simplify configuration and validation. While everything can be done manually, the CLI provides helper commands. ## Installation From 5f9cbeb50d8d9034c54b7aacde36e0d392020cda Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:15:29 -0400 Subject: [PATCH 02/15] feat: add OAuth 2.1 resource server authentication with shared adapter chain - Add AuthOAuth2 to the shared Authentication union in the JSON schema - Add OAuth2AuthenticationSpec for YAML deserialization - Create OAuth2AuthenticationRestlet with JWKS-based JWT validation, AS metadata discovery, and token caching - Create McpOAuth2Restlet extending the shared restlet with MCP Protected Resource Metadata (RFC 9728) - Lift authentication field and buildServerChain into ServerAdapter base class, eliminating duplication across MCP, REST, and Skill - MCP adapter overrides createOAuth2Restlet for McpOAuth2Restlet - Add nimbus-jose-jwt 9.37.3 dependency for JWT/JWKS handling - Add 3 Spectral rules for OAuth2 validation - Add ServerAdapterAuthenticationTest (9 tests) for shared auth chain - Add OAuth2AuthenticationRestletTest (18 tests) for JWT validation - Add McpOAuth2RestletTest (8 tests) for MCP metadata extension - Add McpOAuth2IntegrationTest (5 tests) with mock AS server --- pom.xml | 5 + .../exposes/OAuth2AuthenticationRestlet.java | 425 ++++++++++++++++++ .../naftiko/engine/exposes/ServerAdapter.java | 153 +++++++ .../engine/exposes/mcp/McpOAuth2Restlet.java | 140 ++++++ .../engine/exposes/mcp/McpServerAdapter.java | 11 +- .../exposes/rest/RestServerAdapter.java | 142 +----- .../exposes/skill/SkillServerAdapter.java | 4 +- .../spec/consumes/AuthenticationSpec.java | 3 +- .../consumes/OAuth2AuthenticationSpec.java | 76 ++++ .../naftiko/spec/exposes/McpServerSpec.java | 2 +- .../naftiko/spec/exposes/RestServerSpec.java | 12 - .../io/naftiko/spec/exposes/ServerSpec.java | 12 + .../blueprints/mcp-server-authentication.md | 219 +++++---- src/main/resources/rules/naftiko-rules.yml | 54 +++ .../resources/schemas/naftiko-schema.json | 52 +++ .../OAuth2AuthenticationRestletTest.java | 414 +++++++++++++++++ .../ServerAdapterAuthenticationTest.java | 243 ++++++++++ .../mcp/McpAuthenticationIntegrationTest.java | 319 +++++++++++++ .../exposes/mcp/McpOAuth2IntegrationTest.java | 371 +++++++++++++++ .../exposes/mcp/McpOAuth2RestletTest.java | 248 ++++++++++ .../exposes/rest/RestServerAdapterTest.java | 3 +- 21 files changed, 2660 insertions(+), 248 deletions(-) create mode 100644 src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java create mode 100644 src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java create mode 100644 src/main/java/io/naftiko/spec/consumes/OAuth2AuthenticationSpec.java create mode 100644 src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java create mode 100644 src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java create mode 100644 src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java create mode 100644 src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java create mode 100644 src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java diff --git a/pom.xml b/pom.xml index 27d28165..d89832ea 100644 --- a/pom.xml +++ b/pom.xml @@ -170,6 +170,11 @@ json-smart ${json-smart.version} + + com.nimbusds + nimbus-jose-jwt + 9.37.3 + org.json-structure json-structure diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java new file mode 100644 index 00000000..56462405 --- /dev/null +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -0,0 +1,425 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes; + +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.text.ParseException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.ECKey; +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.naftiko.spec.consumes.OAuth2AuthenticationSpec; + +/** + * Shared Restlet that implements OAuth 2.1 resource server authentication. Validates bearer tokens + * (JWTs) issued by an external authorization server using JWKS-based signature verification. + * + *

Used directly by REST and Skill adapters. Extended by {@code McpOAuth2Restlet} for + * MCP-specific protocol behavior (Protected Resource Metadata, {@code resource_metadata} in + * {@code WWW-Authenticate} headers).

+ */ +public class OAuth2AuthenticationRestlet extends Restlet { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private static final ChallengeScheme BEARER_SCHEME = + new ChallengeScheme("HTTP_Bearer", "Bearer"); + + static final long JWKS_CACHE_TTL_MS = 5 * 60 * 1000L; + static final long JWKS_MIN_REFRESH_MS = 30 * 1000L; + + private final OAuth2AuthenticationSpec spec; + private final Restlet next; + + private volatile JWKSet cachedJwkSet; + private volatile long jwkSetTimestamp; + private volatile String discoveredJwksUri; + private volatile boolean initialized; + + private final Object initLock = new Object(); + private final Object jwkRefreshLock = new Object(); + + public OAuth2AuthenticationRestlet(OAuth2AuthenticationSpec spec, Restlet next) { + this.spec = spec; + this.next = next; + } + + /** + * Test constructor — allows injecting a pre-loaded JWK set to bypass AS metadata discovery. + */ + protected OAuth2AuthenticationRestlet(OAuth2AuthenticationSpec spec, Restlet next, JWKSet jwkSet) { + this.spec = spec; + this.next = next; + this.cachedJwkSet = jwkSet; + this.jwkSetTimestamp = System.currentTimeMillis(); + this.initialized = true; + } + + @Override + public void handle(Request request, Response response) { + String token = extractBearerToken(request); + if (token == null) { + unauthorized(response, null, null); + return; + } + + try { + ensureInitialized(); + validateAndDispatch(token, request, response); + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, "Token validation error", e); + unauthorized(response, "invalid_token", "Token validation failed"); + } + } + + void validateAndDispatch(String token, Request request, Response response) + throws ParseException, JOSEException { + String validationMode = + spec.getTokenValidation() != null ? spec.getTokenValidation() : "jwks"; + + if ("introspection".equals(validationMode)) { + unauthorized(response, "invalid_request", + "Token introspection is not yet supported — use tokenValidation: jwks"); + return; + } + + SignedJWT jwt = SignedJWT.parse(token); + + if (!verifySignature(jwt)) { + unauthorized(response, "invalid_token", "Signature verification failed"); + return; + } + + JWTClaimsSet claims = jwt.getJWTClaimsSet(); + String error = validateClaims(claims); + if (error != null) { + if (error.startsWith("insufficient_scope:")) { + String requiredScope = error.substring("insufficient_scope:".length()).trim(); + forbidden(response, requiredScope); + } else { + unauthorized(response, "invalid_token", error); + } + return; + } + + next.handle(request, response); + } + + String extractBearerToken(Request request) { + if (request.getChallengeResponse() != null + && request.getChallengeResponse().getRawValue() != null) { + return request.getChallengeResponse().getRawValue(); + } + + String authorization = request.getHeaders().getFirstValue("Authorization", true); + if (authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7)) { + return authorization.substring(7).trim(); + } + + return null; + } + + boolean verifySignature(SignedJWT jwt) throws JOSEException { + String kid = jwt.getHeader().getKeyID(); + + JWKSet keys = getCachedOrFetchJwkSet(); + if (keys == null) { + return false; + } + + JWK key = findKey(keys, kid); + if (key == null) { + keys = refreshJwkSet(); + if (keys != null) { + key = findKey(keys, kid); + } + } + + if (key == null) { + return false; + } + + JWSVerifier verifier = buildVerifier(key); + return verifier != null && jwt.verify(verifier); + } + + String validateClaims(JWTClaimsSet claims) { + if (claims.getExpirationTime() != null + && claims.getExpirationTime().before(new Date())) { + return "Token expired"; + } + + String expectedIssuer = spec.getAuthorizationServerUrl(); + if (expectedIssuer != null && claims.getIssuer() != null + && !expectedIssuer.equals(claims.getIssuer())) { + return "Invalid issuer"; + } + + String expectedAudience = + spec.getAudience() != null ? spec.getAudience() : spec.getResource(); + if (expectedAudience != null) { + List audiences = claims.getAudience(); + if (audiences != null && !audiences.isEmpty() + && !audiences.contains(expectedAudience)) { + return "Invalid audience"; + } + } + + if (spec.getScopes() != null && !spec.getScopes().isEmpty()) { + Object scopeClaim = claims.getClaim("scope"); + if (scopeClaim == null) { + return "insufficient_scope: " + spec.getScopes().get(0); + } + Set tokenScopes = + new HashSet<>(Arrays.asList(scopeClaim.toString().split("\\s+"))); + for (String required : spec.getScopes()) { + if (!tokenScopes.contains(required)) { + return "insufficient_scope: " + required; + } + } + } + + return null; + } + + protected void unauthorized(Response response, String error, String description) { + response.setStatus(Status.CLIENT_ERROR_UNAUTHORIZED); + addBearerChallenge(response, + buildBearerChallengeParams(error, description, null)); + response.setEntity("Unauthorized", MediaType.TEXT_PLAIN); + } + + protected void forbidden(Response response, String requiredScope) { + response.setStatus(Status.CLIENT_ERROR_FORBIDDEN); + String scopeValue = spec.getScopes() != null ? String.join(" ", spec.getScopes()) : null; + addBearerChallenge(response, + buildBearerChallengeParams("insufficient_scope", + "Required scope '" + requiredScope + "' not present in token", + scopeValue)); + response.setEntity("Forbidden", MediaType.TEXT_PLAIN); + } + + private void addBearerChallenge(Response response, String params) { + ChallengeRequest cr = new ChallengeRequest(BEARER_SCHEME); + if (params != null && !params.isEmpty()) { + cr.setRawValue(params); + } + response.getChallengeRequests().add(cr); + } + + protected String buildBearerChallengeParams(String error, String description, String scope) { + StringBuilder sb = new StringBuilder(); + boolean hasParams = false; + + if (error != null) { + sb.append("error=\"").append(error).append("\""); + hasParams = true; + } + if (description != null) { + sb.append(hasParams ? ", " : ""); + sb.append("error_description=\"").append(description).append("\""); + hasParams = true; + } + if (scope != null) { + sb.append(hasParams ? ", " : ""); + sb.append("scope=\"").append(scope).append("\""); + } + + return sb.toString(); + } + + protected OAuth2AuthenticationSpec getSpec() { + return spec; + } + + protected Restlet getNext() { + return next; + } + + // ─── AS Metadata & JWKS Discovery ─────────────────────────────────────────── + + void ensureInitialized() { + if (initialized) { + return; + } + synchronized (initLock) { + if (initialized) { + return; + } + try { + discoverAsMetadata(); + if (discoveredJwksUri != null) { + fetchAndCacheJwkSet(discoveredJwksUri); + } + initialized = true; + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, "AS metadata discovery failed", e); + initialized = true; + } + } + } + + void discoverAsMetadata() throws IOException, InterruptedException { + String baseUrl = spec.getAuthorizationServerUrl(); + if (baseUrl == null) { + return; + } + + String asMetadataUrl = stripTrailingSlash(baseUrl) + "/.well-known/oauth-authorization-server"; + String body = fetchUrl(asMetadataUrl); + + if (body == null) { + String oidcUrl = stripTrailingSlash(baseUrl) + "/.well-known/openid-configuration"; + body = fetchUrl(oidcUrl); + } + + if (body != null) { + try { + JsonNode metadata = JSON.readTree(body); + if (metadata.has("jwks_uri")) { + discoveredJwksUri = metadata.get("jwks_uri").asText(); + } + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, "Failed to parse AS metadata", e); + } + } + } + + JWKSet getCachedOrFetchJwkSet() { + if (cachedJwkSet != null) { + long elapsed = System.currentTimeMillis() - jwkSetTimestamp; + if (elapsed < JWKS_CACHE_TTL_MS) { + return cachedJwkSet; + } + return refreshJwkSet(); + } + + if (discoveredJwksUri != null) { + return refreshJwkSet(); + } + + return null; + } + + JWKSet refreshJwkSet() { + if (discoveredJwksUri == null) { + return cachedJwkSet; + } + + long now = System.currentTimeMillis(); + if (cachedJwkSet != null && (now - jwkSetTimestamp) < JWKS_MIN_REFRESH_MS) { + return cachedJwkSet; + } + + synchronized (jwkRefreshLock) { + if (cachedJwkSet != null && (System.currentTimeMillis() - jwkSetTimestamp) < JWKS_MIN_REFRESH_MS) { + return cachedJwkSet; + } + try { + fetchAndCacheJwkSet(discoveredJwksUri); + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, "JWKS refresh failed", e); + } + return cachedJwkSet; + } + } + + void fetchAndCacheJwkSet(String jwksUri) throws IOException, InterruptedException { + String body = fetchUrl(jwksUri); + if (body != null) { + try { + cachedJwkSet = JWKSet.parse(body); + jwkSetTimestamp = System.currentTimeMillis(); + } catch (ParseException e) { + throw new IOException("Failed to parse JWKS", e); + } + } + } + + /** + * HTTP GET a URL and return the response body, or null on failure. Protected for test + * overriding. + */ + protected String fetchUrl(String url) throws IOException, InterruptedException { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .GET() + .timeout(Duration.ofSeconds(10)) + .build(); + HttpResponse response = + client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return response.body(); + } + + Context.getCurrentLogger().log(Level.WARNING, + "HTTP {0} from {1}", new Object[] {response.statusCode(), url}); + return null; + } + + // ─── JWK Helpers ──────────────────────────────────────────────────────────── + + static JWK findKey(JWKSet jwkSet, String kid) { + if (kid != null) { + return jwkSet.getKeyByKeyId(kid); + } + List keys = jwkSet.getKeys(); + return keys.isEmpty() ? null : keys.get(0); + } + + static JWSVerifier buildVerifier(JWK key) throws JOSEException { + if (key instanceof RSAKey rsaKey) { + return new RSASSAVerifier(rsaKey); + } + if (key instanceof ECKey ecKey) { + return new ECDSAVerifier(ecKey); + } + return null; + } + + private static String stripTrailingSlash(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + +} diff --git a/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java index 185b9a9a..840f42fa 100644 --- a/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java @@ -13,12 +13,31 @@ */ package io.naftiko.engine.exposes; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; import org.restlet.Context; import org.restlet.Restlet; import org.restlet.Server; +import org.restlet.data.ChallengeScheme; import org.restlet.data.Protocol; +import org.restlet.security.ChallengeAuthenticator; +import org.restlet.security.SecretVerifier; +import org.restlet.security.Verifier; import io.naftiko.Capability; import io.naftiko.engine.Adapter; +import io.naftiko.engine.exposes.rest.ServerAuthenticationRestlet; +import io.naftiko.engine.util.Resolver; +import io.naftiko.spec.BindingKeysSpec; +import io.naftiko.spec.BindingSpec; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.consumes.AuthenticationSpec; +import io.naftiko.spec.consumes.BasicAuthenticationSpec; +import io.naftiko.spec.consumes.DigestAuthenticationSpec; +import io.naftiko.spec.consumes.OAuth2AuthenticationSpec; import io.naftiko.spec.exposes.ServerSpec; /** @@ -76,4 +95,138 @@ public void stop() throws Exception { } } + /** + * Extracts all allowed variable names from the capability spec's bindings. These are the + * variable names defined in the binds keys mapping. + * + * @param spec The Naftiko spec + * @return Set of allowed variable names from binds declarations + */ + public static Set extractAllowedVariables(NaftikoSpec spec) { + Set allowed = new HashSet<>(); + + if (spec == null || spec.getBinds() == null) { + return allowed; + } + + for (BindingSpec bind : spec.getBinds()) { + BindingKeysSpec keysSpec = bind.getKeys(); + if (keysSpec != null && keysSpec.getKeys() != null) { + allowed.addAll(keysSpec.getKeys().keySet()); + } + } + + return allowed; + } + + /** + * Builds the Restlet handler chain, optionally wrapping the next restlet with an + * authentication filter based on the adapter's spec. Subclasses may override + * {@link #createOAuth2Restlet} to provide adapter-specific OAuth 2.1 behaviour. + */ + protected Restlet buildServerChain(Restlet next) { + AuthenticationSpec authentication = getSpec().getAuthentication(); + if (authentication == null || authentication.getType() == null) { + return next; + } + + if ("basic".equals(authentication.getType()) + || "digest".equals(authentication.getType())) { + return buildChallengeAuthenticator(authentication, next); + } + + if ("oauth2".equals(authentication.getType()) + && authentication instanceof OAuth2AuthenticationSpec oauth2) { + return createOAuth2Restlet(oauth2, next); + } + + Set allowedVariables = extractAllowedVariables(getCapability().getSpec()); + return new ServerAuthenticationRestlet(authentication, next, allowedVariables); + } + + /** + * Creates the OAuth 2.1 authentication restlet. Subclasses may override to return an + * adapter-specific variant (e.g. MCP's Protected Resource Metadata extension). + */ + protected Restlet createOAuth2Restlet(OAuth2AuthenticationSpec oauth2, Restlet next) { + return new OAuth2AuthenticationRestlet(oauth2, next); + } + + private Restlet buildChallengeAuthenticator(AuthenticationSpec authentication, Restlet next) { + ChallengeScheme scheme = "digest".equals(authentication.getType()) + ? ChallengeScheme.HTTP_DIGEST + : ChallengeScheme.HTTP_BASIC; + + ChallengeAuthenticator authenticator = + new ChallengeAuthenticator(next.getContext(), false, scheme, "naftiko"); + authenticator.setVerifier(new SecretVerifier() { + + @Override + public int verify(String identifier, char[] secret) { + String expectedUsername = null; + char[] expectedPassword = null; + + if (authentication instanceof BasicAuthenticationSpec basic) { + expectedUsername = resolveTemplate(basic.getUsername()); + expectedPassword = resolveTemplateChars(basic.getPassword()); + } else if (authentication instanceof DigestAuthenticationSpec digest) { + expectedUsername = resolveTemplate(digest.getUsername()); + expectedPassword = resolveTemplateChars(digest.getPassword()); + } + + if (expectedUsername == null || expectedPassword == null || identifier == null + || secret == null) { + return Verifier.RESULT_INVALID; + } + + boolean usernameMatches = secureEquals(expectedUsername, identifier); + boolean passwordMatches = secureEquals(expectedPassword, secret); + + return (usernameMatches && passwordMatches) ? Verifier.RESULT_VALID + : Verifier.RESULT_INVALID; + } + }); + authenticator.setNext(next); + return authenticator; + } + + private static String resolveTemplate(String value) { + if (value == null) { + return null; + } + Map env = new HashMap<>(); + if (value.contains("{{") && value.contains("}}")) { + for (Map.Entry entry : System.getenv().entrySet()) { + env.put(entry.getKey(), entry.getValue()); + } + } + return Resolver.resolveMustacheTemplate(value, env); + } + + private static char[] resolveTemplateChars(char[] value) { + if (value == null) { + return null; + } + String resolved = resolveTemplate(new String(value)); + return resolved == null ? null : resolved.toCharArray(); + } + + private static boolean secureEquals(String expected, String actual) { + if (expected == null || actual == null) { + return false; + } + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + actual.getBytes(StandardCharsets.UTF_8)); + } + + private static boolean secureEquals(char[] expected, char[] actual) { + if (expected == null || actual == null) { + return false; + } + return MessageDigest.isEqual( + new String(expected).getBytes(StandardCharsets.UTF_8), + new String(actual).getBytes(StandardCharsets.UTF_8)); + } + } \ No newline at end of file diff --git a/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java new file mode 100644 index 00000000..5f201b9d --- /dev/null +++ b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java @@ -0,0 +1,140 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes.mcp; + +import java.net.URI; +import java.util.List; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Status; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.nimbusds.jose.jwk.JWKSet; +import io.naftiko.engine.exposes.OAuth2AuthenticationRestlet; +import io.naftiko.spec.consumes.OAuth2AuthenticationSpec; + +/** + * MCP-specific OAuth 2.1 Restlet that extends {@link OAuth2AuthenticationRestlet} with + * MCP 2025-11-25 protocol behavior: + *
    + *
  • Auto-serves Protected Resource Metadata (RFC 9728) at the well-known path
  • + *
  • Includes {@code resource_metadata} URL in {@code WWW-Authenticate} headers
  • + *
+ */ +public class McpOAuth2Restlet extends OAuth2AuthenticationRestlet { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final String metadataPath; + private final String metadataUrl; + private final String metadataJson; + + public McpOAuth2Restlet(OAuth2AuthenticationSpec spec, Restlet next) { + super(spec, next); + + URI resourceUri = URI.create(spec.getResource()); + String resourcePath = resourceUri.getPath(); + if (resourcePath == null || "/".equals(resourcePath) || resourcePath.isEmpty()) { + this.metadataPath = "/.well-known/oauth-protected-resource"; + } else { + this.metadataPath = "/.well-known/oauth-protected-resource" + resourcePath; + } + this.metadataUrl = resourceUri.getScheme() + "://" + resourceUri.getAuthority() + + metadataPath; + this.metadataJson = buildProtectedResourceMetadata(spec); + } + + /** + * Test constructor — allows injecting a pre-loaded JWK set. + */ + McpOAuth2Restlet(OAuth2AuthenticationSpec spec, Restlet next, JWKSet jwkSet) { + super(spec, next, jwkSet); + + URI resourceUri = URI.create(spec.getResource()); + String resourcePath = resourceUri.getPath(); + if (resourcePath == null || "/".equals(resourcePath) || resourcePath.isEmpty()) { + this.metadataPath = "/.well-known/oauth-protected-resource"; + } else { + this.metadataPath = "/.well-known/oauth-protected-resource" + resourcePath; + } + this.metadataUrl = resourceUri.getScheme() + "://" + resourceUri.getAuthority() + + metadataPath; + this.metadataJson = buildProtectedResourceMetadata(spec); + } + + @Override + public void handle(Request request, Response response) { + String path = request.getResourceRef() != null ? request.getResourceRef().getPath() : null; + if (Method.GET.equals(request.getMethod()) && metadataPath.equals(path)) { + serveProtectedResourceMetadata(response); + return; + } + + super.handle(request, response); + } + + @Override + protected String buildBearerChallengeParams(String error, String description, String scope) { + String baseParams = super.buildBearerChallengeParams(error, description, scope); + String metadata = "resource_metadata=\"" + metadataUrl + "\""; + if (baseParams.isEmpty()) { + return metadata; + } + return baseParams + ", " + metadata; + } + + void serveProtectedResourceMetadata(Response response) { + response.setStatus(Status.SUCCESS_OK); + response.setEntity(metadataJson, MediaType.APPLICATION_JSON); + } + + String getMetadataPath() { + return metadataPath; + } + + String getMetadataUrl() { + return metadataUrl; + } + + private static String buildProtectedResourceMetadata(OAuth2AuthenticationSpec spec) { + ObjectNode metadata = JSON.createObjectNode(); + metadata.put("resource", spec.getResource()); + + ArrayNode authServers = metadata.putArray("authorization_servers"); + authServers.add(spec.getAuthorizationServerUrl()); + + List scopes = spec.getScopes(); + if (scopes != null && !scopes.isEmpty()) { + ArrayNode scopesArray = metadata.putArray("scopes_supported"); + for (String scope : scopes) { + scopesArray.add(scope); + } + } + + ArrayNode bearerMethods = metadata.putArray("bearer_methods_supported"); + bearerMethods.add("header"); + + try { + return JSON.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize Protected Resource Metadata", e); + } + } + +} diff --git a/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java index 3f5f4189..7d05f366 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java @@ -21,12 +21,14 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; import org.restlet.Context; +import org.restlet.Restlet; import org.restlet.routing.Router; import io.modelcontextprotocol.spec.McpSchema; import io.naftiko.Capability; import io.naftiko.engine.aggregates.AggregateFunction; import io.naftiko.engine.exposes.ServerAdapter; import io.naftiko.spec.InputParameterSpec; +import io.naftiko.spec.consumes.OAuth2AuthenticationSpec; import io.naftiko.spec.exposes.McpServerSpec; import io.naftiko.spec.exposes.McpServerToolSpec; import io.naftiko.spec.exposes.McpToolHintsSpec; @@ -102,8 +104,15 @@ private void initHttpTransport(McpServerSpec serverSpec) { Router router = new Router(context); router.attachDefault(McpServerResource.class); + Restlet chain = buildServerChain(router); + String address = serverSpec.getAddress() != null ? serverSpec.getAddress() : "localhost"; - initServer(address, serverSpec.getPort(), router); + initServer(address, serverSpec.getPort(), chain); + } + + @Override + protected Restlet createOAuth2Restlet(OAuth2AuthenticationSpec oauth2, Restlet next) { + return new McpOAuth2Restlet(oauth2, next); } /** diff --git a/src/main/java/io/naftiko/engine/exposes/rest/RestServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/rest/RestServerAdapter.java index bddee024..659f3614 100644 --- a/src/main/java/io/naftiko/engine/exposes/rest/RestServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/rest/RestServerAdapter.java @@ -13,29 +13,12 @@ */ package io.naftiko.engine.exposes.rest; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; import org.restlet.Restlet; -import org.restlet.data.ChallengeScheme; import org.restlet.routing.Router; import org.restlet.routing.TemplateRoute; import org.restlet.routing.Variable; -import org.restlet.security.ChallengeAuthenticator; -import org.restlet.security.SecretVerifier; -import org.restlet.security.Verifier; import io.naftiko.Capability; import io.naftiko.engine.exposes.ServerAdapter; -import io.naftiko.engine.util.Resolver; -import io.naftiko.spec.consumes.AuthenticationSpec; -import io.naftiko.spec.consumes.BasicAuthenticationSpec; -import io.naftiko.spec.consumes.DigestAuthenticationSpec; -import io.naftiko.spec.BindingSpec; -import io.naftiko.spec.NaftikoSpec; -import io.naftiko.spec.BindingKeysSpec; import io.naftiko.spec.exposes.RestServerResourceSpec; import io.naftiko.spec.exposes.RestServerSpec; @@ -59,105 +42,7 @@ public RestServerAdapter(Capability capability, RestServerSpec serverSpec) { } initServer(serverSpec.getAddress(), serverSpec.getPort(), - buildServerChain(serverSpec)); - } - - private Restlet buildServerChain(RestServerSpec serverSpec) { - Restlet next = this.router; - AuthenticationSpec authentication = serverSpec.getAuthentication(); - - if (authentication == null || authentication.getType() == null) { - return next; - } - - if ("basic".equals(authentication.getType()) || "digest".equals(authentication.getType())) { - return buildChallengeAuthenticator(authentication, next); - } - - // Extract allowed variable names from capability's external refs - Set allowedVariables = extractAllowedVariables(getCapability().getSpec()); - return new ServerAuthenticationRestlet(authentication, next, allowedVariables); - } - - private Restlet buildChallengeAuthenticator(AuthenticationSpec authentication, Restlet next) { - ChallengeScheme scheme = "digest".equals(authentication.getType()) - ? ChallengeScheme.HTTP_DIGEST - : ChallengeScheme.HTTP_BASIC; - - ChallengeAuthenticator authenticator = - new ChallengeAuthenticator(this.router.getContext(), false, scheme, "naftiko"); - authenticator.setVerifier(new SecretVerifier() { - - @Override - public int verify(String identifier, char[] secret) { - String expectedUsername = null; - char[] expectedPassword = null; - - if (authentication instanceof BasicAuthenticationSpec basic) { - expectedUsername = resolveTemplate(basic.getUsername()); - expectedPassword = resolveTemplateChars(basic.getPassword()); - } else if (authentication instanceof DigestAuthenticationSpec digest) { - expectedUsername = resolveTemplate(digest.getUsername()); - expectedPassword = resolveTemplateChars(digest.getPassword()); - } - - if (expectedUsername == null || expectedPassword == null || identifier == null - || secret == null) { - return Verifier.RESULT_INVALID; - } - - boolean usernameMatches = secureEquals(expectedUsername, identifier); - boolean passwordMatches = secureEquals(expectedPassword, secret); - - return (usernameMatches && passwordMatches) ? Verifier.RESULT_VALID - : Verifier.RESULT_INVALID; - } - }); - authenticator.setNext(next); - return authenticator; - } - - private static String resolveTemplate(String value) { - if (value == null) { - return null; - } - - Map env = new HashMap<>(); - - if (value.contains("{{") && value.contains("}}")) { - for (Map.Entry entry : System.getenv().entrySet()) { - env.put(entry.getKey(), entry.getValue()); - } - } - - return Resolver.resolveMustacheTemplate(value, env); - } - - private static char[] resolveTemplateChars(char[] value) { - if (value == null) { - return null; - } - - String resolved = resolveTemplate(new String(value)); - return resolved == null ? null : resolved.toCharArray(); - } - - private static boolean secureEquals(String expected, String actual) { - if (expected == null || actual == null) { - return false; - } - - return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), - actual.getBytes(StandardCharsets.UTF_8)); - } - - private static boolean secureEquals(char[] expected, char[] actual) { - if (expected == null || actual == null) { - return false; - } - - return MessageDigest.isEqual(new String(expected).getBytes(StandardCharsets.UTF_8), - new String(actual).getBytes(StandardCharsets.UTF_8)); + buildServerChain(this.router)); } public RestServerSpec getRestServerSpec() { @@ -168,29 +53,4 @@ public Router getRouter() { return router; } - /** - * Extracts all allowed variable names from the capability spec's bindings. - * These are the variable names defined in the binds keys mapping. - * - * @param spec The Naftiko spec - * @return Set of allowed variable names from binds declarations - */ - static Set extractAllowedVariables(NaftikoSpec spec) { - Set allowed = new HashSet<>(); - - if (spec == null || spec.getBinds() == null) { - return allowed; - } - - for (BindingSpec bind : spec.getBinds()) { - BindingKeysSpec keysSpec = bind.getKeys(); - if (keysSpec != null && keysSpec.getKeys() != null) { - // The keys are the variable names used for template injection - allowed.addAll(keysSpec.getKeys().keySet()); - } - } - - return allowed; - } - } diff --git a/src/main/java/io/naftiko/engine/exposes/skill/SkillServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/skill/SkillServerAdapter.java index dffaabff..27d277df 100644 --- a/src/main/java/io/naftiko/engine/exposes/skill/SkillServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/skill/SkillServerAdapter.java @@ -17,6 +17,7 @@ import java.util.Map; import java.util.logging.Level; import org.restlet.Context; +import org.restlet.Restlet; import org.restlet.routing.Router; import org.restlet.routing.TemplateRoute; import org.restlet.routing.Variable; @@ -69,7 +70,8 @@ public SkillServerAdapter(Capability capability, SkillServerSpec serverSpec) { router.attach("/skills/{name}/contents/{file}", FileResource.class); fileRoute.getTemplate().getVariables().put("file", new Variable(Variable.TYPE_URI_PATH)); - initServer(serverSpec.getAddress(), serverSpec.getPort(), router); + Restlet chain = buildServerChain(router); + initServer(serverSpec.getAddress(), serverSpec.getPort(), chain); } /** diff --git a/src/main/java/io/naftiko/spec/consumes/AuthenticationSpec.java b/src/main/java/io/naftiko/spec/consumes/AuthenticationSpec.java index d7662a8e..e86a342e 100644 --- a/src/main/java/io/naftiko/spec/consumes/AuthenticationSpec.java +++ b/src/main/java/io/naftiko/spec/consumes/AuthenticationSpec.java @@ -28,7 +28,8 @@ @JsonSubTypes.Type(value = ApiKeyAuthenticationSpec.class, name = "apikey"), @JsonSubTypes.Type(value = BasicAuthenticationSpec.class, name = "basic"), @JsonSubTypes.Type(value = BearerAuthenticationSpec.class, name = "bearer"), - @JsonSubTypes.Type(value = DigestAuthenticationSpec.class, name = "digest") + @JsonSubTypes.Type(value = DigestAuthenticationSpec.class, name = "digest"), + @JsonSubTypes.Type(value = OAuth2AuthenticationSpec.class, name = "oauth2") }) public abstract class AuthenticationSpec { diff --git a/src/main/java/io/naftiko/spec/consumes/OAuth2AuthenticationSpec.java b/src/main/java/io/naftiko/spec/consumes/OAuth2AuthenticationSpec.java new file mode 100644 index 00000000..7a33bd88 --- /dev/null +++ b/src/main/java/io/naftiko/spec/consumes/OAuth2AuthenticationSpec.java @@ -0,0 +1,76 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.spec.consumes; + +import java.util.List; + +/** + * OAuth 2.1 Resource Server Authentication Specification Element. + * + *

The server validates bearer tokens issued by an external authorization server. + * Supports JWKS-based JWT validation (default) and token introspection (RFC 7662).

+ */ +public class OAuth2AuthenticationSpec extends AuthenticationSpec { + + private volatile String authorizationServerUrl; + private volatile String resource; + private volatile List scopes; + private volatile String audience; + private volatile String tokenValidation; + + public OAuth2AuthenticationSpec() { + super("oauth2"); + } + + public String getAuthorizationServerUrl() { + return authorizationServerUrl; + } + + public void setAuthorizationServerUrl(String authorizationServerUrl) { + this.authorizationServerUrl = authorizationServerUrl; + } + + public String getResource() { + return resource; + } + + public void setResource(String resource) { + this.resource = resource; + } + + public List getScopes() { + return scopes; + } + + public void setScopes(List scopes) { + this.scopes = scopes; + } + + public String getAudience() { + return audience; + } + + public void setAudience(String audience) { + this.audience = audience; + } + + public String getTokenValidation() { + return tokenValidation; + } + + public void setTokenValidation(String tokenValidation) { + this.tokenValidation = tokenValidation; + } + +} diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java index 5e9761a7..36fe168f 100644 --- a/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java @@ -23,7 +23,7 @@ * Defines an MCP server that exposes tools over a configurable transport. * Supported transports: *
    - *
  • {@code http} (default) — Streamable HTTP via Jetty, for networked deployments
  • + *
  • {@code http} (default) — Streamable HTTP via Restlet, for networked deployments
  • *
  • {@code stdio} — stdin/stdout JSON-RPC, for local IDE development
  • *
* Each tool maps to one or more consumed HTTP operations. diff --git a/src/main/java/io/naftiko/spec/exposes/RestServerSpec.java b/src/main/java/io/naftiko/spec/exposes/RestServerSpec.java index e029add1..e0d982d5 100644 --- a/src/main/java/io/naftiko/spec/exposes/RestServerSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/RestServerSpec.java @@ -16,7 +16,6 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import com.fasterxml.jackson.annotation.JsonInclude; -import io.naftiko.spec.consumes.AuthenticationSpec; /** * Web API Server Specification Element @@ -29,9 +28,6 @@ public class RestServerSpec extends ServerSpec { @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List resources; - @JsonInclude(JsonInclude.Include.NON_NULL) - private volatile AuthenticationSpec authentication; - public RestServerSpec() { this(null, 0, null); } @@ -54,12 +50,4 @@ public List getResources() { return resources; } - public AuthenticationSpec getAuthentication() { - return authentication; - } - - public void setAuthentication(AuthenticationSpec authentication) { - this.authentication = authentication; - } - } diff --git a/src/main/java/io/naftiko/spec/exposes/ServerSpec.java b/src/main/java/io/naftiko/spec/exposes/ServerSpec.java index 525dc642..36b85a61 100644 --- a/src/main/java/io/naftiko/spec/exposes/ServerSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/ServerSpec.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import io.naftiko.spec.InputParameterSpec; +import io.naftiko.spec.consumes.AuthenticationSpec; /** * Base Exposed Adapter Specification Element @@ -44,6 +45,9 @@ public abstract class ServerSpec { @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List inputParameters; + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile AuthenticationSpec authentication; + public ServerSpec() { this(null, "localhost", 0); } @@ -88,4 +92,12 @@ public List getInputParameters() { return inputParameters; } + public AuthenticationSpec getAuthentication() { + return authentication; + } + + public void setAuthentication(AuthenticationSpec authentication) { + this.authentication = authentication; + } + } diff --git a/src/main/resources/blueprints/mcp-server-authentication.md b/src/main/resources/blueprints/mcp-server-authentication.md index 66788c8c..6670638b 100644 --- a/src/main/resources/blueprints/mcp-server-authentication.md +++ b/src/main/resources/blueprints/mcp-server-authentication.md @@ -38,7 +38,7 @@ Authentication support for the MCP server adapter, adding two complementary auth 1. **`authentication` with existing types** (bearer, apikey, basic, digest) — Reuse the same `Authentication` union already defined for `ExposesRest` and `ExposesSkill`. The engine validates incoming `Authorization` headers on every HTTP request to the MCP endpoint before dispatching to `McpServerResource`. This covers the common case of a shared secret, API key, or static bearer token protecting the MCP server — identical to how the REST adapter works today. -2. **`authentication` with new `oauth2` type** — A new `AuthOAuth2` schema definition that aligns with the [MCP 2025-11-25 Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). The MCP server acts as an OAuth 2.1 resource server: it validates bearer tokens issued by an external authorization server, serves Protected Resource Metadata (RFC 9728), and returns proper `WWW-Authenticate` challenges on `401`/`403`. This is the protocol-native authorization mode for MCP over Streamable HTTP. +2. **`authentication` with new `oauth2` type** — A new `AuthOAuth2` schema definition added to the **shared** `Authentication` union, making OAuth 2.1 available to all three adapter types (REST, MCP, Skill). The server acts as an OAuth 2.1 resource server: it validates bearer tokens issued by an external authorization server. A shared `OAuth2AuthenticationRestlet` handles core JWT/JWKS validation, audience, expiry, and scope checks — identical across adapters. The MCP adapter layers protocol-specific behavior on top: auto-serving Protected Resource Metadata (RFC 9728) and returning `resource_metadata` in `WWW-Authenticate` challenges per the [MCP 2025-11-25 Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). ### What This Does NOT Do @@ -61,11 +61,11 @@ Authentication support for the MCP server adapter, adding two complementary auth ### Key Design Decisions -1. **Two-tier authentication model**: Simple static credentials (bearer/apikey/basic/digest) reuse the existing `Authentication` union and `ServerAuthenticationRestlet` pattern from the REST adapter. OAuth 2.1 adds a new `AuthOAuth2` type for protocol-native MCP authorization. +1. **Shared `Authentication` union with `oauth2`**: `AuthOAuth2` is added to the shared `Authentication` union used by all three adapters. Core JWT/JWKS validation lives in a shared `OAuth2AuthenticationRestlet` in `io.naftiko.engine.exposes`, reusable by REST, MCP, and Skill adapters alike. -2. **Restlet filter chain**: Authentication is implemented as a Restlet `Restlet` wrapper that intercepts requests before the `Router` → `McpServerResource` chain, following the same filter-before-router pattern used by the REST and Skill adapters. +2. **Restlet filter chain**: Authentication is implemented as a Restlet `Restlet` wrapper that intercepts requests before the `Router` → `ServerResource` chain, following the same filter-before-router pattern used by all adapters. -3. **Protected Resource Metadata is auto-served**: When `oauth2` authentication is configured, the engine auto-serves the `/.well-known/oauth-protected-resource` endpoint via additional routes on the `Router` — no manual metadata file needed. +3. **MCP-specific: Protected Resource Metadata is auto-served**: When `oauth2` authentication is configured on an MCP adapter, the engine auto-serves the `/.well-known/oauth-protected-resource` endpoint and includes `resource_metadata` in `WWW-Authenticate` challenges per MCP 2025-11-25. REST and Skill adapters use the shared `OAuth2AuthenticationRestlet` directly without this MCP protocol overlay. 4. **`WWW-Authenticate` challenges follow the spec**: On `401 Unauthorized`, the server includes the `resource_metadata` URL and optional `scope` in the `WWW-Authenticate: Bearer` header, as required by RFC 9728 and MCP 2025-11-25. @@ -173,25 +173,37 @@ Restlet HTTP Chain: ### Proposed State ``` +Authentication union (shared — all adapters) +├── AuthBasic +├── AuthApiKey +├── AuthBearer +├── AuthDigest +└── AuthOAuth2 ← NEW + ExposesMcp ├── type: "mcp" ├── transport: "http" | "stdio" ├── namespace ├── description -├── authentication ← NEW (optional) -│ ├── Static: bearer | apikey | basic | digest (existing Authentication union) -│ └── OAuth2: oauth2 (new AuthOAuth2 type) +├── authentication ← NEW (optional, refs shared Authentication) ├── tools[] ├── resources[] └── prompts[] -Restlet HTTP Chain (with static auth): +Shared engine classes: + OAuth2AuthenticationRestlet (JWT/JWKS validation, audience, scope — reused by all adapters) + ServerAuthenticationRestlet (bearer/apikey — already shared) + +Restlet HTTP Chain — MCP (with static auth): Server → ServerAuthenticationRestlet → Router → McpServerResource - (same pattern as REST adapter's ServerAuthenticationRestlet wrapping Router) -Restlet HTTP Chain (with OAuth2 auth): +Restlet HTTP Chain — MCP (with OAuth2 auth): Server → McpOAuth2Restlet → Router → McpServerResource - (validates JWT, serves /.well-known/oauth-protected-resource) + (extends OAuth2AuthenticationRestlet + adds Protected Resource Metadata + resource_metadata WWW-Authenticate) + +Restlet HTTP Chain — REST (with OAuth2 auth): + Server → OAuth2AuthenticationRestlet → Router → ResourceRestlet + (JWT validation only — no MCP protocol overlay) ``` --- @@ -201,7 +213,7 @@ Restlet HTTP Chain (with OAuth2 auth): ### How authentication works across adapter types ``` -REST Adapter (today) MCP Adapter (proposed) Skill Adapter (today) +REST Adapter (proposed) MCP Adapter (proposed) Skill Adapter (proposed) ──────────────────── ────────────────────── ──────────────────── ExposesRest ExposesMcp ExposesSkill ├─ authentication ├─ authentication ← NEW ├─ authentication @@ -209,7 +221,7 @@ ExposesRest ExposesMcp ExposesSki │ ├─ type: apikey │ ├─ type: apikey │ ├─ type: apikey │ ├─ type: basic │ ├─ type: basic │ ├─ type: basic │ ├─ type: digest │ ├─ type: digest │ ├─ type: digest -│ └─ (no OAuth2) │ └─ type: oauth2 ← NEW │ └─ (no OAuth2) +│ └─ type: oauth2 ← NEW │ └─ type: oauth2 ← NEW │ └─ type: oauth2 ← NEW │ │ │ ├─ resources[] ├─ tools[] ├─ skills[] │ └─ operations[] ├─ resources[] │ @@ -218,11 +230,14 @@ ExposesRest ExposesMcp ExposesSki Filter/Handler flow: -REST: ChallengeAuthenticator ──→ Router ──→ ResourceRestlet - ServerAuthenticationRestlet ──→ Router ──→ ResourceRestlet +REST: OAuth2AuthenticationRestlet ──→ Router ──→ ResourceRestlet + (shared — JWT validation only) -MCP: ServerAuthenticationRestlet ──→ Router ──→ McpServerResource ──→ ProtocolDispatcher - McpOAuth2Restlet ──→ Router ──→ McpServerResource ──→ ProtocolDispatcher +MCP: McpOAuth2Restlet ──→ Router ──→ McpServerResource ──→ ProtocolDispatcher + (extends shared + Protected Resource Metadata + resource_metadata WWW-Authenticate) + +Skill: OAuth2AuthenticationRestlet ──→ Router ──→ SkillServerResource + (shared — JWT validation only) ``` ### Conceptual mapping @@ -232,7 +247,7 @@ MCP: ServerAuthenticationRestlet ──→ Router ──→ McpServerResource | Auth config location | `exposes[].authentication` | `exposes[].authentication` | | Static credential validation | `ServerAuthenticationRestlet` | `ServerAuthenticationRestlet` (reused) | | HTTP challenge (basic/digest) | Restlet `ChallengeAuthenticator` | Restlet `ChallengeAuthenticator` (reused) | -| OAuth2 resource server | Not supported | `McpOAuth2Restlet` (new) | +| OAuth2 resource server | `OAuth2AuthenticationRestlet` (shared, new) | `McpOAuth2Restlet` (extends shared + MCP protocol) | | Credential source | `binds` → environment vars | `binds` → environment vars | | Timing-safe comparison | `MessageDigest.isEqual()` | `MessageDigest.isEqual()` | | Transport applicability | HTTP only | HTTP only (skip for stdio) | @@ -248,7 +263,7 @@ Identical to the REST adapter. The engine resolves credential templates from `bi - **Bearer**: Extract `Authorization: Bearer ` header; compare with `MessageDigest.isEqual()` - **API Key**: Extract from header or query parameter by configured key name; compare value - **Basic**: Decode `Authorization: Basic ` to `username:password`; compare both -- **Digest**: HTTP Digest challenge-response (implemented via Jetty's `SecurityHandler` or custom handler) +- **Digest**: HTTP Digest challenge-response (implemented via Restlet's `ChallengeAuthenticator`) Static authentication is best for: - Internal/private MCP servers with a shared secret @@ -257,7 +272,7 @@ Static authentication is best for: ### 5.2 OAuth 2.1 Authentication (oauth2) -The MCP server acts as an **OAuth 2.1 resource server** (RFC 6749 / OAuth 2.1 draft-13). It does not issue tokens — it validates tokens issued by an external authorization server. +Any adapter acts as an **OAuth 2.1 resource server** (RFC 6749 / OAuth 2.1 draft-13). It does not issue tokens — it validates tokens issued by an external authorization server. The core validation logic (JWT/JWKS, audience, expiry, scope) is shared across all adapter types via `OAuth2AuthenticationRestlet`. **Configuration declares:** - `authorizationServerUrl` — The authorization server's issuer URL (used to derive metadata endpoints) @@ -293,11 +308,28 @@ This is generated from the YAML configuration — no manual metadata file needed --- -## 6. Schema Changes — Exposes (MCP) +## 6. Schema Changes — Authentication (Shared) + +### 6.1 Add `AuthOAuth2` to the shared `Authentication` union + +Add `AuthOAuth2` to the existing `Authentication` union used by all adapter types. This makes OAuth 2.1 available to REST, MCP, and Skill adapters through the same `authentication` property they already support: + +```json +"Authentication": { + "description": "Authentication for exposed server adapters. Supports static credentials and OAuth 2.1 resource server mode.", + "oneOf": [ + { "$ref": "#/$defs/AuthBasic" }, + { "$ref": "#/$defs/AuthApiKey" }, + { "$ref": "#/$defs/AuthBearer" }, + { "$ref": "#/$defs/AuthDigest" }, + { "$ref": "#/$defs/AuthOAuth2" } + ] +} +``` -### 6.1 Add `authentication` to `ExposesMcp` +### 6.2 Add `authentication` to `ExposesMcp` -Add the optional `authentication` property to the existing `ExposesMcp` definition, using an extended authentication union that includes the new `AuthOAuth2` type: +The `ExposesMcp` definition references the same shared `Authentication` union already used by `ExposesRest` and `ExposesSkill`: ```json "ExposesMcp": { @@ -309,7 +341,7 @@ Add the optional `authentication` property to the existing `ExposesMcp` definiti "namespace": { ... }, "description": { ... }, "authentication": { - "$ref": "#/$defs/McpAuthentication" + "$ref": "#/$defs/Authentication" }, "tools": { ... }, "resources": { ... }, @@ -318,31 +350,14 @@ Add the optional `authentication` property to the existing `ExposesMcp` definiti } ``` -### 6.2 New `McpAuthentication` Union - -A superset of the existing `Authentication` union that adds the `AuthOAuth2` type: - -```json -"McpAuthentication": { - "description": "Authentication for MCP server adapter. Supports static credentials (shared with REST/Skill adapters) and OAuth 2.1 resource server mode (MCP-specific).", - "oneOf": [ - { "$ref": "#/$defs/AuthBasic" }, - { "$ref": "#/$defs/AuthApiKey" }, - { "$ref": "#/$defs/AuthBearer" }, - { "$ref": "#/$defs/AuthDigest" }, - { "$ref": "#/$defs/AuthOAuth2" } - ] -} -``` - -**Design note:** A separate `McpAuthentication` union (rather than adding `AuthOAuth2` to the shared `Authentication` union) ensures the REST and Skill adapters are unaffected. If OAuth2 support is later desired for REST/Skill, the shared union can be extended then. +**Design note:** No separate `McpAuthentication` union is needed. The `AuthOAuth2` type is generic — its JWT/JWKS validation, audience, and scope logic applies equally to all adapters. MCP-specific protocol concerns (Protected Resource Metadata, `resource_metadata` in `WWW-Authenticate`) are handled in the engine layer, not the schema. ### 6.3 New `AuthOAuth2` Definition ```json "AuthOAuth2": { "type": "object", - "description": "OAuth 2.1 resource server authentication. The MCP server validates bearer tokens issued by an external authorization server, conforming to MCP 2025-11-25 Authorization specification.", + "description": "OAuth 2.1 resource server authentication. The server validates bearer tokens issued by an external authorization server. Available on all adapter types.", "properties": { "type": { "type": "string", @@ -595,31 +610,30 @@ initServer(address, port, chain); Where `buildServerChain` returns: - `ServerAuthenticationRestlet` wrapping the router for static types (bearer, apikey) - Restlet `ChallengeAuthenticator` wrapping the router for basic/digest -- `McpOAuth2Restlet` wrapping the router for `oauth2` - -The static authentication path reuses `ServerAuthenticationRestlet` directly — no MCP-specific class needed. +- `McpOAuth2Restlet` wrapping the router for `oauth2` (MCP adapter only) +- `OAuth2AuthenticationRestlet` wrapping the router for `oauth2` (REST and Skill adapters) ### 9.2 Static Authentication (reuses `ServerAuthenticationRestlet`) -The existing `ServerAuthenticationRestlet` is reused directly — no MCP-specific authentication class is needed for static credentials. The Restlet sits before the `Router` in the chain: +The existing `ServerAuthenticationRestlet` is reused directly — no adapter-specific class needed. The Restlet sits before the `Router` in the chain: 1. Extracts credentials from the HTTP request (bearer token or API key) 2. Resolves `{{VARIABLE}}` templates from environment, restricted to `binds`-declared keys 3. Compares using `MessageDigest.isEqual()` (timing-safe) -4. On success: delegates to the next `Restlet` (the `Router` → `McpServerResource`) +4. On success: delegates to the next `Restlet` (the `Router` → `ServerResource`) 5. On failure: returns `401 Unauthorized` with appropriate challenge header -For basic/digest authentication, the existing Restlet `ChallengeAuthenticator` is reused, again following the same pattern as the REST adapter. +For basic/digest authentication, the existing Restlet `ChallengeAuthenticator` is reused. ``` Request → ServerAuthenticationRestlet - ├─ Valid credentials → Router → McpServerResource → ProtocolDispatcher + ├─ Valid credentials → Router → ServerResource └─ Invalid/missing → 401 Unauthorized ``` -### 9.3 OAuth 2.1 Handler (`McpOAuth2Restlet`) +### 9.3 Shared OAuth 2.1 Handler (`OAuth2AuthenticationRestlet`) -A Restlet `Restlet` subclass that implements the resource server side of MCP 2025-11-25 authorization. It wraps the `Router` in the same position as `ServerAuthenticationRestlet`: +A shared Restlet `Restlet` subclass in `io.naftiko.engine.exposes` that implements the core OAuth 2.1 resource server logic. Used directly by REST and Skill adapters; extended by `McpOAuth2Restlet` for MCP-specific protocol concerns. **Initialization:** 1. Fetch AS metadata from `authorizationServerUrl` (try `.well-known/oauth-authorization-server` then `.well-known/openid-configuration`) @@ -629,12 +643,11 @@ A Restlet `Restlet` subclass that implements the resource server side of MCP 202 **Request handling:** ``` -Request → McpOAuth2Restlet - ├─ GET /.well-known/oauth-protected-resource → Return metadata JSON - ├─ No Authorization header → 401 + WWW-Authenticate - ├─ Invalid/expired token → 401 + WWW-Authenticate - ├─ Insufficient scope → 403 + WWW-Authenticate (insufficient_scope) - └─ Valid token → Router → McpServerResource → ProtocolDispatcher +Request → OAuth2AuthenticationRestlet + ├─ No Authorization header → 401 + WWW-Authenticate: Bearer + ├─ Invalid/expired token → 401 + WWW-Authenticate: Bearer + ├─ Insufficient scope → 403 + WWW-Authenticate: Bearer error="insufficient_scope" + └─ Valid token → next Restlet (Router → ServerResource) ``` **Token validation (JWKS mode):** @@ -648,7 +661,22 @@ Request → McpOAuth2Restlet 2. POST to AS `introspection_endpoint` with `token=` 3. Verify response `active: true`, check audience and scope -### 9.4 Protected Resource Metadata Endpoint +### 9.4 MCP OAuth 2.1 Handler (`McpOAuth2Restlet`) + +`McpOAuth2Restlet` extends `OAuth2AuthenticationRestlet` to add MCP 2025-11-25 protocol-specific behavior: + +1. **Protected Resource Metadata** — intercepts `GET /.well-known/oauth-protected-resource` and serves auto-generated metadata (see §9.5) +2. **`resource_metadata` in WWW-Authenticate** — enriches the `401`/`403` challenge headers with the `resource_metadata` URL parameter per RFC 9728 + +``` +Request → McpOAuth2Restlet + ├─ GET /.well-known/oauth-protected-resource → Return metadata JSON + └─ All other requests → delegate to OAuth2AuthenticationRestlet + ├─ Valid token → Router → McpServerResource → ProtocolDispatcher + └─ Invalid → 401/403 + WWW-Authenticate (with resource_metadata) +``` + +### 9.5 Protected Resource Metadata Endpoint Auto-generated from configuration: @@ -663,7 +691,7 @@ Auto-generated from configuration: Served at the well-known path derived from the MCP endpoint. -### 9.5 WWW-Authenticate Header Generation +### 9.6 WWW-Authenticate Header Generation On `401 Unauthorized`: ``` @@ -684,7 +712,7 @@ WWW-Authenticate: Bearer error="insufficient_scope", error_description="Required scope 'tools:execute' not present in token" ``` -### 9.6 stdio Transport — No Authentication +### 9.7 stdio Transport — No Authentication Per MCP spec §1.2: "Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment." @@ -753,30 +781,30 @@ These constraints are enforced by the schema itself: Additional cross-field validations: -| Rule Name | Severity | Description | -|-----------|----------|-------------| -| `naftiko-mcp-oauth2-https-authserver` | error | `authorizationServerUrl` must use `https://` scheme | -| `naftiko-mcp-oauth2-resource-https` | warn | `resource` should use `https://` scheme for production | -| `naftiko-mcp-auth-stdio-conflict` | warn | `authentication` should not be set when `transport: "stdio"` | -| `naftiko-mcp-oauth2-scopes-defined` | warn | `scopes` array should be defined for OAuth2 auth (enables WWW-Authenticate scope challenges) | +| Rule Name | Severity | Scope | Description | +|-----------|----------|-------|-------------| +| `naftiko-oauth2-https-authserver` | error | All adapters | `authorizationServerUrl` must use `https://` scheme | +| `naftiko-oauth2-resource-https` | warn | All adapters | `resource` should use `https://` scheme for production | +| `naftiko-oauth2-scopes-defined` | warn | All adapters | `scopes` array should be defined for OAuth2 auth (enables scope challenges) | +| `naftiko-mcp-auth-stdio-conflict` | warn | MCP only | `authentication` should not be set when `transport: "stdio"` | --- ## 12. Design Decisions & Rationale -### D1: Separate `McpAuthentication` union vs. extending shared `Authentication` +### D1: Shared `Authentication` union with `AuthOAuth2` -**Decision**: Create `McpAuthentication` as a new union that includes all four existing auth types plus `AuthOAuth2`. +**Decision**: Add `AuthOAuth2` directly to the shared `Authentication` union used by all three adapters. -**Rationale**: Adding `AuthOAuth2` to the shared `Authentication` union would expose OAuth2 as a valid option on `ExposesRest` and `ExposesSkill`, which those adapters don't support. A separate union keeps the schema honest while avoiding duplication of the four shared types (they remain `$ref`'d from the same definitions). +**Rationale**: The core OAuth 2.1 resource server logic — JWT/JWKS validation, audience/expiry/scope checks, token introspection — is not adapter-specific. Any HTTP server protecting endpoints with bearer tokens needs the same validation. A shared union avoids a parallel `McpAuthentication` type and ensures REST and Skill adapters get OAuth 2.1 support without additional schema work. -**Alternative considered**: A single `Authentication` union with all five types, and adapter-level validation rules rejecting `oauth2` on REST/Skill. Rejected because schema-level enforcement is stronger than rule-level enforcement, and because OAuth2 may eventually need different configuration for REST (e.g., token relay) vs. MCP (resource server). +**Alternative considered**: A separate `McpAuthentication` union containing the four existing types plus `AuthOAuth2`, keeping REST/Skill unaffected. Rejected because the `AuthOAuth2` configuration shape is generic, and restricting it to one adapter would force duplication when the others inevitably need it. -### D2: Restlet filter chain — reuse `ServerAuthenticationRestlet` +### D2: Shared `OAuth2AuthenticationRestlet` with MCP extension -**Decision**: Reuse the existing `ServerAuthenticationRestlet` for static credentials and implement `McpOAuth2Restlet` as a new `Restlet` subclass for OAuth 2.1. +**Decision**: Implement a shared `OAuth2AuthenticationRestlet` in `io.naftiko.engine.exposes` for core JWT validation, and extend it in `McpOAuth2Restlet` for MCP protocol-specific concerns. -**Rationale**: The MCP adapter now uses the Restlet framework for HTTP transport, the same as the REST and Skill adapters. `ServerAuthenticationRestlet` already implements bearer and API key validation with timing-safe comparison. Reusing it directly avoids code duplication and ensures consistent authentication behavior across all three adapter types. OAuth 2.1 requires MCP-specific logic (Protected Resource Metadata, JWT validation) that justifies a dedicated class. +**Rationale**: The MCP adapter requires Protected Resource Metadata (RFC 9728) and `resource_metadata` in `WWW-Authenticate` headers — neither of which applies to REST or Skill. The two-class design cleanly separates shared validation from protocol overlay. REST and Skill adapters use `OAuth2AuthenticationRestlet` directly; the MCP adapter uses its subclass. ### D3: No per-tool scope mapping @@ -816,30 +844,40 @@ Additional cross-field validations: 4. Add Spectral rule `naftiko-mcp-auth-stdio-conflict` 5. Tests: unit tests for chain wiring, integration test with bearer-protected MCP server -### Phase 2: OAuth 2.1 Resource Server +### Phase 2: OAuth 2.1 Resource Server (shared) -**Full MCP 2025-11-25 authorization compliance.** +**Core OAuth 2.1 validation, available to all adapters.** -1. Add `AuthOAuth2` definition to JSON schema -2. Replace `Authentication` ref on `ExposesMcp` with `McpAuthentication` union -3. Create `McpOAuth2Restlet`: +1. Add `AuthOAuth2` definition to JSON schema, in the shared `Authentication` union +2. Create `OAuth2AuthenticationRestlet` in `io.naftiko.engine.exposes`: - AS metadata discovery (RFC 8414) - JWKS fetching and caching - JWT validation (signature, expiry, issuer, audience) + - Scope checking and `403` challenge + - `WWW-Authenticate: Bearer` header generation +3. Wire `OAuth2AuthenticationRestlet` into `RestServerAdapter.buildServerChain()` and `SkillServerAdapter` +4. Add Spectral rules: `naftiko-oauth2-https-authserver`, `naftiko-oauth2-resource-https`, `naftiko-oauth2-scopes-defined` (adapter-agnostic) +5. Tests: unit tests for shared JWT validation; integration tests for REST and Skill adapters with OAuth2 + +### Phase 3: MCP Protocol Overlay + +**MCP 2025-11-25-specific authorization behavior.** + +1. Create `McpOAuth2Restlet` extending `OAuth2AuthenticationRestlet`: - Protected Resource Metadata endpoint (RFC 9728) - - `WWW-Authenticate` header generation -4. Add Spectral rules: `naftiko-mcp-oauth2-https-authserver`, `naftiko-mcp-oauth2-resource-https`, `naftiko-mcp-oauth2-scopes-defined` -5. Tests: unit tests for JWT validation, integration test with mock AS + - `resource_metadata` URL in `WWW-Authenticate` headers +2. Wire into `McpServerAdapter.buildServerChain()` for `oauth2` type +3. Add MCP-specific Spectral rule: `naftiko-mcp-oauth2-resource-defined` (warn if `resource` missing) +4. Tests: integration test with mock AS, Protected Resource Metadata endpoint test -### Phase 3: Token Introspection and Scope Challenges +### Phase 4: Token Introspection and Extended Features -**Extended OAuth features.** +**Extended OAuth features in the shared layer.** -1. Add introspection support to `McpOAuth2Restlet` (RFC 7662) -2. Implement `403` scope challenge responses -3. Origin header validation for DNS rebinding prevention -4. JWKS cache key rotation support (unknown-kid refresh) -5. Tests: introspection flow, scope challenge flow, Origin validation +1. Add introspection support to `OAuth2AuthenticationRestlet` (RFC 7662) +2. JWKS cache key rotation support (unknown-kid refresh) +3. Origin header validation for DNS rebinding prevention (MCP adapter) +4. Tests: introspection flow, scope challenge flow, Origin validation --- @@ -848,8 +886,9 @@ Additional cross-field validations: ### Schema - `authentication` on `ExposesMcp` is optional (not in `required`) — existing capabilities without it continue to work unchanged +- `AuthOAuth2` is added to the shared `Authentication` union — existing REST and Skill capabilities without it continue to work unchanged - No properties removed or renamed -- New `McpAuthentication` and `AuthOAuth2` definitions are purely additive +- New `AuthOAuth2` definition is purely additive ### Engine diff --git a/src/main/resources/rules/naftiko-rules.yml b/src/main/resources/rules/naftiko-rules.yml index de87e723..636f3691 100644 --- a/src/main/resources/rules/naftiko-rules.yml +++ b/src/main/resources/rules/naftiko-rules.yml @@ -139,6 +139,60 @@ rules: then: function: aggregate-semantics-consistency + naftiko-mcp-auth-stdio-conflict: + message: "MCP `authentication` should not be set when `transport` is `stdio`." + description: > + Per MCP specification §1.2, stdio transport should not follow the HTTP + authorization flow — credentials are retrieved from the environment instead. + Authentication is only meaningful for the HTTP transport. + severity: warn + recommended: true + given: "$.capability.exposes[?(@.type == 'mcp' && @.transport == 'stdio')]" + then: + field: "authentication" + function: falsy + + naftiko-oauth2-https-authserver: + message: "OAuth2 `authorizationServerUrl` must use the `https://` scheme." + description: > + The OAuth 2.1 specification requires all authorization server endpoints to be + served over HTTPS. A non-HTTPS authorization server URL is a security risk. + severity: error + recommended: true + given: "$.capability.exposes[*].authentication[?(@.type == 'oauth2')]" + then: + field: "authorizationServerUrl" + function: pattern + functionOptions: + match: "^https://" + + naftiko-oauth2-resource-https: + message: "OAuth2 `resource` should use the `https://` scheme for production." + description: > + The resource URI identifies this server in Protected Resource Metadata and + audience validation. Using HTTPS ensures proper security in production. + severity: warn + recommended: true + given: "$.capability.exposes[*].authentication[?(@.type == 'oauth2')]" + then: + field: "resource" + function: pattern + functionOptions: + match: "^https://" + + naftiko-oauth2-scopes-defined: + message: "OAuth2 authentication should define `scopes` for scope challenge support." + description: > + Defining scopes enables the server to include scope information in + WWW-Authenticate challenges and Protected Resource Metadata, improving + client interoperability. + severity: warn + recommended: true + given: "$.capability.exposes[*].authentication[?(@.type == 'oauth2')]" + then: + field: "scopes" + function: truthy + # ──────────────────────────────────────────────────────────────── # 2. QUALITY & DISCOVERABILITY # ──────────────────────────────────────────────────────────────── diff --git a/src/main/resources/schemas/naftiko-schema.json b/src/main/resources/schemas/naftiko-schema.json index 64bce31e..a75b5800 100644 --- a/src/main/resources/schemas/naftiko-schema.json +++ b/src/main/resources/schemas/naftiko-schema.json @@ -982,6 +982,9 @@ "type": "string", "description": "A meaningful description of this MCP server's purpose. Used as the server instructions sent during MCP initialization." }, + "authentication": { + "$ref": "#/$defs/Authentication" + }, "tools": { "type": "array", "description": "List of MCP tools exposed by this server", @@ -2061,6 +2064,9 @@ }, { "$ref": "#/$defs/AuthDigest" + }, + { + "$ref": "#/$defs/AuthOAuth2" } ] }, @@ -2156,6 +2162,52 @@ ], "additionalProperties": false }, + "AuthOAuth2": { + "type": "object", + "description": "OAuth 2.1 resource server authentication. The server validates bearer tokens issued by an external authorization server.", + "properties": { + "type": { + "type": "string", + "const": "oauth2" + }, + "authorizationServerUrl": { + "type": "string", + "format": "uri", + "description": "Issuer URL of the OAuth 2.1 authorization server. The engine derives metadata endpoints (.well-known/oauth-authorization-server) from this URL." + }, + "resource": { + "type": "string", + "format": "uri", + "description": "Canonical URI of this server (RFC 8707). Used for audience validation and Protected Resource Metadata." + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scopes this resource server recognizes." + }, + "audience": { + "type": "string", + "description": "Expected 'aud' claim in the JWT. Defaults to the 'resource' URI if not set." + }, + "tokenValidation": { + "type": "string", + "enum": [ + "jwks", + "introspection" + ], + "default": "jwks", + "description": "How to validate incoming access tokens. 'jwks' (default): fetch the AS public keys and validate JWT signatures locally. 'introspection': call the AS token introspection endpoint (RFC 7662) for each request." + } + }, + "required": [ + "type", + "authorizationServerUrl", + "resource" + ], + "additionalProperties": false + }, "ExposesSkill": { "type": "object", "description": "Skill server adapter — metadata and catalog layer. Skills declare tools derived from sibling api or mcp adapters or defined as local file instructions. Does not execute tools.", diff --git a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java new file mode 100644 index 00000000..bc6fb5af --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java @@ -0,0 +1,414 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Date; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.Method; +import org.restlet.data.Reference; +import org.restlet.data.Status; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.naftiko.spec.consumes.OAuth2AuthenticationSpec; + +/** + * Unit tests for the shared {@link OAuth2AuthenticationRestlet}. Validates JWT signature + * verification, claims validation, and WWW-Authenticate header generation. + */ +class OAuth2AuthenticationRestletTest { + + private static RSAKey rsaJWK; + private static RSAKey rsaPublicJWK; + private static JWKSet jwkSet; + + @BeforeAll + static void generateKeys() throws Exception { + rsaJWK = new RSAKeyGenerator(2048).keyID("test-key-1").generate(); + rsaPublicJWK = rsaJWK.toPublicJWK(); + jwkSet = new JWKSet(rsaPublicJWK); + } + + @Test + void handleShouldRejectRequestWithoutBearerToken() { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + Request request = new Request(Method.POST, "http://localhost/mcp"); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + assertFalse(response.getChallengeRequests().isEmpty(), + "Should include a Bearer challenge"); + assertEquals("Bearer", + response.getChallengeRequests().get(0).getScheme().getTechnicalName()); + } + + @Test + void handleShouldAcceptValidJwt() throws Exception { + TrackingRestlet tracker = new TrackingRestlet(); + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec(), tracker); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertTrue(tracker.wasCalled(), "Valid JWT should delegate to next restlet"); + } + + @Test + void handleShouldRejectExpiredJwt() throws Exception { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(new Date(System.currentTimeMillis() - 60_000)) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertTrue(rawValue.contains("invalid_token")); + assertTrue(rawValue.contains("Token expired")); + } + + @Test + void handleShouldRejectInvalidIssuer() throws Exception { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://wrong-issuer.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertTrue(rawValue.contains("Invalid issuer")); + } + + @Test + void handleShouldRejectInvalidAudience() throws Exception { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://wrong-audience.example.com") + .expirationTime(futureDate()) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertTrue(rawValue.contains("Invalid audience")); + } + + @Test + void handleShouldReturnForbiddenForInsufficientScope() throws Exception { + OAuth2AuthenticationSpec spec = minimalSpec(); + spec.setScopes(List.of("tools:read", "tools:execute")); + OAuth2AuthenticationRestlet restlet = buildRestlet(spec); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .claim("scope", "tools:read") + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_FORBIDDEN, response.getStatus()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertTrue(rawValue.contains("insufficient_scope")); + assertTrue(rawValue.contains("tools:execute")); + } + + @Test + void handleShouldAcceptJwtWithAllRequiredScopes() throws Exception { + OAuth2AuthenticationSpec spec = minimalSpec(); + spec.setScopes(List.of("tools:read", "tools:execute")); + TrackingRestlet tracker = new TrackingRestlet(); + OAuth2AuthenticationRestlet restlet = buildRestlet(spec, tracker); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .claim("scope", "tools:read tools:execute admin") + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertTrue(tracker.wasCalled(), "JWT with all required scopes should pass"); + } + + @Test + void handleShouldRejectJwtWithInvalidSignature() throws Exception { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + RSAKey otherKey = new RSAKeyGenerator(2048).keyID("other-key").generate(); + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("other-key").build(), + new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .build()); + jwt.sign(new RSASSASigner(otherKey)); + String token = jwt.serialize(); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + } + + @Test + void handleShouldUseAudienceFieldWhenConfigured() throws Exception { + OAuth2AuthenticationSpec spec = minimalSpec(); + spec.setAudience("custom-audience"); + TrackingRestlet tracker = new TrackingRestlet(); + OAuth2AuthenticationRestlet restlet = buildRestlet(spec, tracker); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("custom-audience") + .expirationTime(futureDate()) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertTrue(tracker.wasCalled(), + "JWT with matching custom audience should be accepted"); + } + + @Test + void handleShouldRejectMalformedJwt() { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + Request request = bearerRequest("not.a.valid.jwt"); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + } + + @Test + void validateClaimsShouldPassWhenNoScopesConfigured() throws Exception { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .build(); + + assertNull(restlet.validateClaims(claims), + "No scopes configured should not trigger scope validation"); + } + + @Test + void findKeyShouldReturnFirstKeyWhenNoKid() { + JWKSet keys = new JWKSet(rsaPublicJWK); + assertNotNull(OAuth2AuthenticationRestlet.findKey(keys, null)); + } + + @Test + void findKeyShouldReturnNullForUnknownKid() { + JWKSet keys = new JWKSet(rsaPublicJWK); + assertNull(OAuth2AuthenticationRestlet.findKey(keys, "unknown-kid")); + } + + @Test + void buildBearerChallengeParamsShouldReturnEmptyWhenNoParams() { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + assertEquals("", restlet.buildBearerChallengeParams(null, null, null)); + } + + @Test + void buildBearerChallengeParamsShouldIncludeErrorAndDescription() { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + String params = restlet.buildBearerChallengeParams("invalid_token", "Token expired", null); + assertTrue(params.contains("error=\"invalid_token\"")); + assertTrue(params.contains("error_description=\"Token expired\"")); + } + + @Test + void extractBearerTokenShouldReturnNullWithoutAuthorizationHeader() { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + Request request = new Request(Method.POST, "http://localhost/mcp"); + assertNull(restlet.extractBearerToken(request)); + } + + @Test + void extractBearerTokenShouldExtractFromAuthorizationHeader() { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + Request request = bearerRequest("my-token-123"); + assertEquals("my-token-123", restlet.extractBearerToken(request)); + } + + @Test + void handleShouldReturnForbiddenWhenTokenHasNoScopeClaimButScopesRequired() throws Exception { + OAuth2AuthenticationSpec spec = minimalSpec(); + spec.setScopes(List.of("tools:read")); + OAuth2AuthenticationRestlet restlet = buildRestlet(spec); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_FORBIDDEN, response.getStatus()); + } + + @Test + void handleShouldRejectIntrospectionMode() throws Exception { + OAuth2AuthenticationSpec spec = minimalSpec(); + spec.setTokenValidation("introspection"); + OAuth2AuthenticationRestlet restlet = buildRestlet(spec); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .expirationTime(futureDate()) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertTrue(rawValue.contains("introspection is not yet supported")); + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private static OAuth2AuthenticationSpec minimalSpec() { + OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec(); + spec.setAuthorizationServerUrl("https://auth.example.com"); + spec.setResource("https://mcp.example.com/mcp"); + return spec; + } + + private OAuth2AuthenticationRestlet buildRestlet(OAuth2AuthenticationSpec spec) { + return new OAuth2AuthenticationRestlet(spec, new NoOpRestlet(), jwkSet); + } + + private OAuth2AuthenticationRestlet buildRestlet(OAuth2AuthenticationSpec spec, + Restlet next) { + return new OAuth2AuthenticationRestlet(spec, next, jwkSet); + } + + private static String signedJwt(JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJWK.getKeyID()).build(), + claims); + jwt.sign(new RSASSASigner(rsaJWK)); + return jwt.serialize(); + } + + private static Request bearerRequest(String token) { + Request request = new Request(Method.POST, "http://localhost/mcp"); + request.getHeaders().set("Authorization", "Bearer " + token); + return request; + } + + private static Date futureDate() { + return new Date(System.currentTimeMillis() + 300_000); + } + + private static class NoOpRestlet extends Restlet { + + @Override + public void handle(Request request, Response response) { + response.setStatus(Status.SUCCESS_OK); + } + } + + private static class TrackingRestlet extends Restlet { + + private boolean called; + + @Override + public void handle(Request request, Response response) { + called = true; + response.setStatus(Status.SUCCESS_OK); + } + + boolean wasCalled() { + return called; + } + } + +} diff --git a/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java b/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java new file mode 100644 index 00000000..a14a60e5 --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java @@ -0,0 +1,243 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Restlet; +import org.restlet.routing.Router; +import org.restlet.security.ChallengeAuthenticator; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.naftiko.Capability; +import io.naftiko.engine.exposes.mcp.McpOAuth2Restlet; +import io.naftiko.engine.exposes.rest.ServerAuthenticationRestlet; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.util.VersionHelper; + +/** + * Unit tests for the shared authentication chain wiring in {@link ServerAdapter#buildServerChain}. + * + *

Uses MCP adapter YAML for generic auth tests (the logic is in ServerAdapter, not + * adapter-specific). The MCP-specific OAuth 2.1 test verifies the {@code createOAuth2Restlet} + * override produces {@link McpOAuth2Restlet}.

+ */ +class ServerAdapterAuthenticationTest { + + private String schemaVersion; + + @BeforeEach + void setUp() { + schemaVersion = VersionHelper.getSchemaVersion(); + } + + @Test + void buildServerChainShouldReturnRouterWhenNoAuthentication() throws Exception { + ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, "")); + + Router router = new Router(); + Restlet chain = adapter.buildServerChain(router); + + assertSame(router, chain, "Without authentication, chain should be the router itself"); + } + + @Test + void buildServerChainShouldReturnServerAuthenticationRestletForBearer() throws Exception { + String authBlock = """ + authentication: + type: "bearer" + token: "secret-token" + """; + ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock)); + + Router router = new Router(); + Restlet chain = adapter.buildServerChain(router); + + assertInstanceOf(ServerAuthenticationRestlet.class, chain, + "Bearer auth should produce ServerAuthenticationRestlet"); + } + + @Test + void buildServerChainShouldReturnServerAuthenticationRestletForApiKey() throws Exception { + String authBlock = """ + authentication: + type: "apikey" + key: "X-API-Key" + value: "abc123" + placement: "header" + """; + ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock)); + + Router router = new Router(); + Restlet chain = adapter.buildServerChain(router); + + assertInstanceOf(ServerAuthenticationRestlet.class, chain, + "API key auth should produce ServerAuthenticationRestlet"); + } + + @Test + void buildServerChainShouldReturnChallengeAuthenticatorForBasic() throws Exception { + String authBlock = """ + authentication: + type: "basic" + username: "admin" + password: "pass" + """; + ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock)); + + Router router = new Router(); + Restlet chain = adapter.buildServerChain(router); + + assertInstanceOf(ChallengeAuthenticator.class, chain, + "Basic auth should produce ChallengeAuthenticator"); + } + + @Test + void buildServerChainShouldReturnChallengeAuthenticatorForDigest() throws Exception { + String authBlock = """ + authentication: + type: "digest" + username: "admin" + password: "pass" + """; + ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock)); + + Router router = new Router(); + Restlet chain = adapter.buildServerChain(router); + + assertInstanceOf(ChallengeAuthenticator.class, chain, + "Digest auth should produce ChallengeAuthenticator"); + } + + @Test + void buildServerChainShouldReturnOAuth2RestletForOAuth2() throws Exception { + String authBlock = """ + authentication: + type: "oauth2" + authorizationServerUrl: "https://auth.example.com" + resource: "https://api.example.com" + scopes: + - "read" + """; + ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock)); + + Router router = new Router(); + Restlet chain = adapter.buildServerChain(router); + + assertInstanceOf(OAuth2AuthenticationRestlet.class, chain, + "OAuth2 auth should produce an OAuth2AuthenticationRestlet subtype"); + } + + @Test + void mcpAdapterShouldReturnMcpOAuth2RestletForOAuth2() throws Exception { + String authBlock = """ + authentication: + type: "oauth2" + authorizationServerUrl: "https://auth.example.com" + resource: "https://mcp.example.com/mcp" + """; + ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock)); + + Router router = new Router(); + Restlet chain = adapter.buildServerChain(router); + + assertInstanceOf(McpOAuth2Restlet.class, chain, + "MCP adapter OAuth2 should produce McpOAuth2Restlet"); + } + + @Test + void skillAdapterShouldReturnGenericOAuth2RestletForOAuth2() throws Exception { + String authBlock = """ + authentication: + type: "oauth2" + authorizationServerUrl: "https://auth.example.com" + resource: "https://skills.example.com" + scopes: + - "read" + """; + ServerAdapter adapter = adapterFromYaml(SKILL_YAML.formatted(schemaVersion, authBlock)); + + Router router = new Router(); + Restlet chain = adapter.buildServerChain(router); + + assertInstanceOf(OAuth2AuthenticationRestlet.class, chain, + "Skill adapter OAuth2 should produce OAuth2AuthenticationRestlet"); + } + + @Test + void serverSpecShouldDeserializeAuthentication() throws Exception { + String authBlock = """ + authentication: + type: "bearer" + token: "my-token" + """; + ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock)); + + assertNotNull(adapter.getSpec().getAuthentication(), + "Authentication spec should be deserialized"); + assertNotNull(adapter.getSpec().getAuthentication().getType(), + "Auth type should be set"); + } + + /** MCP adapter YAML template. First %s = schema version, second %s = authentication block. */ + private static final String MCP_YAML = """ + naftiko: "%s" + capability: + exposes: + - type: "mcp" + address: "localhost" + port: 0 + namespace: "test-mcp" + %s tools: + - name: "my-tool" + description: "A test tool" + outputParameters: + - type: "string" + value: "ok" + consumes: [] + """; + + /** Skill adapter YAML template. First %s = schema version, second %s = authentication block. */ + private static final String SKILL_YAML = """ + naftiko: "%s" + capability: + exposes: + - type: "skill" + address: "localhost" + port: 0 + namespace: "test-skills" + %s skills: + - name: "test-skill" + description: "A test skill" + location: "file:///tmp/test-skill" + tools: + - name: "test-tool" + description: "A test tool" + instruction: "guide.md" + consumes: [] + """; + + private static ServerAdapter adapterFromYaml(String yaml) throws Exception { + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + NaftikoSpec spec = mapper.readValue(yaml, NaftikoSpec.class); + Capability capability = new Capability(spec); + return (ServerAdapter) capability.getServerAdapters().get(0); + } +} diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java new file mode 100644 index 00000000..4e8f3cb8 --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java @@ -0,0 +1,319 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.naftiko.Capability; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.exposes.McpServerSpec; +import io.naftiko.util.VersionHelper; + +/** + * Integration tests for MCP server authentication. Validates end-to-end HTTP behavior with bearer + * and API key authentication through actual HTTP calls against a running MCP server. + */ +class McpAuthenticationIntegrationTest { + + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void bearerAuthShouldRejectRequestWithoutToken() throws Exception { + McpServerAdapter adapter = startBearerProtectedServer(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(401, response.statusCode(), + "Request without bearer token should be rejected"); + } finally { + adapter.stop(); + } + } + + @Test + void bearerAuthShouldRejectRequestWithWrongToken() throws Exception { + McpServerAdapter adapter = startBearerProtectedServer(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer wrong-token") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(401, response.statusCode(), + "Request with wrong bearer token should be rejected"); + } finally { + adapter.stop(); + } + } + + @Test + void bearerAuthShouldAcceptRequestWithCorrectToken() throws Exception { + McpServerAdapter adapter = startBearerProtectedServer(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer mcp-secret-token-123") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), + "Request with correct bearer token should be accepted"); + + JsonNode body = JSON.readTree(response.body()); + assertNotNull(body.path("result").path("protocolVersion").asText(), + "Initialize response should contain protocolVersion"); + } finally { + adapter.stop(); + } + } + + @Test + void apiKeyAuthShouldRejectRequestWithoutKey() throws Exception { + McpServerAdapter adapter = startApiKeyProtectedServer(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(401, response.statusCode(), + "Request without API key should be rejected"); + } finally { + adapter.stop(); + } + } + + @Test + void apiKeyAuthShouldAcceptRequestWithCorrectKey() throws Exception { + McpServerAdapter adapter = startApiKeyProtectedServer(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .header("X-MCP-Key", "abc-key-456") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), + "Request with correct API key should be accepted"); + + JsonNode body = JSON.readTree(response.body()); + assertNotNull(body.path("result").path("protocolVersion").asText(), + "Initialize response should contain protocolVersion"); + } finally { + adapter.stop(); + } + } + + @Test + void bearerAuthShouldProtectAllMethods() throws Exception { + McpServerAdapter adapter = startBearerProtectedServer(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + // GET without token should be rejected + HttpResponse getResponse = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(401, getResponse.statusCode(), + "GET without token should be rejected"); + + // DELETE without token should be rejected + HttpResponse deleteResponse = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .method("DELETE", HttpRequest.BodyPublishers.noBody()) + .build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(401, deleteResponse.statusCode(), + "DELETE without token should be rejected"); + } finally { + adapter.stop(); + } + } + + @Test + void noAuthShouldAllowAllRequests() throws Exception { + McpServerAdapter adapter = startUnprotectedServer(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), + "Unprotected server should accept all requests"); + } finally { + adapter.stop(); + } + } + + private McpServerAdapter startBearerProtectedServer() throws Exception { + String schemaVersion = VersionHelper.getSchemaVersion(); + String yaml = """ + naftiko: "%s" + info: + label: "MCP Auth Test" + description: "Bearer auth integration test" + capability: + exposes: + - type: "mcp" + address: "127.0.0.1" + port: %d + namespace: "auth-test-mcp" + description: "Test MCP server with bearer auth" + authentication: + type: "bearer" + token: "mcp-secret-token-123" + tools: + - name: "test-tool" + description: "A test tool" + outputParameters: + - type: "string" + value: "ok" + consumes: [] + """.formatted(schemaVersion, findFreePort()); + + return startAdapter(yaml); + } + + private McpServerAdapter startApiKeyProtectedServer() throws Exception { + String schemaVersion = VersionHelper.getSchemaVersion(); + String yaml = """ + naftiko: "%s" + info: + label: "MCP Auth Test" + description: "API key auth integration test" + capability: + exposes: + - type: "mcp" + address: "127.0.0.1" + port: %d + namespace: "auth-test-mcp" + description: "Test MCP server with API key auth" + authentication: + type: "apikey" + key: "X-MCP-Key" + value: "abc-key-456" + placement: "header" + tools: + - name: "test-tool" + description: "A test tool" + outputParameters: + - type: "string" + value: "ok" + consumes: [] + """.formatted(schemaVersion, findFreePort()); + + return startAdapter(yaml); + } + + private McpServerAdapter startUnprotectedServer() throws Exception { + String schemaVersion = VersionHelper.getSchemaVersion(); + String yaml = """ + naftiko: "%s" + info: + label: "MCP Auth Test" + description: "No auth integration test" + capability: + exposes: + - type: "mcp" + address: "127.0.0.1" + port: %d + namespace: "auth-test-mcp" + description: "Test MCP server without auth" + tools: + - name: "test-tool" + description: "A test tool" + outputParameters: + - type: "string" + value: "ok" + consumes: [] + """.formatted(schemaVersion, findFreePort()); + + return startAdapter(yaml); + } + + private McpServerAdapter startAdapter(String yaml) throws Exception { + NaftikoSpec spec = YAML_MAPPER.readValue(yaml, NaftikoSpec.class); + Capability capability = new Capability(spec); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + adapter.start(); + return adapter; + } + + private static String baseUrlFor(McpServerAdapter adapter) { + return "http://" + adapter.getMcpServerSpec().getAddress() + ":" + + adapter.getMcpServerSpec().getPort() + "/"; + } + + private static int findFreePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java new file mode 100644 index 00000000..5b1636c2 --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java @@ -0,0 +1,371 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Date; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.Server; +import org.restlet.data.MediaType; +import org.restlet.data.Protocol; +import org.restlet.data.Status; +import org.restlet.routing.Router; +import org.restlet.resource.Get; +import org.restlet.resource.ServerResource; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.naftiko.Capability; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.util.VersionHelper; + +/** + * Integration tests for MCP OAuth 2.1 authentication. Starts a mock authorization server + * (serving AS metadata and JWKS) and an OAuth2-protected MCP server, then exercises the + * full authentication flow with real HTTP calls. + */ +class McpOAuth2IntegrationTest { + + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final ObjectMapper JSON = new ObjectMapper(); + + private static RSAKey rsaJWK; + private static int mockAsPort; + private static Server mockAsServer; + private static String jwksJson; + + @BeforeAll + static void startMockAuthorizationServer() throws Exception { + rsaJWK = new RSAKeyGenerator(2048).keyID("integration-key").generate(); + RSAKey publicKey = rsaJWK.toPublicJWK(); + JWKSet jwkSet = new JWKSet(publicKey); + jwksJson = jwkSet.toString(); + + mockAsPort = findFreePort(); + + // Create AS metadata JSON + ObjectNode asMetadata = JSON.createObjectNode(); + asMetadata.put("issuer", "http://127.0.0.1:" + mockAsPort); + asMetadata.put("jwks_uri", "http://127.0.0.1:" + mockAsPort + "/jwks"); + asMetadata.put("authorization_endpoint", + "http://127.0.0.1:" + mockAsPort + "/authorize"); + asMetadata.put("token_endpoint", "http://127.0.0.1:" + mockAsPort + "/token"); + + String asMetadataJson = JSON.writeValueAsString(asMetadata); + + // Use plain Restlet chain for the mock AS (no Router/ServerResource needed) + Restlet handler = new Restlet() { + + @Override + public void handle(Request request, Response response) { + String path = request.getResourceRef().getPath(); + if ("/.well-known/oauth-authorization-server".equals(path)) { + response.setStatus(Status.SUCCESS_OK); + response.setEntity(asMetadataJson, MediaType.APPLICATION_JSON); + } else if ("/jwks".equals(path)) { + response.setStatus(Status.SUCCESS_OK); + response.setEntity(jwksJson, MediaType.APPLICATION_JSON); + } else { + response.setStatus(Status.CLIENT_ERROR_NOT_FOUND); + } + } + }; + + mockAsServer = new Server(Protocol.HTTP, "127.0.0.1", mockAsPort); + mockAsServer.setNext(handler); + mockAsServer.start(); + } + + @AfterAll + static void stopMockAuthorizationServer() throws Exception { + if (mockAsServer != null) { + mockAsServer.stop(); + } + } + + @Test + void oauth2ShouldRejectRequestWithoutToken() throws Exception { + McpServerAdapter adapter = startOAuth2Server(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(401, response.statusCode(), + "Request without bearer token should return 401"); + + String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse(""); + assertTrue(wwwAuth.contains("Bearer"), "Should contain Bearer challenge"); + assertTrue(wwwAuth.contains("resource_metadata"), + "Should contain resource_metadata URL"); + } finally { + adapter.stop(); + } + } + + @Test + void oauth2ShouldRejectExpiredToken() throws Exception { + McpServerAdapter adapter = startOAuth2Server(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("http://127.0.0.1:" + mockAsPort) + .audience("http://127.0.0.1:" + adapter.getMcpServerSpec().getPort() + "/mcp") + .expirationTime(new Date(System.currentTimeMillis() - 60_000)) + .claim("scope", "tools:read") + .build()); + + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(401, response.statusCode(), + "Expired token should return 401"); + } finally { + adapter.stop(); + } + } + + @Test + void oauth2ShouldAcceptValidToken() throws Exception { + McpServerAdapter adapter = startOAuth2Server(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + String resourceUri = "http://127.0.0.1:" + adapter.getMcpServerSpec().getPort() + "/mcp"; + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("http://127.0.0.1:" + mockAsPort) + .audience(resourceUri) + .expirationTime(new Date(System.currentTimeMillis() + 300_000)) + .claim("scope", "tools:read") + .build()); + + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), + "Valid token should return 200"); + + JsonNode body = JSON.readTree(response.body()); + assertNotNull(body.path("result").path("protocolVersion").asText(null), + "Initialize response should contain protocolVersion"); + } finally { + adapter.stop(); + } + } + + @Test + void oauth2ShouldServeProtectedResourceMetadata() throws Exception { + McpServerAdapter adapter = startOAuth2Server(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = "http://127.0.0.1:" + adapter.getMcpServerSpec().getPort(); + + try { + HttpResponse response = client.send( + HttpRequest.newBuilder( + URI.create(baseUrl + "/.well-known/oauth-protected-resource/mcp")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), + "Protected Resource Metadata should be served"); + + JsonNode metadata = JSON.readTree(response.body()); + assertNotNull(metadata.get("resource"), "Metadata should contain resource"); + assertNotNull(metadata.get("authorization_servers"), + "Metadata should contain authorization_servers"); + assertEquals("header", + metadata.get("bearer_methods_supported").get(0).asText()); + } finally { + adapter.stop(); + } + } + + @Test + void oauth2ShouldReturnForbiddenForInsufficientScope() throws Exception { + McpServerAdapter adapter = startOAuth2ServerWithScopes(); + HttpClient client = HttpClient.newHttpClient(); + String baseUrl = baseUrlFor(adapter); + + try { + String resourceUri = "http://127.0.0.1:" + adapter.getMcpServerSpec().getPort() + "/mcp"; + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("http://127.0.0.1:" + mockAsPort) + .audience(resourceUri) + .expirationTime(new Date(System.currentTimeMillis() + 300_000)) + .claim("scope", "tools:read") + .build()); + + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(403, response.statusCode(), + "Token missing required scope should return 403"); + + String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse(""); + assertTrue(wwwAuth.contains("insufficient_scope")); + assertTrue(wwwAuth.contains("resource_metadata")); + } finally { + adapter.stop(); + } + } + + // ─── Server Helpers ───────────────────────────────────────────────────────── + + private McpServerAdapter startOAuth2Server() throws Exception { + int port = findFreePort(); + String resourceUri = "http://127.0.0.1:" + port + "/mcp"; + String yaml = """ + naftiko: "%s" + info: + label: "OAuth2 MCP Test" + description: "OAuth2 integration test" + capability: + exposes: + - type: "mcp" + address: "127.0.0.1" + port: %d + namespace: "oauth2-test-mcp" + description: "OAuth2 protected MCP server" + authentication: + type: "oauth2" + authorizationServerUrl: "http://127.0.0.1:%d" + resource: "%s" + scopes: + - "tools:read" + tools: + - name: "test-tool" + description: "A test tool" + outputParameters: + - type: "string" + value: "ok" + consumes: [] + """.formatted(VersionHelper.getSchemaVersion(), port, mockAsPort, resourceUri); + + return startAdapter(yaml); + } + + private McpServerAdapter startOAuth2ServerWithScopes() throws Exception { + int port = findFreePort(); + String resourceUri = "http://127.0.0.1:" + port + "/mcp"; + String yaml = """ + naftiko: "%s" + info: + label: "OAuth2 MCP Test" + description: "OAuth2 scope integration test" + capability: + exposes: + - type: "mcp" + address: "127.0.0.1" + port: %d + namespace: "oauth2-test-mcp" + description: "OAuth2 protected MCP server with scopes" + authentication: + type: "oauth2" + authorizationServerUrl: "http://127.0.0.1:%d" + resource: "%s" + scopes: + - "tools:read" + - "tools:execute" + tools: + - name: "test-tool" + description: "A test tool" + outputParameters: + - type: "string" + value: "ok" + consumes: [] + """.formatted(VersionHelper.getSchemaVersion(), port, mockAsPort, resourceUri); + + return startAdapter(yaml); + } + + private McpServerAdapter startAdapter(String yaml) throws Exception { + NaftikoSpec spec = YAML_MAPPER.readValue(yaml, NaftikoSpec.class); + Capability capability = new Capability(spec); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + adapter.start(); + return adapter; + } + + private static String signedJwt(JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJWK.getKeyID()).build(), + claims); + jwt.sign(new RSASSASigner(rsaJWK)); + return jwt.serialize(); + } + + private static String baseUrlFor(McpServerAdapter adapter) { + return "http://127.0.0.1:" + adapter.getMcpServerSpec().getPort() + "/"; + } + + private static int findFreePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + +} diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java new file mode 100644 index 00000000..4261be66 --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java @@ -0,0 +1,248 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.Method; +import org.restlet.data.Reference; +import org.restlet.data.Status; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.naftiko.spec.consumes.OAuth2AuthenticationSpec; + +/** + * Unit tests for {@link McpOAuth2Restlet} — MCP-specific OAuth 2.1 behavior including + * Protected Resource Metadata and {@code resource_metadata} in WWW-Authenticate. + */ +class McpOAuth2RestletTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private static RSAKey rsaJWK; + private static RSAKey rsaPublicJWK; + private static JWKSet jwkSet; + + @BeforeAll + static void generateKeys() throws Exception { + rsaJWK = new RSAKeyGenerator(2048).keyID("mcp-key-1").generate(); + rsaPublicJWK = rsaJWK.toPublicJWK(); + jwkSet = new JWKSet(rsaPublicJWK); + } + + @Test + void handleShouldServeProtectedResourceMetadataOnGetWellKnown() throws Exception { + OAuth2AuthenticationSpec spec = specWithScopes(); + spec.setResource("https://mcp.example.com/"); + McpOAuth2Restlet restlet = buildRestlet(spec); + + Request request = new Request(Method.GET, + "http://localhost/.well-known/oauth-protected-resource"); + request.setResourceRef( + new Reference("http://localhost/.well-known/oauth-protected-resource")); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.SUCCESS_OK, response.getStatus()); + assertNotNull(response.getEntity()); + + String body = response.getEntity().getText(); + JsonNode metadata = JSON.readTree(body); + + assertEquals("https://mcp.example.com/", metadata.get("resource").asText()); + assertEquals("https://auth.example.com", + metadata.get("authorization_servers").get(0).asText()); + assertTrue(metadata.has("scopes_supported")); + assertEquals(2, metadata.get("scopes_supported").size()); + assertEquals("header", metadata.get("bearer_methods_supported").get(0).asText()); + } + + @Test + void handleShouldServeMetadataAtPathDerivedFromResource() { + OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec(); + spec.setAuthorizationServerUrl("https://auth.example.com"); + spec.setResource("https://mcp.example.com/api/v1"); + + McpOAuth2Restlet restlet = new McpOAuth2Restlet(spec, new NoOpRestlet(), jwkSet); + + assertEquals("/.well-known/oauth-protected-resource/api/v1", + restlet.getMetadataPath()); + } + + @Test + void handleShouldServeMetadataAtRootPathForRootResource() { + OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec(); + spec.setAuthorizationServerUrl("https://auth.example.com"); + spec.setResource("https://mcp.example.com/"); + + McpOAuth2Restlet restlet = new McpOAuth2Restlet(spec, new NoOpRestlet(), jwkSet); + + assertEquals("/.well-known/oauth-protected-resource", + restlet.getMetadataPath()); + } + + @Test + void unauthorizedShouldIncludeResourceMetadataInWwwAuthenticate() { + McpOAuth2Restlet restlet = buildRestlet(specWithScopes()); + + Request request = new Request(Method.POST, "http://localhost/mcp"); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + assertFalse(response.getChallengeRequests().isEmpty()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertNotNull(rawValue); + assertTrue(rawValue.contains("resource_metadata="), + "WWW-Authenticate should contain resource_metadata"); + assertTrue(rawValue.contains("/.well-known/oauth-protected-resource"), + "resource_metadata should point to well-known path"); + } + + @Test + void forbiddenShouldIncludeResourceMetadataInWwwAuthenticate() throws Exception { + OAuth2AuthenticationSpec spec = specWithScopes(); + McpOAuth2Restlet restlet = buildRestlet(spec); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .claim("scope", "tools:read") + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_FORBIDDEN, response.getStatus()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertTrue(rawValue.contains("resource_metadata=")); + assertTrue(rawValue.contains("insufficient_scope")); + } + + @Test + void handleShouldDelegateToParentForValidJwt() throws Exception { + TrackingRestlet tracker = new TrackingRestlet(); + McpOAuth2Restlet restlet = new McpOAuth2Restlet(specWithScopes(), tracker, jwkSet); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .claim("scope", "tools:read tools:execute") + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertTrue(tracker.wasCalled(), "Valid JWT should pass through to next restlet"); + } + + @Test + void handleShouldNotServeMetadataForNonGetRequests() { + McpOAuth2Restlet restlet = buildRestlet(specWithScopes()); + + Request request = new Request(Method.POST, + "http://localhost/.well-known/oauth-protected-resource"); + request.setResourceRef( + new Reference("http://localhost/.well-known/oauth-protected-resource")); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus(), + "POST to metadata path without token should be rejected, not serve metadata"); + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private static OAuth2AuthenticationSpec specWithScopes() { + OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec(); + spec.setAuthorizationServerUrl("https://auth.example.com"); + spec.setResource("https://mcp.example.com/mcp"); + spec.setScopes(List.of("tools:read", "tools:execute")); + return spec; + } + + private McpOAuth2Restlet buildRestlet(OAuth2AuthenticationSpec spec) { + return new McpOAuth2Restlet(spec, new NoOpRestlet(), jwkSet); + } + + private static String signedJwt(JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJWK.getKeyID()).build(), + claims); + jwt.sign(new RSASSASigner(rsaJWK)); + return jwt.serialize(); + } + + private static Request bearerRequest(String token) { + Request request = new Request(Method.POST, "http://localhost/mcp"); + request.getHeaders().set("Authorization", "Bearer " + token); + return request; + } + + private static Date futureDate() { + return new Date(System.currentTimeMillis() + 300_000); + } + + private static class NoOpRestlet extends Restlet { + + @Override + public void handle(Request request, Response response) { + response.setStatus(Status.SUCCESS_OK); + } + } + + private static class TrackingRestlet extends Restlet { + + private boolean called; + + @Override + public void handle(Request request, Response response) { + called = true; + response.setStatus(Status.SUCCESS_OK); + } + + boolean wasCalled() { + return called; + } + } + +} diff --git a/src/test/java/io/naftiko/engine/exposes/rest/RestServerAdapterTest.java b/src/test/java/io/naftiko/engine/exposes/rest/RestServerAdapterTest.java index ce691d00..1961eb52 100644 --- a/src/test/java/io/naftiko/engine/exposes/rest/RestServerAdapterTest.java +++ b/src/test/java/io/naftiko/engine/exposes/rest/RestServerAdapterTest.java @@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.Capability; +import io.naftiko.engine.exposes.ServerAdapter; import io.naftiko.spec.NaftikoSpec; import io.naftiko.util.VersionHelper; @@ -102,7 +103,7 @@ public void extractAllowedVariablesShouldReturnAllBindingKeys() throws Exception consumes: [] """.formatted(schemaVersion)); - Set keys = RestServerAdapter.extractAllowedVariables(spec); + Set keys = ServerAdapter.extractAllowedVariables(spec); assertEquals(2, keys.size()); assertTrue(keys.contains("auth_token")); From ecd28f805133de797b521dd16ee66642210217f0 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:43:56 -0400 Subject: [PATCH 03/15] chore: remove unused imports from OAuth2 test classes --- .../engine/exposes/OAuth2AuthenticationRestletTest.java | 3 --- .../engine/exposes/mcp/McpAuthenticationIntegrationTest.java | 2 -- .../naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java | 3 --- .../io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java | 1 - 4 files changed, 9 deletions(-) diff --git a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java index bc6fb5af..94f018d5 100644 --- a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java @@ -21,15 +21,12 @@ import java.util.Date; import java.util.List; -import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.restlet.Request; import org.restlet.Response; import org.restlet.Restlet; -import org.restlet.data.ChallengeRequest; import org.restlet.data.Method; -import org.restlet.data.Reference; import org.restlet.data.Status; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java index 4e8f3cb8..680b3f0a 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java @@ -15,7 +15,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.ServerSocket; import java.net.URI; @@ -29,7 +28,6 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.Capability; import io.naftiko.spec.NaftikoSpec; -import io.naftiko.spec.exposes.McpServerSpec; import io.naftiko.util.VersionHelper; /** diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java index 5b1636c2..38396cda 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java @@ -33,9 +33,6 @@ import org.restlet.data.MediaType; import org.restlet.data.Protocol; import org.restlet.data.Status; -import org.restlet.routing.Router; -import org.restlet.resource.Get; -import org.restlet.resource.ServerResource; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java index 4261be66..6432886c 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java @@ -25,7 +25,6 @@ import org.restlet.Request; import org.restlet.Response; import org.restlet.Restlet; -import org.restlet.data.ChallengeRequest; import org.restlet.data.Method; import org.restlet.data.Reference; import org.restlet.data.Status; From adaca0c4b0df1e27aabee674b204296f5532e3ae Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:17:02 -0400 Subject: [PATCH 04/15] refactor: deduplicate constructor initialization in OAuth2 restlets --- .../engine/exposes/OAuth2AuthenticationRestlet.java | 11 +++++++---- .../engine/exposes/mcp/McpOAuth2Restlet.java | 13 +------------ 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index 56462405..97ed2efb 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -78,8 +78,7 @@ public class OAuth2AuthenticationRestlet extends Restlet { private final Object jwkRefreshLock = new Object(); public OAuth2AuthenticationRestlet(OAuth2AuthenticationSpec spec, Restlet next) { - this.spec = spec; - this.next = next; + this(spec, next, null); } /** @@ -88,8 +87,12 @@ public OAuth2AuthenticationRestlet(OAuth2AuthenticationSpec spec, Restlet next) protected OAuth2AuthenticationRestlet(OAuth2AuthenticationSpec spec, Restlet next, JWKSet jwkSet) { this.spec = spec; this.next = next; - this.cachedJwkSet = jwkSet; - this.jwkSetTimestamp = System.currentTimeMillis(); + + if(jwkSet != null) { + this.cachedJwkSet = jwkSet; + this.jwkSetTimestamp = System.currentTimeMillis(); + } + this.initialized = true; } diff --git a/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java index 5f201b9d..589c4b9d 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java @@ -46,18 +46,7 @@ public class McpOAuth2Restlet extends OAuth2AuthenticationRestlet { private final String metadataJson; public McpOAuth2Restlet(OAuth2AuthenticationSpec spec, Restlet next) { - super(spec, next); - - URI resourceUri = URI.create(spec.getResource()); - String resourcePath = resourceUri.getPath(); - if (resourcePath == null || "/".equals(resourcePath) || resourcePath.isEmpty()) { - this.metadataPath = "/.well-known/oauth-protected-resource"; - } else { - this.metadataPath = "/.well-known/oauth-protected-resource" + resourcePath; - } - this.metadataUrl = resourceUri.getScheme() + "://" + resourceUri.getAuthority() - + metadataPath; - this.metadataJson = buildProtectedResourceMetadata(spec); + this(spec, next, null); } /** From 5aa984aaa92cbcc27f9523e0dca3a93c9867f29e Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:18:01 -0400 Subject: [PATCH 05/15] refactor: rename metadataUrl to metadataUri in McpOAuth2Restlet --- .../naftiko/engine/exposes/mcp/McpOAuth2Restlet.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java index 589c4b9d..3ad674fd 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java @@ -42,7 +42,7 @@ public class McpOAuth2Restlet extends OAuth2AuthenticationRestlet { private static final ObjectMapper JSON = new ObjectMapper(); private final String metadataPath; - private final String metadataUrl; + private final String metadataUri; private final String metadataJson; public McpOAuth2Restlet(OAuth2AuthenticationSpec spec, Restlet next) { @@ -62,7 +62,7 @@ public McpOAuth2Restlet(OAuth2AuthenticationSpec spec, Restlet next) { } else { this.metadataPath = "/.well-known/oauth-protected-resource" + resourcePath; } - this.metadataUrl = resourceUri.getScheme() + "://" + resourceUri.getAuthority() + this.metadataUri = resourceUri.getScheme() + "://" + resourceUri.getAuthority() + metadataPath; this.metadataJson = buildProtectedResourceMetadata(spec); } @@ -81,7 +81,7 @@ public void handle(Request request, Response response) { @Override protected String buildBearerChallengeParams(String error, String description, String scope) { String baseParams = super.buildBearerChallengeParams(error, description, scope); - String metadata = "resource_metadata=\"" + metadataUrl + "\""; + String metadata = "resource_metadata=\"" + metadataUri + "\""; if (baseParams.isEmpty()) { return metadata; } @@ -97,8 +97,8 @@ String getMetadataPath() { return metadataPath; } - String getMetadataUrl() { - return metadataUrl; + String getMetadataUri() { + return metadataUri; } private static String buildProtectedResourceMetadata(OAuth2AuthenticationSpec spec) { From 883d8c1b97175cce1d0f6ada2f6925926d85ee16 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:37:58 -0400 Subject: [PATCH 06/15] refactor: rename authorizationServerUrl to authorizationServerUri and use Restlet HTTP client --- .../exposes/OAuth2AuthenticationRestlet.java | 83 ++++++++++--------- .../engine/exposes/mcp/McpOAuth2Restlet.java | 2 +- .../consumes/OAuth2AuthenticationSpec.java | 10 +-- .../blueprints/mcp-server-authentication.md | 28 +++---- src/main/resources/rules/naftiko-rules.yml | 6 +- .../resources/schemas/naftiko-schema.json | 6 +- .../OAuth2AuthenticationRestletTest.java | 2 +- .../ServerAdapterAuthenticationTest.java | 6 +- .../exposes/mcp/McpOAuth2IntegrationTest.java | 4 +- .../exposes/mcp/McpOAuth2RestletTest.java | 6 +- 10 files changed, 80 insertions(+), 73 deletions(-) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index 97ed2efb..4e3e7718 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -14,18 +14,14 @@ package io.naftiko.engine.exposes; 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.text.ParseException; -import java.time.Duration; import java.util.Arrays; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.logging.Level; +import org.restlet.Client; import org.restlet.Context; import org.restlet.Request; import org.restlet.Response; @@ -33,6 +29,9 @@ import org.restlet.data.ChallengeRequest; import org.restlet.data.ChallengeScheme; import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Protocol; +import org.restlet.data.Reference; import org.restlet.data.Status; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -68,6 +67,7 @@ public class OAuth2AuthenticationRestlet extends Restlet { private final OAuth2AuthenticationSpec spec; private final Restlet next; + private final Client httpClient; private volatile JWKSet cachedJwkSet; private volatile long jwkSetTimestamp; @@ -87,13 +87,18 @@ public OAuth2AuthenticationRestlet(OAuth2AuthenticationSpec spec, Restlet next) protected OAuth2AuthenticationRestlet(OAuth2AuthenticationSpec spec, Restlet next, JWKSet jwkSet) { this.spec = spec; this.next = next; + this.httpClient = new Client(Protocol.HTTP, Protocol.HTTPS); + try { + this.httpClient.start(); + } catch (Exception e) { + throw new IllegalStateException("Failed to start HTTP client", e); + } if(jwkSet != null) { this.cachedJwkSet = jwkSet; this.jwkSetTimestamp = System.currentTimeMillis(); + this.initialized = true; } - - this.initialized = true; } @Override @@ -190,7 +195,7 @@ String validateClaims(JWTClaimsSet claims) { return "Token expired"; } - String expectedIssuer = spec.getAuthorizationServerUrl(); + String expectedIssuer = spec.getAuthorizationServerUri(); if (expectedIssuer != null && claims.getIssuer() != null && !expectedIssuer.equals(claims.getIssuer())) { return "Invalid issuer"; @@ -300,18 +305,18 @@ void ensureInitialized() { } } - void discoverAsMetadata() throws IOException, InterruptedException { - String baseUrl = spec.getAuthorizationServerUrl(); - if (baseUrl == null) { + void discoverAsMetadata() throws IOException { + String baseUri = spec.getAuthorizationServerUri(); + if (baseUri == null) { return; } - String asMetadataUrl = stripTrailingSlash(baseUrl) + "/.well-known/oauth-authorization-server"; - String body = fetchUrl(asMetadataUrl); + String asMetadataUri = stripTrailingSlash(baseUri) + "/.well-known/oauth-authorization-server"; + String body = fetchUri(asMetadataUri); if (body == null) { - String oidcUrl = stripTrailingSlash(baseUrl) + "/.well-known/openid-configuration"; - body = fetchUrl(oidcUrl); + String oidcUri = stripTrailingSlash(baseUri) + "/.well-known/openid-configuration"; + body = fetchUri(oidcUri); } if (body != null) { @@ -365,8 +370,8 @@ JWKSet refreshJwkSet() { } } - void fetchAndCacheJwkSet(String jwksUri) throws IOException, InterruptedException { - String body = fetchUrl(jwksUri); + void fetchAndCacheJwkSet(String jwksUri) throws IOException { + String body = fetchUri(jwksUri); if (body != null) { try { cachedJwkSet = JWKSet.parse(body); @@ -378,27 +383,29 @@ void fetchAndCacheJwkSet(String jwksUri) throws IOException, InterruptedExceptio } /** - * HTTP GET a URL and return the response body, or null on failure. Protected for test + * HTTP GET a URI and return the response body, or null on failure. Protected for test * overriding. */ - protected String fetchUrl(String url) throws IOException, InterruptedException { - HttpClient client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - HttpRequest request = HttpRequest.newBuilder(URI.create(url)) - .GET() - .timeout(Duration.ofSeconds(10)) - .build(); - HttpResponse response = - client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() == 200) { - return response.body(); - } - - Context.getCurrentLogger().log(Level.WARNING, - "HTTP {0} from {1}", new Object[] {response.statusCode(), url}); - return null; + protected String fetchUri(String uri) throws IOException { + try { + Request req = new Request(Method.GET, new Reference(uri)); + Response resp = httpClient.handle(req); + + if (resp.getStatus().equals(Status.SUCCESS_OK) + && resp.getEntity() != null) { + return resp.getEntity().getText(); + } + + Context.getCurrentLogger().log(Level.WARNING, + "HTTP {0} from {1}", new Object[] {resp.getStatus().getCode(), uri}); + return null; + } catch (Exception e) { + throw new IOException("Failed to fetch " + uri, e); + } + } + + Client getHttpClient() { + return httpClient; } // ─── JWK Helpers ──────────────────────────────────────────────────────────── @@ -421,8 +428,8 @@ static JWSVerifier buildVerifier(JWK key) throws JOSEException { return null; } - private static String stripTrailingSlash(String url) { - return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + private static String stripTrailingSlash(String uri) { + return uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri; } } diff --git a/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java index 3ad674fd..c271a4f2 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java @@ -106,7 +106,7 @@ private static String buildProtectedResourceMetadata(OAuth2AuthenticationSpec sp metadata.put("resource", spec.getResource()); ArrayNode authServers = metadata.putArray("authorization_servers"); - authServers.add(spec.getAuthorizationServerUrl()); + authServers.add(spec.getAuthorizationServerUri()); List scopes = spec.getScopes(); if (scopes != null && !scopes.isEmpty()) { diff --git a/src/main/java/io/naftiko/spec/consumes/OAuth2AuthenticationSpec.java b/src/main/java/io/naftiko/spec/consumes/OAuth2AuthenticationSpec.java index 7a33bd88..03751c62 100644 --- a/src/main/java/io/naftiko/spec/consumes/OAuth2AuthenticationSpec.java +++ b/src/main/java/io/naftiko/spec/consumes/OAuth2AuthenticationSpec.java @@ -23,7 +23,7 @@ */ public class OAuth2AuthenticationSpec extends AuthenticationSpec { - private volatile String authorizationServerUrl; + private volatile String authorizationServerUri; private volatile String resource; private volatile List scopes; private volatile String audience; @@ -33,12 +33,12 @@ public OAuth2AuthenticationSpec() { super("oauth2"); } - public String getAuthorizationServerUrl() { - return authorizationServerUrl; + public String getAuthorizationServerUri() { + return authorizationServerUri; } - public void setAuthorizationServerUrl(String authorizationServerUrl) { - this.authorizationServerUrl = authorizationServerUrl; + public void setAuthorizationServerUri(String authorizationServerUri) { + this.authorizationServerUri = authorizationServerUri; } public String getResource() { diff --git a/src/main/resources/blueprints/mcp-server-authentication.md b/src/main/resources/blueprints/mcp-server-authentication.md index 6670638b..40f58bb1 100644 --- a/src/main/resources/blueprints/mcp-server-authentication.md +++ b/src/main/resources/blueprints/mcp-server-authentication.md @@ -275,7 +275,7 @@ Static authentication is best for: Any adapter acts as an **OAuth 2.1 resource server** (RFC 6749 / OAuth 2.1 draft-13). It does not issue tokens — it validates tokens issued by an external authorization server. The core validation logic (JWT/JWKS, audience, expiry, scope) is shared across all adapter types via `OAuth2AuthenticationRestlet`. **Configuration declares:** -- `authorizationServerUrl` — The authorization server's issuer URL (used to derive metadata endpoints) +- `authorizationServerUri` — The authorization server's issuer URL (used to derive metadata endpoints) - `resource` — The canonical URI of this MCP server (used in `resource_metadata`, RFC 8707) - `scopes` — Scopes this resource server recognizes (used in `scopes_supported` in Protected Resource Metadata, and in `WWW-Authenticate` challenges) - `tokenValidation` — How to validate tokens: `jwks` (default, fetch public keys from AS) or `introspection` (call AS token introspection endpoint, RFC 7662) @@ -363,7 +363,7 @@ The `ExposesMcp` definition references the same shared `Authentication` union al "type": "string", "const": "oauth2" }, - "authorizationServerUrl": { + "authorizationServerUri": { "type": "string", "format": "uri", "description": "Issuer URL of the OAuth 2.1 authorization server. The engine derives metadata endpoints (.well-known/oauth-authorization-server) from this URL." @@ -389,7 +389,7 @@ The `ExposesMcp` definition references the same shared `Authentication` union al "description": "How to validate incoming access tokens. 'jwks' (default): fetch the AS public keys and validate JWT signatures locally. 'introspection': call the AS token introspection endpoint (RFC 7662) for each request." } }, - "required": ["type", "authorizationServerUrl", "resource"], + "required": ["type", "authorizationServerUri", "resource"], "additionalProperties": false } ``` @@ -482,7 +482,7 @@ capability: description: "Enterprise tools requiring OAuth authorization" authentication: type: oauth2 - authorizationServerUrl: "https://keycloak.example.com/realms/mcp" + authorizationServerUri: "https://keycloak.example.com/realms/mcp" resource: "https://mcp.example.com/mcp" scopes: - "tools:read" @@ -542,7 +542,7 @@ For opaque tokens (not JWTs) — validate via AS introspection endpoint: ```yaml authentication: type: oauth2 - authorizationServerUrl: "https://auth0.example.com" + authorizationServerUri: "https://auth0.example.com" resource: "https://mcp.example.com/mcp" scopes: - "read:tools" @@ -577,7 +577,7 @@ capability: description: "Order management tools" authentication: type: oauth2 - authorizationServerUrl: "https://keycloak.example.com/realms/mcp" + authorizationServerUri: "https://keycloak.example.com/realms/mcp" resource: "https://mcp.example.com/mcp" scopes: - "orders:read" @@ -636,7 +636,7 @@ Request → ServerAuthenticationRestlet A shared Restlet `Restlet` subclass in `io.naftiko.engine.exposes` that implements the core OAuth 2.1 resource server logic. Used directly by REST and Skill adapters; extended by `McpOAuth2Restlet` for MCP-specific protocol concerns. **Initialization:** -1. Fetch AS metadata from `authorizationServerUrl` (try `.well-known/oauth-authorization-server` then `.well-known/openid-configuration`) +1. Fetch AS metadata from `authorizationServerUri` (try `.well-known/oauth-authorization-server` then `.well-known/openid-configuration`) 2. If `tokenValidation: jwks` — fetch JWKS from the AS `jwks_uri` endpoint; cache keys with configurable TTL 3. If `tokenValidation: introspection` — store AS `introspection_endpoint` URL @@ -653,7 +653,7 @@ Request → OAuth2AuthenticationRestlet **Token validation (JWKS mode):** 1. Extract `Authorization: Bearer ` header 2. Decode JWT; verify signature against cached JWKS -3. Check `exp` (expiry), `iss` (issuer matches `authorizationServerUrl`), `aud` (matches `audience` or `resource`) +3. Check `exp` (expiry), `iss` (issuer matches `authorizationServerUri`), `aud` (matches `audience` or `resource`) 4. Check `scope` claim against configured `scopes` (if scopes are declared) **Token validation (introspection mode):** @@ -683,7 +683,7 @@ Auto-generated from configuration: ```json { "resource": "", - "authorization_servers": [""], + "authorization_servers": [""], "scopes_supported": ["", "..."], "bearer_methods_supported": ["header"] } @@ -746,9 +746,9 @@ Static authentication (bearer, apikey, basic, digest) MUST continue to use `Mess ### 10.4 HTTPS Enforcement -The MCP spec requires "All authorization server endpoints MUST be served over HTTPS." While Naftiko does not enforce HTTPS on the MCP server itself (that is typically handled by a reverse proxy), the `authorizationServerUrl` MUST use the `https` scheme. +The MCP spec requires "All authorization server endpoints MUST be served over HTTPS." While Naftiko does not enforce HTTPS on the MCP server itself (that is typically handled by a reverse proxy), the `authorizationServerUri` MUST use the `https` scheme. -Validation rule: `authorizationServerUrl` must start with `https://`. +Validation rule: `authorizationServerUri` must start with `https://`. ### 10.5 JWKS Key Caching @@ -772,7 +772,7 @@ These constraints are enforced by the schema itself: | Rule | Enforcement | |------|-------------| | `authentication` is optional on `ExposesMcp` | Not in `required` array | -| `AuthOAuth2.authorizationServerUrl` is required | In `required` array | +| `AuthOAuth2.authorizationServerUri` is required | In `required` array | | `AuthOAuth2.resource` is required | In `required` array | | `AuthOAuth2.tokenValidation` defaults to `jwks` | `default: "jwks"` | | `AuthOAuth2.type` must be `"oauth2"` | `const: "oauth2"` | @@ -783,7 +783,7 @@ Additional cross-field validations: | Rule Name | Severity | Scope | Description | |-----------|----------|-------|-------------| -| `naftiko-oauth2-https-authserver` | error | All adapters | `authorizationServerUrl` must use `https://` scheme | +| `naftiko-oauth2-https-authserver` | error | All adapters | `authorizationServerUri` must use `https://` scheme | | `naftiko-oauth2-resource-https` | warn | All adapters | `resource` should use `https://` scheme for production | | `naftiko-oauth2-scopes-defined` | warn | All adapters | `scopes` array should be defined for OAuth2 auth (enables scope challenges) | | `naftiko-mcp-auth-stdio-conflict` | warn | MCP only | `authentication` should not be set when `transport: "stdio"` | @@ -824,7 +824,7 @@ Additional cross-field validations: **Rationale**: The MCP spec requires this endpoint for authorization server discovery (RFC 9728). Requiring capability authors to manually create and serve this metadata would be a poor UX. By generating it from the `oauth2` authentication configuration, the YAML remains the single source of truth. -### D6: `authorizationServerUrl` vs. inline AS metadata +### D6: `authorizationServerUri` vs. inline AS metadata **Decision**: Require only the AS issuer URL, not inline metadata fields. diff --git a/src/main/resources/rules/naftiko-rules.yml b/src/main/resources/rules/naftiko-rules.yml index 636f3691..3f18f529 100644 --- a/src/main/resources/rules/naftiko-rules.yml +++ b/src/main/resources/rules/naftiko-rules.yml @@ -153,15 +153,15 @@ rules: function: falsy naftiko-oauth2-https-authserver: - message: "OAuth2 `authorizationServerUrl` must use the `https://` scheme." + message: "OAuth2 `authorizationServerUri` must use the `https://` scheme." description: > The OAuth 2.1 specification requires all authorization server endpoints to be - served over HTTPS. A non-HTTPS authorization server URL is a security risk. + served over HTTPS. A non-HTTPS authorization server URI is a security risk. severity: error recommended: true given: "$.capability.exposes[*].authentication[?(@.type == 'oauth2')]" then: - field: "authorizationServerUrl" + field: "authorizationServerUri" function: pattern functionOptions: match: "^https://" diff --git a/src/main/resources/schemas/naftiko-schema.json b/src/main/resources/schemas/naftiko-schema.json index a75b5800..d7cd2de9 100644 --- a/src/main/resources/schemas/naftiko-schema.json +++ b/src/main/resources/schemas/naftiko-schema.json @@ -2170,10 +2170,10 @@ "type": "string", "const": "oauth2" }, - "authorizationServerUrl": { + "authorizationServerUri": { "type": "string", "format": "uri", - "description": "Issuer URL of the OAuth 2.1 authorization server. The engine derives metadata endpoints (.well-known/oauth-authorization-server) from this URL." + "description": "Issuer URI of the OAuth 2.1 authorization server. The engine derives metadata endpoints (.well-known/oauth-authorization-server) from this URI." }, "resource": { "type": "string", @@ -2203,7 +2203,7 @@ }, "required": [ "type", - "authorizationServerUrl", + "authorizationServerUri", "resource" ], "additionalProperties": false diff --git a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java index 94f018d5..2fc25f29 100644 --- a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java @@ -353,7 +353,7 @@ void handleShouldRejectIntrospectionMode() throws Exception { private static OAuth2AuthenticationSpec minimalSpec() { OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec(); - spec.setAuthorizationServerUrl("https://auth.example.com"); + spec.setAuthorizationServerUri("https://auth.example.com"); spec.setResource("https://mcp.example.com/mcp"); return spec; } diff --git a/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java b/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java index a14a60e5..90a21ada 100644 --- a/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/ServerAdapterAuthenticationTest.java @@ -130,7 +130,7 @@ void buildServerChainShouldReturnOAuth2RestletForOAuth2() throws Exception { String authBlock = """ authentication: type: "oauth2" - authorizationServerUrl: "https://auth.example.com" + authorizationServerUri: "https://auth.example.com" resource: "https://api.example.com" scopes: - "read" @@ -149,7 +149,7 @@ void mcpAdapterShouldReturnMcpOAuth2RestletForOAuth2() throws Exception { String authBlock = """ authentication: type: "oauth2" - authorizationServerUrl: "https://auth.example.com" + authorizationServerUri: "https://auth.example.com" resource: "https://mcp.example.com/mcp" """; ServerAdapter adapter = adapterFromYaml(MCP_YAML.formatted(schemaVersion, authBlock)); @@ -166,7 +166,7 @@ void skillAdapterShouldReturnGenericOAuth2RestletForOAuth2() throws Exception { String authBlock = """ authentication: type: "oauth2" - authorizationServerUrl: "https://auth.example.com" + authorizationServerUri: "https://auth.example.com" resource: "https://skills.example.com" scopes: - "read" diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java index 38396cda..0dd79fcc 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java @@ -289,7 +289,7 @@ private McpServerAdapter startOAuth2Server() throws Exception { description: "OAuth2 protected MCP server" authentication: type: "oauth2" - authorizationServerUrl: "http://127.0.0.1:%d" + authorizationServerUri: "http://127.0.0.1:%d" resource: "%s" scopes: - "tools:read" @@ -322,7 +322,7 @@ private McpServerAdapter startOAuth2ServerWithScopes() throws Exception { description: "OAuth2 protected MCP server with scopes" authentication: type: "oauth2" - authorizationServerUrl: "http://127.0.0.1:%d" + authorizationServerUri: "http://127.0.0.1:%d" resource: "%s" scopes: - "tools:read" diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java index 6432886c..19aef1ca 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java @@ -90,7 +90,7 @@ void handleShouldServeProtectedResourceMetadataOnGetWellKnown() throws Exception @Test void handleShouldServeMetadataAtPathDerivedFromResource() { OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec(); - spec.setAuthorizationServerUrl("https://auth.example.com"); + spec.setAuthorizationServerUri("https://auth.example.com"); spec.setResource("https://mcp.example.com/api/v1"); McpOAuth2Restlet restlet = new McpOAuth2Restlet(spec, new NoOpRestlet(), jwkSet); @@ -102,7 +102,7 @@ void handleShouldServeMetadataAtPathDerivedFromResource() { @Test void handleShouldServeMetadataAtRootPathForRootResource() { OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec(); - spec.setAuthorizationServerUrl("https://auth.example.com"); + spec.setAuthorizationServerUri("https://auth.example.com"); spec.setResource("https://mcp.example.com/"); McpOAuth2Restlet restlet = new McpOAuth2Restlet(spec, new NoOpRestlet(), jwkSet); @@ -193,7 +193,7 @@ void handleShouldNotServeMetadataForNonGetRequests() { private static OAuth2AuthenticationSpec specWithScopes() { OAuth2AuthenticationSpec spec = new OAuth2AuthenticationSpec(); - spec.setAuthorizationServerUrl("https://auth.example.com"); + spec.setAuthorizationServerUri("https://auth.example.com"); spec.setResource("https://mcp.example.com/mcp"); spec.setScopes(List.of("tools:read", "tools:execute")); return spec; From 032ac39206c131a49532a664c3630352c69d73a7 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:52:37 -0400 Subject: [PATCH 07/15] fix: validate nbf (not-before) claim in JWT per RFC 7519 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateClaims checked exp but not nbf — a JWT with nbf in the future was accepted. Added nbf check and corresponding unit tests. --- .../exposes/OAuth2AuthenticationRestlet.java | 5 +++ .../OAuth2AuthenticationRestletTest.java | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index 4e3e7718..39355888 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -195,6 +195,11 @@ String validateClaims(JWTClaimsSet claims) { return "Token expired"; } + if (claims.getNotBeforeTime() != null + && claims.getNotBeforeTime().after(new Date())) { + return "Token not yet valid"; + } + String expectedIssuer = spec.getAuthorizationServerUri(); if (expectedIssuer != null && claims.getIssuer() != null && !expectedIssuer.equals(claims.getIssuer())) { diff --git a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java index 2fc25f29..fb3f876a 100644 --- a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java @@ -328,6 +328,48 @@ void handleShouldReturnForbiddenWhenTokenHasNoScopeClaimButScopesRequired() thro assertEquals(Status.CLIENT_ERROR_FORBIDDEN, response.getStatus()); } + @Test + void handleShouldRejectJwtWithNotBeforeInFuture() throws Exception { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .notBeforeTime(new Date(System.currentTimeMillis() + 300_000)) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertTrue(rawValue.contains("invalid_token")); + assertTrue(rawValue.contains("Token not yet valid")); + } + + @Test + void handleShouldAcceptJwtWithNotBeforeInPast() throws Exception { + TrackingRestlet tracker = new TrackingRestlet(); + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec(), tracker); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .notBeforeTime(new Date(System.currentTimeMillis() - 60_000)) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertTrue(tracker.wasCalled(), "JWT with nbf in the past should be accepted"); + } + @Test void handleShouldRejectIntrospectionMode() throws Exception { OAuth2AuthenticationSpec spec = minimalSpec(); From 1951b69449d6619f97d6576f7a5674add71ae14b Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:59:02 -0400 Subject: [PATCH 08/15] fix: reject JWT with absent issuer claim and normalize trailing slashes --- .../exposes/OAuth2AuthenticationRestlet.java | 8 ++-- .../OAuth2AuthenticationRestletTest.java | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index 39355888..bb89df94 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -201,9 +201,11 @@ String validateClaims(JWTClaimsSet claims) { } String expectedIssuer = spec.getAuthorizationServerUri(); - if (expectedIssuer != null && claims.getIssuer() != null - && !expectedIssuer.equals(claims.getIssuer())) { - return "Invalid issuer"; + if (expectedIssuer != null) { + String iss = claims.getIssuer(); + if (iss == null || !stripTrailingSlash(expectedIssuer).equals(stripTrailingSlash(iss))) { + return "Invalid issuer"; + } } String expectedAudience = diff --git a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java index fb3f876a..7851a5b2 100644 --- a/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java @@ -370,6 +370,45 @@ void handleShouldAcceptJwtWithNotBeforeInPast() throws Exception { assertTrue(tracker.wasCalled(), "JWT with nbf in the past should be accepted"); } + @Test + void handleShouldRejectJwtWithMissingIssuerClaim() throws Exception { + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec()); + + String token = signedJwt(new JWTClaimsSet.Builder() + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + String rawValue = response.getChallengeRequests().get(0).getRawValue(); + assertTrue(rawValue.contains("Invalid issuer")); + } + + @Test + void handleShouldAcceptIssuerWithTrailingSlash() throws Exception { + TrackingRestlet tracker = new TrackingRestlet(); + OAuth2AuthenticationRestlet restlet = buildRestlet(minimalSpec(), tracker); + + String token = signedJwt(new JWTClaimsSet.Builder() + .issuer("https://auth.example.com/") + .audience("https://mcp.example.com/mcp") + .expirationTime(futureDate()) + .build()); + + Request request = bearerRequest(token); + Response response = new Response(request); + + restlet.handle(request, response); + + assertTrue(tracker.wasCalled(), + "Issuer with trailing slash should match configured issuer without trailing slash"); + } + @Test void handleShouldRejectIntrospectionMode() throws Exception { OAuth2AuthenticationSpec spec = minimalSpec(); From 29988eef865239d728b6c8b95450efed9e9b0ec2 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:42:30 -0400 Subject: [PATCH 09/15] fix: reject JWT with missing or empty audience claim when audience is expected --- .../naftiko/engine/exposes/OAuth2AuthenticationRestlet.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index bb89df94..33f8b500 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -212,8 +212,8 @@ String validateClaims(JWTClaimsSet claims) { spec.getAudience() != null ? spec.getAudience() : spec.getResource(); if (expectedAudience != null) { List audiences = claims.getAudience(); - if (audiences != null && !audiences.isEmpty() - && !audiences.contains(expectedAudience)) { + if (audiences == null || audiences.isEmpty() + || !audiences.contains(expectedAudience)) { return "Invalid audience"; } } From 2b53cc6ddf629531ae911bff88c3de6d25d640ac Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:47:04 -0400 Subject: [PATCH 10/15] Update src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index 33f8b500..a5c67aa7 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -60,7 +60,7 @@ public class OAuth2AuthenticationRestlet extends Restlet { private static final ObjectMapper JSON = new ObjectMapper(); private static final ChallengeScheme BEARER_SCHEME = - new ChallengeScheme("HTTP_Bearer", "Bearer"); + ChallengeScheme.HTTP_OAUTH_BEARER; static final long JWKS_CACHE_TTL_MS = 5 * 60 * 1000L; static final long JWKS_MIN_REFRESH_MS = 30 * 1000L; From 57fe2c3243fa4d0063841df420cc1d2779c9bdcd Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:47:42 -0400 Subject: [PATCH 11/15] Update src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index a5c67aa7..5921b373 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -307,7 +307,6 @@ void ensureInitialized() { initialized = true; } catch (Exception e) { Context.getCurrentLogger().log(Level.WARNING, "AS metadata discovery failed", e); - initialized = true; } } } From 28ab14fabe9b1bc4b85961ebbbfc99320bc286c1 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:51:39 -0400 Subject: [PATCH 12/15] fix: harden server adapter env resolution and stop httpClient on shutdown --- .../engine/exposes/OAuth2AuthenticationRestlet.java | 8 ++++++++ .../java/io/naftiko/engine/exposes/ServerAdapter.java | 8 ++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index 5921b373..c2bd41fe 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -101,6 +101,14 @@ protected OAuth2AuthenticationRestlet(OAuth2AuthenticationSpec spec, Restlet nex } } + @Override + public void stop() throws Exception { + if (httpClient != null) { + httpClient.stop(); + } + super.stop(); + } + @Override public void handle(Request request, Response response) { String token = extractBearerToken(request); diff --git a/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java index 840f42fa..b13f3249 100644 --- a/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java @@ -196,8 +196,12 @@ private static String resolveTemplate(String value) { } Map env = new HashMap<>(); if (value.contains("{{") && value.contains("}}")) { - for (Map.Entry entry : System.getenv().entrySet()) { - env.put(entry.getKey(), entry.getValue()); + Set allowedVariables = extractAllowedVariables(null); + for (String varName : allowedVariables) { + String varValue = System.getenv(varName); + if (varValue != null) { + env.put(varName, varValue); + } } } return Resolver.resolveMustacheTemplate(value, env); From f25ff9e81f253472fe1e01a7f04b8b7318210caa Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:34:16 -0400 Subject: [PATCH 13/15] Update src/main/resources/wiki/Installation.md This example now shows baseUri: "http://host.docker.internal:8080/api/" with a trailing slash, but the repository's Spectral rule naftiko-consumes-baseuri-no-trailing-slash warns against trailing slashes to avoid double-slash path joins. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/resources/wiki/Installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/wiki/Installation.md b/src/main/resources/wiki/Installation.md index 5c020031..62d1a4de 100644 --- a/src/main/resources/wiki/Installation.md +++ b/src/main/resources/wiki/Installation.md @@ -31,7 +31,7 @@ To use Naftiko Framework, you need to install and then run the Naftiko Engine, p * If your capability refers to some local hosts, be careful to not use 'localhost', but 'host.docker.internal' instead. This is because your capability will run into an isolated docker container, so 'localhost' will refer to the container and not your local machine.\ For example: ```bash - baseUri: "http://host.docker.internal:8080/api/" + baseUri: "http://host.docker.internal:8080/api" ``` * In the same way, if your capability expose a local host, be careful to not use 'localhost', but '0.0.0.0' instead. Else requests to localhost coming from outside of the container won't succeed.\ For example: From d465de5938ac4af2d0f4bd7f48da71da7dd47602 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:35:02 -0400 Subject: [PATCH 14/15] Update src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java buildBearerChallengeParams() interpolates error_description/scope directly into a quoted header parameter without escaping. If configured scopes (or other values) contain quotes, backslashes, or CR/LF, this can produce malformed WWW-Authenticate headers and potentially enable header injection. Using RFC 6750-compliant escaping/sanitization of parameter values before building the raw header string. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../exposes/OAuth2AuthenticationRestlet.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java index c2bd41fe..ba623423 100644 --- a/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -268,22 +268,35 @@ private void addBearerChallenge(Response response, String params) { response.getChallengeRequests().add(cr); } + private String escapeBearerChallengeParamValue(String value) { + if (value == null) { + return null; + } + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", " ") + .replace("\n", " "); + } + protected String buildBearerChallengeParams(String error, String description, String scope) { StringBuilder sb = new StringBuilder(); boolean hasParams = false; if (error != null) { - sb.append("error=\"").append(error).append("\""); + sb.append("error=\"").append(escapeBearerChallengeParamValue(error)).append("\""); hasParams = true; } if (description != null) { sb.append(hasParams ? ", " : ""); - sb.append("error_description=\"").append(description).append("\""); + sb.append("error_description=\"") + .append(escapeBearerChallengeParamValue(description)) + .append("\""); hasParams = true; } if (scope != null) { sb.append(hasParams ? ", " : ""); - sb.append("scope=\"").append(scope).append("\""); + sb.append("scope=\"").append(escapeBearerChallengeParamValue(scope)).append("\""); } return sb.toString(); From 45bb22c846c841612c82d111df0e2c2ebb3a627d Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:41:17 -0400 Subject: [PATCH 15/15] fix: extract allowedVariables once before authenticator closure Move extractAllowedVariables call outside the SecretVerifier anonymous class so the capability spec is resolved once at setup time rather than on every authentication request. Pass the precomputed set into resolveTemplate and resolveTemplateChars. --- .../io/naftiko/engine/exposes/ServerAdapter.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java index b13f3249..8bf47ce6 100644 --- a/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java @@ -157,6 +157,7 @@ private Restlet buildChallengeAuthenticator(AuthenticationSpec authentication, R ? ChallengeScheme.HTTP_DIGEST : ChallengeScheme.HTTP_BASIC; + Set allowedVariables = extractAllowedVariables(getCapability().getSpec()); ChallengeAuthenticator authenticator = new ChallengeAuthenticator(next.getContext(), false, scheme, "naftiko"); authenticator.setVerifier(new SecretVerifier() { @@ -167,11 +168,11 @@ public int verify(String identifier, char[] secret) { char[] expectedPassword = null; if (authentication instanceof BasicAuthenticationSpec basic) { - expectedUsername = resolveTemplate(basic.getUsername()); - expectedPassword = resolveTemplateChars(basic.getPassword()); + expectedUsername = resolveTemplate(basic.getUsername(), allowedVariables); + expectedPassword = resolveTemplateChars(basic.getPassword(), allowedVariables); } else if (authentication instanceof DigestAuthenticationSpec digest) { - expectedUsername = resolveTemplate(digest.getUsername()); - expectedPassword = resolveTemplateChars(digest.getPassword()); + expectedUsername = resolveTemplate(digest.getUsername(), allowedVariables); + expectedPassword = resolveTemplateChars(digest.getPassword(), allowedVariables); } if (expectedUsername == null || expectedPassword == null || identifier == null @@ -190,13 +191,12 @@ public int verify(String identifier, char[] secret) { return authenticator; } - private static String resolveTemplate(String value) { + private static String resolveTemplate(String value, Set allowedVariables) { if (value == null) { return null; } Map env = new HashMap<>(); if (value.contains("{{") && value.contains("}}")) { - Set allowedVariables = extractAllowedVariables(null); for (String varName : allowedVariables) { String varValue = System.getenv(varName); if (varValue != null) { @@ -207,11 +207,11 @@ private static String resolveTemplate(String value) { return Resolver.resolveMustacheTemplate(value, env); } - private static char[] resolveTemplateChars(char[] value) { + private static char[] resolveTemplateChars(char[] value, Set allowedVariables) { if (value == null) { return null; } - String resolved = resolveTemplate(new String(value)); + String resolved = resolveTemplate(new String(value), allowedVariables); return resolved == null ? null : resolved.toCharArray(); }