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..ba623423 --- /dev/null +++ b/src/main/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestlet.java @@ -0,0 +1,462 @@ +/** + * 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.text.ParseException; +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; +import org.restlet.Restlet; +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; +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 = + ChallengeScheme.HTTP_OAUTH_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 final Client httpClient; + + 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, next, null); + } + + /** + * 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.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; + } + } + + @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); + 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"; + } + + if (claims.getNotBeforeTime() != null + && claims.getNotBeforeTime().after(new Date())) { + return "Token not yet valid"; + } + + String expectedIssuer = spec.getAuthorizationServerUri(); + if (expectedIssuer != null) { + String iss = claims.getIssuer(); + if (iss == null || !stripTrailingSlash(expectedIssuer).equals(stripTrailingSlash(iss))) { + 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); + } + + 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(escapeBearerChallengeParamValue(error)).append("\""); + hasParams = true; + } + if (description != null) { + sb.append(hasParams ? ", " : ""); + sb.append("error_description=\"") + .append(escapeBearerChallengeParamValue(description)) + .append("\""); + hasParams = true; + } + if (scope != null) { + sb.append(hasParams ? ", " : ""); + sb.append("scope=\"").append(escapeBearerChallengeParamValue(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); + } + } + } + + void discoverAsMetadata() throws IOException { + String baseUri = spec.getAuthorizationServerUri(); + if (baseUri == null) { + return; + } + + String asMetadataUri = stripTrailingSlash(baseUri) + "/.well-known/oauth-authorization-server"; + String body = fetchUri(asMetadataUri); + + if (body == null) { + String oidcUri = stripTrailingSlash(baseUri) + "/.well-known/openid-configuration"; + body = fetchUri(oidcUri); + } + + 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 { + String body = fetchUri(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 URI and return the response body, or null on failure. Protected for test + * overriding. + */ + 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 ──────────────────────────────────────────────────────────── + + 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 uri) { + return uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri; + } + +} diff --git a/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/ServerAdapter.java index 185b9a9a..8bf47ce6 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,142 @@ 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; + + Set allowedVariables = extractAllowedVariables(getCapability().getSpec()); + 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(), allowedVariables); + expectedPassword = resolveTemplateChars(basic.getPassword(), allowedVariables); + } else if (authentication instanceof DigestAuthenticationSpec digest) { + expectedUsername = resolveTemplate(digest.getUsername(), allowedVariables); + expectedPassword = resolveTemplateChars(digest.getPassword(), allowedVariables); + } + + 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, Set allowedVariables) { + if (value == null) { + return null; + } + Map env = new HashMap<>(); + if (value.contains("{{") && value.contains("}}")) { + for (String varName : allowedVariables) { + String varValue = System.getenv(varName); + if (varValue != null) { + env.put(varName, varValue); + } + } + } + return Resolver.resolveMustacheTemplate(value, env); + } + + private static char[] resolveTemplateChars(char[] value, Set allowedVariables) { + if (value == null) { + return null; + } + String resolved = resolveTemplate(new String(value), allowedVariables); + 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..c271a4f2 --- /dev/null +++ b/src/main/java/io/naftiko/engine/exposes/mcp/McpOAuth2Restlet.java @@ -0,0 +1,129 @@ +/** + * 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 metadataUri; + private final String metadataJson; + + public McpOAuth2Restlet(OAuth2AuthenticationSpec spec, Restlet next) { + this(spec, next, null); + } + + /** + * 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.metadataUri = 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=\"" + metadataUri + "\""; + 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 getMetadataUri() { + return metadataUri; + } + + private static String buildProtectedResourceMetadata(OAuth2AuthenticationSpec spec) { + ObjectNode metadata = JSON.createObjectNode(); + metadata.put("resource", spec.getResource()); + + ArrayNode authServers = metadata.putArray("authorization_servers"); + authServers.add(spec.getAuthorizationServerUri()); + + 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..03751c62 --- /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 authorizationServerUri; + private volatile String resource; + private volatile List scopes; + private volatile String audience; + private volatile String tokenValidation; + + public OAuth2AuthenticationSpec() { + super("oauth2"); + } + + public String getAuthorizationServerUri() { + return authorizationServerUri; + } + + public void setAuthorizationServerUri(String authorizationServerUri) { + this.authorizationServerUri = authorizationServerUri; + } + + 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 74062e45..40f58bb1 100644 --- a/src/main/resources/blueprints/mcp-server-authentication.md +++ b/src/main/resources/blueprints/mcp-server-authentication.md @@ -36,9 +36,9 @@ 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. +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 @@ -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. **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. **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` → `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 with metadata derived from the YAML configuration — 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. @@ -165,33 +165,45 @@ ExposesMcp ├── resources[] └── prompts[] -Jetty HTTP Chain: - Server → ServerConnector → JettyStreamableHandler → ProtocolDispatcher +Restlet HTTP Chain: + Server → Router → McpServerResource → ProtocolDispatcher (no authentication) ``` ### 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[] -Jetty HTTP Chain (with static auth): - Server → ServerConnector → McpAuthenticationHandler → JettyStreamableHandler - (same pattern as REST adapter's ServerAuthenticationRestlet wrapping Router) +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 + +Restlet HTTP Chain — MCP (with OAuth2 auth): + Server → McpOAuth2Restlet → Router → McpServerResource + (extends OAuth2AuthenticationRestlet + adds Protected Resource Metadata + resource_metadata WWW-Authenticate) -Jetty HTTP Chain (with OAuth2 auth): - Server → ServerConnector → McpOAuth2Handler → JettyStreamableHandler - (validates JWT, serves /.well-known/oauth-protected-resource) +Restlet HTTP Chain — REST (with OAuth2 auth): + Server → OAuth2AuthenticationRestlet → Router → ResourceRestlet + (JWT validation only — no MCP protocol overlay) ``` --- @@ -201,7 +213,7 @@ Jetty 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,20 +221,23 @@ 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[] │ └─ (Restlet filter chain) └─ prompts[] └─ (Restlet filter chain) - (Jetty handler chain) + (Restlet filter chain) Filter/Handler flow: -REST: ChallengeAuthenticator ──→ Router ──→ ResourceRestlet - ServerAuthenticationRestlet ──→ Router ──→ ResourceRestlet +REST: OAuth2AuthenticationRestlet ──→ Router ──→ ResourceRestlet + (shared — JWT validation only) -MCP: McpAuthenticationHandler ──→ JettyStreamableHandler ──→ ProtocolDispatcher - McpOAuth2Handler ──→ JettyStreamableHandler ──→ 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 @@ -230,9 +245,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 | `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,10 +272,10 @@ 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) +- `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) @@ -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,37 +350,20 @@ 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", "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." @@ -374,7 +389,7 @@ A superset of the existing `Authentication` union that adds the `AuthOAuth2` typ "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 } ``` @@ -467,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" @@ -527,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" @@ -562,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" @@ -577,66 +592,68 @@ 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` (MCP adapter only) +- `OAuth2AuthenticationRestlet` wrapping the router for `oauth2` (REST and Skill adapters) -Both wrap the downstream handler and intercept requests before they reach `JettyStreamableHandler`. +### 9.2 Static Authentication (reuses `ServerAuthenticationRestlet`) -### 9.2 Static Authentication Handler (`McpAuthenticationHandler`) +The existing `ServerAuthenticationRestlet` is reused directly — no adapter-specific class needed. The Restlet sits before the `Router` in the chain: -A Jetty `Handler.Wrapper` that: - -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` → `ServerResource`) 5. On failure: returns `401 Unauthorized` with appropriate challenge header +For basic/digest authentication, the existing Restlet `ChallengeAuthenticator` is reused. + ``` -Request → McpAuthenticationHandler - ├─ Valid credentials → JettyStreamableHandler → ProtocolDispatcher +Request → ServerAuthenticationRestlet + ├─ Valid credentials → Router → ServerResource └─ Invalid/missing → 401 Unauthorized ``` -### 9.3 OAuth 2.1 Handler (`McpOAuth2Handler`) +### 9.3 Shared OAuth 2.1 Handler (`OAuth2AuthenticationRestlet`) -A Jetty `Handler.Wrapper` that implements the resource server side of MCP 2025-11-25 authorization: +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 **Request handling:** ``` -Request → McpOAuth2Handler - ├─ 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 +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):** 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):** @@ -644,14 +661,29 @@ Request → McpOAuth2Handler 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: ```json { "resource": "", - "authorization_servers": [""], + "authorization_servers": [""], "scopes_supported": ["", "..."], "bearer_methods_supported": ["header"] } @@ -659,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`: ``` @@ -680,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." @@ -714,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 @@ -740,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"` | @@ -749,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 | `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"` | --- ## 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: Jetty Handler chain vs. Servlet Filter +### D2: Shared `OAuth2AuthenticationRestlet` with MCP extension -**Decision**: Implement authentication as a Jetty `Handler.Wrapper`. +**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 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 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 @@ -792,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. @@ -807,35 +839,45 @@ 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 +### 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 `McpOAuth2Handler`: +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 `McpOAuth2Handler` (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 --- @@ -844,13 +886,14 @@ 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 -- `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/rules/naftiko-rules.yml b/src/main/resources/rules/naftiko-rules.yml index de87e723..3f18f529 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 `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 URI is a security risk. + severity: error + recommended: true + given: "$.capability.exposes[*].authentication[?(@.type == 'oauth2')]" + then: + field: "authorizationServerUri" + 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..d7cd2de9 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" + }, + "authorizationServerUri": { + "type": "string", + "format": "uri", + "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", + "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", + "authorizationServerUri", + "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/main/resources/wiki/Installation.md b/src/main/resources/wiki/Installation.md index 5e7f9d57..62d1a4de 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/) @@ -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 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..7851a5b2 --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/OAuth2AuthenticationRestletTest.java @@ -0,0 +1,492 @@ +/** + * 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 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.Method; +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 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 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(); + 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.setAuthorizationServerUri("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..90a21ada --- /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" + authorizationServerUri: "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" + authorizationServerUri: "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" + authorizationServerUri: "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..680b3f0a --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpAuthenticationIntegrationTest.java @@ -0,0 +1,317 @@ +/** + * 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 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.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..0dd79fcc --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2IntegrationTest.java @@ -0,0 +1,368 @@ +/** + * 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 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" + authorizationServerUri: "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" + authorizationServerUri: "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..19aef1ca --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpOAuth2RestletTest.java @@ -0,0 +1,247 @@ +/** + * 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.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.setAuthorizationServerUri("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.setAuthorizationServerUri("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.setAuthorizationServerUri("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"));