diff --git a/go.work b/go.work index 9d5e121..6372600 100644 --- a/go.work +++ b/go.work @@ -1,7 +1,8 @@ -go 1.25.0 +go 1.25.5 use ( ./core ./http ./mcp + ./mark3labs ) diff --git a/mark3labs/README.md b/mark3labs/README.md new file mode 100644 index 0000000..5096232 --- /dev/null +++ b/mark3labs/README.md @@ -0,0 +1,57 @@ +# Authplane Go SDK — mark3labs + +[![Go Reference](https://pkg.go.dev/badge/github.com/authplane/go-sdk/mark3labs.svg)](https://pkg.go.dev/github.com/authplane/go-sdk/mark3labs) + +Adapter between the [Authplane core SDK](../core/README.md) and [`github.com/mark3labs/mcp-go`](https://github.com/mark3labs/mcp-go). Validates OAuth 2.1 JWT access tokens, serves RFC 9728 Protected Resource Metadata, bridges verified claims into per-tool-call contexts, and maps RFC 8693 token-exchange consent errors to MCP URL elicitation (JSON-RPC -32042). + +If you're using the [official MCP Go SDK](https://github.com/modelcontextprotocol/go-sdk) instead, see [`github.com/authplane/go-sdk/mcp`](../mcp/README.md). + +## Install + +```bash +go get github.com/authplane/go-sdk/mark3labs +``` + +## Quickstart + +```go +package main + +import ( + "context" + "net/http" + + "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" + "github.com/mark3labs/mcp-go/server" +) + +func main() { + ctx := context.Background() + + adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + Issuer: "https://auth.example.com", + Resource: "https://mcp.example.com/mcp", + Scopes: []string{"tools/query", "tools/write"}, + }) + if err != nil { + panic(err) + } + defer adapter.Close() // stops background refresh goroutines, closes the client + + mcpServer := server.NewMCPServer("My Server", "1.0.0", + server.WithToolCapabilities(false), + server.WithRecovery(), + ) + + streamable := server.NewStreamableHTTPServer(mcpServer, + server.WithHTTPContextFunc(adapter.HTTPContextFunc()), + ) + + http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) + http.Handle("/mcp", adapter.AuthMiddleware(streamable)) + + http.ListenAndServe(":8080", nil) +} +``` + +See the **[User Guide](docs/user-guide.md)** for the full API, per-tool scope enforcement, revocation checking, token exchange, the URL elicitation propagation caveat, dev mode, and lifecycle details. diff --git a/mark3labs/demo/README.md b/mark3labs/demo/README.md new file mode 100644 index 0000000..ef6e8e2 --- /dev/null +++ b/mark3labs/demo/README.md @@ -0,0 +1,63 @@ +# Calculator Service Example — mark3labs/mcp-go + +A minimal MCP server built with [`github.com/mark3labs/mcp-go`](https://github.com/mark3labs/mcp-go) and the Authplane adapter, demonstrating JWT authentication with per-tool scope enforcement. + +The server exposes two tools: + +| Tool | Required scope | +|------|----------------| +| `add` | `tools/add` | +| `multiply` | `tools/multiply` | + +A token with only `tools/add` can call `add` but not `multiply`. + +## Prerequisites + +- Go 1.25.5+ +- The **Authplane authserver** running locally — from a checkout of the `authserver` repo, run: + + ```bash + bash demo/mcp-demo-server-start.sh + ``` + + This starts the auth server on `http://localhost:9000`, registers the calculator client and scopes, and creates a demo user. + +## Run + +```bash +cd go-sdk/mark3labs +./demo/run.sh +``` + +All demo credentials are pre-configured — no additional setup needed. The server starts on port `8080`. + +## How it works + +``` +MCP Client ──Bearer JWT──► demo/main.go (port 8080) + │ + ├─ authplanemark3labs.NewAdapter() + │ • Discovers AS metadata + JWKS (RFC 8414) + │ • Validates JWT signature, aud, exp + │ • Introspects token for revocation (RFC 7662) + │ • Injects VerifiedClaims into HTTP request context + │ + ├─ server.WithHTTPContextFunc(adapter.HTTPContextFunc()) + │ • Forwards claims into the per-tool-call MCP context + │ + └─ claims.RequireScope("tools/add") + • Tool handler reads claims via ClaimsFromContext() + • Returns isError=true CallToolResult on missing scope +``` + +## Key patterns shown + +**`ClientOptions` with `authplane.WithClientCredentials`** — providing credentials automatically wires RFC 7662 token introspection (revocation checking). No separate `WithIntrospection` option needed. + +**`adapter.AuthMiddleware(streamable)`** — wraps the `*server.StreamableHTTPServer` (which is an `http.Handler`) with bearer-token verification. + +**`server.WithHTTPContextFunc(adapter.HTTPContextFunc())`** — the bridge. mark3labs/mcp-go invokes this once per HTTP request to derive the context that tool handlers see. Without it, tool handlers receive a fresh context with no claims. + +**`authplanemark3labs.ClaimsFromContext(ctx)`** — retrieves the `VerifiedClaims` from inside a tool handler. + +**`claims.RequireScope(scope)` → `mcp.NewToolResultError(...)`** — mark3labs/mcp-go coerces any error returned from a tool handler to JSON-RPC `-32603` (INTERNAL_ERROR), so scope failures are surfaced as `IsError: true` tool results instead. The MCP client sees a structured failure with the message intact. diff --git a/mark3labs/demo/main.go b/mark3labs/demo/main.go new file mode 100644 index 0000000..83e4615 --- /dev/null +++ b/mark3labs/demo/main.go @@ -0,0 +1,137 @@ +// Calculator Service — Authplane mark3labs/mcp-go adapter demo. +// +// Mirrors the official-SDK demo at go-sdk/mcp/demo/, demonstrating: +// - Token introspection for revocation checking (RFC 7662) +// - Per-tool scope enforcement via ClaimsFromContext +// - Bridging verified claims from AuthMiddleware into the per-tool-call +// MCP context via server.WithHTTPContextFunc(adapter.HTTPContextFunc()) +// +// Run: +// +// go run ./demo/ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + + "github.com/authplane/go-sdk/core/authplane" + "github.com/authplane/go-sdk/core/resource/verifier" + "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func main() { + if err := run(); err != nil { + slog.Error("fatal", "error", err) + os.Exit(1) + } +} + +func run() error { + issuer := env("ISSUER_URL", "http://localhost:9000") + resource := env("RESOURCE_URL", "http://localhost:8080/mcp") + clientID := env("CLIENT_ID", resource) + clientSecret := env("CLIENT_SECRET", "") + port := env("PORT", "8080") + + ctx := context.Background() + + // WithClientCredentials wires introspection (revocation) and enables token exchange. + var clientOpts []authplane.Option + if clientSecret != "" { + clientOpts = append(clientOpts, authplane.WithClientCredentials(clientID, clientSecret)) + } + + adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + Issuer: issuer, + Resource: resource, + Scopes: []string{"tools/add", "tools/multiply"}, + DevMode: true, // relaxes SSRF for local dev; remove before deploying to production + ClientOptions: clientOpts, + }) + if err != nil { + return fmt.Errorf("initialize adapter: %w", err) + } + defer func() { _ = adapter.Close() }() + + mcpServer := server.NewMCPServer( + "Calculator Service", + "0.1.0", + server.WithToolCapabilities(false), + server.WithRecovery(), + ) + + // add — requires scope "tools/add" + addTool := mcp.NewTool("add", + mcp.WithDescription("Add two numbers"), + mcp.WithNumber("a", mcp.Required(), mcp.Description("First addend")), + mcp.WithNumber("b", mcp.Required(), mcp.Description("Second addend")), + ) + mcpServer.AddTool(addTool, scopedHandler("tools/add", func(a, b float64) float64 { + return a + b + })) + + // multiply — requires scope "tools/multiply" + multiplyTool := mcp.NewTool("multiply", + mcp.WithDescription("Multiply two numbers"), + mcp.WithNumber("a", mcp.Required(), mcp.Description("First factor")), + mcp.WithNumber("b", mcp.Required(), mcp.Description("Second factor")), + ) + mcpServer.AddTool(multiplyTool, scopedHandler("tools/multiply", func(a, b float64) float64 { + return a * b + })) + + // Wire HTTPContextFunc so AuthMiddleware's per-request claims/token reach + // each tool handler's context. + streamable := server.NewStreamableHTTPServer(mcpServer, + server.WithHTTPContextFunc(adapter.HTTPContextFunc()), + ) + + http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) + http.Handle("/mcp", adapter.AuthMiddleware(streamable)) + + slog.Info("Calculator MCP server listening", "url", "http://localhost:"+port) + + return http.ListenAndServe(":"+port, nil) +} + +// scopedHandler returns a mark3labs/mcp-go tool handler that first enforces +// the required OAuth scope via ClaimsFromContext, then performs the operation. +// +// Scope failure is surfaced as a CallToolResult with IsError=true rather than +// a returned error: mark3labs/mcp-go coerces handler-returned errors to +// JSON-RPC -32603 (INTERNAL_ERROR), so an isError result is the only way to +// give the client a structured failure with details. +func scopedHandler(scope string, op func(a, b float64) float64) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + claims := authplanemark3labs.ClaimsFromContext(ctx) + if claims == nil { + return mcp.NewToolResultError(verifier.ErrTokenMissing.Error()), nil + } + if err := claims.RequireScope(scope); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + a, err := request.RequireFloat("a") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + b, err := request.RequireFloat("b") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultText(fmt.Sprintf("%g", op(a, b))), nil + } +} + +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/mark3labs/demo/run.sh b/mark3labs/demo/run.sh new file mode 100755 index 0000000..664cf48 --- /dev/null +++ b/mark3labs/demo/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ADAPTER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Demo defaults — override by setting the variable in your shell before running. +export ISSUER_URL="${ISSUER_URL:-http://localhost:9000}" +export RESOURCE_URL="${RESOURCE_URL:-http://localhost:8080/mcp}" +export PORT="${PORT:-8080}" +if [[ -z "${CLIENT_ID:-}" && -f /tmp/authserver-demo.client-id ]]; then + export CLIENT_ID="$(cat /tmp/authserver-demo.client-id)" +fi +if [[ -z "${CLIENT_SECRET:-}" && -f /tmp/authserver-demo.key ]]; then + export CLIENT_SECRET="$(cat /tmp/authserver-demo.key)" +fi + +echo "==> Starting Calculator MCP server (mark3labs/mcp-go) on http://localhost:${PORT}/mcp ..." +cd "$ADAPTER_DIR" +go run ./demo/ diff --git a/mark3labs/docs/user-guide.md b/mark3labs/docs/user-guide.md new file mode 100644 index 0000000..c0c8425 --- /dev/null +++ b/mark3labs/docs/user-guide.md @@ -0,0 +1,399 @@ +# Authplane mark3labs/mcp-go adapter — User Guide + +`github.com/authplane/go-sdk/mark3labs` is a thin adapter between the [Authplane core SDK](../../core/docs/user-guide.md) and [`github.com/mark3labs/mcp-go`](https://github.com/mark3labs/mcp-go). It validates OAuth 2.1 JWT access tokens on MCP server requests, serves RFC 9728 Protected Resource Metadata, and bridges RFC 8693 token-exchange consent errors to the MCP URL elicitation shape (JSON-RPC `-32042`). + +This guide is the thorough reference. The [README](../README.md) holds the hero snippet. + +## 1. Install + +```bash +go get github.com/authplane/go-sdk/mark3labs +``` + +Requires Go 1.25.5+ (the minimum mark3labs/mcp-go v0.54.0 needs). Also pulls in `github.com/authplane/go-sdk/core` and `github.com/mark3labs/mcp-go`. + +## 2. Quickstart + +```go +package main + +import ( + "context" + "net/http" + + "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" + "github.com/mark3labs/mcp-go/server" +) + +func main() { + ctx := context.Background() + + adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + Issuer: "https://auth.example.com", + Resource: "https://mcp.example.com/mcp", + Scopes: []string{"tools/query", "tools/write"}, + }) + if err != nil { + panic(err) + } + defer adapter.Close() + + mcpServer := server.NewMCPServer("My Server", "1.0.0", + server.WithToolCapabilities(false), + server.WithRecovery(), + ) + + streamable := server.NewStreamableHTTPServer(mcpServer, + server.WithHTTPContextFunc(adapter.HTTPContextFunc()), + ) + + http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) + http.Handle("/mcp", adapter.AuthMiddleware(streamable)) + + http.ListenAndServe(":8080", nil) +} +``` + +## 3. Core concepts + +`NewAdapter` constructs and owns an `*authplane.Client` and a `*resource.Resource`: + +1. `authplane.NewClient` performs RFC 8414 AS metadata discovery. +2. `client.Resource(uri, resource.WithScopes(...))` builds the resource (the JWKS cache is warmed and background refresh starts). +3. If `ClientOptions` includes `WithClientCredentials` or `WithClientAuthentication`, RFC 7662 introspection is auto-wired as the revocation checker, and `TokenExchange` becomes operational. + +The adapter integrates with mark3labs/mcp-go through **two coordinated hooks**: + +| Hook | Purpose | +|---|---| +| `adapter.AuthMiddleware(next)` | Standard `http.Handler` middleware that parses the `Authorization: Bearer …` header, runs the verifier, and on success stores `*verifier.VerifiedClaims` plus the raw token in the **HTTP request** context. On failure it writes a 401 with an RFC 6750 §3.1 quoted `WWW-Authenticate` header pointing to the PRM URL. | +| `server.WithHTTPContextFunc(adapter.HTTPContextFunc())` | Forwards claims/token from the HTTP request context into the **per-tool-call** MCP context. Without it, tool handlers receive a fresh context with no claims. | + +Scope enforcement is **per-tool**, not per-request. The middleware itself accepts any valid token; individual tool handlers call `ClaimsFromContext(ctx).RequireScope(...)`. This matches the MCP protocol: `initialize` and protocol-level messages must succeed with any authenticated client. + +## 4. Basic usage + +### 4.1 Construct the adapter + +```go +adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + Issuer: "https://auth.example.com", + Resource: "https://mcp.example.com/mcp", + Scopes: []string{"tools/query"}, +}) +``` + +All three fields are required. The `Scopes` slice is advertised in the PRM document; it does **not** enforce that every token carries all listed scopes — individual tools decide what they need. + +### 4.2 Mount the handlers + +```go +mcpServer := server.NewMCPServer("My Server", "1.0.0", + server.WithToolCapabilities(false), + server.WithRecovery(), +) +streamable := server.NewStreamableHTTPServer(mcpServer, + server.WithHTTPContextFunc(adapter.HTTPContextFunc()), +) + +http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) +http.Handle("/mcp", adapter.AuthMiddleware(streamable)) +``` + +PRM is always served unauthenticated. `AuthMiddleware` wraps the streamable HTTP server. + +> **Don't use `server.WithProtectedResourceMetadata(...)`** for this adapter. mark3labs/mcp-go's built-in PRM serializer and our `resource.PRMJSON()` can drift; using ours keeps the document byte-identical across Authplane adapters. + +### 4.3 Enforce scope inside tool handlers + +```go +import ( + "github.com/authplane/go-sdk/core/resource/verifier" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +addTool := mcp.NewTool("add", + mcp.WithDescription("Add two numbers"), + mcp.WithNumber("a", mcp.Required(), mcp.Description("First addend")), + mcp.WithNumber("b", mcp.Required(), mcp.Description("Second addend")), +) + +mcpServer.AddTool(addTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + claims := authplanemark3labs.ClaimsFromContext(ctx) + if claims == nil { + return mcp.NewToolResultError(verifier.ErrTokenMissing.Error()), nil + } + if err := claims.RequireScope("tools/add"); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + a, _ := request.RequireFloat("a") + b, _ := request.RequireFloat("b") + return mcp.NewToolResultText(fmt.Sprintf("%g", a+b)), nil +}) +``` + +`claims` is never nil when the tool is reached through `AuthMiddleware` + `WithHTTPContextFunc`, but the guard is cheap and makes the handler robust when called from other code paths (tests, direct invocations). + +Why `mcp.NewToolResultError(...)` instead of returning `err`? mark3labs/mcp-go coerces every error returned from a tool handler to JSON-RPC `-32603` (INTERNAL_ERROR), so an `IsError: true` result is the only way to surface a structured failure with a useful message to the client. + +## 5. `Options` reference + +| Field | Type | Required | Description | +|---|---|---|---| +| `Issuer` | `string` | yes | Authorization server issuer URL. | +| `Resource` | `string` | yes | Protected resource URL (also used to derive the PRM path). | +| `Scopes` | `[]string` | yes | Scopes advertised in the PRM document. | +| `DevMode` | `bool` | no | Relaxes SSRF to allow HTTP, localhost, private networks. Also enabled if `AUTHPLANE_DEV_MODE=1`. Remove before production. | +| `ClientOptions` | `[]authplane.Option` | no | SDK-level options: `WithClientCredentials`, `WithClientAuthentication`, `WithJWKSCacheTTL`, `WithCircuitBreaker`, `WithDPoP`, etc. | +| `VerifierOptions` | `[]verifier.Option` | no | Verifier-level options: `WithAlgorithms`, `WithClockSkew`, `WithRevocationChecker`, `WithFailClosed`. | + +`VerifierOptions` **replaces** the verifier option list set by `client.Resource`. When `ClientOptions` supplies credentials, the SDK auto-wires an introspection-backed revocation checker — if you also pass `VerifierOptions`, include `verifier.WithRevocationChecker(...)` (or `NullRevocationChecker`) explicitly if you want to keep, replace, or disable it. + +## 6. Main API reference + +### `NewAdapter(ctx context.Context, options Options) (*Adapter, error)` + +Constructs an adapter. Performs AS metadata discovery and warms the JWKS cache using `ctx`. Background refresh goroutines use their own context; `ctx` is only for startup. + +### `NewAdapterFromClientAndResource(client *authplane.Client, res *resource.Resource) (*Adapter, error)` + +Constructs an adapter from an already-built client and resource. Use this when sharing a single client across multiple adapters or when you need full control over construction. + +> **Lifecycle note.** `adapter.Close()` calls `client.Close()` regardless of which constructor you used. If you share a client across multiple adapters, do *not* defer `adapter.Close()` on every one — manage `client.Close()` yourself and let the adapters go out of scope. + +### `(a *Adapter) AuthMiddleware(handler http.Handler) http.Handler` + +Wraps an HTTP handler with bearer-token authentication. + +- Rejects unauthenticated requests with 401 and a `WWW-Authenticate` header pointing to the PRM URL. +- Rejects invalid tokens with 401, `error="invalid_token"`, and the verifier message in `error_description=` (sanitised so it cannot inject header lines). +- On success, injects `*verifier.VerifiedClaims` and the raw token into the request context. + +Scopes are not checked at this layer; tools enforce their own scope (see §4.3). + +### `(a *Adapter) HTTPContextFunc() server.HTTPContextFunc` + +Returns a `server.HTTPContextFunc` to pass to `server.WithHTTPContextFunc(...)` on the streamable server. Copies the claims and raw token from the HTTP request context (set by `AuthMiddleware`) into the context that mark3labs/mcp-go uses as the parent for tool-call contexts. + +### `(a *Adapter) ProtectedResourceMetadataHandler() http.Handler` + +Serves the RFC 9728 PRM JSON. `GET` only; other methods return 405. Sets `Content-Type: application/json` and `Cache-Control: max-age=3600`. + +### `(a *Adapter) WellKnownPRMPath() string` + +Returns the well-known PRM path, e.g. `/.well-known/oauth-protected-resource/mcp`. + +### `(a *Adapter) TokenExchange(ctx context.Context, input authplane.TokenExchangeInput) (*authplane.TokenResponse, error)` + +Performs RFC 8693 token exchange via the underlying client. Automatically maps `*authplane.ConsentRequiredError` with a non-empty `ConsentURL` to `*URLElicitationError` (see §7). Requires credentials (`WithClientCredentials` or `WithClientAuthentication`) in `ClientOptions`. + +### `ConsentElicitationError(err error) error` + +Checks whether `err` wraps an `*authplane.ConsentRequiredError` with a non-empty `ConsentURL`; returns `*URLElicitationError` if so, or the original error otherwise. Use when calling `Client().TokenExchange()` directly and you still want the mapping. + +### `URLElicitationError` + +Typed error carrying the URL elicitation payload (`mcp.ElicitationParams` with `Mode="url"`). `Code()` returns `mcp.URL_ELICITATION_REQUIRED` (-32042). `MarshalData()` returns the JSON payload suitable for the `data` field of a JSON-RPC `-32042` error. + +### `(a *Adapter) Client() *authplane.Client` + +Returns the underlying client for operations not exposed on the adapter: `ClientCredentials`, `Revoke`, `Introspect`, `DPoPSigner`. Do not call `Close()` on it — the adapter owns the lifecycle. + +### `(a *Adapter) Resource() *resource.Resource` + +Returns the underlying resource. Useful for calling `VerifyToken` directly with `resource.WithDPoP(...)` for DPoP-bound flows. + +### `(a *Adapter) Close() error` + +Stops background refresh goroutines and closes idle HTTP connections. Safe to call more than once. + +### `ClaimsFromContext(ctx context.Context) *verifier.VerifiedClaims` + +Returns the verified claims forwarded by `HTTPContextFunc`. Returns `nil` outside an authenticated request. + +### `TokenFromContext(ctx context.Context) string` + +Returns the raw bearer token forwarded by `HTTPContextFunc`. Returns `""` outside an authenticated request. + +## 7. Token exchange and URL elicitation + +RFC 8693 token exchange frequently runs into an authorization-server response of `consent_required` when the user has not yet granted the requested downstream access. The MCP URL elicitation protocol (JSON-RPC error code `-32042`) lets the server ask the MCP client to open a URL out-of-band — typically a consent page — and retry the original operation once the user is done. + +### 7.1 Detecting consent errors + +```go +import "github.com/authplane/go-sdk/core/authplane" + +resp, err := adapter.TokenExchange(ctx, authplane.TokenExchangeInput{ + SubjectToken: authplanemark3labs.TokenFromContext(ctx), + Scopes: []string{"calendar.read"}, + Resources: []string{"https://calendar.example.com/"}, +}) +if err != nil { + var elic *authplanemark3labs.URLElicitationError + if errors.As(err, &elic) { + // Build an isError CallToolResult that carries the elicitation data — + // see §7.2 for the propagation caveat. + data, _ := elic.MarshalData() + return mcp.NewToolResultErrorFromErr(elic.Error(), errors.New(string(data))), nil + } + return mcp.NewToolResultErrorFromErr("token exchange failed", err), nil +} +``` + +### 7.2 Propagation caveat + +mark3labs/mcp-go coerces every error returned from a tool handler to JSON-RPC `-32603` (INTERNAL_ERROR); custom JSON-RPC error codes are **not** propagated from tool handlers as of v0.54.0. That means returning `*URLElicitationError` from a tool handler will not produce a `-32042` JSON-RPC error on the wire — the client will see a generic internal error instead. + +Two workarounds are practical today: + +1. **Return an `IsError: true` `CallToolResult`** carrying the consent URL in the result content. The client receives a successful JSON-RPC response with `result.isError=true` and can interpret the URL out-of-band. +2. **Intercept errors at the streamable transport layer** with a custom wrapper that serialises `*URLElicitationError` into a proper JSON-RPC `-32042` response before mark3labs/mcp-go writes its own response. This requires either a fork or upstream support. + +The same constraint applies to the equivalent Python `authplane-fastmcp` adapter — see [its demo notes](../../python-sdk/authplane-fastmcp/demo/mcpserver.py). + +### 7.3 Custom consent handling + +When you need custom behavior (e.g. logging, metrics) before the mapping: + +```go +resp, err := adapter.Client().TokenExchange(ctx, input) +if err != nil { + // inspect, log, metric... + return nil, authplanemark3labs.ConsentElicitationError(err) +} +``` + +`ConsentElicitationError` performs the same mapping as `adapter.TokenExchange`'s internal path and returns the original error unchanged for anything that isn't a consent-required error with a URL. + +## 8. Revocation checking + +When credentials are supplied in `ClientOptions`, the SDK auto-wires RFC 7662 introspection as the revocation checker. Every successful JWT verification triggers an introspection round-trip; the token is rejected if the AS reports `active: false`. + +```go +adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + Issuer: "https://auth.example.com", + Resource: "https://mcp.example.com/mcp", + Scopes: []string{"tools/query"}, + ClientOptions: []authplane.Option{ + authplane.WithClientCredentials(clientID, clientSecret), + }, +}) +``` + +### 8.1 Disabling introspection + +```go +import "github.com/authplane/go-sdk/core/resource/verifier" + +adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + // ... + ClientOptions: []authplane.Option{ + authplane.WithClientCredentials(clientID, clientSecret), + }, + VerifierOptions: []verifier.Option{ + verifier.WithRevocationChecker(verifier.NullRevocationChecker), + }, +}) +``` + +`NullRevocationChecker` is a pre-built no-op checker. Use it when you want credentials (for token exchange) but not per-request introspection. + +### 8.2 Custom revocation checker + +```go +VerifierOptions: []verifier.Option{ + verifier.WithRevocationChecker(func(ctx context.Context, claims *verifier.VerifiedClaims, rawToken string) (bool, error) { + revoked, err := redis.SIsMember(ctx, "revoked_tokens", claims.JTI()).Result() + return revoked, err + }), +}, +``` + +By default a checker error is treated as *not revoked* (fail-open). Pair with `verifier.WithFailClosed()` if you want the opposite. + +## 9. DPoP (sender-constrained tokens) + +`AuthMiddleware` is Bearer-only. If you need to verify DPoP-bound access tokens on an MCP endpoint, bypass `AuthMiddleware` for that route and call `adapter.Resource().VerifyToken(ctx, token, resource.WithDPoP(dpopCtx))` yourself, following the pattern documented in the [http user guide §7](../../http/docs/user-guide.md#7-dpop-sender-constrained-tokens). + +Outbound DPoP (signing token requests to the AS) is configured on the client via `authplane.WithDPoP(km)` and is independent of the MCP middleware — see the [core user guide](../../core/docs/user-guide.md). + +## 10. Sharing a pre-built client + +```go +client, err := authplane.NewClient(ctx, issuer, authplane.WithClientCredentials(id, secret)) +if err != nil { + return err +} +defer client.Close() + +resA, _ := client.Resource("https://mcp.example.com/mcp-a", resource.WithScopes("tools/a")) +resB, _ := client.Resource("https://mcp.example.com/mcp-b", resource.WithScopes("tools/b")) + +adapterA, _ := authplanemark3labs.NewAdapterFromClientAndResource(client, resA) +adapterB, _ := authplanemark3labs.NewAdapterFromClientAndResource(client, resB) +// Do NOT defer adapterA.Close() or adapterB.Close() — they would both call client.Close(). +``` + +One `*authplane.Client` can back many resources and adapters. When you use `NewAdapterFromClientAndResource`, the adapter still calls `client.Close()` on its own `Close()`, so either: + +- Use `adapter.Close()` on exactly one adapter per client, or +- Call `client.Close()` yourself and let adapters go out of scope. + +## 11. Development mode + +```go +adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + Issuer: "http://localhost:9000", + Resource: "http://localhost:8080/mcp", + Scopes: []string{"tools/query"}, + DevMode: true, +}) +``` + +`DevMode: true` relaxes the SDK's SSRF defenses: HTTP (non-TLS) issuers, `localhost`, private networks, and link-local addresses are allowed. It is also honored via the `AUTHPLANE_DEV_MODE=1` environment variable as a fallback. + +Do not enable `DevMode` in production. Metadata discovery and JWKS fetching are the primary attack surface the setting loosens. + +## 12. Error handling + +Verifier failures during `AuthMiddleware` become 401 responses with a proper `WWW-Authenticate` challenge. Errors returned from tool handlers become JSON-RPC `-32603` regardless of type (see §7.2); prefer `IsError: true` `CallToolResult` for structured tool failures. + +When calling `adapter.Client()` operations directly (e.g. `Revoke`, `Introspect`, `ClientCredentials`), you may see any of the OAuth sentinels re-exported from `authplane`: + +| Error | Meaning | +|---|---| +| `ErrInvalidGrant` | Subject/actor token invalid or expired. | +| `ErrInvalidScope` | Requested scope exceeds grant. | +| `ErrInvalidClient` | Client authentication failed. | +| `ErrUnauthorizedClient` | Client not authorized for grant type. | +| `ErrUnsupportedGrantType` | Grant type not supported. | +| `ErrInvalidRequest` | Malformed request. | +| `ErrServerError` | AS returned a server error. | +| `ErrCircuitOpen` | Circuit breaker is open; AS recently failed repeatedly. | +| `ErrProtocolError` | Malformed response from AS. | +| `ErrConsentRequired` | User consent required — prefer `*ConsentRequiredError` for the URL. | +| `ErrInteractionRequired` | User interaction required. | +| `ErrUseDPoPNonce` | AS returned a DPoP nonce; the client auto-retries with the nonce. | + +The full verifier error list (signature, claims, DPoP, etc.) lives in the [core user guide](../../core/docs/user-guide.md). + +## 13. Lifecycle + +```go +adapter, err := authplanemark3labs.NewAdapter(ctx, opts) +if err != nil { + return err +} +defer adapter.Close() // stops JWKS/metadata refresh goroutines, closes the client +``` + +`Close()` is safe to call multiple times and always calls `client.Close()`. See §10 for the shared-client nuance. + +## 14. See also + +- [Core user guide](../../core/docs/user-guide.md) — client, resource, verifier, outbound DPoP, token exchange semantics, full error reference. +- [Official MCP Go SDK adapter](../../mcp/docs/user-guide.md) — same SDK, different MCP library (`modelcontextprotocol/go-sdk`). Use that one if you're already on the official SDK. +- [HTTP adapter user guide](../../http/docs/user-guide.md) — parallel middleware for plain HTTP resource servers, including DPoP inbound verification. +- [`demo/`](../demo/) — runnable Calculator Service with introspection-backed revocation and per-tool scope enforcement. diff --git a/mark3labs/go.mod b/mark3labs/go.mod new file mode 100644 index 0000000..fea8a5b --- /dev/null +++ b/mark3labs/go.mod @@ -0,0 +1,20 @@ +module github.com/authplane/go-sdk/mark3labs + +go 1.25.5 + +require ( + github.com/authplane/go-sdk/core v0.0.0 + github.com/go-jose/go-jose/v4 v4.1.3 + github.com/mark3labs/mcp-go v0.54.0 +) + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/text v0.14.0 // indirect +) + +replace github.com/authplane/go-sdk/core => ../core diff --git a/mark3labs/go.sum b/mark3labs/go.sum new file mode 100644 index 0000000..9639308 --- /dev/null +++ b/mark3labs/go.sum @@ -0,0 +1,36 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA= +github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mark3labs/pkg/authplanemark3labs/adapter.go b/mark3labs/pkg/authplanemark3labs/adapter.go new file mode 100644 index 0000000..120ce49 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/adapter.go @@ -0,0 +1,407 @@ +// Package authplanemark3labs is the Authplane adapter for the mark3labs/mcp-go +// server library. +// +// It validates OAuth 2.1 JWT access tokens on incoming HTTP requests, serves +// RFC 9728 Protected Resource Metadata, bridges verified claims into the +// per-tool-call context that mark3labs/mcp-go exposes through +// server.WithHTTPContextFunc, and maps RFC 8693 token-exchange consent errors +// to the MCP URL elicitation shape (JSON-RPC -32042). +package authplanemark3labs + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/authplane/go-sdk/core/authplane" + "github.com/authplane/go-sdk/core/resource" + "github.com/authplane/go-sdk/core/resource/verifier" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// claimsKey is the context key used to store VerifiedClaims for downstream handlers. +type claimsKey struct{} + +// tokenKey is the context key used to store the raw bearer token for downstream handlers. +type tokenKey struct{} + +// Options holds the configuration for creating an Adapter. +type Options struct { + // Required + Issuer string + Resource string + Scopes []string + + // DevMode relaxes SSRF protection to allow HTTP and localhost — required when + // the issuer runs on a local development server. Remove before deploying to production. + // The SDK also checks the AUTHPLANE_DEV_MODE=1 env var as a fallback. + DevMode bool + + // ClientOptions are SDK-level options: WithClientCredentials, WithClientAuthentication, + // WithJWKSCacheTTL, WithCircuitBreaker, WithDPoP, etc. + // + // When WithClientCredentials or WithClientAuthentication is present, RFC 7662 + // introspection is automatically wired as the default revocation checker. + // To disable it, pass verifier.WithRevocationChecker(verifier.NullRevocationChecker) + // in VerifierOptions. + ClientOptions []authplane.Option + + // VerifierOptions are verifier-level options: WithAlgorithms, WithClockSkew, + // WithRevocationChecker (overrides auto-wired introspection), etc. + VerifierOptions []verifier.Option +} + +// Adapter provides authentication middleware, an HTTPContextFunc bridge, and +// PRM handlers for integrating the Authplane core SDK with the mark3labs/mcp-go +// streamable HTTP server. +// +// Always call Close() when the adapter is no longer needed to stop background +// refresh goroutines and release HTTP connections. +type Adapter struct { + client *authplane.Client + resource *resource.Resource + prmURL string // full URL advertised in the WWW-Authenticate resource_metadata param +} + +// NewAdapter creates and initializes an Adapter. It calls authplane.NewClient, +// which performs RFC 8414 AS metadata discovery and warms the JWKS cache. +// +// When ClientOptions includes WithClientCredentials or WithClientAuthentication, +// RFC 7662 introspection is automatically wired as the default revocation checker, +// and TokenExchange becomes operational. +// +// The provided ctx is used only for initial discovery; background refresh +// goroutines use their own context. Call Close() when the adapter is no longer needed. +func NewAdapter(ctx context.Context, options Options) (*Adapter, error) { + clientOpts := make([]authplane.Option, 0, 1+len(options.ClientOptions)) + if options.DevMode { + clientOpts = append(clientOpts, authplane.WithFetchSettings(authplane.DevModeFetchSettings())) + } + clientOpts = append(clientOpts, options.ClientOptions...) + + client, err := authplane.NewClient(ctx, options.Issuer, clientOpts...) + if err != nil { + return nil, err + } + + resourceOpts := []resource.Option{resource.WithScopes(options.Scopes...)} + if len(options.VerifierOptions) > 0 { + // Only pass WithVerifierOptions when non-empty: WithVerifierOptions replaces + // (not appends) the verifier option list, so passing an empty slice would + // overwrite any options already auto-wired by client.Resource (e.g. the + // RFC 7662 introspection revocation checker). + resourceOpts = append(resourceOpts, resource.WithVerifierOptions(options.VerifierOptions...)) + } + res, err := client.Resource(options.Resource, resourceOpts...) + if err != nil { + _ = client.Close() + return nil, err + } + + resourceURL, err := url.Parse(options.Resource) + if err != nil { + _ = client.Close() + return nil, fmt.Errorf("authplane-mark3labs: invalid resource URL: %w", err) + } + prmURL := resourceURL.ResolveReference(&url.URL{Path: res.WellKnownPRMPath()}).String() + + return &Adapter{ + client: client, + resource: res, + prmURL: prmURL, + }, nil +} + +// NewAdapterFromClientAndResource creates an Adapter from an already-configured +// authplane.Client and resource.Resource. Adapter.Close() still calls client.Close(); +// when sharing a client across adapters, manage client lifecycle yourself and let +// the adapters go out of scope. +func NewAdapterFromClientAndResource(client *authplane.Client, res *resource.Resource) (*Adapter, error) { + resourceURL, err := url.Parse(res.URI()) + if err != nil { + return nil, fmt.Errorf("authplane-mark3labs: invalid resource URL: %w", err) + } + prmURL := resourceURL.ResolveReference(&url.URL{Path: res.WellKnownPRMPath()}).String() + + return &Adapter{ + client: client, + resource: res, + prmURL: prmURL, + }, nil +} + +// AuthMiddleware returns an HTTP handler that enforces bearer-token authentication +// in front of a mark3labs/mcp-go *server.StreamableHTTPServer. +// +// On failure it writes a 401 with an RFC 6750 §3.1 compliant WWW-Authenticate +// header pointing to the Protected Resource Metadata URL, so MCP clients can +// auto-discover the authorization server. +// +// On success the verified claims and raw token are stored in the *request* +// context. To forward them into the per-tool-call MCP context (which +// mark3labs/mcp-go derives via WithHTTPContextFunc), wire HTTPContextFunc() +// when constructing the streamable server. Scope enforcement is per-tool — +// individual tool handlers call ClaimsFromContext(ctx).RequireScope(...). +func (a *Adapter) AuthMiddleware(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := extractBearer(r.Header.Get("Authorization")) + if !ok { + a.writeUnauthorized(w, "", "Bearer token required") + return + } + + claims, err := a.resource.VerifyToken(r.Context(), token) + if err != nil { + a.writeUnauthorized(w, "invalid_token", err.Error()) + return + } + + ctx := context.WithValue(r.Context(), claimsKey{}, claims) + ctx = context.WithValue(ctx, tokenKey{}, token) + handler.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// extractBearer parses the Authorization header and returns the bearer token, +// or ("", false) if the scheme is missing or not Bearer. Comparison of the +// scheme is case-insensitive per RFC 7235 §2.1. +func extractBearer(header string) (string, bool) { + scheme, rest, ok := strings.Cut(header, " ") + if !ok { + return "", false + } + if !strings.EqualFold(scheme, "Bearer") { + return "", false + } + token := strings.TrimSpace(rest) + if token == "" { + return "", false + } + return token, true +} + +// writeUnauthorized writes a 401 with an RFC 6750 §3.1 compliant +// WWW-Authenticate: Bearer challenge. resource_metadata always carries the +// PRM URL; when errCode is non-empty it is included with errDescription +// (quoted, no double-quotes in the description). +func (a *Adapter) writeUnauthorized(w http.ResponseWriter, errCode, errDescription string) { + var b strings.Builder + b.WriteString(`Bearer resource_metadata="`) + b.WriteString(a.prmURL) + b.WriteString(`"`) + if errCode != "" { + b.WriteString(`, error="`) + b.WriteString(errCode) + b.WriteString(`"`) + if errDescription != "" { + b.WriteString(`, error_description="`) + b.WriteString(sanitizeQuoted(errDescription)) + b.WriteString(`"`) + } + } + w.Header().Set("WWW-Authenticate", b.String()) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) +} + +// sanitizeQuoted removes any characters that would break a quoted-string +// auth-param value: backslash and double-quote (RFC 7230 §3.2.6). Newlines are +// also stripped so a verifier error message cannot inject extra header lines. +func sanitizeQuoted(s string) string { + r := strings.NewReplacer(`"`, "'", `\`, "/", "\r", " ", "\n", " ") + return r.Replace(s) +} + +// HTTPContextFunc returns a server.HTTPContextFunc that forwards the verified +// claims and raw bearer token from the HTTP request context (where +// AuthMiddleware stored them) into the per-tool-call MCP context. +// +// Pass it to mark3labs/mcp-go when constructing the streamable HTTP server: +// +// httpServer := server.NewStreamableHTTPServer(mcpServer, +// server.WithHTTPContextFunc(adapter.HTTPContextFunc()), +// ) +// http.Handle("/mcp", adapter.AuthMiddleware(httpServer)) +// +// Without this option, tool handlers receive a fresh context that does not see +// the values placed by AuthMiddleware. +func (a *Adapter) HTTPContextFunc() server.HTTPContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + if claims, ok := r.Context().Value(claimsKey{}).(*verifier.VerifiedClaims); ok && claims != nil { + ctx = context.WithValue(ctx, claimsKey{}, claims) + } + if token, ok := r.Context().Value(tokenKey{}).(string); ok && token != "" { + ctx = context.WithValue(ctx, tokenKey{}, token) + } + return ctx + } +} + +// ProtectedResourceMetadataHandler returns an HTTP handler that serves the +// Protected Resource Metadata (PRM) document as JSON per RFC 9728. Only GET +// is allowed; other methods return 405. +func (a *Adapter) ProtectedResourceMetadataHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "max-age=3600") + _, _ = w.Write(a.resource.PRMJSON()) + }) +} + +// WellKnownPRMPath returns the RFC 9728 well-known path for this resource, +// e.g. "/.well-known/oauth-protected-resource/mcp". +// +// http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) +func (a *Adapter) WellKnownPRMPath() string { + return a.resource.WellKnownPRMPath() +} + +// Client returns the underlying authplane.Client, providing access to all SDK +// operations: TokenExchange, Revoke, Introspect, ClientCredentials, DPoPSigner, etc. +// +// Do not call Close() on the returned client directly — call adapter.Close() instead, +// as the adapter owns the client lifecycle. +func (a *Adapter) Client() *authplane.Client { + return a.client +} + +// Resource returns the underlying resource.Resource, providing access to token +// verification with custom options (e.g. DPoP), PRM generation, and the well-known path. +func (a *Adapter) Resource() *resource.Resource { + return a.resource +} + +// TokenFromContext returns the raw bearer token injected by AuthMiddleware (and +// forwarded by HTTPContextFunc) into the request context. Returns an empty +// string if called outside an authenticated request. +// +// token := authplanemark3labs.TokenFromContext(ctx) +// err := adapter.Client().Revoke(ctx, token) +func TokenFromContext(ctx context.Context) string { + token, _ := ctx.Value(tokenKey{}).(string) + return token +} + +// ClaimsFromContext returns the VerifiedClaims injected by AuthMiddleware (and +// forwarded by HTTPContextFunc) into the per-tool-call context. +// +// claims := authplanemark3labs.ClaimsFromContext(ctx) +// if err := claims.RequireScope("tools/add"); err != nil { +// return mcp.NewToolResultErrorFromErr("forbidden", err), nil +// } +func ClaimsFromContext(ctx context.Context) *verifier.VerifiedClaims { + claims, _ := ctx.Value(claimsKey{}).(*verifier.VerifiedClaims) + return claims +} + +// Close stops all background goroutines and releases resources held by the +// underlying client. It is safe to call multiple times. +// +// When using NewAdapterFromClientAndResource and sharing a client across multiple +// adapters, call client.Close() directly instead of adapter.Close(). +func (a *Adapter) Close() error { + return a.client.Close() +} + +// defaultConsentMessage is used when ConsentRequiredError.Description is empty. +const defaultConsentMessage = "Consent is required to proceed" + +// URLElicitationError carries the data for an MCP URL elicitation +// (JSON-RPC error code -32042). It is what ConsentElicitationError returns +// when it detects a consent-required error with a non-empty consent URL. +// +// mark3labs/mcp-go's tool-call error path coerces every returned error to +// JSON-RPC code -32603 (INTERNAL_ERROR), so returning a URLElicitationError +// directly from a tool handler will not propagate code -32042 to the client. +// Tool authors who need the elicitation behaviour should instead return a +// CallToolResult with IsError=true and serialise the elicitation params into +// the content (see the demo). Use Code() and Params to construct that +// response. The error type is also a stable handle for errors.As inspection +// in code that calls adapter.TokenExchange directly. +type URLElicitationError struct { + Params mcp.ElicitationParams + Cause error +} + +// Code returns the MCP URL elicitation error code (-32042). +func (e *URLElicitationError) Code() int { return mcp.URL_ELICITATION_REQUIRED } + +// Error implements the error interface, returning the elicitation message. +func (e *URLElicitationError) Error() string { + if e.Params.Message != "" { + return e.Params.Message + } + return defaultConsentMessage +} + +// Unwrap returns the underlying cause, so errors.Is / errors.As can reach +// the original ConsentRequiredError. +func (e *URLElicitationError) Unwrap() error { return e.Cause } + +// MarshalData returns the JSON-encoded ElicitationParams payload suitable for +// the data field of a JSON-RPC -32042 error. +func (e *URLElicitationError) MarshalData() ([]byte, error) { + return json.Marshal(e.Params) +} + +// ConsentElicitationError checks whether err wraps an *authplane.ConsentRequiredError +// with a non-empty ConsentURL. If so, it returns a *URLElicitationError carrying +// the URL, the AS-provided description (falling back to a default), and a freshly +// minted elicitation ID. Otherwise the original error is returned unchanged. +// +// See the URLElicitationError doc comment for the mark3labs/mcp-go propagation +// caveat. +func ConsentElicitationError(err error) error { + if err == nil { + return nil + } + var consentErr *authplane.ConsentRequiredError + if !errors.As(err, &consentErr) || consentErr.ConsentURL == "" { + return err + } + msg := consentErr.Description + if msg == "" { + msg = defaultConsentMessage + } + return &URLElicitationError{ + Params: mcp.ElicitationParams{ + Mode: "url", + URL: consentErr.ConsentURL, + Message: msg, + ElicitationID: newUUID(), + }, + Cause: err, + } +} + +// TokenExchange performs an RFC 8693 token exchange via the underlying client and +// automatically maps ConsentRequiredError to *URLElicitationError when a consent +// URL is available. +// +// For custom consent handling, use Client().TokenExchange() directly and pass the +// error through ConsentElicitationError. +func (a *Adapter) TokenExchange(ctx context.Context, input authplane.TokenExchangeInput) (*authplane.TokenResponse, error) { + resp, err := a.client.TokenExchange(ctx, input) + if err != nil { + return nil, ConsentElicitationError(err) + } + return resp, nil +} + +// newUUID generates a random v4 UUID string without external dependencies. +func newUUID() string { + var u [16]byte + _, _ = rand.Read(u[:]) // crypto/rand.Read never returns an error on supported platforms + u[6] = (u[6] & 0x0f) | 0x40 // version 4 + u[8] = (u[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", u[0:4], u[4:6], u[6:8], u[8:10], u[10:16]) +} diff --git a/mark3labs/pkg/authplanemark3labs/adapter_test.go b/mark3labs/pkg/authplanemark3labs/adapter_test.go new file mode 100644 index 0000000..d8b2210 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/adapter_test.go @@ -0,0 +1,437 @@ +package authplanemark3labs_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/authplane/go-sdk/core/authplane" + "github.com/authplane/go-sdk/core/resource/verifier" + "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" + "github.com/mark3labs/mcp-go/mcp" +) + +// TestAuthMiddlewareNoToken verifies that unauthenticated requests receive a +// 401 with a quoted WWW-Authenticate header pointing to the PRM endpoint. +func TestAuthMiddlewareNoToken(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } + wwwAuth := rec.Header().Get("Www-Authenticate") + if !strings.Contains(wwwAuth, `resource_metadata="`) { + t.Errorf("WWW-Authenticate = %q; want quoted resource_metadata", wwwAuth) + } + if !strings.Contains(wwwAuth, ".well-known/oauth-protected-resource") { + t.Errorf("WWW-Authenticate = %q; want PRM path in resource_metadata", wwwAuth) + } +} + +// TestAuthMiddlewareInvalidTokenReturns401 verifies that an invalid (malformed) +// token produces a 401 with error="invalid_token" in the challenge. +func TestAuthMiddlewareInvalidTokenReturns401(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer not.a.valid.jwt") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } + wwwAuth := rec.Header().Get("Www-Authenticate") + if !strings.Contains(wwwAuth, `error="invalid_token"`) { + t.Errorf("WWW-Authenticate = %q; want error=\"invalid_token\"", wwwAuth) + } + if !strings.Contains(wwwAuth, `resource_metadata="`) { + t.Errorf("WWW-Authenticate = %q; want quoted resource_metadata", wwwAuth) + } +} + +// TestAuthMiddlewareNoScopeEnforcement verifies that AuthMiddleware does NOT +// reject tokens based on scope. A valid token with no scopes must be passed +// through to the inner handler — scope enforcement is the tool handler's job. +func TestAuthMiddlewareNoScopeEnforcement(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + token := e.makeToken(t, nil, time.Now().Add(time.Hour)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200; middleware must not enforce scopes", rec.Code) + } +} + +// TestAuthMiddlewareInjectsClaimsIntoContext verifies that a valid token causes +// ClaimsFromContext to return non-nil claims inside the inner handler. +func TestAuthMiddlewareInjectsClaimsIntoContext(t *testing.T) { + e := newTestEnv(t) + + var gotClaims *verifier.VerifiedClaims + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClaims = authplanemark3labs.ClaimsFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + }) + handler := e.adapter.AuthMiddleware(inner) + + token := e.makeToken(t, []string{"tools/add"}, time.Now().Add(time.Hour)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if gotClaims == nil { + t.Error("ClaimsFromContext returned nil; AuthMiddleware did not inject claims") + } +} + +// TestClaimsFromContextNilOutsideAuth verifies that ClaimsFromContext returns nil +// when called with a plain context that has no claims injected. +func TestClaimsFromContextNilOutsideAuth(t *testing.T) { + if got := authplanemark3labs.ClaimsFromContext(context.Background()); got != nil { + t.Errorf("ClaimsFromContext outside authenticated request = %v, want nil", got) + } +} + +// TestAuthMiddlewareExpiredTokenReturns401 verifies that an expired token is +// rejected with 401 (not 500). +func TestAuthMiddlewareExpiredTokenReturns401(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + token := e.makeToken(t, []string{"tools/add"}, time.Now().Add(-time.Hour)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +// TestAuthMiddlewareNonBearerScheme verifies that a non-Bearer auth scheme is +// treated as missing token: 401 + Bearer challenge. +func TestAuthMiddlewareNonBearerScheme(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.AuthMiddleware(okHandler()) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +// TestProtectedResourceMetadataHandler verifies the PRM handler. +func TestProtectedResourceMetadataHandler(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.ProtectedResourceMetadataHandler() + + t.Run("GET returns JSON", func(t *testing.T) { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/.well-known/oauth-protected-resource", nil)) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if body["resource"] != testResource { + t.Errorf("resource = %v, want %s", body["resource"], testResource) + } + }) + + t.Run("POST returns 405", func(t *testing.T) { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/.well-known/oauth-protected-resource", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } + }) +} + +// TestProtectedResourceMetadataHandlerCacheControl verifies that the PRM handler +// sets a Cache-Control header on GET responses. +func TestProtectedResourceMetadataHandlerCacheControl(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.ProtectedResourceMetadataHandler() + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/.well-known/oauth-protected-resource", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != "max-age=3600" { + t.Errorf("Cache-Control = %q, want %q", got, "max-age=3600") + } +} + +// TestTokenFromContextInjectsRawToken verifies that AuthMiddleware stores the +// raw bearer token in context, accessible via TokenFromContext. +func TestTokenFromContextInjectsRawToken(t *testing.T) { + e := newTestEnv(t) + + var gotToken string + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotToken = authplanemark3labs.TokenFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + }) + handler := e.adapter.AuthMiddleware(inner) + + token := e.makeToken(t, []string{"tools/add"}, time.Now().Add(time.Hour)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if gotToken != token { + t.Errorf("TokenFromContext = %q, want %q", gotToken, token) + } +} + +// TestTokenFromContextNilOutsideAuth verifies that TokenFromContext returns an +// empty string when called with a plain context that has no token injected. +func TestTokenFromContextNilOutsideAuth(t *testing.T) { + if got := authplanemark3labs.TokenFromContext(context.Background()); got != "" { + t.Errorf("TokenFromContext outside authenticated request = %q, want empty string", got) + } +} + +// TestAuthMiddlewareES256Token verifies that a token signed with ES256 is +// accepted and claims are injected into context. +func TestAuthMiddlewareES256Token(t *testing.T) { + e := newTestEnv(t) + + var gotClaims *verifier.VerifiedClaims + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClaims = authplanemark3labs.ClaimsFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + }) + handler := e.adapter.AuthMiddleware(inner) + + token := e.makeES256Token(t, []string{"tools/add"}, time.Now().Add(time.Hour)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if gotClaims == nil { + t.Error("ClaimsFromContext returned nil; ES256 token was not accepted") + } +} + +// TestHTTPContextFuncForwardsClaims verifies that HTTPContextFunc copies the +// verified claims from the HTTP request context into the per-call MCP context. +func TestHTTPContextFuncForwardsClaims(t *testing.T) { + e := newTestEnv(t) + contextFunc := e.adapter.HTTPContextFunc() + + // Simulate what AuthMiddleware does: put claims and token in r.Context(). + wantClaims := &verifier.VerifiedClaims{} + const wantToken = "test-token-abc" + reqCtx := authplanemark3labs.ContextWithClaims(context.Background(), wantClaims) + reqCtx = authplanemark3labs.ContextWithToken(reqCtx, wantToken) + req := httptest.NewRequest(http.MethodPost, "/mcp", nil).WithContext(reqCtx) + + // mark3labs/mcp-go calls HTTPContextFunc with a fresh ctx + the original + // request. The returned ctx becomes the parent for tool-call contexts. + mcpCtx := contextFunc(context.Background(), req) + + if got := authplanemark3labs.ClaimsFromContext(mcpCtx); got != wantClaims { + t.Errorf("ClaimsFromContext after HTTPContextFunc = %v, want %v", got, wantClaims) + } + if got := authplanemark3labs.TokenFromContext(mcpCtx); got != wantToken { + t.Errorf("TokenFromContext after HTTPContextFunc = %q, want %q", got, wantToken) + } +} + +// TestHTTPContextFuncNoClaims verifies that HTTPContextFunc is a no-op when the +// request context has no claims (e.g. an unauthenticated request reaches it). +func TestHTTPContextFuncNoClaims(t *testing.T) { + e := newTestEnv(t) + contextFunc := e.adapter.HTTPContextFunc() + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + mcpCtx := contextFunc(context.Background(), req) + + if got := authplanemark3labs.ClaimsFromContext(mcpCtx); got != nil { + t.Errorf("ClaimsFromContext on empty request = %v, want nil", got) + } + if got := authplanemark3labs.TokenFromContext(mcpCtx); got != "" { + t.Errorf("TokenFromContext on empty request = %q, want empty string", got) + } +} + +// --- ConsentElicitationError tests --- + +// TestConsentElicitationErrorWithConsentURL verifies that a ConsentRequiredError +// with a ConsentURL is mapped to a URLElicitationError with the correct fields. +func TestConsentElicitationErrorWithConsentURL(t *testing.T) { + consentErr := &authplane.ConsentRequiredError{ + ConsentURL: "https://as.example.com/consent/calendar", + Description: "Authorize access to Google Calendar", + Cause: authplane.ErrConsentRequired, + } + + got := authplanemark3labs.ConsentElicitationError(consentErr) + + var elic *authplanemark3labs.URLElicitationError + if !errors.As(got, &elic) { + t.Fatalf("got error type %T, want *URLElicitationError", got) + } + if code := elic.Code(); code != mcp.URL_ELICITATION_REQUIRED { + t.Errorf("code = %d, want %d", code, mcp.URL_ELICITATION_REQUIRED) + } + if elic.Params.Mode != "url" { + t.Errorf("mode = %q, want %q", elic.Params.Mode, "url") + } + if elic.Params.URL != "https://as.example.com/consent/calendar" { + t.Errorf("url = %q, want %q", elic.Params.URL, "https://as.example.com/consent/calendar") + } + if elic.Params.Message != "Authorize access to Google Calendar" { + t.Errorf("message = %q, want %q", elic.Params.Message, "Authorize access to Google Calendar") + } + if elic.Params.ElicitationID == "" { + t.Error("elicitationId is empty, want non-empty UUID") + } +} + +// TestConsentElicitationErrorEmptyURL verifies that a ConsentRequiredError with +// an empty ConsentURL is returned unchanged (not mapped to elicitation). +func TestConsentElicitationErrorEmptyURL(t *testing.T) { + consentErr := &authplane.ConsentRequiredError{ + Description: "Consent needed", + Cause: authplane.ErrConsentRequired, + } + + got := authplanemark3labs.ConsentElicitationError(consentErr) + if got != consentErr { + t.Errorf("got %v, want original ConsentRequiredError returned unchanged", got) + } +} + +// TestConsentElicitationErrorNonConsentError verifies that non-consent errors +// are returned unchanged. +func TestConsentElicitationErrorNonConsentError(t *testing.T) { + orig := errors.New("some other error") + got := authplanemark3labs.ConsentElicitationError(orig) + if got != orig { + t.Errorf("got %v, want original error returned unchanged", got) + } +} + +// TestConsentElicitationErrorNil verifies that nil input returns nil. +func TestConsentElicitationErrorNil(t *testing.T) { + got := authplanemark3labs.ConsentElicitationError(nil) + if got != nil { + t.Errorf("got %v, want nil", got) + } +} + +// TestConsentElicitationErrorDefaultMessage verifies that an empty Description +// falls back to the default consent message. +func TestConsentElicitationErrorDefaultMessage(t *testing.T) { + consentErr := &authplane.ConsentRequiredError{ + ConsentURL: "https://as.example.com/consent", + Cause: authplane.ErrConsentRequired, + } + + got := authplanemark3labs.ConsentElicitationError(consentErr) + var elic *authplanemark3labs.URLElicitationError + if !errors.As(got, &elic) { + t.Fatalf("got error type %T, want *URLElicitationError", got) + } + if elic.Params.Message != "Consent is required to proceed" { + t.Errorf("message = %q, want default %q", elic.Params.Message, "Consent is required to proceed") + } +} + +// TestConsentElicitationErrorWrappedConsentError verifies that errors.As detects +// a ConsentRequiredError even when wrapped with additional context. +func TestConsentElicitationErrorWrappedConsentError(t *testing.T) { + consentErr := &authplane.ConsentRequiredError{ + ConsentURL: "https://as.example.com/consent", + Description: "Grant access", + Cause: authplane.ErrInteractionRequired, + } + wrapped := fmt.Errorf("token exchange failed: %w", consentErr) + + got := authplanemark3labs.ConsentElicitationError(wrapped) + var elic *authplanemark3labs.URLElicitationError + if !errors.As(got, &elic) { + t.Fatalf("got error type %T, want *URLElicitationError (wrapped consent error not detected)", got) + } + if elic.Code() != mcp.URL_ELICITATION_REQUIRED { + t.Errorf("code = %d, want %d", elic.Code(), mcp.URL_ELICITATION_REQUIRED) + } +} + +// TestURLElicitationErrorMarshalData verifies that MarshalData produces a JSON +// payload matching the ElicitationParams wire shape (mode/url/message/elicitationId). +func TestURLElicitationErrorMarshalData(t *testing.T) { + consentErr := &authplane.ConsentRequiredError{ + ConsentURL: "https://as.example.com/consent", + Description: "Grant access", + Cause: authplane.ErrConsentRequired, + } + got := authplanemark3labs.ConsentElicitationError(consentErr) + var elic *authplanemark3labs.URLElicitationError + if !errors.As(got, &elic) { + t.Fatalf("got error type %T, want *URLElicitationError", got) + } + + data, err := elic.MarshalData() + if err != nil { + t.Fatalf("MarshalData: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("unmarshal data: %v", err) + } + if payload["mode"] != "url" { + t.Errorf("mode = %v, want %q", payload["mode"], "url") + } + if payload["url"] != "https://as.example.com/consent" { + t.Errorf("url = %v, want consent URL", payload["url"]) + } + if payload["message"] != "Grant access" { + t.Errorf("message = %v, want description", payload["message"]) + } + if payload["elicitationId"] == "" { + t.Error("elicitationId is empty in payload") + } +} diff --git a/mark3labs/pkg/authplanemark3labs/constructor_test.go b/mark3labs/pkg/authplanemark3labs/constructor_test.go new file mode 100644 index 0000000..1de8635 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/constructor_test.go @@ -0,0 +1,200 @@ +package authplanemark3labs_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/authplane/go-sdk/core/authplane" + "github.com/authplane/go-sdk/core/resource/verifier" + "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" +) + +// TestNewAdapterInvalidIssuer verifies that NewAdapter returns an error when +// the issuer URL is unreachable. +func TestNewAdapterInvalidIssuer(t *testing.T) { + _, err := authplanemark3labs.NewAdapter(context.Background(), authplanemark3labs.Options{ + Issuer: "http://127.0.0.1:1", // unreachable port + Resource: testResource, + Scopes: []string{"tools/add"}, + DevMode: true, + }) + if err == nil { + t.Fatal("NewAdapter with unreachable issuer should return error") + } +} + +// TestNewAdapterWithVerifierOptions verifies that passing VerifierOptions +// configures the adapter correctly (exercises the VerifierOptions branch). +func TestNewAdapterWithVerifierOptions(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + const kid = "test-key" + jwksBody := mustMarshal(t, map[string]any{ + "keys": []any{rsaJWK(&key.PublicKey, kid)}, + }) + + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(jwksBody) + }) + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(mustMarshal(t, map[string]any{ + "issuer": srv.URL, + "jwks_uri": srv.URL + "/.well-known/jwks.json", + "token_endpoint": srv.URL + "/oauth/token", + })) + }) + + adapter, err := authplanemark3labs.NewAdapter(context.Background(), authplanemark3labs.Options{ + Issuer: srv.URL, + Resource: testResource, + Scopes: []string{"tools/add"}, + DevMode: true, + VerifierOptions: []verifier.Option{verifier.WithClockSkew(5 * time.Second)}, + }) + if err != nil { + t.Fatalf("NewAdapter with VerifierOptions: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + if adapter.Client() == nil { + t.Error("Client() returned nil") + } +} + +// TestClientAndResourceAccessors verifies that Client() and Resource() return +// non-nil values, confirming the adapter wired them correctly during construction. +func TestClientAndResourceAccessors(t *testing.T) { + e := newTestEnv(t) + if e.adapter.Client() == nil { + t.Error("Client() returned nil") + } + if e.adapter.Resource() == nil { + t.Error("Resource() returned nil") + } +} + +// TestNewAdapterFromClientAndResource verifies that an adapter built from a +// pre-existing client and resource functions correctly as middleware. +func TestNewAdapterFromClientAndResource(t *testing.T) { + e := newTestEnv(t) + + adapter2, err := authplanemark3labs.NewAdapterFromClientAndResource(e.adapter.Client(), e.adapter.Resource()) + if err != nil { + t.Fatalf("NewAdapterFromClientAndResource: %v", err) + } + + handler := adapter2.AuthMiddleware(okHandler()) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +// TestAutoWiredIntrospectionNotWipedByEmptyVerifierOptions is a regression test +// for the bug where passing an empty VerifierOptions slice wiped the auto-wired +// RFC 7662 introspection revocation checker. +func TestAutoWiredIntrospectionNotWipedByEmptyVerifierOptions(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + const kid = "test-key" + jwksBody := mustMarshal(t, map[string]any{ + "keys": []any{rsaJWK(&key.PublicKey, kid)}, + }) + + var introspectCalled bool + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(jwksBody) + }) + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(mustMarshal(t, map[string]any{ + "issuer": srv.URL, + "jwks_uri": srv.URL + "/.well-known/jwks.json", + "token_endpoint": srv.URL + "/oauth/token", + "introspection_endpoint": srv.URL + "/oauth/introspect", + })) + }) + mux.HandleFunc("/oauth/introspect", func(w http.ResponseWriter, r *http.Request) { + introspectCalled = true + w.Header().Set("Content-Type", "application/json") + w.Write(mustMarshal(t, map[string]any{"active": false})) + }) + + adapter, err := authplanemark3labs.NewAdapter(context.Background(), authplanemark3labs.Options{ + Issuer: srv.URL, + Resource: testResource, + Scopes: []string{"tools/add"}, + DevMode: true, + ClientOptions: []authplane.Option{ + authplane.WithClientCredentials("test-client", "test-secret"), + }, + }) + if err != nil { + t.Fatalf("NewAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + e := &testEnv{key: key, issuer: srv.URL, kid: kid} + token := e.makeToken(t, []string{"tools/add"}, time.Now().Add(time.Hour)) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + adapter.AuthMiddleware(okHandler()).ServeHTTP(rec, req) + + if !introspectCalled { + t.Error("introspection endpoint was never called; auto-wired checker was wiped by empty VerifierOptions") + } + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401; introspection returned active=false but token was accepted", rec.Code) + } +} + +// TestWellKnownPRMPath verifies that WellKnownPRMPath returns the expected +// RFC 9728 well-known path. +func TestWellKnownPRMPath(t *testing.T) { + e := newTestEnv(t) + got := e.adapter.WellKnownPRMPath() + want := "/.well-known/oauth-protected-resource/mcp" + if got != want { + t.Errorf("WellKnownPRMPath() = %q, want %q", got, want) + } +} + +// TestContextWithClaimsRoundTrip verifies the test helper ContextWithClaims. +func TestContextWithClaimsRoundTrip(t *testing.T) { + ctx := authplanemark3labs.ContextWithClaims(context.Background(), nil) + if got := authplanemark3labs.ClaimsFromContext(ctx); got != nil { + t.Errorf("ClaimsFromContext after ContextWithClaims(nil) = %v, want nil", got) + } +} + +// TestContextWithTokenRoundTrip verifies the test helper ContextWithToken. +func TestContextWithTokenRoundTrip(t *testing.T) { + const want = "test-token-value" + ctx := authplanemark3labs.ContextWithToken(context.Background(), want) + if got := authplanemark3labs.TokenFromContext(ctx); got != want { + t.Errorf("TokenFromContext = %q, want %q", got, want) + } +} diff --git a/mark3labs/pkg/authplanemark3labs/helpers_test.go b/mark3labs/pkg/authplanemark3labs/helpers_test.go new file mode 100644 index 0000000..4d61346 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/helpers_test.go @@ -0,0 +1,192 @@ +package authplanemark3labs_test + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" + "github.com/go-jose/go-jose/v4" +) + +// testResource is the resource URL used in all adapter tests. +const testResource = "http://localhost:8080/mcp" + +// testEnv holds a mock authorization server and an Adapter configured against it. +type testEnv struct { + adapter *authplanemark3labs.Adapter + key *rsa.PrivateKey + ecKey *ecdsa.PrivateKey + issuer string + kid string + ecKid string +} + +// newTestEnv starts a mock AS (JWKS + metadata endpoints) and returns a fully +// initialized Adapter configured to use it. The adapter is closed automatically +// when the test completes. +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + const kid = "test-key" + + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate EC key: %v", err) + } + const ecKid = "test-ec-key" + + jwksBody := mustMarshal(t, map[string]any{ + "keys": []any{rsaJWK(&key.PublicKey, kid), ecJWK(&ecKey.PublicKey, ecKid)}, + }) + + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(jwksBody) + }) + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(mustMarshal(t, map[string]any{ + "issuer": srv.URL, + "jwks_uri": srv.URL + "/.well-known/jwks.json", + "token_endpoint": srv.URL + "/oauth/token", + })) + }) + + adapter, err := authplanemark3labs.NewAdapter(context.Background(), authplanemark3labs.Options{ + Issuer: srv.URL, + Resource: testResource, + Scopes: []string{"tools/add", "tools/multiply"}, + DevMode: true, // allow HTTP + localhost in tests + }) + if err != nil { + t.Fatalf("NewAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + return &testEnv{adapter: adapter, key: key, ecKey: ecKey, issuer: srv.URL, kid: kid, ecKid: ecKid} +} + +// makeToken signs a minimal RS256 JWT (typ=at+jwt) using only stdlib crypto. +func (e *testEnv) makeToken(t *testing.T, scopes []string, exp time.Time) string { + t.Helper() + + headerJSON := mustMarshal(t, map[string]string{ + "alg": "RS256", + "typ": "at+jwt", + "kid": e.kid, + }) + claimsJSON := mustMarshal(t, map[string]any{ + "iss": e.issuer, + "aud": testResource, + "sub": "user-test", + "jti": "jti-test", + "client_id": testResource, // required by authplane verifier + "iat": time.Now().Unix(), + "exp": exp.Unix(), + "scope": strings.Join(scopes, " "), + }) + + sigInput := b64url(headerJSON) + "." + b64url(claimsJSON) + h := sha256.New() + h.Write([]byte(sigInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, e.key, crypto.SHA256, h.Sum(nil)) + if err != nil { + t.Fatalf("sign JWT: %v", err) + } + return sigInput + "." + b64url(sig) +} + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func b64url(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + return b +} + +func rsaJWK(pub *rsa.PublicKey, kid string) map[string]any { + return map[string]any{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": b64url(pub.N.Bytes()), + "e": b64url(big.NewInt(int64(pub.E)).Bytes()), + } +} + +// ecJWK builds a JWK map for an EC P-256 public key using go-jose marshaling +// to avoid deprecated direct field access on ecdsa.PublicKey. +func ecJWK(pub *ecdsa.PublicKey, kid string) map[string]any { + jwk := jose.JSONWebKey{Key: pub, KeyID: kid, Algorithm: "ES256", Use: "sig"} + b, _ := jwk.MarshalJSON() + var m map[string]any + _ = json.Unmarshal(b, &m) + return m +} + +// makeES256Token signs a minimal ES256 JWT (typ=at+jwt) using go-jose. +func (e *testEnv) makeES256Token(t *testing.T, scopes []string, exp time.Time) string { + t.Helper() + + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: e.ecKey}, + (&jose.SignerOptions{}).WithType("at+jwt").WithHeader("kid", e.ecKid), + ) + if err != nil { + t.Fatalf("create ES256 signer: %v", err) + } + + payload := mustMarshal(t, map[string]any{ + "iss": e.issuer, + "aud": testResource, + "sub": "user-test", + "jti": "jti-test-ec", + "client_id": testResource, + "iat": time.Now().Unix(), + "exp": exp.Unix(), + "scope": strings.Join(scopes, " "), + }) + + jws, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign ES256 JWT: %v", err) + } + token, err := jws.CompactSerialize() + if err != nil { + t.Fatalf("serialize ES256 JWT: %v", err) + } + return token +} diff --git a/mark3labs/pkg/authplanemark3labs/testing.go b/mark3labs/pkg/authplanemark3labs/testing.go new file mode 100644 index 0000000..10c7dab --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/testing.go @@ -0,0 +1,19 @@ +package authplanemark3labs + +import ( + "context" + + "github.com/authplane/go-sdk/core/resource/verifier" +) + +// ContextWithClaims returns a context carrying the given VerifiedClaims, +// as if AuthMiddleware had validated a request. Use in tests only. +func ContextWithClaims(ctx context.Context, claims *verifier.VerifiedClaims) context.Context { + return context.WithValue(ctx, claimsKey{}, claims) +} + +// ContextWithToken returns a context carrying the given raw bearer token, +// as if AuthMiddleware had injected it. Use in tests only. +func ContextWithToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, tokenKey{}, token) +}