plugins/turul-mcp-skills/skills/auth-patterns/SKILL.md
This skill should be used when the user asks about "OAuth", "authentication", "authorization", "JWT", "Bearer", "JwtValidator", "oauth_resource_server", "ProtectedResourceMetadata", "turul-mcp-oauth", "API key auth", "auth middleware", "token validation", "WWW-Authenticate", "audience validation", "OAuthResourceMiddleware", "TokenClaims", "JWKS", "well-known", "oauth-protected-resource", "RFC 9728", "Bearer token", "auth-patterns", or "WellKnownOAuthHandler". Covers authentication and authorization patterns for MCP servers in the Turul MCP Framework (Rust): OAuth 2.1 Resource Server compliance, JWT validation, API key middleware, and Lambda authorizer integration. Do NOT use for issuing tokens or building an Authorization Server — see authorization-server-patterns.
npx skillsauth add aussierobots/turul-mcp-framework auth-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Add authentication to MCP servers. In OAuth terms, your MCP server plays the Resource Server (RS) role — it validates tokens, it doesn't issue them. But how you implement that validation depends on your deployment topology:
turul-mcp-oauth middleware validates JWTs against JWKS (Patterns 1-2)McpMiddleware checks a header value (Pattern 3)These approaches can be combined. For example, a Gateway authorizer handles token validation at the edge while turul-mcp-oauth provides RFC 9728 metadata discovery and structured claims access inside the server.
How do you want to authenticate MCP clients?
│
├─ OAuth 2.1 / JWT tokens from an AS ──────→ turul-mcp-oauth (this skill, Pattern 1-2)
│ ├─ Single Authorization Server ────────→ oauth_resource_server() convenience function
│ └─ Multiple ASes or custom config ────→ Manual JwtValidator + OAuthResourceMiddleware
│
├─ API key in header ──────────────────────→ Custom McpMiddleware (Pattern 3)
│ └─ See middleware-patterns skill
│
└─ API Gateway authorizer (Lambda) ────────→ x-authorizer-* headers (Pattern 4)
└─ See lambda-deployment skill
The convenience function for the most common case: one AS, audience = resource URL.
// turul-mcp-server v0.3
use turul_mcp_oauth::{ProtectedResourceMetadata, oauth_resource_server};
use turul_mcp_server::prelude::*;
// 1. Define metadata (RFC 9728)
let metadata = ProtectedResourceMetadata::new(
"https://example.com/mcp", // Resource URL (= audience)
vec!["https://auth.example.com".to_string()], // Authorization Server
)?
.with_scopes(vec!["mcp:read".to_string(), "mcp:write".to_string()]);
// 2. Create middleware + well-known routes
let (auth_middleware, routes) = oauth_resource_server(metadata, jwks_uri)?;
// 3. Wire into server
let mut builder = McpServer::builder()
.name("my-server")
.middleware(auth_middleware) // Validates Bearer tokens
.tool(MyTool::default());
for (path, handler) in routes {
builder = builder.route(&path, handler); // /.well-known/oauth-protected-resource
}
let server = builder.build()?;
What oauth_resource_server() does for you:
JwtValidator with audience = resource URL, issuer = first ASOAuthResourceMiddleware (pre-session, so 401s don't allocate sessions)WellKnownOAuthHandler for both root-form and path-form endpoints(Arc<OAuthResourceMiddleware>, Vec<RouteEntry>) ready to wire inSee: examples/oauth-resource-server.rs for the full annotated version.
When you need custom audience, multiple ASes, or algorithm restrictions:
// turul-mcp-server v0.3
use std::sync::Arc;
use turul_mcp_oauth::{JwtValidator, OAuthResourceMiddleware, ProtectedResourceMetadata,
WellKnownOAuthHandler};
// Custom audience (not the resource URL)
let validator = Arc::new(
JwtValidator::new(
"https://auth.example.com/.well-known/jwks.json",
"my-custom-audience", // Audience is ALWAYS required
)
.with_issuer("https://auth.example.com")
.with_algorithms(vec![
jsonwebtoken::Algorithm::RS256,
jsonwebtoken::Algorithm::ES256,
])
.with_refresh_interval(std::time::Duration::from_secs(120)),
);
let metadata = ProtectedResourceMetadata::new(
"https://example.com/mcp",
vec!["https://auth.example.com".to_string()],
)?;
let middleware = Arc::new(OAuthResourceMiddleware::new(validator, metadata.clone()));
let well_known = Arc::new(WellKnownOAuthHandler::new(metadata.clone()));
let mut builder = McpServer::builder()
.name("my-server")
.middleware(middleware);
for path in metadata.well_known_paths() {
builder = builder.route(&path, well_known.clone());
}
See: references/jwt-validator-reference.md for the full JwtValidator API.
The metadata document tells clients how to authenticate. It's served at /.well-known/oauth-protected-resource.
let metadata = ProtectedResourceMetadata::new(
"https://example.com/mcp", // Resource identifier
vec!["https://auth.example.com".to_string()], // Authorization Server(s)
)?
.with_jwks_uri("https://auth.example.com/.well-known/jwks.json")
.with_scopes(vec!["mcp:read".to_string(), "mcp:write".to_string()]);
Validation rules (enforced at construction):
resource must be a canonical HTTP(S) URI (no fragments, no non-HTTP schemes)authorization_servers must also be canonical HTTP(S) URIsWell-known endpoints (auto-registered):
/.well-known/oauth-protected-resource (always)/.well-known/oauth-protected-resource/mcp (when resource has path /mcp)The handler returns JSON with Cache-Control: public, max-age=3600.
The RS publishes the AS URL via PRM; clients then fetch the AS metadata themselves. The RS does NOT serve /.well-known/oauth-authorization-server — that endpoint lives on the AS.
AS
Client │
│ 1. GET /.well-known/ │
│ oauth-protected-resource ───► RS │
│ │
│ 2. Reads "authorization_servers" ──────│──► (URL of the AS)
│ │
│ 3. GET /.well-known/ ▼
│ oauth-authorization-server ──────────────► RFC 8414
│ (or .../openid-configuration) AS metadata
│
│ 4. Run PKCE flow against AS, then call RS with bearer
Normative requirements (MCP 2025-11-25 § Authorization):
The RS implementer's job stops at point 2: publish a truthful authorization_servers list in PRM and ensure your resource URL matches what clients will send.
Audience validation is always required — there's no opt-out.
// Audience is a required parameter in JwtValidator::new()
let validator = JwtValidator::new(jwks_uri, "my-audience"); // NOT Option<&str>
With oauth_resource_server(), the audience is automatically set to the resource URL from your metadata. With manual construction, you choose any audience string.
Why mandatory: Without audience validation, a token issued for https://other-service.com would be accepted by your MCP server. This is a critical security requirement per OAuth 2.1.
Audience validation only works because clients are required to bind the token to your specific RS at the point of issuance. MCP makes this a normative client-side requirement.
MCP 2025-11-25 § Resource Parameter Implementation: "MCP clients MUST implement Resource Indicators for OAuth 2.0 as defined in RFC 8707... The
resourceparameter:
- MUST be included in both authorization requests and token requests.
- MUST identify the MCP server that the client intends to use the token with..."
"MCP clients MUST send this parameter regardless of whether authorization servers support it."
Concretely, a compliant MCP client sends:
GET /authorize?response_type=code
&client_id=...
&redirect_uri=...
&resource=https://api.example.com/mcp ← MUST
&code_challenge=...&code_challenge_method=S256
&scope=mcp:read mcp:write
POST /token grant_type=authorization_code
&code=...
&resource=https://api.example.com/mcp ← MUST (same value)
&code_verifier=...
The AS uses resource to set the token's aud claim. The RS later validates that aud equals its own canonical URI.
RS implementer responsibilities (still small):
https://api.example.com/mcp).ProtectedResourceMetadata::new(...).JwtValidator::new(...) (or let oauth_resource_server() propagate it for you).resource and aud.The middleware injects TokenClaims into request extensions. Tools read them via SessionContext:
// turul-mcp-server v0.3
use turul_mcp_oauth::TokenClaims;
#[derive(McpTool, Clone, Default)]
#[tool(name = "whoami", description = "Returns authenticated user identity")]
struct WhoAmITool {}
impl WhoAmITool {
async fn execute(&self, session: Option<SessionContext>) -> McpResult<serde_json::Value> {
let session = session.ok_or_else(|| McpError::InvalidRequest {
message: "Session required".to_string(),
})?;
// Current internal convention — no public constant yet
let claims: TokenClaims = session
.get_typed_extension("__turul_internal.auth_claims")
.ok_or_else(|| McpError::InvalidRequest {
message: "Not authenticated".to_string(),
})?;
Ok(serde_json::json!({
"subject": claims.sub,
"issuer": claims.iss,
"scopes": claims.scope.map(|s| s.split_whitespace().collect::<Vec<_>>()),
}))
}
}
TokenClaims fields:
| Field | Type | Description |
|---|---|---|
| sub | String | Subject (user identifier) |
| iss | String | Issuer (Authorization Server URL) |
| aud | serde_json::Value | Audience (string or array) |
| exp | u64 | Expiration (Unix timestamp) |
| iat | u64 | Issued at (Unix timestamp) |
| scope | Option<String> | Space-separated scopes |
| extra | HashMap<String, Value> | All other claims |
Extension key: "__turul_internal.auth_claims" — this is the current internal convention used by OAuthResourceMiddleware. There is no public constant for it yet, so use this exact string. If the framework introduces a public constant in a future version, prefer that over the raw string.
JwtValidator caches JWKS keys in memory with automatic refresh:
jwks_uri, caches by kid (key ID).with_refresh_interval())Key types supported: RSA (RS256/384/512) and EC (ES256 P-256, ES384 P-384). Default algorithms: RS256, ES256.
Cold start on Lambda: JWKS keys are cached in the OnceCell handler — fetched once per container, reused across invocations. No repeated JWKS fetches on warm invocations.
For simple API key authentication without OAuth:
// turul-mcp-server v0.3
use turul_mcp_server::prelude::*;
struct ApiKeyMiddleware {
valid_keys: Vec<String>,
}
#[async_trait::async_trait]
impl McpMiddleware for ApiKeyMiddleware {
fn runs_before_session(&self) -> bool { true }
async fn before_dispatch(
&self,
ctx: &mut RequestContext<'_>,
_session: Option<&dyn SessionView>,
_injection: &mut SessionInjection,
) -> Result<(), MiddlewareError> {
let key = ctx.metadata()
.get("x-api-key")
.and_then(|v| v.as_str())
.ok_or_else(|| MiddlewareError::Unauthorized {
message: "Missing X-API-Key header".into(),
})?;
if !self.valid_keys.contains(&key.to_string()) {
return Err(MiddlewareError::Unauthorized {
message: "Invalid API key".into(),
});
}
Ok(())
}
}
When to use API keys instead of OAuth:
See: the middleware-patterns skill for the full McpMiddleware trait, error variants, and session injection.
On Lambda, API Gateway can validate tokens before your MCP server runs. The authorizer's custom context fields (whatever your authorizer Lambda returns under context: {...}) are forwarded as x-authorizer-* headers automatically:
// Read authorizer claims in middleware or tools — use the custom fields
// your authorizer returns, e.g. user_id, account_id, scope.
let user_id = ctx.metadata()
.get("x-authorizer-user_id")
.and_then(|v| v.as_str());
The Lambda adapter converts camelCase authorizer fields to snake_case headers (userId → x-authorizer-user_id). Both V1 (REST API) and V2 (HTTP API) formats are supported. Not forwarded: API Gateway internals (principalId, integrationLatency, usageIdentifierKey) — surface your own user_id/subject/etc. from the authorizer's context instead.
When to use Gateway authorizers instead of turul-mcp-oauth:
Combining both: You can use a Gateway authorizer for initial validation AND turul-mcp-oauth for additional claims extraction. The middleware runs after the Gateway authorizer has already validated the token.
See: the lambda-deployment skill for full API Gateway authorizer integration.
Wire turul-mcp-oauth into a Lambda server. Register well-known routes via .route() on the builder — handle_streaming() checks the route registry before MCP dispatch, so no custom dispatch logic is needed:
// turul-mcp-server v0.3
use turul_mcp_aws_lambda::{LambdaMcpServerBuilder, run_streaming};
use turul_mcp_oauth::{ProtectedResourceMetadata, oauth_resource_server};
let metadata = ProtectedResourceMetadata::new(
"https://api.example.com/mcp",
vec!["https://auth.example.com".to_string()],
)?;
let (auth_middleware, routes) = oauth_resource_server(metadata, jwks_uri)?;
let mut builder = LambdaMcpServerBuilder::new()
.name("oauth-lambda")
.version("1.0.0")
.middleware(auth_middleware)
.tool(MyTool::default())
.storage(storage)
.sse(true);
// Register well-known routes — handle_streaming() checks these before MCP dispatch
for (path, handler) in routes {
builder = builder.route(&path, handler);
}
let server = builder.build().await?;
let handler = server.handler().await?;
// run_streaming() works here — well-known routes are in the route registry
run_streaming(handler).await
See: examples/lambda-oauth-server.rs for the full annotated version.
[dependencies]
turul-mcp-server = { version = "0.3" }
turul-mcp-oauth = { version = "0.3" }
# For Lambda deployment:
turul-mcp-aws-lambda = { version = "0.3", features = ["streaming", "dynamodb"] }
The turul-mcp-oauth crate has no feature flags — all functionality is always available.
| Variant | When | HTTP |
|---|---|---|
| InvalidToken(String) | JWT decode or validation failed | 401 |
| TokenExpired | exp claim is in the past | 401 |
| InvalidAudience | aud claim doesn't match required audience | 401 |
| InvalidIssuer | iss claim doesn't match configured issuer | 401 |
| UnsupportedAlgorithm(String) | JWT uses algorithm not in allowed list | 401 |
| JwksFetchError(String) | HTTP error fetching JWKS endpoint | 401 |
| KeyNotFound(String) | kid not found in JWKS after refresh | 401 |
| DecodingError(String) | JWT header malformed | 401 |
| InvalidResourceUri(String) | Non-canonical URI in metadata | (construction) |
| InvalidConfiguration(String) | Invalid metadata or validator config | (construction) |
All runtime errors produce HTTP 401 with a WWW-Authenticate header:
Bearer realm="mcp", resource_metadata="https://example.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="..."
When scopes_supported is configured, scope="mcp:read mcp:write" is included in the header.
Forgetting audience validation is mandatory — JwtValidator::new() requires an audience string. There's no way to skip it. This is by design per OAuth 2.1.
Using oauth_resource_server() with multiple ASes — The convenience function takes only the first AS from authorization_servers. For multi-AS setups, construct JwtValidator and OAuthResourceMiddleware manually.
Wrong extension key for claims — Claims are currently stored at "__turul_internal.auth_claims" (no public constant yet). Use this exact string. If the framework introduces a public constant, prefer that.
Not registering well-known routes — oauth_resource_server() returns routes that must be registered via .route(). Without them, clients can't discover your Authorization Server per RFC 9728.
Expecting turul-mcp-oauth to issue tokens — The crate is a Resource Server only. Token issuance is the Authorization Server's job (Cognito, Auth0, Keycloak, etc.).
JWKS endpoint hammering — The validator rate-limits refresh to once per 60s by default. If you set a very short refresh interval, you risk being rate-limited by the AS.
Manually dispatching well-known routes in run_streaming_with() — You don't need custom dispatch for .well-known routes. Register them via .route() on LambdaMcpServerBuilder — handle_streaming() checks the route registry before MCP dispatch. Use run_streaming_with() only when you need pre-dispatch logic that isn't route-based (e.g., request logging, custom health checks).
Resource / audience URI mismatch between RS and what clients send — The single most common interop failure. If your ProtectedResourceMetadata::new("https://api.example.com/mcp", ...) declares one URI but clients send resource=https://api.example.com (no /mcp path), the AS issues aud=https://api.example.com and your RS rejects with invalid audience. Fix: pick one canonical URI, use it verbatim in ProtectedResourceMetadata::new, JwtValidator::new, and document it so client integrators send the exact same string as resource in both /authorize and /token.
Expecting the RS to serve /.well-known/oauth-authorization-server — That endpoint lives on the AS, not the RS. The RS only serves /.well-known/oauth-protected-resource (RFC 9728); it advertises the AS URL via the authorization_servers field and clients fetch RFC 8414 metadata from the AS directly.
Middleware details? → See the middleware-patterns skill for McpMiddleware trait, error variants, and session injection.
Lambda deployment? → See the lambda-deployment skill for LambdaMcpServerBuilder, cold-start caching, and streaming modes.
Error handling in tools? → See the error-handling-patterns skill for McpError variants and decision tree.
JWT Validator API? → See references/jwt-validator-reference.md for the full API surface.
Need a demo Authorization Server? → See the authorization-server-patterns skill for building a standalone demo AS with PKCE, JWKS, and MCP interop.
tools
This skill should be used when the user asks to "create a tool", "add a tool", "new tool", "which tool pattern", "compare tool patterns", "function macro vs derive", "mcp_tool macro", "#[mcp_tool]", "derive McpTool", "#[derive(McpTool)]", "ToolBuilder", "tool creation", "function macro tool", "server icon", "server branding", ".icons()", "Icon::data_uri", "server identity", "dynamic tools", "ToolChangeMode", "activate_tool", "deactivate_tool", "ToolRegistry", "tool_change_mode", or "notifications/tools/list_changed". Covers choosing between function macro (#[mcp_tool]), derive macro (#[derive(McpTool)]), and runtime builder (ToolBuilder) patterns, plus server identity (icons), in the Turul MCP Framework (Rust).
tools
This skill should be used when the user asks about "testing", "test patterns", "write tests", "unit test", "e2e test", "integration test", "McpTestClient", "TestServerManager", "compliance test", "test server", "test fixture", "doctest", "cargo test", "test organization", "SSE testing", or "test consolidation". Covers unit testing, E2E testing, compliance testing, SSE testing, and test organization in the Turul MCP Framework (Rust). McpTestClient is the in-process test harness; for the production client API see mcp-client-patterns.
tools
This skill should be used when the user asks about "task support", "TaskRuntime", "TaskStorage", "task_support attribute", "long-running tool", "CancellationHandle", "tasks/get", "tasks/list", "tasks/cancel", "tasks/result", "TaskStatus", "TaskRecord", "TaskOutcome", "InMemoryTaskStorage", "with_task_storage", "task state machine", "TaskExecutor", "TokioTaskExecutor", "task_support = optional", "task_support = required", "task_support = forbidden", "with_task_runtime", or "task storage backend". Covers MCP task support for long-running tools, state machine, storage backends, cancellation, and capability truthfulness in the Turul MCP Framework (Rust). TaskStorage is distinct from SessionStorage; for session persistence see session-storage-backends.
tools
This skill should be used when the user asks about "session storage", "SessionStorage trait", "SqliteSessionStorage", "PostgresSessionStorage", "DynamoDbSessionStorage", "InMemorySessionStorage", "session backend", "session persistence", "session events", "SSE reconnection storage", "which storage backend", "session TTL", "session cleanup", "session event management", "SseEvent", or "SessionStorageError". Covers the SessionStorage trait, backend selection, event management for SSE resumability, error types, and background cleanup in the Turul MCP Framework (Rust). Do NOT use for TaskStorage — task persistence is a separate trait; see task-patterns.