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