From 3ff1217e342b8ba2a5e786396acffd6077a70802 Mon Sep 17 00:00:00 2001 From: muralx Date: Wed, 10 Jun 2026 09:33:20 +0100 Subject: [PATCH 1/2] feat: add mark3labs/mcp-go adapter; harden DPoP htu and RFC 9728 PRM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `mark3labs` module adapting mark3labs/mcp-go (bearer + DPoP auth, RFC 9728 PRM, HTTPContextFunc forwarding), and tighten DPoP proof verification and Protected Resource Metadata handling across core and the net/http adapter. - DPoP htu binding: compare EscapedPath (encoded %2F is not /), strip default ports and collapse empty paths on both sides (RFC 9110 §7.2, RFC 9449 §4.3), and pin scheme/authority to the configured resource URI instead of the proxy-controlled Host header and r.TLS. - Reject requests carrying more than one DPoP header via NewDPoPContext / ErrMultipleDpopProofs (RFC 9449 §4.3 #1, §7.1). - VerifiedClaims.RequireScopes plural helper with enriched insufficient-scope errors. - Resource.PRMURL() and typed Resource.PRMConfig() for RFC 9728 metadata. - NewAdapterFromClientAndResource constructors return (*Adapter, error) instead of panicking on a nil client. Includes BREAKING (pre-1.0) changes to DPoPContext and the net/http htu reconstruction; see CHANGELOG for migration steps. --- .github/workflows/ci.yml | 23 +- .github/workflows/security.yml | 1 + CHANGELOG.md | 22 + README.md | 11 +- core/authplane/dpop.go | 16 +- core/conformancetests/rfc6750_test.go | 9 + core/conformancetests/rfc9449_test.go | 135 ++-- core/go.mod | 2 +- core/internal/dpop/htu.go | 62 ++ core/internal/dpop/htu_test.go | 61 ++ core/resource/errors.go | 9 +- core/resource/errors_test.go | 32 + core/resource/resource.go | 127 ++- core/resource/resource_test.go | 154 ++++ core/resource/verifier/claims.go | 73 +- core/resource/verifier/claims_test.go | 115 +++ core/resource/verifier/dpop.go | 25 +- core/resource/verifier/dpop_test.go | 99 ++- core/resource/verifier/errors.go | 7 + core/resource/verifier/policy_test.go | 20 +- core/resource/verifier/types.go | 70 +- core/resource/verifier/types_test.go | 83 ++ core/resource/verifier/verifier.go | 4 +- core/resource/verifier/verifier_test.go | 20 +- go.work | 3 +- http/go.mod | 2 +- http/pkg/authplanehttp/adapter.go | 104 ++- http/pkg/authplanehttp/adapter_test.go | 48 ++ http/pkg/authplanehttp/dpop_test.go | 206 +++++ http/pkg/authplanehttp/helpers_test.go | 92 +++ http/pkg/authplanehttp/testing.go | 11 +- llm-full.txt | 54 +- llm.txt | 21 +- mark3labs/README.md | 57 ++ mark3labs/demo/README.md | 63 ++ mark3labs/demo/main.go | 137 ++++ mark3labs/demo/run.sh | 20 + mark3labs/docs/user-guide.md | 435 +++++++++++ mark3labs/go.mod | 24 + mark3labs/go.sum | 36 + mark3labs/pkg/authplanemark3labs/adapter.go | 436 +++++++++++ .../pkg/authplanemark3labs/adapter_test.go | 723 ++++++++++++++++++ .../authplanemark3labs/constructor_test.go | 229 ++++++ .../pkg/authplanemark3labs/helpers_test.go | 286 +++++++ mark3labs/pkg/authplanemark3labs/testing.go | 31 + mcp/go.mod | 2 + mcp/pkg/authplanemcp/adapter.go | 28 +- mcp/pkg/authplanemcp/constructor_test.go | 29 + 48 files changed, 4028 insertions(+), 229 deletions(-) create mode 100644 core/internal/dpop/htu.go create mode 100644 core/internal/dpop/htu_test.go create mode 100644 core/resource/errors_test.go create mode 100644 core/resource/verifier/types_test.go create mode 100644 mark3labs/README.md create mode 100644 mark3labs/demo/README.md create mode 100644 mark3labs/demo/main.go create mode 100755 mark3labs/demo/run.sh create mode 100644 mark3labs/docs/user-guide.md create mode 100644 mark3labs/go.mod create mode 100644 mark3labs/go.sum create mode 100644 mark3labs/pkg/authplanemark3labs/adapter.go create mode 100644 mark3labs/pkg/authplanemark3labs/adapter_test.go create mode 100644 mark3labs/pkg/authplanemark3labs/constructor_test.go create mode 100644 mark3labs/pkg/authplanemark3labs/helpers_test.go create mode 100644 mark3labs/pkg/authplanemark3labs/testing.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63213ee..4a00090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,18 +19,25 @@ jobs: - core - mcp - http + - mark3labs steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # Only core consumes the catalog (mcp/http have no conformance suite). - - name: Check out shared conformance catalog + # AuthPlane/conformance is the public sibling repo carrying the shared + # oauth-sdk-conformance-catalog.yaml. Cloned to $RUNNER_TEMP — outside + # $GITHUB_WORKSPACE — so the catalog stays out of the working tree and + # never trips `go list ./...` or coverage glob patterns. Plain git + # clone is enough: the repo is public read-only, no auth or + # persist-credentials features needed. + # Only `core` consumes the catalog (mcp/http/mark3labs have no + # conformance suite). + - name: Clone shared conformance catalog (out of tree) if: matrix.module == 'core' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: AuthPlane/conformance - path: conformance - fetch-depth: 1 + run: | + git -c advice.detachedHead=false clone --depth=1 \ + https://github.com/AuthPlane/conformance.git \ + "$RUNNER_TEMP/conformance" - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -60,7 +67,7 @@ jobs: - name: Test working-directory: ${{ matrix.module }} env: - CONFORMANCE_CATALOG_PATH: ${{ github.workspace }}/conformance/oauth-sdk-conformance-catalog.yaml + CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml run: | # Per-module coverage floors. Adapters (http, mcp) are thinner than # core so their floors are lower, but they still need a floor to diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 61b3289..afc3e67 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -39,6 +39,7 @@ jobs: - core - http - mcp + - mark3labs steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1d381..18b6fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `core/resource/verifier`: `(*VerifiedClaims).RequireScopes(scopes ...string) error` plural helper. Returns `nil` on empty input, and on failure wraps `ErrInsufficientScope` naming every missing scope plus the scopes the token does carry, so an adapter can surface it verbatim in the `WWW-Authenticate` `error_description`. +- `core/resource/verifier`: `ErrMultipleDpopProofs` sentinel for RFC 9449 §4.3 #1 violations. `core/resource.AuthErrorResponse` maps it to a `DPoP error="invalid_dpop_proof"` 401 challenge per RFC 9449 §7.1. +- `core/resource/verifier`: `NewDPoPContext(method, url, dpopHeaderValues []string) (*DPoPContext, error)` factory — the canonical §4.3 #1 enforcement boundary. Filters blanks, splits on `,` defensively for proxies that pre-join duplicate headers, and returns `ErrMultipleDpopProofs` on more than one non-blank value. +- `core/resource/verifier`: `(*DPoPContext).Proof()` nil-safe accessor returning the single proof (or `""` when none). +- `mark3labs` module: adapter for [`mark3labs/mcp-go`](https://github.com/mark3labs/mcp-go). Wraps `*authplanehttp.Adapter` so consumers get bearer + DPoP auth, RFC 9728 PRM, and `HTTPContextFunc` integration for the mark3labs HTTP server. `HTTPContextFunc` takes `WithForwardedContextKeys(keys ...any)` and `WithContextForwarding(fn)` options to propagate values (request IDs, tracing spans, …) from the upstream request context onto the per-tool-call MCP context. See `mark3labs/docs/user-guide.md`. +- `core/resource`: `Resource.PRMURL()` returns the precomputed absolute Protected Resource Metadata URL as a single infallible source of truth. +- `core/resource`: `Resource.PRMConfig()` returns the Protected Resource Metadata as a typed `PRMConfig` struct, for feeding into third-party PRM-serving handlers (e.g. mark3labs/mcp-go's `NewProtectedResourceMetadataHandler`) without going through the dynamic `PRMResponse` map. + +### Fixed +- `core/resource/verifier`: `validateHTU` compares `EscapedPath()` instead of `Path`, so an encoded `%2F` is no longer treated as equivalent to a literal `/`. The previous comparison conflated distinct request targets, weakening the RFC 9449 §4.3 `htu` binding (RFC 3986 §6.2.2.2 only permits decoding *unreserved* characters when comparing URLs). +- `core/resource/verifier`: `validateHTU` strips an explicit default port (`:80` for http, `:443` for https) on both sides before comparing (RFC 9110 §7.2), so a resource configured as `http://api.example.com:80/mcp` no longer mismatches every client that signs the port-less form. +- `core/resource/verifier`: `validateHTU` collapses an empty path to `/` on both sides before comparing, so a client signing a bare-origin htu (e.g. `https://host`) no longer fails against a server where every inbound `r.URL.EscapedPath()` is at least `/`. + +### Changed +- **BREAKING (pre-1.0)** `http`: DPoP `htu` reconstruction in the `net/http` adapter no longer reads the inbound `Host` header, `r.TLS`, or `r.URL.RawQuery`. Both `Host` and `r.TLS` are proxy-controlled and would otherwise let a misconfigured edge — or an attacker forging `Host` — shift the `htu` binding to a different origin or downgrade `https` to `http` behind a TLS-terminating proxy. The adapter now sources scheme + authority from the operator-configured resource URI and contributes only the request path in raw `EscapedPath` form (query and fragment are dropped per RFC 9449 §4.3 #5). **Migration:** mount the middleware **before** any `http.StripPrefix` so `r.URL.EscapedPath()` still reflects the path the client signed; apps relying on `Host`-derived htu (or `r.TLS`-derived scheme) will see proof rejections until the resource URI is corrected to match the canonical origin. +- `core/resource/verifier`: `(*VerifiedClaims).RequireScope` delegates to `RequireScopes`, so the singular helper also emits the enriched `required scope "X"; token has scopes: …` `error_description`. Behaviour (`errors.Is(err, ErrInsufficientScope)`, 403 status, `scope="…"` parameter) is unchanged; only the error string is enriched. +- **BREAKING** `core/resource/verifier`: `DPoPContext` no longer exposes the raw proof through a public field — the previous `DPoPContext.Proof string` is replaced with an unexported slice plus the `(*DPoPContext).Proof()` accessor and the `NewDPoPContext` constructor. Route through the factory so RFC 9449 §4.3 #1 enforcement stays single-source and invalid (multi-proof) states stay unconstructable. +- `http`: HTTP adapter middleware reads `r.Header.Values("DPoP")` and routes the slice through `NewDPoPContext`. A request carrying more than one `DPoP` header returns HTTP 401 + `WWW-Authenticate: DPoP error="invalid_dpop_proof"` (RFC 9449 §4.3 #1, §7.1); the previous `r.Header.Get("DPoP")` silently picked only the first copy. +- `http`: `WWW-Authenticate` now uses a space (not a comma) before `resource_metadata` when no prior auth-param is present, matching RFC 7235 §2.1 (`Bearer resource_metadata="…"` instead of `Bearer, resource_metadata="…"`). +- `mcp.NewAdapterFromClientAndResource` now returns `(*Adapter, error)` and rejects a nil client with a typed error instead of panicking, matching the shape of its discovery-driven `NewAdapter` sibling. + ## [0.1.1] - 2026-05-22 ### Fixed diff --git a/README.md b/README.md index 185f52d..7184048 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ func main() { } ``` -That's a complete, secure, standards-compliant MCP resource server. For a plain HTTP resource server, see the [`http` adapter](http/README.md). +That's a complete, secure, standards-compliant MCP resource server. For a plain HTTP resource server, see the [`http` adapter](http/README.md); for `mark3labs/mcp-go` servers, see the [`mark3labs` adapter](mark3labs/README.md). ## Packages @@ -61,12 +61,13 @@ That's a complete, secure, standards-compliant MCP resource server. For a plain | [`core`](core/README.md) | `go get github.com/authplane/go-sdk/core` | Framework-agnostic JWT validation, JWKS caching, DPoP, introspection, token exchange, PRM. | | [`http`](http/README.md) | `go get github.com/authplane/go-sdk/http` | `net/http` middleware with Bearer and DPoP sender-constrained token support. | | [`mcp`](mcp/README.md) | `go get github.com/authplane/go-sdk/mcp` | Adapter for the [official MCP Go SDK](https://github.com/modelcontextprotocol/go-sdk). | +| [`mark3labs`](mark3labs/README.md) | `go get github.com/authplane/go-sdk/mark3labs` | Adapter for [`mark3labs/mcp-go`](https://github.com/mark3labs/mcp-go), wrapping the `net/http` adapter for bearer + DPoP auth and RFC 9728 PRM. | Each package has its own quickstart and user guide; start at the package README that matches your integration target. ## Requirements -- Go 1.24+ (`core`, `http`) / Go 1.25+ (`mcp`, forced by `github.com/modelcontextprotocol/go-sdk`) +- Go 1.24+ (`core`, `http`) / Go 1.25+ (`mcp`, forced by `github.com/modelcontextprotocol/go-sdk`; `mark3labs`, forced by `github.com/mark3labs/mcp-go`) ## Capabilities @@ -100,6 +101,7 @@ Each package has its own quickstart and user guide; start at the package README - [`net/http`](http/README.md) middleware for plain resource servers. - [MCP Go SDK](mcp/README.md) adapter, including URL elicitation mapping for RFC 8693 consent flows. +- [`mark3labs/mcp-go`](mark3labs/README.md) adapter, wrapping `net/http` for bearer + DPoP auth with `HTTPContextFunc` integration. ### Observability @@ -107,8 +109,8 @@ Each package has its own quickstart and user guide; start at the package README ## Documentation -- Package READMEs — [`core`](core/README.md) · [`http`](http/README.md) · [`mcp`](mcp/README.md) -- Package user guides — [`core`](core/docs/user-guide.md) · [`http`](http/docs/user-guide.md) · [`mcp`](mcp/docs/user-guide.md) +- Package READMEs — [`core`](core/README.md) · [`http`](http/README.md) · [`mcp`](mcp/README.md) · [`mark3labs`](mark3labs/README.md) +- Package user guides — [`core`](core/docs/user-guide.md) · [`http`](http/docs/user-guide.md) · [`mcp`](mcp/docs/user-guide.md) · [`mark3labs`](mark3labs/docs/user-guide.md) - [`CHANGELOG.md`](CHANGELOG.md) - [`SECURITY.md`](SECURITY.md) — reporting vulnerabilities - [`CONTRIBUTING.md`](CONTRIBUTING.md) — development setup and PR guidelines @@ -121,6 +123,7 @@ Each package has its own quickstart and user guide; start at the package README | `core` | [![Go Reference](https://pkg.go.dev/badge/github.com/authplane/go-sdk/core.svg)](https://pkg.go.dev/github.com/authplane/go-sdk/core) | | `http` | [![Go Reference](https://pkg.go.dev/badge/github.com/authplane/go-sdk/http.svg)](https://pkg.go.dev/github.com/authplane/go-sdk/http) | | `mcp` | [![Go Reference](https://pkg.go.dev/badge/github.com/authplane/go-sdk/mcp.svg)](https://pkg.go.dev/github.com/authplane/go-sdk/mcp) | +| `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) | ## License diff --git a/core/authplane/dpop.go b/core/authplane/dpop.go index 3eeccbb..f06967f 100644 --- a/core/authplane/dpop.go +++ b/core/authplane/dpop.go @@ -11,9 +11,9 @@ import ( "encoding/json" "fmt" "net/url" - "strings" "time" + "github.com/authplane/go-sdk/core/internal/dpop" "github.com/go-jose/go-jose/v4" ) @@ -186,7 +186,9 @@ func (d *DPoPSigner) Thumbprint() string { // Host header (RFC 9110 §7.2): an explicit default port (:80 for http, :443 for // https) is dropped while non-default ports are kept. Without this, an htu // carrying an explicit default port would not match the host the AS reconstructs -// from a port-less Host header. IPv6 literals stay bracketed. +// from a port-less Host header. IPv6 literals stay bracketed. The default-port +// rule is shared with the inbound verifier (`core/resource/verifier.validateHTU`) +// through `core/internal/dpop.NormalizeHost` so the two sides cannot drift. func normalizeHTU(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { @@ -196,15 +198,7 @@ func normalizeHTU(rawURL string) string { u.RawQuery = "" u.Fragment = "" u.RawFragment = "" - if port := u.Port(); port != "" { - if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { - host := u.Hostname() - if strings.Contains(host, ":") { - host = "[" + host + "]" - } - u.Host = host - } - } + u.Host = dpop.NormalizeHost(u) return u.String() } diff --git a/core/conformancetests/rfc6750_test.go b/core/conformancetests/rfc6750_test.go index 5cb6e48..437cf6c 100644 --- a/core/conformancetests/rfc6750_test.go +++ b/core/conformancetests/rfc6750_test.go @@ -62,6 +62,15 @@ func TestRFC6750ErrorResponseMustMapErrorCodes(t *testing.T) { {"dpop_replay → invalid_token DPoP", verifier.ErrDPoPReplayDetected, "DPoP", "invalid_token", 401}, {"dpop_key_mismatch → invalid_token DPoP", verifier.ErrDPoPKeyMismatch, "DPoP", "invalid_token", 401}, {"dpop_required → invalid_token DPoP", verifier.ErrDPoPRequired, "DPoP", "invalid_token", 401}, + // NOTE: `ErrMultipleDpopProofs → invalid_dpop_proof / DPoP / 401` + // is intentionally NOT in this catalog-aligned table. The + // shared `rfc6750-error-response-must-map-error-codes` catalog + // entry only enumerates `dpop_error → invalid_token / DPoP`, so + // adding a row with a different `error_code` here would diverge + // from the catalog. The mapping is covered at the package level + // in `core/resource/auth_error_response_test.go` until the + // catalog gains a `rfc9449-verifier-must-reject-multiple-dpop-headers` + // row (catalog ID does not exist yet). } for _, tt := range tests { diff --git a/core/conformancetests/rfc9449_test.go b/core/conformancetests/rfc9449_test.go index 5ca542e..400df5f 100644 --- a/core/conformancetests/rfc9449_test.go +++ b/core/conformancetests/rfc9449_test.go @@ -22,6 +22,34 @@ import ( "github.com/go-jose/go-jose/v4/jwt" ) +// dpopCtxWithProof routes every conformance test that submits a DPoP +// proof through NewDPoPContext, the §4.3 enforcement boundary. Direct +// `&verifier.DPoPContext{...}` literals would bypass the factory and +// undercut the single-source architectural guarantee — the verifier +// reads `Proof()` and never re-checks header cardinality. The helper +// is fatal on factory errors because every caller below passes a +// single non-blank proof; cardinality violations are the only thing +// NewDPoPContext rejects. +func dpopCtxWithProof(t *testing.T, method, url, proof string) *verifier.DPoPContext { + t.Helper() + ctx, err := verifier.NewDPoPContext(method, url, []string{proof}) + if err != nil { + t.Fatalf("NewDPoPContext(%q, %q, [...]): %v", method, url, err) + } + return ctx +} + +// dpopCtxNoProof is the no-proof companion for tests that exercise the +// "DPoP-bound token but no proof attached" path. +func dpopCtxNoProof(t *testing.T, method, url string) *verifier.DPoPContext { + t.Helper() + ctx, err := verifier.NewDPoPContext(method, url, nil) + if err != nil { + t.Fatalf("NewDPoPContext(%q, %q, nil): %v", method, url, err) + } + return ctx +} + // inMemoryReplayStore is a simple DPoP replay store for testing. type inMemoryReplayStore struct { seen map[string]time.Time @@ -136,11 +164,7 @@ func TestRFC9449DPoPProofHeaderTypMustBeDPoPJWT(t *testing.T) { proof, _ := jws.CompactSerialize() // Submit the wrong-typ proof to the verifier — must be rejected. - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err == nil { t.Fatal("expected error for DPoP proof with wrong typ") } @@ -340,11 +364,7 @@ func TestRFC9449InboundDPoPProofMustValidateMethodURLAndBinding(t *testing.T) { t.Fatalf("generate proof: %v", err) } - result, err := tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + result, err := tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err != nil { t.Fatalf("verify: %v", err) } @@ -369,10 +389,7 @@ func TestRFC9449BearerTokenWithRequestContextAndNoProofMustStillVerifyAsBearer(t token, _ := testutil.SignToken(claims, key, jose.ES256, "key-0") // Verify with a DPoP context that has no proof. Bearer tokens should still verify. - result, err := tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - }) + result, err := tv.VerifyToken(ctx, token, dpopCtxNoProof(t, "POST", "https://api.example.com/resource")) if err != nil { t.Fatalf("verify: %v", err) } @@ -432,11 +449,7 @@ func TestRFC9449DPoPReplayMustBeDetected(t *testing.T) { AccessToken: token, }) - dpopCtx := &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - } + dpopCtx := dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof) // First use should succeed. _, err = tv.VerifyToken(ctx, token, dpopCtx) @@ -479,11 +492,7 @@ func TestRFC9449DPoPMethodMismatchMustBeRejected(t *testing.T) { AccessToken: token, }) - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "GET", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "GET", "https://api.example.com/resource", proof)) // Note: The Go SDK uses EqualFold for htm comparison, so POST vs GET will still fail // because the strings are different. if err == nil { @@ -518,11 +527,7 @@ func TestRFC9449DPoPURLMismatchMustBeRejected(t *testing.T) { AccessToken: token, }) - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/different-resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/different-resource", proof)) if err == nil { t.Fatal("expected error for URL mismatch") } @@ -558,11 +563,7 @@ func TestRFC9449DPoPProofHTUMustBeNormalizedBeforeComparison(t *testing.T) { }) // Request URL with lowercase. - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err != nil { t.Fatalf("normalized HTU comparison should pass: %v", err) } @@ -575,11 +576,7 @@ func TestRFC9449DPoPProofHTUMustBeNormalizedBeforeComparison(t *testing.T) { }) // Request URL WITH query params — verifier must strip them before comparing. - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource?page=1&sort=asc", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource?page=1&sort=asc", proof)) if err != nil { t.Fatalf("htu comparison must ignore query/fragment (RFC 9449 §4.3): %v", err) } @@ -612,11 +609,7 @@ func TestRFC9449DPoPProofHTMMustBeCaseSensitive(t *testing.T) { }) // Request with "POST" (uppercase) — must be rejected. - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err == nil { t.Fatal("expected error: htm comparison must be case-sensitive per RFC 9110 §9.1") } @@ -718,11 +711,7 @@ func TestRFC9449DPoPProofExpMustBeEnforcedWhenPresent(t *testing.T) { t.Fatalf("serialize proof: %v", err) } - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err == nil { t.Fatal("expected error for expired DPoP proof exp") } @@ -868,11 +857,7 @@ func TestRFC9449DPoPProofIATMustNotBeInTheFutureBeyondLeeway(t *testing.T) { jws, _ := proofSigner.Sign(payload) proof, _ := jws.CompactSerialize() - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err == nil { t.Fatal("expected error for future iat in DPoP proof") } @@ -924,11 +909,7 @@ func TestRFC9449DPoPProofMustNotBeTooOld(t *testing.T) { jws, _ := proofSigner.Sign(payload) proof, _ := jws.CompactSerialize() - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err == nil { t.Fatal("expected error for old DPoP proof") } @@ -962,11 +943,7 @@ func TestRFC9449DPoPProofRequiredWhenValidatingDPoPBoundToken(t *testing.T) { } // DPoP context with empty proof. - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: "", - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxNoProof(t, "POST", "https://api.example.com/resource")) if err == nil { t.Fatal("expected error") } @@ -1000,11 +977,7 @@ func TestRFC9449DPoPBindingMismatchMustBeRejected(t *testing.T) { AccessToken: token, }) - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err == nil { t.Fatal("expected error for binding mismatch") } @@ -1038,11 +1011,7 @@ func TestRFC9449DPoPATHMismatchMustBeRejected(t *testing.T) { AccessToken: "different-token-value", }) - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if err == nil { t.Fatal("expected error for ath mismatch") } @@ -1102,11 +1071,7 @@ func TestRFC9449DPoPProofValidationMustNotSkipBindingWhenAccessTokenIsProvided(t // Proof without ath (access token hash) when token is provided. proof, _ := dpopSigner.GenerateProof("POST", "https://api.example.com/resource", nil) - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) // The verifier should reject because ath is missing/mismatched. if err == nil { t.Fatal("expected error when proof lacks ath for a bound token") @@ -1268,11 +1233,7 @@ func TestRFC9449DPoPNotSupportedResourceRejectsBoundToken(t *testing.T) { t.Fatalf("generate proof: %v", err) } - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if !errors.Is(err, verifier.ErrDPoPNotSupported) { t.Errorf("expected ErrDPoPNotSupported, got %v", err) } @@ -1312,11 +1273,7 @@ func TestRFC9449VerifierMustRejectDPoPProofWhenAccessTokenIsNotDPoPBound(t *test t.Fatalf("generate proof: %v", err) } - _, err = tv.VerifyToken(ctx, token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + _, err = tv.VerifyToken(ctx, token, dpopCtxWithProof(t, "POST", "https://api.example.com/resource", proof)) if !errors.Is(err, verifier.ErrDPoPBindingMismatch) { t.Errorf("expected ErrDPoPBindingMismatch, got %v", err) } diff --git a/core/go.mod b/core/go.mod index 329011a..de5a43f 100644 --- a/core/go.mod +++ b/core/go.mod @@ -2,6 +2,6 @@ module github.com/authplane/go-sdk/core go 1.24.0 -toolchain go1.25.0 +toolchain go1.25.5 require github.com/go-jose/go-jose/v4 v4.1.4 diff --git a/core/internal/dpop/htu.go b/core/internal/dpop/htu.go new file mode 100644 index 0000000..829c843 --- /dev/null +++ b/core/internal/dpop/htu.go @@ -0,0 +1,62 @@ +// Package dpop holds shared internal helpers for DPoP htu normalization +// (RFC 9449 §4.3) used by both the outbound signer (`core/authplane`) and +// the inbound verifier (`core/resource/verifier`). Centralizing the rules +// here keeps the two sides from drifting: if the signer emits an htu whose +// authority the verifier won't accept (or vice versa), every DPoP-bound +// proof from a client to a resource silently breaks. +package dpop + +import ( + "net/url" + "strings" +) + +// NormalizeHost returns u.Host with an explicit default port removed +// (`:80` for `http`, `:443` for `https`), per RFC 9110 §7.2. IPv6 literals +// stay bracketed. Non-default ports and other schemes are returned as-is. +// +// The signer side calls this to mirror the outbound Host header (where an +// explicit default port is omitted); the verifier side calls it on both +// the proof's htu and the reconstructed request URL before comparing them. +// +// Precondition: when used to compare two URLs (verifier side), the caller +// must have already verified scheme equality between them. The strip rule +// is scheme-conditional — `:80` is "default" only when scheme is `http` — +// so applying NormalizeHost across two URLs with different schemes can +// erase a port on one side but not the other, producing a spurious match. +// `validateHTU` enforces the scheme check above its NormalizeHost call. +func NormalizeHost(u *url.URL) string { + port := u.Port() + if port == "" { + return u.Host + } + if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { + host := u.Hostname() + if strings.Contains(host, ":") { + return "[" + host + "]" + } + return host + } + return u.Host +} + +// NormalizePath collapses an empty path to "/" to match the ts and python +// reference SDKs (`parsed.pathname || "/"` / `parsed.path or "/"`). The Go +// net/http server hands every request a path of at least "/", so the +// asymmetry only bites when an htu is built from a bare-origin URL — a +// Go-signed proof for `https://host` would otherwise fail against a request +// the server sees as `https://host/`. +// +// Percent-encoded triplets are intentionally NOT case-folded. RFC 3986 +// §6.2.2.1 says that the hex digits within a triplet (`%2f` vs `%2F`) +// *should* be normalized to upper-case when comparing URIs, but the verifier +// compares the path byte-for-byte. Folding case unilaterally would let the +// verifier accept a proof that a byte-exact signer rejects (and vice-versa) +// on the exact path the §4.3 binding is meant to lock down. Holding the +// byte-equality contract until an aligned RFC §6.2.2.1 pass lands. +func NormalizePath(p string) string { + if p == "" { + return "/" + } + return p +} diff --git a/core/internal/dpop/htu_test.go b/core/internal/dpop/htu_test.go new file mode 100644 index 0000000..78241a9 --- /dev/null +++ b/core/internal/dpop/htu_test.go @@ -0,0 +1,61 @@ +package dpop + +import ( + "net/url" + "testing" +) + +func TestNormalizeHost(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {"http default port stripped", "http://example.com:80/p", "example.com"}, + {"https default port stripped", "https://example.com:443/p", "example.com"}, + {"http non-default port kept", "http://example.com:8080/p", "example.com:8080"}, + {"https non-default port kept", "https://example.com:8443/p", "example.com:8443"}, + {"no port unchanged", "https://example.com/p", "example.com"}, + {"ipv6 default port stripped, brackets kept", "https://[::1]:443/p", "[::1]"}, + {"ipv6 non-default port kept", "http://[::1]:9000/p", "[::1]:9000"}, + // Mismatched scheme/port: ":443" on http is not "default" for http, + // so it stays — the helper only strips the per-scheme default. + {"http with 443 port preserved", "http://example.com:443/p", "example.com:443"}, + {"https with 80 port preserved", "https://example.com:80/p", "example.com:80"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got := NormalizeHost(u); got != tt.want { + t.Errorf("NormalizeHost(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestNormalizePath(t *testing.T) { + tests := []struct { + in, want string + }{ + {"", "/"}, + {"/", "/"}, + {"/foo", "/foo"}, + {"/foo/bar", "/foo/bar"}, + {"/foo%2Fbar", "/foo%2Fbar"}, + // Percent-encoded triplets are preserved byte-for-byte — no + // case-folding of the hex digits. RFC 3986 §6.2.2.1 would allow + // `%2f` == `%2F`, but the verifier compares byte-by-byte so the + // §4.3 htu binding stays exact. + {"/foo%2fbar", "/foo%2fbar"}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := NormalizePath(tt.in); got != tt.want { + t.Errorf("NormalizePath(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/core/resource/errors.go b/core/resource/errors.go index f92549e..f8e8bfb 100644 --- a/core/resource/errors.go +++ b/core/resource/errors.go @@ -52,7 +52,8 @@ func HTTPStatus(err error) int { errors.Is(err, verifier.ErrDPoPInvalid), errors.Is(err, verifier.ErrDPoPKeyMismatch), errors.Is(err, verifier.ErrDPoPBindingMismatch), - errors.Is(err, verifier.ErrDPoPReplayDetected): + errors.Is(err, verifier.ErrDPoPReplayDetected), + errors.Is(err, verifier.ErrMultipleDpopProofs): return http.StatusUnauthorized case errors.Is(err, verifier.ErrSSRFBlocked), errors.Is(err, verifier.ErrProtocolError): @@ -79,6 +80,12 @@ func AuthErrorResponse(err error, realm ...string) (status int, headers map[stri errors.Is(err, verifier.ErrDPoPBindingMismatch), errors.Is(err, verifier.ErrDPoPReplayDetected): scheme = "DPoP" + case errors.Is(err, verifier.ErrMultipleDpopProofs): + // RFC 9449 §7.1 prescribes invalid_dpop_proof for §4.3 rejections, + // not the SDK's historical invalid_token used by the other ErrDPoP* + // shapes. Scoped to this error; a broader sweep is a separate change. + scheme = "DPoP" + errorCode = "invalid_dpop_proof" // ErrDPoPNotSupported deliberately falls through to the default "Bearer" // scheme: the resource does not support DPoP, so the client should retry // with a non-DPoP-bound token. diff --git a/core/resource/errors_test.go b/core/resource/errors_test.go new file mode 100644 index 0000000..fa720a8 --- /dev/null +++ b/core/resource/errors_test.go @@ -0,0 +1,32 @@ +package resource_test + +import ( + "strings" + "testing" + + "github.com/authplane/go-sdk/core/resource" + "github.com/authplane/go-sdk/core/resource/verifier" +) + +// TestAuthErrorResponse_MultipleDpopProofs_MapsToInvalidDpopProof pins +// the RFC 9449 §4.3 #1 / §7.1 mapping at the package level rather than +// in `core/conformancetests/rfc6750_test.go`. The shared catalog entry +// `rfc6750-error-response-must-map-error-codes` only enumerates +// `dpop_error → invalid_token / DPoP`; we don't add the +// `invalid_dpop_proof` row to the conformance suite until the catalog +// gains a `rfc9449-verifier-must-reject-multiple-dpop-headers` entry. +// Until then the assertion lives here. +func TestAuthErrorResponse_MultipleDpopProofs_MapsToInvalidDpopProof(t *testing.T) { + status, headers, _ := resource.AuthErrorResponse(verifier.ErrMultipleDpopProofs) + + if status != 401 { + t.Errorf("status = %d, want 401", status) + } + wwwAuth := headers["WWW-Authenticate"] + if !strings.HasPrefix(wwwAuth, "DPoP") { + t.Errorf("WWW-Authenticate = %q, want DPoP-scheme challenge", wwwAuth) + } + if !strings.Contains(wwwAuth, `error="invalid_dpop_proof"`) { + t.Errorf("WWW-Authenticate = %q, want invalid_dpop_proof error code", wwwAuth) + } +} diff --git a/core/resource/resource.go b/core/resource/resource.go index aff3cb1..e0a465e 100644 --- a/core/resource/resource.go +++ b/core/resource/resource.go @@ -12,12 +12,39 @@ import ( // Resource represents a protected resource with PRM generation and token verification. type Resource struct { - uri string - scopes []string - issuer string - verifier *verifier.TokenVerifier - prmJSON []byte - prmMap map[string]any + uri string + scopes []string + issuer string + verifier *verifier.TokenVerifier + prmJSON []byte + prmMap map[string]any + prmConfig PRMConfig + prmURL string +} + +// PRMConfig is the typed view of the Protected Resource Metadata document +// (RFC 9728), suitable for adapters that need to feed the values into a +// third-party PRM-serving handler (e.g. mark3labs/mcp-go's +// server.NewProtectedResourceMetadataHandler). +// +// Adapters should consume this instead of the dynamic map returned by +// PRMResponse: the field-by-field mapping keeps adapters in sync with the +// emitter — when buildPRM gains a new field, it appears here too, and the +// (unlikely) silent-drop class of bug from map-key typos or interface{} +// type-assertions is gone. +// +// Field naming and JSON encoding mirror RFC 9728 §2 (snake_case JSON, +// PascalCase Go), and only fields actually populated by Resource.buildPRM are +// present. Pointer-typed fields encode the difference between "field absent" +// (nil) and "field present with zero value" — only DPoPBoundAccessTokensRequired +// currently needs this. New fields added to buildPRM must be added here. +type PRMConfig struct { + Resource string + AuthorizationServers []string + BearerMethodsSupported []string + ScopesSupported []string + DPoPSigningAlgValuesSupported []string + DPoPBoundAccessTokensRequired *bool } // Option configures a Resource. @@ -61,6 +88,16 @@ func (r *Resource) URI() string { return r.uri } +// PRMURL returns the absolute Protected Resource Metadata URL (RFC 9728 §3), +// e.g. "https://api.example.com/.well-known/oauth-protected-resource/mcp". +// +// The value is precomputed at construction time from the already-validated +// resource URI, so this accessor is infallible — adapters should consume it +// instead of re-deriving the URL from URI() and WellKnownPRMPath(). +func (r *Resource) PRMURL() string { + return r.prmURL +} + // WellKnownPRMPath returns the RFC 9728 well-known path for this resource. // The path is formed by inserting "/.well-known/oauth-protected-resource" // between the host and the path component of the resource URI. @@ -83,10 +120,21 @@ func wellKnownPRMPath(resourceURI string) string { } // New creates a new Resource. +// +// The resource URI must be an absolute URL with a scheme and host — +// `url.ParseRequestURI` alone accepts absolute paths like `/mcp` and +// non-authority schemes, neither of which can anchor a DPoP htu binding +// or a PRM well-known URL. Rejecting them at this boundary keeps the +// invariant that downstream consumers (the HTTP adapter, the PRM emitter) +// rely on consistent. func New(uri, issuer string, jwksCache *verifier.JWKSCache, opts ...Option) (*Resource, error) { - if _, err := url.ParseRequestURI(uri); err != nil { + parsed, err := url.ParseRequestURI(uri) + if err != nil { return nil, fmt.Errorf("resource: invalid resource URI: %w", err) } + if parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("resource: resource URI must be absolute with scheme and host, got %q", uri) + } cfg := &resourceConfig{} for _, opt := range opts { @@ -132,22 +180,73 @@ func (r *Resource) PRMJSON() []byte { return cp } +// PRMConfig returns the Protected Resource Metadata document as a typed +// struct. Slice fields are copied; the returned value is safe to mutate +// without affecting subsequent calls. +// +// Prefer this over PRMResponse when feeding the values into another +// PRM-serving library — it removes the map-key typo and interface{} +// type-assertion failure modes. +func (r *Resource) PRMConfig() PRMConfig { + cp := r.prmConfig + if r.prmConfig.AuthorizationServers != nil { + cp.AuthorizationServers = append([]string(nil), r.prmConfig.AuthorizationServers...) + } + if r.prmConfig.BearerMethodsSupported != nil { + cp.BearerMethodsSupported = append([]string(nil), r.prmConfig.BearerMethodsSupported...) + } + if r.prmConfig.ScopesSupported != nil { + cp.ScopesSupported = append([]string(nil), r.prmConfig.ScopesSupported...) + } + if r.prmConfig.DPoPSigningAlgValuesSupported != nil { + cp.DPoPSigningAlgValuesSupported = append([]string(nil), r.prmConfig.DPoPSigningAlgValuesSupported...) + } + if r.prmConfig.DPoPBoundAccessTokensRequired != nil { + v := *r.prmConfig.DPoPBoundAccessTokensRequired + cp.DPoPBoundAccessTokensRequired = &v + } + return cp +} + func (r *Resource) buildPRM() { - prm := map[string]any{ - "resource": r.uri, - "authorization_servers": []string{r.issuer}, - "bearer_methods_supported": []string{"header"}, + cfg := PRMConfig{ + Resource: r.uri, + AuthorizationServers: []string{r.issuer}, + BearerMethodsSupported: []string{"header"}, } if len(r.scopes) > 0 { - prm["scopes_supported"] = r.scopes + cfg.ScopesSupported = r.scopes } // RFC 9728 §2: advertise DPoP signing algorithms when DPoP is supported. if dpop := r.verifier.InboundDPoPView(); dpop != nil { - prm["dpop_signing_alg_values_supported"] = dpop.AllowedAlgorithmStrings + cfg.DPoPSigningAlgValuesSupported = dpop.AllowedAlgorithmStrings if dpop.Required { - prm["dpop_bound_access_tokens_required"] = true + t := true + cfg.DPoPBoundAccessTokensRequired = &t } } + r.prmConfig = cfg + + prm := map[string]any{ + "resource": cfg.Resource, + "authorization_servers": cfg.AuthorizationServers, + "bearer_methods_supported": cfg.BearerMethodsSupported, + } + if cfg.ScopesSupported != nil { + prm["scopes_supported"] = cfg.ScopesSupported + } + if cfg.DPoPSigningAlgValuesSupported != nil { + prm["dpop_signing_alg_values_supported"] = cfg.DPoPSigningAlgValuesSupported + } + if cfg.DPoPBoundAccessTokensRequired != nil { + prm["dpop_bound_access_tokens_required"] = *cfg.DPoPBoundAccessTokensRequired + } r.prmMap = prm r.prmJSON, _ = json.Marshal(prm) + + // r.uri was validated by New (url.ParseRequestURI), so url.Parse cannot + // fail here — this is the single, infallible source of truth that + // adapters consume via PRMURL(). + u, _ := url.Parse(r.uri) + r.prmURL = u.ResolveReference(&url.URL{Path: wellKnownPRMPath(r.uri)}).String() } diff --git a/core/resource/resource_test.go b/core/resource/resource_test.go index 420eead..8ef3854 100644 --- a/core/resource/resource_test.go +++ b/core/resource/resource_test.go @@ -157,6 +157,64 @@ func TestPRMResponse_ReturnsCopy(t *testing.T) { } } +func TestPRMURL(t *testing.T) { + tests := []struct { + name string + resourceURI string + want string + }{ + { + name: "root resource", + resourceURI: "https://api.example.com", + want: "https://api.example.com/.well-known/oauth-protected-resource", + }, + { + name: "single-segment path", + resourceURI: "https://api.example.com/mcp", + want: "https://api.example.com/.well-known/oauth-protected-resource/mcp", + }, + { + name: "multi-segment path", + resourceURI: "https://api.example.com/v2/mcp", + want: "https://api.example.com/.well-known/oauth-protected-resource/v2/mcp", + }, + { + // url.ResolveReference preserves trailing slashes in the resource path; + // pin that here so the contract doesn't drift. + name: "trailing slash preserved", + resourceURI: "https://api.example.com/mcp/", + want: "https://api.example.com/.well-known/oauth-protected-resource/mcp/", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + key, err := testutil.GenerateES256Key() + if err != nil { + t.Fatalf("generate key: %v", err) + } + jwksData, err := testutil.BuildJWKSWithKID(&key.PublicKey, testKID) + if err != nil { + t.Fatalf("build jwks: %v", err) + } + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return jwksData, nil, nil + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + res, err := resource.New(tc.resourceURI, testIssuer, jc) + if err != nil { + t.Fatalf("resource.New: %v", err) + } + if got := res.PRMURL(); got != tc.want { + t.Errorf("PRMURL() = %q, want %q", got, tc.want) + } + }) + } +} + func TestPRMResponse_DPoPNotConfigured_OmitsDPoPFields(t *testing.T) { res, _ := makeResource(t) prm := res.PRMResponse() @@ -199,6 +257,68 @@ func TestPRMResponse_DPoPRequired(t *testing.T) { } } +func TestPRMConfig_BaseFields(t *testing.T) { + res, _ := makeResource(t, resource.WithScopes("read", "write")) + cfg := res.PRMConfig() + + if cfg.Resource != testResource { + t.Errorf("Resource = %q, want %q", cfg.Resource, testResource) + } + if got := cfg.AuthorizationServers; len(got) != 1 || got[0] != testIssuer { + t.Errorf("AuthorizationServers = %v, want [%q]", got, testIssuer) + } + if got := cfg.BearerMethodsSupported; len(got) != 1 || got[0] != "header" { + t.Errorf("BearerMethodsSupported = %v, want [header]", got) + } + if got := cfg.ScopesSupported; len(got) != 2 || got[0] != "read" || got[1] != "write" { + t.Errorf("ScopesSupported = %v, want [read write]", got) + } + if cfg.DPoPSigningAlgValuesSupported != nil { + t.Errorf("DPoPSigningAlgValuesSupported = %v, want nil when DPoP unset", cfg.DPoPSigningAlgValuesSupported) + } + if cfg.DPoPBoundAccessTokensRequired != nil { + t.Errorf("DPoPBoundAccessTokensRequired = %v, want nil when DPoP unset", *cfg.DPoPBoundAccessTokensRequired) + } +} + +func TestPRMConfig_DPoPRequired(t *testing.T) { + res, _ := makeResource(t, + resource.WithVerifierOptions(verifier.WithInboundDPoP(verifier.InboundDPoPOptions{ + AllowedProofAlgorithms: []string{"ES256"}, + Required: true, + })), + ) + cfg := res.PRMConfig() + + if got := cfg.DPoPSigningAlgValuesSupported; len(got) != 1 || got[0] != "ES256" { + t.Errorf("DPoPSigningAlgValuesSupported = %v, want [ES256]", got) + } + if cfg.DPoPBoundAccessTokensRequired == nil || !*cfg.DPoPBoundAccessTokensRequired { + t.Errorf("DPoPBoundAccessTokensRequired = %v, want *true", cfg.DPoPBoundAccessTokensRequired) + } +} + +func TestPRMConfig_ReturnsIndependentCopy(t *testing.T) { + res, _ := makeResource(t, resource.WithScopes("read")) + cfg := res.PRMConfig() + + // Mutating the returned slices must not affect a subsequent call. + cfg.AuthorizationServers[0] = "injected" + cfg.ScopesSupported[0] = "injected" + cfg.BearerMethodsSupported = append(cfg.BearerMethodsSupported, "injected") + + cfg2 := res.PRMConfig() + if cfg2.AuthorizationServers[0] == "injected" { + t.Error("PRMConfig should return independent slices (AuthorizationServers leaked)") + } + if cfg2.ScopesSupported[0] == "injected" { + t.Error("PRMConfig should return independent slices (ScopesSupported leaked)") + } + if len(cfg2.BearerMethodsSupported) != 1 { + t.Errorf("PRMConfig should return independent slices (BearerMethodsSupported len = %d)", len(cfg2.BearerMethodsSupported)) + } +} + // ─────────────────────────── VerifyToken tests ─────────────────────────────── func TestVerifyToken_ValidToken(t *testing.T) { @@ -686,3 +806,37 @@ func TestNew_InvalidVerifierOption(t *testing.T) { t.Fatal("expected error for HMAC algorithm, got nil") } } + +// resource.New must reject URIs that ParseRequestURI accepts but that +// can't anchor a DPoP htu binding or a PRM URL — scheme-less absolute +// paths (`/mcp`) and authority-less schemes. Pushing the rejection up to +// the boundary the operator calls means downstream consumers (the HTTP +// adapter's resourceOrigin, the PRM emitter) can rely on Scheme + Host +// being non-empty without defensive panics. +func TestNew_RejectsInvalidResourceURI(t *testing.T) { + jc := verifier.NewJWKSCache(verifier.JWKSCacheConfig{ + FetchFn: func(ctx context.Context) ([]byte, map[string][]string, error) { + return nil, nil, fmt.Errorf("not called") + }, + DefaultTTL: time.Hour, + }) + t.Cleanup(jc.Close) + + tests := []struct { + name string + uri string + }{ + {"scheme-less absolute path", "/mcp"}, + {"authority-less scheme", "file:///tmp/mcp"}, + {"empty", ""}, + {"malformed", "://no-scheme"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resource.New(tt.uri, testIssuer, jc) + if err == nil { + t.Fatalf("resource.New(%q): expected error, got nil", tt.uri) + } + }) + } +} diff --git a/core/resource/verifier/claims.go b/core/resource/verifier/claims.go index 2f19467..65e3b7d 100644 --- a/core/resource/verifier/claims.go +++ b/core/resource/verifier/claims.go @@ -99,11 +99,78 @@ func (c *VerifiedClaims) HasScope(scope string) bool { } // RequireScope returns ErrInsufficientScope if the token lacks the given scope. +// +// Thin wrapper over [RequireScopes] so the singular helper carries the +// same enriched `required scope "X"; token has scopes: …` shape the +// plural one does. The wire body produced from this path is byte-identical +// to a `claims.RequireScopes(scope)` call. Note that the adapter middleware +// at github.com/authplane/go-sdk/http/pkg/authplanehttp.Adapter.RequireScopes +// still loops over scopes and returns on the first miss, so a multi-scope +// adapter failure names only one scope; a direct +// `claims.RequireScopes("a", "b", ...)` call names every missing scope. +// The two paths converge only on the single-missing-scope case until the +// adapter is switched over to the plural helper. func (c *VerifiedClaims) RequireScope(scope string) error { - if !c.HasScope(scope) { - return fmt.Errorf("%w: required %q", ErrInsufficientScope, scope) + return c.RequireScopes(scope) +} + +// RequireScopes returns ErrInsufficientScope unless the token carries every +// scope in scopes. Empty input is a no-op (no required scopes ⇒ always +// satisfied), matching the adapter-middleware +// github.com/authplane/go-sdk/http/pkg/authplanehttp.Adapter.RequireScopes +// semantic. +// +// On failure the returned error names every missing scope and the scopes +// the token does carry, so the adapter can surface it verbatim in the +// `error_description` of the `WWW-Authenticate` challenge without an +// out-of-band log lookup. The error wraps ErrInsufficientScope, so +// adapters that already branch on `errors.Is(err, ErrInsufficientScope)` +// (e.g. `resource.HTTPStatus`) keep producing a 403 with the right +// `scope="..."` parameter when the surrounding ScopeError carries the +// full required-scope list. +func (c *VerifiedClaims) RequireScopes(scopes ...string) error { + if len(scopes) == 0 { + return nil } - return nil + var missing []string + for _, scope := range scopes { + if !c.HasScope(scope) { + missing = append(missing, scope) + } + } + if len(missing) == 0 { + return nil + } + plural := "" + if len(missing) > 1 { + plural = "s" + } + available := "(none)" + if len(c.scopes) > 0 { + available = strings.Join(c.scopes, " ") + } + return fmt.Errorf( + "%w: required scope%s %s; token has scopes: %s", + ErrInsufficientScope, + plural, + strings.Join(quoteAll(missing), ", "), + available, + ) +} + +// quoteAll wraps each entry in %q-style double-quotes so the rendered +// error_description visually delimits each scope and stays unambiguous +// even if a malformed token carries scope tokens containing control +// characters. RFC 6749 §3.3 (`scope-token = 1*( %x21 / %x23-5B / +// %x5D-7E )`) explicitly forbids whitespace inside a scope, so this is +// defense against non-conformant inputs rather than a spec-permitted +// case. +func quoteAll(values []string) []string { + out := make([]string, len(values)) + for i, v := range values { + out[i] = fmt.Sprintf("%q", v) + } + return out } // HasClaim returns true if the raw claims contain the given key. diff --git a/core/resource/verifier/claims_test.go b/core/resource/verifier/claims_test.go index 9343893..eb944d4 100644 --- a/core/resource/verifier/claims_test.go +++ b/core/resource/verifier/claims_test.go @@ -2,6 +2,7 @@ package verifier import ( "errors" + "strings" "testing" ) @@ -132,6 +133,120 @@ func TestVerifiedClaims_RequireScope(t *testing.T) { } } +func TestVerifiedClaims_RequireScope_EnrichedErrorString(t *testing.T) { + // RequireScope now delegates to RequireScopes, which means the singular + // path also carries the `required scope "X"; token has scopes: …` + // rich shape. Pin the wire body so a future refactor that reverts the + // delegation (or changes the message format) fails this test instead + // of silently regressing the adapter's WWW-Authenticate error_description. + c := ParseClaims(testClaims(), "kid") + err := c.RequireScope("delete") + if err == nil { + t.Fatal("RequireScope('delete') should fail") + } + msg := err.Error() + // Positive pins. + for _, want := range []string{ + `required scope "delete"`, + "token has scopes:", + "read", // any one of the granted scopes must surface + } { + if !strings.Contains(msg, want) { + t.Errorf("RequireScope error %q missing %q", msg, want) + } + } + // Negative pin: must not regress to the pre-enrichment terser shape. + if strings.Contains(msg, `required "delete"`) { + t.Errorf("RequireScope error %q regressed to the pre-enrichment shape", msg) + } +} + +func TestVerifiedClaims_RequireScopes_EmptyInputIsNoOp(t *testing.T) { + // Empty input must not error — matches the adapter-middleware + // `(*Adapter).RequireScopes(...)` semantic in http/pkg/authplanehttp. + // A handler that calls `claims.RequireScopes()` with no args should + // not need to special-case the empty-slice path. + c := ParseClaims(testClaims(), "kid") + if err := c.RequireScopes(); err != nil { + t.Errorf("RequireScopes() (no args) should be a no-op, got %v", err) + } + if err := c.RequireScopes([]string{}...); err != nil { + t.Errorf("RequireScopes() should be a no-op, got %v", err) + } +} + +func TestVerifiedClaims_RequireScopes_AllPresent(t *testing.T) { + // testClaims() carries "read write admin". The union must succeed. + c := ParseClaims(testClaims(), "kid") + if err := c.RequireScopes("read", "write", "admin"); err != nil { + t.Errorf("RequireScopes('read', 'write', 'admin') should succeed: %v", err) + } +} + +func TestVerifiedClaims_RequireScopes_SingleMissing(t *testing.T) { + c := ParseClaims(testClaims(), "kid") + err := c.RequireScopes("read", "delete") + if err == nil { + t.Fatal("RequireScopes('read', 'delete') should fail") + } + if !errors.Is(err, ErrInsufficientScope) { + t.Errorf("expected ErrInsufficientScope, got %v", err) + } + // Error names the missing scope (singular "scope") and the available set. + msg := err.Error() + if !strings.Contains(msg, `"delete"`) { + t.Errorf("error %q should mention missing scope %q", msg, "delete") + } + if strings.Contains(msg, "required scopes ") { + t.Errorf("error %q should use singular \"required scope\" form for one missing entry", msg) + } + if !strings.Contains(msg, "token has scopes:") { + t.Errorf("error %q should surface the token's available scopes", msg) + } +} + +func TestVerifiedClaims_RequireScopes_MultipleMissing(t *testing.T) { + c := ParseClaims(testClaims(), "kid") + // Both "delete" and "billing" are NOT in the test fixture (which has + // "read write admin"), so both must be reported. + err := c.RequireScopes("delete", "billing") + if err == nil { + t.Fatal("RequireScopes('delete', 'billing') should fail") + } + if !errors.Is(err, ErrInsufficientScope) { + t.Errorf("expected ErrInsufficientScope, got %v", err) + } + msg := err.Error() + for _, want := range []string{`"delete"`, `"billing"`, "required scopes "} { + if !strings.Contains(msg, want) { + t.Errorf("error %q should contain %q", msg, want) + } + } +} + +func TestVerifiedClaims_RequireScopes_NoScopesToken(t *testing.T) { + // A token without any scopes claim must render "token has scopes: + // (none)" so a debugging operator can tell the difference between + // "wrong scope" and "no scopes at all". + raw := map[string]any{ + "sub": "user_1", + "client_id": "client_1", + "iss": "https://issuer.example.com", + "aud": "https://api.example.com", + "exp": float64(9_999_999_999), + "iat": float64(1_700_000_000), + "jti": "jti-1", + } + c := ParseClaims(raw, "kid") + err := c.RequireScopes("read") + if err == nil { + t.Fatal("expected RequireScopes('read') to fail on a scope-less token") + } + if !strings.Contains(err.Error(), "(none)") { + t.Errorf("error %q should surface \"(none)\" for a scope-less token", err) + } +} + func TestVerifiedClaims_HasClaim(t *testing.T) { c := ParseClaims(testClaims(), "kid") if !c.HasClaim("custom") { diff --git a/core/resource/verifier/dpop.go b/core/resource/verifier/dpop.go index de4e99e..3f30100 100644 --- a/core/resource/verifier/dpop.go +++ b/core/resource/verifier/dpop.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/authplane/go-sdk/core/internal/dpop" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" ) @@ -58,9 +59,9 @@ const DefaultDPoPProofLifetime = 300 * time.Second // // rawToken may be empty when validating DPoP proofs not bound to an access // token (e.g., outbound proofs sent to a token endpoint). -func (v *TokenVerifier) validateDPoPProof(dpop *DPoPContext, rawToken string) (*VerifiedDPoPProof, error) { +func (v *TokenVerifier) validateDPoPProof(dpopCtx *DPoPContext, rawToken string) (*VerifiedDPoPProof, error) { cfg := v.inboundDPoP - parsed, err := jwt.ParseSigned(dpop.Proof, cfg.algorithms) + parsed, err := jwt.ParseSigned(dpopCtx.Proof(), cfg.algorithms) if err != nil { return nil, fmt.Errorf("%w: parse: %v", ErrDPoPInvalid, err) } @@ -87,10 +88,10 @@ func (v *TokenVerifier) validateDPoPProof(dpop *DPoPContext, rawToken string) (* if claims.JTI == "" { return nil, fmt.Errorf("%w: missing jti", ErrDPoPInvalid) } - if claims.HTM != dpop.Method { + if claims.HTM != dpopCtx.Method { return nil, fmt.Errorf("%w: htm mismatch", ErrDPoPInvalid) } - if err := validateHTU(claims.HTU, dpop.URL); err != nil { + if err := validateHTU(claims.HTU, dpopCtx.URL); err != nil { return nil, fmt.Errorf("%w: %v", ErrDPoPInvalid, err) } @@ -204,10 +205,22 @@ func validateHTU(htu, reqURL string) error { if !strings.EqualFold(htuParsed.Scheme, reqParsed.Scheme) { return fmt.Errorf("scheme mismatch") } - if !strings.EqualFold(htuParsed.Host, reqParsed.Host) { + // Default-port stripping (RFC 9110 §7.2) is shared with the outbound + // signer (`core/authplane.normalizeHTU`) via the internal helper so the + // two sides cannot drift: an operator configuring the resource as + // `http://api.example.com:80/mcp` would otherwise mismatch any client + // that signed the port-less form, and vice versa. + if !strings.EqualFold(dpop.NormalizeHost(htuParsed), dpop.NormalizeHost(reqParsed)) { return fmt.Errorf("host mismatch") } - if htuParsed.Path != reqParsed.Path { + // Compare the raw escaped form rather than url.URL.Path: the latter is + // percent-decoded, which conflates `%2F` with `/` and weakens the + // RFC 9449 §4.3 `htu` binding (RFC 3986 §6.2.2.2 only permits decoding + // unreserved characters when comparing URLs). Collapse an empty path + // to "/" on both sides so a bare-origin htu (e.g. `https://host`, + // EscapedPath `""`) still matches an inbound request whose + // `r.URL.EscapedPath()` is always at least `"/"`. + if dpop.NormalizePath(htuParsed.EscapedPath()) != dpop.NormalizePath(reqParsed.EscapedPath()) { return fmt.Errorf("path mismatch") } return nil diff --git a/core/resource/verifier/dpop_test.go b/core/resource/verifier/dpop_test.go index d28763d..20e5c8f 100644 --- a/core/resource/verifier/dpop_test.go +++ b/core/resource/verifier/dpop_test.go @@ -107,6 +107,22 @@ func newTestECKey(t *testing.T) *ecdsa.PrivateKey { return key } +// mustCtx routes every test that exercises validateDPoPProof through +// NewDPoPContext so the factory remains the only entry point — direct +// struct literals would bypass §4.3 enforcement and defeat the +// architectural guarantee this PR established. Errors from +// NewDPoPContext are fatal because the tests below always pass a +// single non-blank proof; the constructor only errors for cardinality +// violations. +func mustCtx(t *testing.T, method, url, proof string) *DPoPContext { + t.Helper() + ctx, err := NewDPoPContext(method, url, []string{proof}) + if err != nil { + t.Fatalf("NewDPoPContext(%q, %q, [...]): %v", method, url, err) + } + return ctx +} + // inMemoryReplayStore is a simple in-memory DPoP replay store for tests. type inMemoryReplayStore struct { seen map[string]time.Time @@ -154,7 +170,7 @@ func TestValidateDPoPProof_Valid(t *testing.T) { }) v := newDPoPTestVerifier(t, nil) - result, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: htu, Proof: proof}, rawToken) + result, err := v.validateDPoPProof(mustCtx(t, method, htu, proof), rawToken) if err != nil { t.Fatalf("expected no error, got: %v", err) } @@ -187,7 +203,7 @@ func TestValidateDPoPProof_HTMMismatch(t *testing.T) { proof := generateDPoPProof(t, key, "GET", htu, "", nil) v := newDPoPTestVerifier(t, nil) - _, err := v.validateDPoPProof(&DPoPContext{Method: "POST", URL: htu, Proof: proof}, "") + _, err := v.validateDPoPProof(mustCtx(t, "POST", htu, proof), "") if !errors.Is(err, ErrDPoPInvalid) { t.Fatalf("expected ErrDPoPInvalid, got: %v", err) } @@ -199,12 +215,71 @@ func TestValidateDPoPProof_HTUMismatch(t *testing.T) { proof := generateDPoPProof(t, key, method, "https://example.com/resource", "", nil) v := newDPoPTestVerifier(t, nil) - _, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: "https://example.com/other", Proof: proof}, "") + _, err := v.validateDPoPProof(mustCtx(t, method, "https://example.com/other", proof), "") if !errors.Is(err, ErrDPoPInvalid) { t.Fatalf("expected ErrDPoPInvalid, got: %v", err) } } +// Encoded slash in the proof's htu must NOT match a literal slash in the +// request path. url.URL.Path is percent-decoded, so the previous +// `htuParsed.Path != reqParsed.Path` check conflated `%2F` with `/`, +// weakening the RFC 9449 §4.3 htu binding (RFC 3986 §6.2.2.2 only permits +// decoding *unreserved* chars when comparing URLs). The fix compares +// EscapedPath instead. +func TestValidateDPoPProof_EncodedSlashIsNotPlainSlash(t *testing.T) { + key := newTestECKey(t) + method := "GET" + proof := generateDPoPProof(t, key, method, "https://example.com/foo%2Fbar", "", nil) + + v := newDPoPTestVerifier(t, nil) + _, err := v.validateDPoPProof(mustCtx(t, method, "https://example.com/foo/bar", proof), "") + if !errors.Is(err, ErrDPoPInvalid) { + t.Fatalf("expected ErrDPoPInvalid for %%2F vs / mismatch, got: %v", err) + } +} + +// Percent-encoded triplets MUST compare case-sensitively. RFC 3986 +// §6.2.2.1 *allows* `%2f` and `%2F` to be considered equivalent, but the +// verifier deliberately compares byte-for-byte — folding case unilaterally +// would let the verifier accept a proof that a byte-exact signer rejects. +// Pin the current contract here so a future "let's just uppercase the +// triplets" patch can't land without the question being answered. +func TestValidateDPoPProof_PercentEncodedCaseIsNotFolded(t *testing.T) { + key := newTestECKey(t) + method := "GET" + // Proof's htu carries lower-case `%2f`; request path carries + // upper-case `%2F`. Same byte semantics under RFC 3986 §6.2.2.1, + // but the verifier intentionally treats them as distinct to keep + // the htu binding byte-exact. + proof := generateDPoPProof(t, key, method, "https://example.com/foo%2fbar", "", nil) + + v := newDPoPTestVerifier(t, nil) + _, err := v.validateDPoPProof(mustCtx(t, method, "https://example.com/foo%2Fbar", proof), "") + if !errors.Is(err, ErrDPoPInvalid) { + t.Fatalf("expected ErrDPoPInvalid for %%2f vs %%2F mismatch, got: %v", err) + } +} + +// Bare-origin htu (no path component) must compare equal to a request whose +// path is `/`. The verifier collapses `""` to `"/"` before comparing via the +// shared dpop.NormalizePath helper. Without the collapse, a client that +// signed a bare origin would silently fail against a server where every +// inbound `r.URL.EscapedPath()` is at least `/`. +func TestValidateDPoPProof_EmptyPathEqualsSlash(t *testing.T) { + key := newTestECKey(t) + method := "GET" + // Proof signed for `https://example.com` (no path) — matches what the + // outbound `normalizeHTU` emits for a bare-origin URL. + proof := generateDPoPProof(t, key, method, "https://example.com", "", nil) + + v := newDPoPTestVerifier(t, nil) + // Inbound request always carries at least `/`. + if _, err := v.validateDPoPProof(mustCtx(t, method, "https://example.com/", proof), ""); err != nil { + t.Fatalf("expected empty-path htu to match request path `/`, got: %v", err) + } +} + func TestValidateDPoPProof_MissingJTI(t *testing.T) { key := newTestECKey(t) method := "GET" @@ -212,7 +287,7 @@ func TestValidateDPoPProof_MissingJTI(t *testing.T) { proof := generateDPoPProof(t, key, method, htu, "", map[string]any{"jti": nil}) v := newDPoPTestVerifier(t, nil) - _, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: htu, Proof: proof}, "") + _, err := v.validateDPoPProof(mustCtx(t, method, htu, proof), "") if !errors.Is(err, ErrDPoPInvalid) { t.Fatalf("expected ErrDPoPInvalid, got: %v", err) } @@ -227,7 +302,7 @@ func TestValidateDPoPProof_StaleProof(t *testing.T) { proof := generateDPoPProof(t, key, method, htu, "", map[string]any{"iat": oldIAT}) v := newDPoPTestVerifier(t, nil) - _, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: htu, Proof: proof}, "") + _, err := v.validateDPoPProof(mustCtx(t, method, htu, proof), "") if !errors.Is(err, ErrDPoPInvalid) { t.Fatalf("expected ErrDPoPInvalid, got: %v", err) } @@ -241,7 +316,7 @@ func TestValidateDPoPProof_ATHMismatch(t *testing.T) { proof := generateDPoPProof(t, key, method, htu, "token-A", nil) v := newDPoPTestVerifier(t, nil) - _, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: htu, Proof: proof}, "token-B") + _, err := v.validateDPoPProof(mustCtx(t, method, htu, proof), "token-B") if !errors.Is(err, ErrDPoPInvalid) { t.Fatalf("expected ErrDPoPInvalid, got: %v", err) } @@ -254,7 +329,7 @@ func TestValidateDPoPProof_WrongTyp(t *testing.T) { proof := generateDPoPProofWithTyp(t, key, method, htu, "JWT") v := newDPoPTestVerifier(t, nil) - _, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: htu, Proof: proof}, "") + _, err := v.validateDPoPProof(mustCtx(t, method, htu, proof), "") if !errors.Is(err, ErrDPoPInvalid) { t.Fatalf("expected ErrDPoPInvalid, got: %v", err) } @@ -270,13 +345,13 @@ func TestValidateDPoPProof_ReplayDetection(t *testing.T) { v := newDPoPTestVerifier(t, store) // First use: should succeed. - _, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: htu, Proof: proof}, "") + _, err := v.validateDPoPProof(mustCtx(t, method, htu, proof), "") if err != nil { t.Fatalf("first use: expected no error, got: %v", err) } // Second use of the same proof: replay detected. - _, err = v.validateDPoPProof(&DPoPContext{Method: method, URL: htu, Proof: proof}, "") + _, err = v.validateDPoPProof(mustCtx(t, method, htu, proof), "") if !errors.Is(err, ErrDPoPReplayDetected) { t.Fatalf("second use: expected ErrDPoPReplayDetected, got: %v", err) } @@ -290,7 +365,7 @@ func TestValidateDPoPProof_ValidNoAccessToken(t *testing.T) { proof := generateDPoPProof(t, key, method, htu, "", nil) v := newDPoPTestVerifier(t, nil) - result, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: htu, Proof: proof}, "") + result, err := v.validateDPoPProof(mustCtx(t, method, htu, proof), "") if err != nil { t.Fatalf("expected no error, got: %v", err) } @@ -410,7 +485,7 @@ func TestVerifyToken_DPoPBound_ValidProof(t *testing.T) { proof := generateDPoPProof(t, dpopKey, method, reqURL, rawToken, nil) v := newDPoPTestVerifier(t, nil) - result, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: reqURL, Proof: proof}, rawToken) + result, err := v.validateDPoPProof(mustCtx(t, method, reqURL, proof), rawToken) if err != nil { t.Fatalf("validateDPoPProof: %v", err) } @@ -440,7 +515,7 @@ func TestVerifyToken_DPoPBound_WrongKey(t *testing.T) { proof := generateDPoPProof(t, dpopKey2, method, reqURL, rawToken, nil) v := newDPoPTestVerifier(t, nil) - result, err := v.validateDPoPProof(&DPoPContext{Method: method, URL: reqURL, Proof: proof}, rawToken) + result, err := v.validateDPoPProof(mustCtx(t, method, reqURL, proof), rawToken) if err != nil { t.Fatalf("validateDPoPProof: %v", err) } diff --git a/core/resource/verifier/errors.go b/core/resource/verifier/errors.go index f537297..c04573a 100644 --- a/core/resource/verifier/errors.go +++ b/core/resource/verifier/errors.go @@ -18,6 +18,13 @@ var ( ErrDPoPInvalid = errors.New("verifier: DPoP invalid") ErrDPoPKeyMismatch = errors.New("verifier: DPoP key mismatch") ErrDPoPReplayDetected = errors.New("verifier: DPoP replay detected") + // ErrMultipleDpopProofs is returned when an inbound request carries more + // than one DPoP HTTP header. RFC 9449 §4.3 #1 is a MUST-level + // receiving-server check; the spec-correct response per §7.1 is HTTP 401 + // with `WWW-Authenticate: DPoP error="invalid_dpop_proof"`. The other + // ErrDPoP* shapes in this SDK still emit invalid_token — only this §4.3 + // error code follows the §7.1 prescription. + ErrMultipleDpopProofs = errors.New("verifier: multiple DPoP proofs") // ErrDPoPBindingMismatch signals a structural mismatch between the DPoP // proof and the access token's binding — distinct from ErrDPoPKeyMismatch // (jkt thumbprint disagreement) and ErrDPoPInvalid (proof JWT itself diff --git a/core/resource/verifier/policy_test.go b/core/resource/verifier/policy_test.go index d4a0e9d..5bc0ecd 100644 --- a/core/resource/verifier/policy_test.go +++ b/core/resource/verifier/policy_test.go @@ -42,11 +42,11 @@ func TestVerifyToken_Policy_NotConfigured_DPoPBoundToken(t *testing.T) { t.Fatalf("generate proof: %v", err) } - _, err = v.VerifyToken(context.Background(), token, &verifier.DPoPContext{ - Method: method, - URL: url, - Proof: proof, - }) + dpopCtx, err := verifier.NewDPoPContext(method, url, []string{proof}) + if err != nil { + t.Fatalf("NewDPoPContext: %v", err) + } + _, err = v.VerifyToken(context.Background(), token, dpopCtx) if !errors.Is(err, verifier.ErrDPoPNotSupported) { t.Fatalf("expected ErrDPoPNotSupported, got %v", err) } @@ -140,11 +140,11 @@ func TestVerifyToken_DPoPProof_FutureIatRejected(t *testing.T) { t.Fatalf("serialize proof: %v", err) } - _, err = v.VerifyToken(context.Background(), token, &verifier.DPoPContext{ - Method: method, - URL: url, - Proof: proof, - }) + dpopCtx, err := verifier.NewDPoPContext(method, url, []string{proof}) + if err != nil { + t.Fatalf("NewDPoPContext: %v", err) + } + _, err = v.VerifyToken(context.Background(), token, dpopCtx) if !errors.Is(err, verifier.ErrDPoPInvalid) { t.Fatalf("expected ErrDPoPInvalid for future-iat proof, got %v", err) } diff --git a/core/resource/verifier/types.go b/core/resource/verifier/types.go index 58f9bbe..a3ac312 100644 --- a/core/resource/verifier/types.go +++ b/core/resource/verifier/types.go @@ -3,6 +3,8 @@ package verifier import ( "context" "encoding/json" + "fmt" + "strings" "time" ) @@ -11,10 +13,76 @@ import ( // Resource-level DPoP policy (replay store, proof lifetime, clock skew, // allowed algorithms, required flag) lives in InboundDPoPOptions and is // configured on the verifier via WithInboundDPoP. +// +// The proof slice is unexported so RFC 9449 §4.3 #1 ("not more than one +// DPoP HTTP request header field") has a single enforcement point: +// NewDPoPContext. Callers outside this package cannot construct an +// invalid DPoPContext — the only way to populate the proof is through +// the constructor, which filters blanks, splits on "," defensively, and +// returns ErrMultipleDpopProofs on > 1 non-blank value: invalid states are +// unconstructable rather than guarded after the fact. type DPoPContext struct { Method string URL string - Proof string + proofs []string +} + +// NewDPoPContext builds a DPoPContext from the raw DPoP header values the +// framework adapter pulled off the inbound request, enforcing RFC 9449 +// §4.3 #1 as the SDK's canonical boundary. +// +// dpopHeaderValues is the list of values the HTTP layer extracted from the +// "DPoP" header on the inbound request. Pass: +// - nil or empty when no DPoP header is present. +// - []string{""} when exactly one DPoP header is present. +// - r.Header.Values("DPoP") to surface duplicates the wire delivered. +// +// Net/http's Header.Values returns each duplicate-named header as its +// own []string entry, so a request with two DPoP headers reaches this +// factory as a 2-element slice and trips the cardinality guard +// directly. The split-on-"," pass is defense-in-depth for upstream +// proxies or frameworks (NGINX, Envoy, fasthttp clients, etc.) that +// pre-join duplicates into a single comma-separated value before the +// SDK sees them. JWS compact serialization never contains a literal +// comma, so split-on-comma is sound. Empty / whitespace-only entries +// are dropped; after filtering, more than one non-empty value returns +// ErrMultipleDpopProofs. +func NewDPoPContext(method, url string, dpopHeaderValues []string) (*DPoPContext, error) { + filtered := make([]string, 0, len(dpopHeaderValues)) + for _, raw := range dpopHeaderValues { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + // SplitN with n=3 bounds the allocation on an attacker-controlled + // header: we only need to distinguish 0 / 1 / ≥ 2 non-blank + // pieces, and any third entry already trips the cardinality + // guard below. + for _, part := range strings.SplitN(trimmed, ",", 3) { + piece := strings.TrimSpace(part) + if piece != "" { + filtered = append(filtered, piece) + } + } + } + if len(filtered) > 1 { + return nil, fmt.Errorf("%w: request carries %d DPoP proofs (RFC 9449 §4.3 forbids it)", ErrMultipleDpopProofs, len(filtered)) + } + return &DPoPContext{ + Method: method, + URL: url, + proofs: filtered, + }, nil +} + +// Proof returns the single DPoP proof carried by the request, or "" when +// no proof accompanied it. The accessor is nil-safe; the underlying +// slice is unexported and only NewDPoPContext can populate it. +func (c *DPoPContext) Proof() string { + if c == nil || len(c.proofs) == 0 { + return "" + } + return c.proofs[0] } // DPoPReplayStore checks and stores DPoP proof JTI values for replay prevention. diff --git a/core/resource/verifier/types_test.go b/core/resource/verifier/types_test.go new file mode 100644 index 0000000..4c811ea --- /dev/null +++ b/core/resource/verifier/types_test.go @@ -0,0 +1,83 @@ +package verifier + +import ( + "errors" + "testing" +) + +// TestNewDPoPContext_NoHeaders covers the no-DPoP fast path: an empty +// slice produces a context with no proof. +func TestNewDPoPContext_NoHeaders(t *testing.T) { + ctx, err := NewDPoPContext("POST", "https://api.example.com/mcp", nil) + if err != nil { + t.Fatalf("NewDPoPContext: %v", err) + } + if got := ctx.Proof(); got != "" { + t.Errorf("Proof() = %q, want \"\"", got) + } +} + +// TestNewDPoPContext_SingleProof covers the common happy path. +func TestNewDPoPContext_SingleProof(t *testing.T) { + ctx, err := NewDPoPContext("POST", "https://api.example.com/mcp", []string{"eyJ.proof.value"}) + if err != nil { + t.Fatalf("NewDPoPContext: %v", err) + } + if got, want := ctx.Proof(), "eyJ.proof.value"; got != want { + t.Errorf("Proof() = %q, want %q", got, want) + } +} + +// TestNewDPoPContext_FiltersBlanks ensures whitespace-only entries are +// dropped before the §4.3 cardinality check fires, matching the Java/TS +// reference implementations. +func TestNewDPoPContext_FiltersBlanks(t *testing.T) { + ctx, err := NewDPoPContext("POST", "https://api.example.com/mcp", []string{"", " ", " proof "}) + if err != nil { + t.Fatalf("NewDPoPContext: %v", err) + } + if got, want := ctx.Proof(), "proof"; got != want { + t.Errorf("Proof() = %q, want %q", got, want) + } +} + +// TestNewDPoPContext_RejectsMultipleProofs is the RFC 9449 §4.3 #1 +// boundary check: two distinct non-blank values must fail with +// ErrMultipleDpopProofs so adapters can surface a DPoP-scheme challenge. +func TestNewDPoPContext_RejectsMultipleProofs(t *testing.T) { + ctx, err := NewDPoPContext("POST", "https://api.example.com/mcp", []string{"eyJ.a", "eyJ.b"}) + if !errors.Is(err, ErrMultipleDpopProofs) { + t.Fatalf("err = %v, want ErrMultipleDpopProofs", err) + } + if ctx != nil { + t.Errorf("ctx = %v, want nil on §4.3 violation", ctx) + } +} + +// TestNewDPoPContext_RejectsCommaMergedProofs exercises the +// split-on-comma defensive detection. Upstream proxies / frameworks +// that pre-join duplicate same-name headers into one comma-separated +// value reach the factory as a single string; the SplitN pass surfaces +// the §4.3 violation. JWS compact serialization cannot contain a +// literal comma, so any comma in a real DPoP header is evidence of a +// previously-merged duplicate. +func TestNewDPoPContext_RejectsCommaMergedProofs(t *testing.T) { + _, err := NewDPoPContext("POST", "https://api.example.com/mcp", []string{"eyJ.a, eyJ.b"}) + if !errors.Is(err, ErrMultipleDpopProofs) { + t.Fatalf("err = %v, want ErrMultipleDpopProofs", err) + } +} + +// TestDPoPContext_Proof_NilSafe documents the convenience accessor's +// nil/empty semantics so the resource verifier can defer the "no proof" +// branch without a nil guard at every call site. +func TestDPoPContext_Proof_NilSafe(t *testing.T) { + var nilCtx *DPoPContext + if got := nilCtx.Proof(); got != "" { + t.Errorf("nil.Proof() = %q, want \"\"", got) + } + empty := &DPoPContext{} + if got := empty.Proof(); got != "" { + t.Errorf("empty.Proof() = %q, want \"\"", got) + } +} diff --git a/core/resource/verifier/verifier.go b/core/resource/verifier/verifier.go index b10c173..10f0b48 100644 --- a/core/resource/verifier/verifier.go +++ b/core/resource/verifier/verifier.go @@ -216,7 +216,7 @@ func (v *TokenVerifier) VerifyToken(ctx context.Context, rawToken string, dpop * // Bearer-only resource, bearer token: no DPoP enforcement. case v.inboundDPoP != nil && boundToToken: - if dpop == nil || dpop.Proof == "" { + if dpop == nil || dpop.Proof() == "" { return nil, ErrDPoPRequired } proof, err := v.validateDPoPProof(dpop, rawToken) @@ -235,7 +235,7 @@ func (v *TokenVerifier) VerifyToken(ctx context.Context, rawToken string, dpop * // Supported mode, bearer token. Reject if a proof is attached: the proof's // ath claim has nothing to bind to (RFC 9449 §7). Accepting it silently // would let a misconfigured client believe its proof was honored. - if dpop != nil && dpop.Proof != "" { + if dpop != nil && dpop.Proof() != "" { return nil, fmt.Errorf("%w: DPoP proof presented with a bearer-only access token (no cnf.jkt to bind to)", ErrDPoPBindingMismatch) } } diff --git a/core/resource/verifier/verifier_test.go b/core/resource/verifier/verifier_test.go index 08deb58..a97ceac 100644 --- a/core/resource/verifier/verifier_test.go +++ b/core/resource/verifier/verifier_test.go @@ -653,11 +653,11 @@ func TestVerifyToken_BearerTokenWithDPoPContext_DPoPProofIsNil(t *testing.T) { t.Fatalf("generate proof: %v", err) } - claims, err := v.VerifyToken(context.Background(), token, &verifier.DPoPContext{ - Method: "POST", - URL: "https://api.example.com/resource", - Proof: proof, - }) + dpopCtx, err := verifier.NewDPoPContext("POST", "https://api.example.com/resource", []string{proof}) + if err != nil { + t.Fatalf("NewDPoPContext: %v", err) + } + claims, err := v.VerifyToken(context.Background(), token, dpopCtx) if err != nil { t.Fatalf("verify: %v", err) } @@ -691,11 +691,11 @@ func TestVerifyToken_DPoPBound_DPoPProofIsPopulated(t *testing.T) { } afterIAT := time.Now().Unix() - claims, err := v.VerifyToken(context.Background(), token, &verifier.DPoPContext{ - Method: method, - URL: url, - Proof: proof, - }) + dpopCtx, err := verifier.NewDPoPContext(method, url, []string{proof}) + if err != nil { + t.Fatalf("NewDPoPContext: %v", err) + } + claims, err := v.VerifyToken(context.Background(), token, dpopCtx) if err != nil { t.Fatalf("verify: %v", err) } 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/http/go.mod b/http/go.mod index b5c35e7..a4d2028 100644 --- a/http/go.mod +++ b/http/go.mod @@ -2,7 +2,7 @@ module github.com/authplane/go-sdk/http go 1.24.0 -toolchain go1.25.0 +toolchain go1.25.5 require github.com/authplane/go-sdk/core v0.0.0 diff --git a/http/pkg/authplanehttp/adapter.go b/http/pkg/authplanehttp/adapter.go index b4458e3..fe91821 100644 --- a/http/pkg/authplanehttp/adapter.go +++ b/http/pkg/authplanehttp/adapter.go @@ -2,7 +2,9 @@ package authplanehttp import ( "context" + "fmt" "net/http" + "net/url" "strings" "github.com/authplane/go-sdk/core/resource" @@ -17,12 +19,35 @@ import ( // flag) is configured on the wrapped Resource via verifier.WithInboundDPoP; // the adapter does not own DPoP policy. type Adapter struct { - resource *resource.Resource + resource *resource.Resource + prmURL string // full URL advertised in the WWW-Authenticate resource_metadata param (RFC 9728 §5.1) + resourceOrigin string // scheme + "://" + authority from the configured resource URI; precomputed for DPoP htu binding } // New creates an Adapter wrapping the given resource.Resource. func New(res *resource.Resource) *Adapter { - return &Adapter{resource: res} + return &Adapter{ + resource: res, + prmURL: res.PRMURL(), + resourceOrigin: resourceOrigin(res.URI()), + } +} + +// resourceOrigin returns "scheme://host[:port]" of the configured resource +// URI — the canonical scheme+authority pinned into every DPoP htu +// comparison. `resource.New` rejects URIs without a scheme or host, so by +// the time a Resource reaches this constructor the URI is guaranteed +// parseable as an absolute URL with a non-empty authority; the discarded +// error from `url.Parse` is unreachable in practice. +func resourceOrigin(resourceURI string) string { + parsed, _ := url.Parse(resourceURI) + return parsed.Scheme + "://" + parsed.Host +} + +// Resource returns the wrapped *resource.Resource, useful when callers need +// direct access (e.g. for resource.VerifyToken with custom VerifyOptions). +func (a *Adapter) Resource() *resource.Resource { + return a.resource } // PRMHandler returns an HTTP handler that serves the Protected Resource Metadata @@ -48,8 +73,28 @@ func (a *Adapter) WellKnownPRMPath() string { // writeAuthError writes the HTTP error response for an auth failure using // resource.AuthErrorResponse, which generates RFC 6750 compliant status, // WWW-Authenticate header, and JSON error body. -func writeAuthError(w http.ResponseWriter, err error) { +// +// When the adapter has a non-empty prmURL, `resource_metadata="..."` is +// appended to the WWW-Authenticate header per RFC 9728 §5.1, so clients can +// auto-discover the authorization server from a 401. The separator is space +// when no auth-param is yet present (e.g. the no-token case where +// resource.AuthErrorResponse returns just `Bearer`) and `, ` otherwise — RFC +// 9110 §11.1 requires `auth-scheme 1*SP auth-param`, with commas only +// *between* params. +func (a *Adapter) writeAuthError(w http.ResponseWriter, err error) { status, headers, body := resource.AuthErrorResponse(err) + if a.prmURL != "" { + wwwAuth := headers["WWW-Authenticate"] + if wwwAuth == "" { + wwwAuth = "Bearer" + } + sep := " " + if strings.Contains(wwwAuth, "=") { + sep = ", " + } + wwwAuth += fmt.Sprintf(`%sresource_metadata="%s"`, sep, a.prmURL) //nolint:gocritic // RFC 6750 §3 requires literal double-quotes + headers["WWW-Authenticate"] = wwwAuth + } for k, v := range headers { w.Header().Set(k, v) } @@ -57,13 +102,23 @@ func writeAuthError(w http.ResponseWriter, err error) { _, _ = w.Write([]byte(body)) } -// buildRequestURL reconstructs the full URL from an HTTP request for DPoP verification. -func buildRequestURL(r *http.Request) string { - scheme := "https" - if r.TLS == nil { - scheme = "http" - } - return scheme + "://" + r.Host + r.URL.RequestURI() +// buildRequestURL reconstructs the absolute URL used as the request side of +// the RFC 9449 §4.3 `htu` comparison. The scheme and authority come from the +// **operator-configured resource origin**, never from the inbound `Host` +// header or `r.TLS` — both are proxy-controlled and would otherwise let a +// misconfigured edge (or an attacker forging `Host`) shift the binding to a +// different origin. Only the request URI's path is taken from the inbound +// request, in raw form (`EscapedPath`) so reserved percent-encoding +// (e.g. `%2F` vs `/`) is preserved per RFC 3986 §6.2.2.2. Query and fragment +// are dropped — RFC 9449 §4.3 #5 defines `htu` as the target URI without +// query or fragment; outbound `normalizeHTU` (`core/authplane/dpop.go`) and +// every sibling SDK (rust/cs/java/python) drop them too. +// +// Operators must mount this middleware **before** any prefix-stripping +// router (`http.StripPrefix`) so `r.URL.EscapedPath()` still reflects the +// path the client signed. +func (a *Adapter) buildRequestURL(r *http.Request) string { + return a.resourceOrigin + r.URL.EscapedPath() } // extractToken parses the Authorization header and returns the token value @@ -106,20 +161,31 @@ func (a *Adapter) Middleware() func(http.Handler) http.Handler { token, isDPoP, err := extractToken(r.Header.Get("Authorization")) if err != nil { - writeAuthError(w, err) + a.writeAuthError(w, err) return } var verifyOpts []resource.VerifyOption if isDPoP { - verifyOpts = append(verifyOpts, resource.WithDPoP(&verifier.DPoPContext{ - Method: r.Method, - URL: buildRequestURL(r), - Proof: r.Header.Get("DPoP"), - })) + // r.Header.Values returns every value the wire delivered for + // a duplicate-named header. NewDPoPContext enforces RFC 9449 + // §4.3 #1 — more than one non-blank value returns + // ErrMultipleDpopProofs, which the error path below routes + // to the DPoP-scheme challenge per §7.1. The previous + // r.Header.Get("DPoP") silently used only the first copy. + dpopCtx, dpopErr := verifier.NewDPoPContext( + r.Method, + a.buildRequestURL(r), + r.Header.Values("DPoP"), + ) + if dpopErr != nil { + a.writeAuthError(w, dpopErr) + return + } + verifyOpts = append(verifyOpts, resource.WithDPoP(dpopCtx)) } claims, err := a.resource.VerifyToken(r.Context(), token, verifyOpts...) if err != nil { - writeAuthError(w, err) + a.writeAuthError(w, err) return } ctx := context.WithValue(r.Context(), claimsKey{}, claims) @@ -138,12 +204,12 @@ func (a *Adapter) RequireScopes(scopes ...string) func(http.Handler) http.Handle return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims := ClaimsFromContext(r.Context()) if claims == nil { - writeAuthError(w, verifier.ErrTokenMissing) + a.writeAuthError(w, verifier.ErrTokenMissing) return } for _, scope := range scopes { if err := claims.RequireScope(scope); err != nil { - writeAuthError(w, &resource.ScopeError{ + a.writeAuthError(w, &resource.ScopeError{ RequiredScopes: scopes, Err: err, }) diff --git a/http/pkg/authplanehttp/adapter_test.go b/http/pkg/authplanehttp/adapter_test.go index 0478720..34555f5 100644 --- a/http/pkg/authplanehttp/adapter_test.go +++ b/http/pkg/authplanehttp/adapter_test.go @@ -147,6 +147,54 @@ func TestMiddlewareNoToken(t *testing.T) { } } +// TestMiddlewareNoTokenWWWAuthenticateExact pins the *exact* header value for +// the no-token 401 to catch malformed separators between the auth-scheme and +// auth-params. RFC 9110 §11.1 requires `auth-scheme 1*SP auth-param`; the +// previous implementation produced `Bearer, resource_metadata="..."` (comma +// straight after the scheme), which is invalid and broke MCP RFC 9728 +// discovery on the very first unauthenticated request. +func TestMiddlewareNoTokenWWWAuthenticateExact(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.Middleware()(okHandler()) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil)) + + got := rec.Header().Get("WWW-Authenticate") + want := `Bearer resource_metadata="` + e.adapter.WellKnownPRMPath() + // The full prmURL is scheme+host+path-derived; assert the prefix and that + // the comma-after-scheme defect is not present. + if !strings.HasPrefix(got, "Bearer resource_metadata=\"") { + t.Errorf("WWW-Authenticate = %q; want it to start with `Bearer resource_metadata=\"`", got) + } + if strings.Contains(got, "Bearer,") { + t.Errorf("WWW-Authenticate = %q; contains malformed `Bearer,` (no SP between scheme and first param)", got) + } + if !strings.HasSuffix(got, `"`) { + t.Errorf("WWW-Authenticate = %q; want closing quote on resource_metadata value", got) + } + _ = want // intentional: the exact PRM URL depends on the test environment +} + +// TestMiddlewareInvalidTokenWWWAuthenticateExact pins the format for the +// invalid-token case — a param (`error="invalid_token"`) is already present, so +// the resource_metadata separator must be `, `. +func TestMiddlewareInvalidTokenWWWAuthenticateExact(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.Middleware()(okHandler()) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/add", nil) + req.Header.Set("Authorization", "Bearer not.a.valid.jwt") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + got := rec.Header().Get("WWW-Authenticate") + if !strings.HasPrefix(got, `Bearer error="invalid_token"`) { + t.Errorf("WWW-Authenticate = %q; want it to start with `Bearer error=\"invalid_token\"`", got) + } + if !strings.Contains(got, `", resource_metadata="`) { + t.Errorf("WWW-Authenticate = %q; want `, resource_metadata=\"…\"` between error and metadata params", got) + } +} + func TestMiddlewareMalformedHeader(t *testing.T) { e := newTestEnv(t) handler := e.adapter.Middleware()(okHandler()) diff --git a/http/pkg/authplanehttp/dpop_test.go b/http/pkg/authplanehttp/dpop_test.go index cf1a0c0..2b3892c 100644 --- a/http/pkg/authplanehttp/dpop_test.go +++ b/http/pkg/authplanehttp/dpop_test.go @@ -84,6 +84,70 @@ func TestMiddlewareDPoPInvalidProof(t *testing.T) { } } +// TestMiddlewareRejectsMultipleDPoPHeaders covers RFC 9449 §4.3 #1: an +// inbound request carrying two DPoP HTTP header fields MUST be rejected +// with the DPoP-scheme WWW-Authenticate challenge per §7.1. The previous +// implementation called r.Header.Get("DPoP") which silently used only +// the first value; the fix routes r.Header.Values("DPoP") through the +// NewDPoPContext factory and surfaces ErrMultipleDpopProofs. +// +// The test also asserts the inner handler is never reached — combined +// with the structural argument that NewDPoPContext returns before +// a.resource.VerifyToken in adapter.go, this is evidence that a +// multi-DPoP-header request short-circuits at the §4.3 boundary and +// doesn't pay the JWKS / signature / replay-store cost of full token +// validation on hostile traffic. +func TestMiddlewareRejectsMultipleDPoPHeaders(t *testing.T) { + e := newTestEnv(t) + signer, err := authplane.NewDPoPSigner(jose.ES256) + if err != nil { + t.Fatalf("NewDPoPSigner: %v", err) + } + token := e.makeTokenWithCnf(t, []string{"tools/add"}, time.Now().Add(time.Hour), + map[string]any{"jkt": signer.Thumbprint()}) + proofA, err := signer.GenerateProof("GET", "http://localhost:8080/mcp/add", &authplane.DPoPProofOptions{ + AccessToken: token, + }) + if err != nil { + t.Fatalf("GenerateProof A: %v", err) + } + proofB, err := signer.GenerateProof("GET", "http://localhost:8080/mcp/add", &authplane.DPoPProofOptions{ + AccessToken: token, + }) + if err != nil { + t.Fatalf("GenerateProof B: %v", err) + } + + innerCalled := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + innerCalled = true + w.WriteHeader(http.StatusOK) + }) + + handler := e.adapter.Middleware()(inner) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:8080/mcp/add", nil) + req.Header.Set("Authorization", "DPoP "+token) + // Two distinct DPoP headers — what RFC 9449 §4.3 #1 forbids. + req.Header.Add("DPoP", proofA) + req.Header.Add("DPoP", proofB) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } + if innerCalled { + t.Error("inner handler was invoked; §4.3 boundary should short-circuit before token verification reaches the wrapped handler") + } + wwwAuth := rec.Header().Get("WWW-Authenticate") + if !strings.HasPrefix(wwwAuth, "DPoP") { + t.Errorf("WWW-Authenticate = %q, want DPoP-scheme challenge", wwwAuth) + } + if !strings.Contains(wwwAuth, `error="invalid_dpop_proof"`) { + t.Errorf("WWW-Authenticate = %q, want invalid_dpop_proof error code (RFC 9449 §7.1)", wwwAuth) + } +} + func TestMiddlewareDPoPReplay(t *testing.T) { e := newTestEnv(t) signer, err := authplane.NewDPoPSigner(jose.ES256) @@ -255,3 +319,145 @@ func TestDPoP_CaseInsensitiveScheme(t *testing.T) { t.Errorf("status = %d, want 200 for lowercase dpop scheme", rec.Code) } } + +// A spoofed inbound Host header must NOT shift the DPoP htu binding away +// from the operator-configured resource origin. Proof is signed against +// the canonical resource origin (the only URL the resource ever +// advertises in its PRM); the request is received with a misleading Host +// header — typical of a misconfigured reverse proxy or an active attacker +// rewriting Host. The proof MUST still validate because the adapter +// reconstructs htu from the configured resource URI, not from r.Host. +func TestDPoP_HtuIgnoresSpoofedHostHeader(t *testing.T) { + e := newTestEnv(t) + signer, err := authplane.NewDPoPSigner(jose.ES256) + if err != nil { + t.Fatalf("NewDPoPSigner: %v", err) + } + token := e.makeTokenWithCnf(t, []string{"tools/add"}, time.Now().Add(time.Hour), + map[string]any{"jkt": signer.Thumbprint()}) + // Sign the proof against the configured resource origin. + proof, err := signer.GenerateProof("GET", "http://localhost:8080/mcp/add", &authplane.DPoPProofOptions{ + AccessToken: token, + }) + if err != nil { + t.Fatalf("GenerateProof: %v", err) + } + handler := e.adapter.Middleware()(okHandler()) + // Build the request URL using the resource origin (so r.URL.Path is + // `/mcp/add`), but overwrite Host to a different authority. + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:8080/mcp/add", nil) + req.Host = "attacker.example.net:443" + req.Header.Set("Authorization", "DPoP "+token) + req.Header.Set("DPoP", proof) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 — adapter must ignore the spoofed Host header", rec.Code) + } +} + +// A misconfigured edge layer can populate r.URL.Scheme as `http` even +// when the configured resource origin is `https://` (e.g. when the +// caller manually parses an `X-Forwarded-Proto: http` value into the +// inbound request). The adapter must ignore the request-side scheme +// entirely and pin it from the resource origin — otherwise a downgrade +// attack reshapes htu and bypasses the binding. +func TestDPoP_HtuIgnoresRequestScheme(t *testing.T) { + e := newHTTPSTestEnv(t) + signer, err := authplane.NewDPoPSigner(jose.ES256) + if err != nil { + t.Fatalf("NewDPoPSigner: %v", err) + } + token := e.makeTokenWithCnf(t, []string{"tools/add"}, time.Now().Add(time.Hour), + map[string]any{"jkt": signer.Thumbprint()}) + // Sign against the canonical https origin. + proof, err := signer.GenerateProof("GET", "https://api.example.com/mcp/add", &authplane.DPoPProofOptions{ + AccessToken: token, + }) + if err != nil { + t.Fatalf("GenerateProof: %v", err) + } + handler := e.adapter.Middleware()(okHandler()) + // Inbound request masquerades as http on the http://api.example.com + // origin — an edge layer downgraded the scheme. The middleware must + // ignore r.URL.Scheme entirely. + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://api.example.com/mcp/add", nil) + req.Header.Set("Authorization", "DPoP "+token) + req.Header.Set("DPoP", proof) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 — adapter must ignore request-side scheme", rec.Code) + } +} + +// Default-port normalization: an operator configuring the resource with an +// explicit `:80` (http) or `:443` (https) must still validate against a +// proof whose htu uses the port-less form, since outbound signers strip +// default ports per RFC 9110 §7.2 / RFC 9449 §4.3. Without normalization on +// the verifier side, the proof's `htu = http://localhost/mcp/add` would +// mismatch the reconstructed `http://localhost:80/mcp/add` and every +// DPoP-bound request would fail. +func TestDPoP_HtuStripsDefaultPortOnBothSides(t *testing.T) { + // The standard test env uses http://localhost:8080/mcp (non-default + // port — exempt from normalization). Build a fresh env on an explicit + // default port to exercise the normalization branch. + e := newDefaultPortTestEnv(t) + signer, err := authplane.NewDPoPSigner(jose.ES256) + if err != nil { + t.Fatalf("NewDPoPSigner: %v", err) + } + token := e.makeTokenWithCnf(t, []string{"tools/add"}, time.Now().Add(time.Hour), + map[string]any{"jkt": signer.Thumbprint()}) + // Sign with port-less htu (what every outbound signer emits after + // normalizeHTU drops the default port). The verifier must accept it + // even though the resource origin carries an explicit `:80`. + proof, err := signer.GenerateProof("GET", "http://api.example.com/mcp/add", &authplane.DPoPProofOptions{ + AccessToken: token, + }) + if err != nil { + t.Fatalf("GenerateProof: %v", err) + } + handler := e.adapter.Middleware()(okHandler()) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://api.example.com/mcp/add", nil) + req.Header.Set("Authorization", "DPoP "+token) + req.Header.Set("DPoP", proof) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 — default port must normalize on both sides", rec.Code) + } +} + +// A TLS-terminating reverse proxy forwards traffic to the resource as +// plain http; r.TLS is nil and the previous code would have reconstructed +// the scheme as `http`, breaking the binding even when the client signed +// against the resource's canonical `https://` URL. The adapter now sources +// the scheme from the configured resource origin, so the proof validates. +func TestDPoP_HtuKeepsResourceSchemeBehindTLSTerminatingProxy(t *testing.T) { + e := newHTTPSTestEnv(t) + signer, err := authplane.NewDPoPSigner(jose.ES256) + if err != nil { + t.Fatalf("NewDPoPSigner: %v", err) + } + token := e.makeTokenWithCnf(t, []string{"tools/add"}, time.Now().Add(time.Hour), + map[string]any{"jkt": signer.Thumbprint()}) + // Resource is https; sign accordingly. + proof, err := signer.GenerateProof("GET", "https://api.example.com/mcp/add", &authplane.DPoPProofOptions{ + AccessToken: token, + }) + if err != nil { + t.Fatalf("GenerateProof: %v", err) + } + handler := e.adapter.Middleware()(okHandler()) + // The proxy terminates TLS and forwards the request as plain http, so + // r.TLS is nil. httptest.NewRequest constructs a request without TLS. + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://api.example.com/mcp/add", nil) + req.Header.Set("Authorization", "DPoP "+token) + req.Header.Set("DPoP", proof) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 — adapter must source https from the configured resource origin even when r.TLS is nil", rec.Code) + } +} diff --git a/http/pkg/authplanehttp/helpers_test.go b/http/pkg/authplanehttp/helpers_test.go index 6b889be..072deb2 100644 --- a/http/pkg/authplanehttp/helpers_test.go +++ b/http/pkg/authplanehttp/helpers_test.go @@ -223,3 +223,95 @@ func ecJWK(pub *ecdsa.PublicKey, kid string) map[string]any { _ = json.Unmarshal(b, &m) return m } + +// httpsTestEnv mirrors testEnv but lets the wrapped Resource use any +// caller-supplied URI so DPoP htu reconstruction can be exercised behind +// TLS-terminating proxies (https resource, plain-http inbound) or against +// explicit-default-port origins. Tokens are minted with the matching aud. +type httpsTestEnv struct { + adapter *authplanehttp.Adapter + key *rsa.PrivateKey + issuer string + kid string + resourceURI string +} + +func newHTTPSTestEnv(t *testing.T) *httpsTestEnv { + return newTestEnvForResource(t, "https://api.example.com/mcp") +} + +// newDefaultPortTestEnv pins the resource to an http URI carrying an +// explicit default port (`:80`) so the htu default-port normalization +// path is exercised. The standard `testEnv` uses `:8080`, which never +// triggers normalization. +func newDefaultPortTestEnv(t *testing.T) *httpsTestEnv { + return newTestEnvForResource(t, "http://api.example.com:80/mcp") +} + +// newTestEnvForResource builds an httpsTestEnv pinned to the given +// resource URI. Shared builder factored out of the per-scenario helpers +// so a single mux/JWKS/issuer scaffold drives every variant. +func newTestEnvForResource(t *testing.T, resourceURI string) *httpsTestEnv { + t.Helper() + 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", + })) + }) + client, err := authplane.NewClient(context.Background(), srv.URL, authplane.WithFetchSettings(authplane.DevModeFetchSettings())) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Cleanup(func() { client.Close() }) + res, err := client.Resource(resourceURI, + resource.WithScopes("tools/add", "tools/multiply"), + resource.WithVerifierOptions(verifier.WithInboundDPoP(verifier.InboundDPoPOptions{ + ReplayStore: verifier.NewInMemoryDPoPReplayStore(), + })), + ) + if err != nil { + t.Fatalf("client.Resource: %v", err) + } + adapter := authplanehttp.New(res) + return &httpsTestEnv{adapter: adapter, key: key, issuer: srv.URL, kid: kid, resourceURI: resourceURI} +} + +func (e *httpsTestEnv) makeTokenWithCnf(t *testing.T, scopes []string, exp time.Time, cnf map[string]any) string { + t.Helper() + headerJSON := mustMarshal(t, map[string]string{"alg": "RS256", "typ": "at+jwt", "kid": e.kid}) + claims := map[string]any{ + "iss": e.issuer, "aud": e.resourceURI, "sub": "user-test", + "jti": "jti-" + time.Now().Format(time.RFC3339Nano), "client_id": e.resourceURI, + "iat": time.Now().Unix(), "exp": exp.Unix(), "scope": strings.Join(scopes, " "), + } + if cnf != nil { + claims["cnf"] = cnf + } + claimsJSON := mustMarshal(t, claims) + 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) +} diff --git a/http/pkg/authplanehttp/testing.go b/http/pkg/authplanehttp/testing.go index 9c90710..360e80b 100644 --- a/http/pkg/authplanehttp/testing.go +++ b/http/pkg/authplanehttp/testing.go @@ -6,14 +6,17 @@ import ( "github.com/authplane/go-sdk/core/resource/verifier" ) -// ContextWithClaims returns a context carrying the given VerifiedClaims, -// as if Middleware had validated a request. Use in tests only. +// ContextWithClaims returns a context carrying the given VerifiedClaims under +// the same context key Middleware uses, so ClaimsFromContext can retrieve them +// downstream. Production handlers should rely on Middleware to populate this; +// these setters exist for tests and for callers that manage the bearer/DPoP +// flow outside Middleware. 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 Middleware had injected it. Use in tests only. +// ContextWithToken returns a context carrying the given raw bearer token under +// the same context key Middleware uses. See ContextWithClaims for usage notes. func ContextWithToken(ctx context.Context, token string) context.Context { return context.WithValue(ctx, tokenKey{}, token) } diff --git a/llm-full.txt b/llm-full.txt index d5f2d67..240d0ea 100644 --- a/llm-full.txt +++ b/llm-full.txt @@ -2,8 +2,8 @@ > Go workspace for protecting MCP servers and OAuth 2.1 resource servers with > tokens issued by an Authplane authorization server. This repository contains -> the framework-agnostic SDK (`core/`) and two framework adapters (`mcp/`, -> `http/`) as three independent Go modules. +> the framework-agnostic SDK (`core/`) and three framework adapters (`mcp/`, +> `http/`, `mark3labs/`) as four independent Go modules. ## When To Use This Repository @@ -14,6 +14,7 @@ Use this repo when you need to: introspection, revocation) - wire the verifier into the official MCP Go SDK's HTTP transport (`mcp/`) - wire the verifier into a plain `net/http` resource server (`http/`) +- wire the verifier into a `mark3labs/mcp-go` server (`mark3labs/`) ## Repository Structure @@ -28,18 +29,21 @@ Use this repo when you need to: | `mcp/pkg/authplanemcp/` | public adapter API | | `http/` | HTTP middleware adapter — module `github.com/authplane/go-sdk/http` | | `http/pkg/authplanehttp/` | public middleware API | +| `mark3labs/` | `mark3labs/mcp-go` adapter — module `github.com/authplane/go-sdk/mark3labs` | +| `mark3labs/pkg/authplanemark3labs/` | public adapter API (wraps `authplanehttp.Adapter`) | | `scripts/` | end-to-end test helpers | ## Layering Rules (Important) 1. Protocol verification, AS interaction, caching, and resilience logic must live in `core/`. -2. Adapter code (`mcp/`, `http/`) must stay thin: translate framework - requests into `core` calls and translate `core` errors into framework - responses. +2. Adapter code (`mcp/`, `http/`, `mark3labs/`) must stay thin: translate + framework requests into `core` calls and translate `core` errors into + framework responses. The `mark3labs/` adapter is itself a thin wrapper + over `http/` so common DPoP/PRM behavior stays single-sourced. 3. Do not duplicate verifier or protocol logic across modules. -4. Keep MCP and HTTP adapter semantics (errors, challenges, headers) - aligned where equivalent. +4. Keep adapter semantics (errors, challenges, headers) aligned where + equivalent. 5. Each module has its own `go.mod` and can be versioned and published independently. @@ -49,8 +53,9 @@ Install the module(s) you need: ```bash go get github.com/authplane/go-sdk/core@latest -go get github.com/authplane/go-sdk/http@latest # net/http middleware -go get github.com/authplane/go-sdk/mcp@latest # MCP adapter +go get github.com/authplane/go-sdk/http@latest # net/http middleware +go get github.com/authplane/go-sdk/mcp@latest # official MCP Go SDK +go get github.com/authplane/go-sdk/mark3labs@latest # mark3labs/mcp-go ``` See the API Examples below, the per-module READMEs, and `docs/user-guide.md` @@ -60,6 +65,7 @@ in each module for full integration walkthroughs. - `core/`, `http/`: Go 1.24+. - `mcp/`: Go 1.25+ (forced by `github.com/modelcontextprotocol/go-sdk`). +- `mark3labs/`: Go 1.25+ (forced by `github.com/mark3labs/mcp-go`). ## API Examples @@ -145,12 +151,40 @@ http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler http.Handle("/mcp", adapter.AuthMiddleware(handler)) ``` +### 4) mark3labs/mcp-go adapter (`mark3labs/`) + +`mark3labs/demo/main.go` is the canonical end-to-end example. The adapter +wraps `*authplanehttp.Adapter` so bearer + DPoP auth, RFC 9728 PRM, and +`WWW-Authenticate` semantics come for free from `http/`: + +```go +import "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs" + +adapter, _ := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{ + Issuer: issuer, + Resource: resourceURL, + Scopes: []string{"tools/add", "tools/multiply"}, + DevMode: true, // local dev only + ClientOptions: []authplane.Option{ + authplane.WithClientCredentials(clientID, clientSecret), + }, +}) +defer adapter.Close() + +mux := http.NewServeMux() +mux.Handle(adapter.WellKnownPRMPath(), adapter.PRMHandler()) +mux.Handle("/mcp", adapter.AuthMiddleware(mcpHandler)) + +http.ListenAndServe(":8080", mux) +``` + ## Practical Example Locations | Example Type | Location | |---|---| | User guide (core) | `core/docs/user-guide.md` | -| MCP integration | `mcp/README.md`, `mcp/docs/user-guide.md`, `mcp/demo/` | +| MCP integration (official Go SDK) | `mcp/README.md`, `mcp/docs/user-guide.md`, `mcp/demo/` | +| MCP integration (mark3labs/mcp-go) | `mark3labs/README.md`, `mark3labs/docs/user-guide.md`, `mark3labs/demo/` | | HTTP integration | `http/README.md`, `http/docs/user-guide.md`, `http/demo/` | ## Public Surface (core) diff --git a/llm.txt b/llm.txt index 64f3d6a..639db0f 100644 --- a/llm.txt +++ b/llm.txt @@ -4,7 +4,7 @@ Short guide. For complete context and detailed examples, read `llm-full.txt`. ## What this repo provides -A Go workspace containing the framework-agnostic Authplane SDK and two +A Go workspace containing the framework-agnostic Authplane SDK and three framework adapters for protecting MCP servers and OAuth 2.1 resource servers with tokens issued by an Authplane authorization server. @@ -17,6 +17,9 @@ Modules: official MCP Go SDK's HTTP transport. - `http/` — module `github.com/authplane/go-sdk/http` — `net/http` middleware with DPoP sender-constrained token support. +- `mark3labs/` — module `github.com/authplane/go-sdk/mark3labs` — adapter for + `github.com/mark3labs/mcp-go` servers; wraps `*authplanehttp.Adapter` and + exposes `HTTPContextFunc` for per-tool-call context propagation. ## First files to read @@ -25,14 +28,16 @@ Modules: - `core/resource/`, `core/resource/verifier/` - `mcp/README.md`, `mcp/pkg/authplanemcp/` - `http/README.md`, `http/pkg/authplanehttp/` +- `mark3labs/README.md`, `mark3labs/pkg/authplanemark3labs/` ## Architecture rules - All protocol verification and AS interaction lives in `core/`. -- Adapters (`mcp/`, `http/`) are thin: they translate framework request - contexts into core-verifier calls and translate core errors into framework - responses. -- Keep MCP and HTTP adapter behavior aligned where equivalent. +- Adapters (`mcp/`, `http/`, `mark3labs/`) are thin: they translate framework + request contexts into core-verifier calls and translate core errors into + framework responses. The `mark3labs/` adapter is itself a thin wrapper + over `http/`, so common DPoP/PRM behavior stays single-sourced. +- Keep adapter behavior aligned where equivalent. - Do not duplicate shared behavior across modules — put it in `core/`. - Keep protocol helpers stateless; keep cache/resilience/orchestration in stateful client structures. @@ -63,8 +68,9 @@ if err != nil { panic(err) } claims, err := res.VerifyToken(ctx, token) ``` -For a runnable end-to-end example see `http/demo/` (net/http resource server) -or `mcp/demo/` (MCP adapter using the official MCP Go SDK). +For a runnable end-to-end example see `http/demo/` (net/http resource server), +`mcp/demo/` (MCP adapter using the official MCP Go SDK), or `mark3labs/demo/` +(MCP adapter using `mark3labs/mcp-go`). ## Key public types @@ -91,6 +97,7 @@ or `mcp/demo/` (MCP adapter using the official MCP Go SDK). - `core/`, `http/`: Go 1.24+. - `mcp/`: Go 1.25+ (forced by `github.com/modelcontextprotocol/go-sdk`). +- `mark3labs/`: Go 1.25+ (forced by `github.com/mark3labs/mcp-go`). ## Cross-repo references 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..b1edfdf --- /dev/null +++ b/mark3labs/demo/main.go @@ -0,0 +1,137 @@ +// Calculator Service — Authplane mark3labs/mcp-go adapter demo. +// +// Mirrors the 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..2337884 --- /dev/null +++ b/mark3labs/docs/user-guide.md @@ -0,0 +1,435 @@ +# 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. + +Internally `*Adapter` embeds [`*authplanehttp.Adapter`](../../http/docs/user-guide.md) — the generic Authplane net/http adapter — so the Bearer/DPoP middleware, scope-enforcing middleware, context helpers, and RFC 6750 / RFC 9728 `WWW-Authenticate` response (including `resource_metadata="..."` advertisement) all come from one shared implementation rather than being re-implemented per adapter. mark3labs-only additions are the context bridge and the URL-elicitation mapping. + +The adapter integrates with mark3labs/mcp-go through **two coordinated hooks**: + +| Hook | Purpose | +|---|---| +| `adapter.AuthMiddleware(next)` | Standard `http.Handler` middleware (delegates to `authplanehttp.Middleware()`). Parses `Authorization: Bearer …` *or* `Authorization: DPoP …`, 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 compliant `WWW-Authenticate` header that advertises the PRM URL via `resource_metadata=` (RFC 9728 §5.1). The PRM well-known path is auto-excluded from authentication. | +| `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. That option only takes effect when the streamable server owns the top-level mux (i.e. when you call `streamable.Start()`); with a custom router, PRM never gets registered. `adapter.ProtectedResourceMetadataHandler()` wraps mark3labs/mcp-go's `server.NewProtectedResourceMetadataHandler` and is mounted on your outer router so it stays reachable independently of the auth-wrapped MCP endpoint. + +### 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. Returns an error if `client` is nil. + +> **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 (and DPoP) token authentication. Equivalent to `a.Middleware()(handler)`; the call shape is preserved for fluency in mark3labs code. + +- Rejects unauthenticated requests with 401 and a `WWW-Authenticate: Bearer resource_metadata="…"` header (RFC 9728 §5.1). +- Rejects invalid Bearer tokens with 401 + `error="invalid_token"`; DPoP-bound errors return the `DPoP` scheme as required by RFC 9449. +- On success, injects `*verifier.VerifiedClaims` and the raw token into the request context. +- The PRM well-known path is auto-excluded so the metadata endpoint stays publicly reachable even when this middleware wraps a broad route prefix. + +Scopes are not checked at this layer; tools enforce their own scope (see §4.3). + +### Inherited from `*authplanehttp.Adapter` + +Because `*Adapter` embeds `*authplanehttp.Adapter`, the following methods from the http adapter are available directly: + +- `Middleware() func(http.Handler) http.Handler` — the underlying middleware (`AuthMiddleware` is a one-line wrapper). +- `PRMHandler() http.Handler` — the plain-HTTP PRM handler (`max-age=3600`, no CORS). Prefer `ProtectedResourceMetadataHandler()` below for MCP clients. +- `WellKnownPRMPath() string` — the RFC 9728 well-known path. +- `RequireScopes(scopes ...string) func(http.Handler) http.Handler` — RFC 6750 `insufficient_scope` middleware (useful for non-MCP HTTP routes mounted alongside `/mcp`). + +### `(a *Adapter) HTTPContextFunc(opts ...HTTPContextOption) 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. + +By default only the auth pair is forwarded — values placed on the upstream request context by other middleware (tracing spans, request IDs, etc.) are dropped on the MCP context. Use the options below to forward more: + +- `WithForwardedContextKeys(keys ...any)` — copy the listed keys from the upstream request context onto the MCP context. Suitable for upstream middleware that exposes its context key publicly: + + ```go + type ctxKey string + const requestIDKey ctxKey = "request-id" + + server.WithHTTPContextFunc(adapter.HTTPContextFunc( + authplanemark3labs.WithForwardedContextKeys(requestIDKey), + )) + ``` + +- `WithContextForwarding(func(parent, mcp context.Context) context.Context)` — escape hatch for libraries whose context key is unexported (e.g. OpenTelemetry's span context). The function receives the upstream request context and the MCP context already populated with claims/token, and returns a context derived from the MCP one. Multiple options compose in registration order. + + ```go + server.WithHTTPContextFunc(adapter.HTTPContextFunc( + authplanemark3labs.WithContextForwarding(func(parent, mcp context.Context) context.Context { + return trace.ContextWithSpanContext(mcp, trace.SpanContextFromContext(parent)) + }), + )) + ``` + +### `(a *Adapter) ProtectedResourceMetadataHandler() http.Handler` + +Returns mark3labs/mcp-go's `server.NewProtectedResourceMetadataHandler` configured from `core`'s `resource.PRMResponse()`. The values (`resource`, `authorization_servers`, `bearer_methods_supported`, `scopes_supported`, DPoP fields) come from `core` so they stay aligned with what other Authplane adapters advertise; HTTP framing is mark3labs/mcp-go's (per RFC 9728 / the MCP authorization spec): + +- `GET` and `HEAD` return the metadata JSON. +- `OPTIONS` returns 204 (CORS preflight). +- Other methods return 405 with an `Allow` header. +- Permissive CORS (`Access-Control-Allow-Origin: *`) and `Cache-Control: no-store` are always set. + +### `(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. + +This is a property of mark3labs/mcp-go v0.54.0's tool-call error path, not of this adapter; track upstream for changes. + +### 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` accepts both `Authorization: Bearer …` and `Authorization: DPoP …` schemes — that comes from the embedded `*authplanehttp.Adapter`. Inbound DPoP policy (replay store, proof age, allowed algorithms, required flag) is configured on the wrapped `*resource.Resource` via `verifier.WithInboundDPoP(...)` passed in `Options.VerifierOptions`. See the [http user guide §7](../../http/docs/user-guide.md#7-dpop-sender-constrained-tokens) for the full DPoP recipe; everything there applies here. + +Outbound DPoP (signing token requests to the AS) is configured on the client via `authplane.WithDPoP(km)` in `Options.ClientOptions` and is independent of the inbound 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..abd3a64 --- /dev/null +++ b/mark3labs/go.mod @@ -0,0 +1,24 @@ +module github.com/authplane/go-sdk/mark3labs + +go 1.25.5 + +require ( + github.com/authplane/go-sdk/core v0.0.0 + github.com/authplane/go-sdk/http v0.0.0 + github.com/go-jose/go-jose/v4 v4.1.4 + 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 + github.com/authplane/go-sdk/http => ../http +) diff --git a/mark3labs/go.sum b/mark3labs/go.sum new file mode 100644 index 0000000..e4cc49d --- /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.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/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..31b0b87 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/adapter.go @@ -0,0 +1,436 @@ +// Package authplanemark3labs is the Authplane adapter for the mark3labs/mcp-go +// server library. +// +// It is a thin layer over the generic Authplane net/http adapter +// (github.com/authplane/go-sdk/http/pkg/authplanehttp) that adds the two +// things genuinely specific to mark3labs/mcp-go: +// +// - HTTPContextFunc — bridges verified claims from the HTTP request context +// into the per-tool-call MCP context that mark3labs/mcp-go exposes via +// server.WithHTTPContextFunc. +// - URLElicitationError / ConsentElicitationError — maps RFC 8693 +// token-exchange consent errors to the MCP URL elicitation shape +// (JSON-RPC -32042). +// +// Bearer/DPoP parsing, the RFC 6750 WWW-Authenticate challenge (including the +// RFC 9728 resource_metadata advertisement), context keys, and scope-enforcing +// middleware all come from the embedded *authplanehttp.Adapter. PRM is served +// via mark3labs/mcp-go's server.NewProtectedResourceMetadataHandler so the +// HTTP framing (CORS, Cache-Control: no-store, allowed methods) matches the +// MCP authorization spec, while core remains the source of the field values. +package authplanemark3labs + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/authplane/go-sdk/core/authplane" + "github.com/authplane/go-sdk/core/resource" + "github.com/authplane/go-sdk/core/resource/verifier" + authplanehttp "github.com/authplane/go-sdk/http/pkg/authplanehttp" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// 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 integrates the Authplane core SDK with mark3labs/mcp-go. +// +// It embeds *authplanehttp.Adapter so the standard HTTP surface — Middleware +// (Bearer + DPoP), RequireScopes, and the context helpers — is available +// directly. The mark3labs-specific additions live on Adapter itself: +// HTTPContextFunc (claims bridge), ProtectedResourceMetadataHandler (uses +// mark3labs/mcp-go's PRM handler with values from core), and TokenExchange +// with URL-elicitation mapping. +// +// Always call Close() when the adapter is no longer needed to stop background +// refresh goroutines and release HTTP connections. +type Adapter struct { + *authplanehttp.Adapter + + client *authplane.Client + prmHandler http.Handler // mark3labs PRM handler, built once at construction +} + +// ClaimsFromContext returns the VerifiedClaims injected by Middleware (and +// forwarded by HTTPContextFunc) into the per-tool-call context. Returns nil +// when called outside an authenticated request. +// +// Thin wrapper around authplanehttp.ClaimsFromContext — the two packages share +// a single context-key namespace so claims flow from Middleware through +// HTTPContextFunc into tool handlers without translation. +func ClaimsFromContext(ctx context.Context) *verifier.VerifiedClaims { + return authplanehttp.ClaimsFromContext(ctx) +} + +// TokenFromContext returns the raw bearer token injected by Middleware (and +// forwarded by HTTPContextFunc). Returns "" outside an authenticated request. +// Thin wrapper around authplanehttp.TokenFromContext for the same reason as +// ClaimsFromContext. +func TokenFromContext(ctx context.Context) string { + return authplanehttp.TokenFromContext(ctx) +} + +// 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 + } + + return &Adapter{ + Adapter: authplanehttp.New(res), + client: client, + prmHandler: server.NewProtectedResourceMetadataHandler(prmConfigFromResource(res)), + }, 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. +// +// Returns an error if client or res is nil. The signature matches the sibling +// mcp adapter — neither constructor panics, and both surface programming +// errors as a typed return value. +func NewAdapterFromClientAndResource(client *authplane.Client, res *resource.Resource) (*Adapter, error) { + if client == nil { + return nil, errors.New("authplanemark3labs: client must not be nil") + } + if res == nil { + return nil, errors.New("authplanemark3labs: res must not be nil") + } + return &Adapter{ + Adapter: authplanehttp.New(res), + client: client, + prmHandler: server.NewProtectedResourceMetadataHandler(prmConfigFromResource(res)), + }, nil +} + +// AuthMiddleware returns an HTTP handler that enforces Bearer (and DPoP) token +// authentication in front of a mark3labs/mcp-go *server.StreamableHTTPServer. +// +// It is a thin wrapper over the embedded *authplanehttp.Adapter's Middleware, +// preserving the mark3labs-style call shape used throughout the docs and demo: +// +// http.Handle("/mcp", adapter.AuthMiddleware(streamable)) +// +// On success the verified claims and raw token are stored in the *request* +// context (via authplanehttp's context keys). To forward them into the +// per-tool-call MCP context that mark3labs/mcp-go derives via +// server.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 a.Middleware()(handler) +} + +// HTTPContextOption configures HTTPContextFunc. See WithForwardedContextKeys +// and WithContextForwarding for the available options. +type HTTPContextOption func(*httpContextConfig) + +type httpContextConfig struct { + keys []any + mergeFns []func(parent, mcp context.Context) context.Context +} + +// WithForwardedContextKeys forwards the listed keys from the upstream HTTP +// request context onto the per-tool-call MCP context, in addition to the +// verified claims and bearer token. Use this for upstream middleware that +// exposes its context key publicly (request IDs, feature flags, tenant +// resolvers). +// +// Keys with no value on the upstream context are skipped silently. For +// libraries whose context key is unexported (OpenTelemetry's span context, +// for example), use WithContextForwarding instead and copy via the library's +// own accessor. +func WithForwardedContextKeys(keys ...any) HTTPContextOption { + return func(c *httpContextConfig) { + c.keys = append(c.keys, keys...) + } +} + +// WithContextForwarding registers a merge function invoked on every +// tool-call context. The function receives the upstream request context +// (parent) and the new MCP context already populated with claims, bearer +// token, and any WithForwardedContextKeys values (mcp), and must return a +// context derived from mcp. +// +// Multiple WithContextForwarding options compose in registration order: the +// returned context of each call is passed as mcp to the next. +// +// Use this for libraries whose context key is unexported — propagate values +// via the library's accessor (e.g. trace.ContextWithSpanContext(mcp, +// trace.SpanContextFromContext(parent))). A nil function is ignored. +func WithContextForwarding(fn func(parent, mcp context.Context) context.Context) HTTPContextOption { + return func(c *httpContextConfig) { + if fn != nil { + c.mergeFns = append(c.mergeFns, fn) + } + } +} + +// HTTPContextFunc returns a server.HTTPContextFunc that forwards the verified +// claims and raw bearer token from the HTTP request context (where the +// embedded http adapter's Middleware 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. +// +// By default only the authentication pair (claims + token) is forwarded; +// values set on the upstream r.Context() by other middleware (tracing spans, +// request IDs, etc.) are NOT propagated. Pass WithForwardedContextKeys for +// the simple key-copy case, or WithContextForwarding for libraries whose +// context key is unexported. +func (a *Adapter) HTTPContextFunc(opts ...HTTPContextOption) server.HTTPContextFunc { + var cfg httpContextConfig + for _, o := range opts { + o(&cfg) + } + return func(ctx context.Context, r *http.Request) context.Context { + parent := r.Context() + if claims := authplanehttp.ClaimsFromContext(parent); claims != nil { + ctx = authplanehttp.ContextWithClaims(ctx, claims) + } + if token := authplanehttp.TokenFromContext(parent); token != "" { + ctx = authplanehttp.ContextWithToken(ctx, token) + } + for _, k := range cfg.keys { + if v := parent.Value(k); v != nil { + ctx = context.WithValue(ctx, k, v) + } + } + for _, fn := range cfg.mergeFns { + ctx = fn(parent, ctx) + } + return ctx + } +} + +// ProtectedResourceMetadataHandler returns mark3labs/mcp-go's +// server.NewProtectedResourceMetadataHandler configured with the values from +// core's resource.PRMResponse(). The mark3labs handler handles the HTTP framing +// per the MCP authorization spec (RFC 9728): GET/HEAD/OPTIONS allowed, CORS +// headers for browser-based clients, and Cache-Control: no-store to avoid stale +// metadata during AS rotation. +// +// core remains the source of truth for the field *values* of the document, so +// they stay aligned with what other Authplane adapters advertise. +// +// The handler is built once at adapter construction and reused across calls; +// callers can safely wire it into a mux at startup without paying for repeated +// PRM config building. +// +// PRMHandler on this adapter is overridden to return the same handler, so MCP +// consumers get the right framing whether they use the mark3labs-style name +// (ProtectedResourceMetadataHandler) or the inherited authplanehttp name +// (PRMHandler). The plain-HTTP framing from authplanehttp (max-age=3600, no +// CORS) is reachable via a.Adapter.PRMHandler() if specifically needed. +func (a *Adapter) ProtectedResourceMetadataHandler() http.Handler { + return a.prmHandler +} + +// PRMHandler is overridden on the outer Adapter so callers using the inherited +// name from authplanehttp still get the mark3labs framing (CORS, no-store, +// HEAD/OPTIONS) appropriate for MCP clients. The original authplanehttp +// PRMHandler remains accessible via a.Adapter.PRMHandler() for callers that +// specifically want the plain-HTTP framing. +func (a *Adapter) PRMHandler() http.Handler { + return a.prmHandler +} + +// prmConfigFromResource maps the typed PRM config from core into mark3labs/mcp-go's +// ProtectedResourceMetadataConfig so the JSON document can be served by +// server.NewProtectedResourceMetadataHandler. +// +// Field-by-field mapping against resource.PRMConfig: when buildPRM gains a +// new RFC 9728 field, core.PRMConfig grows with it and this mapper must be +// updated explicitly — no silent drops via dynamic-map lookups. +func prmConfigFromResource(res *resource.Resource) server.ProtectedResourceMetadataConfig { + src := res.PRMConfig() + return server.ProtectedResourceMetadataConfig{ + Resource: src.Resource, + AuthorizationServers: src.AuthorizationServers, + BearerMethodsSupported: src.BearerMethodsSupported, + ScopesSupported: src.ScopesSupported, + DPoPSigningAlgValuesSupported: src.DPoPSigningAlgValuesSupported, + DPoPBoundAccessTokensRequired: src.DPoPBoundAccessTokensRequired, + } +} + +// 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 +} + +// 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 behavior should instead return a +// CallToolResult with IsError=true and serialize 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. +// Matches the pattern in the sibling mcp adapter (mcp/pkg/authplanemcp/adapter.go). +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..0ed0351 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/adapter_test.go @@ -0,0 +1,723 @@ +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/go-jose/go-jose/v4" + "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) + } +} + +// TestAuthMiddlewareNoTokenWWWAuthenticateWellFormed pins the structure of the +// WWW-Authenticate header for the no-token 401 to catch malformed separators +// (RFC 9110 §11.1: `auth-scheme 1*SP auth-param`, commas only between params). +// A regression here breaks MCP RFC 9728 discovery on the first 401 since +// clients parse `resource_metadata=` from this exact response. +func TestAuthMiddlewareNoTokenWWWAuthenticateWellFormed(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)) + + wwwAuth := rec.Header().Get("Www-Authenticate") + if !strings.HasPrefix(wwwAuth, `Bearer resource_metadata="`) { + t.Errorf("WWW-Authenticate = %q; want it to start with `Bearer resource_metadata=\"` (space between scheme and first param)", wwwAuth) + } + if strings.Contains(wwwAuth, "Bearer,") { + t.Errorf("WWW-Authenticate = %q; contains malformed `Bearer,` (no SP between scheme and first param)", wwwAuth) + } + if !strings.HasSuffix(wwwAuth, `"`) { + t.Errorf("WWW-Authenticate = %q; want closing quote on resource_metadata value", 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 Cache-Control: no-store per the MCP authorization spec (avoids stale +// metadata during AS rotation). +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 != "no-store" { + t.Errorf("Cache-Control = %q, want %q", got, "no-store") + } +} + +// TestProtectedResourceMetadataHandlerCORS verifies that the PRM handler sets +// permissive CORS headers so browser-based MCP clients can discover the resource +// cross-origin. +func TestProtectedResourceMetadataHandlerCORS(t *testing.T) { + e := newTestEnv(t) + handler := e.adapter.ProtectedResourceMetadataHandler() + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodOptions, "/.well-known/oauth-protected-resource", nil)) + if rec.Code != http.StatusNoContent { + t.Errorf("OPTIONS preflight status = %d, want 204", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*") + } +} + +// TestProtectedResourceMetadataHandlerFieldsFromCore verifies that the JSON +// document served by the mark3labs handler carries the field values supplied by +// core's PRMResponse (resource, authorization_servers, bearer_methods_supported, +// scopes_supported). +func TestProtectedResourceMetadataHandlerFieldsFromCore(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) + } + + 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) + } + servers, _ := body["authorization_servers"].([]any) + if len(servers) != 1 || servers[0] != e.issuer { + t.Errorf("authorization_servers = %v, want [%s]", servers, e.issuer) + } + bearer, _ := body["bearer_methods_supported"].([]any) + if len(bearer) != 1 || bearer[0] != "header" { + t.Errorf("bearer_methods_supported = %v, want [header]", bearer) + } + scopes, _ := body["scopes_supported"].([]any) + if len(scopes) != 2 { + t.Errorf("scopes_supported = %v, want 2 entries", scopes) + } +} + +// 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(t.Context(), wantClaims) + reqCtx = authplanemark3labs.ContextWithToken(reqCtx, wantToken) + req := httptest.NewRequestWithContext(reqCtx, http.MethodPost, "/mcp", nil) + + // 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.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil) + mcpCtx := contextFunc(t.Context(), 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) + } +} + +// TestHTTPContextFuncForwardedKeys verifies that WithForwardedContextKeys +// copies the listed keys from the upstream request context onto the per-call +// MCP context, alongside the auth pair. Keys with no value are skipped. +func TestHTTPContextFuncForwardedKeys(t *testing.T) { + e := newTestEnv(t) + + type ctxKey string + const ( + requestIDKey ctxKey = "request-id" + tenantKey ctxKey = "tenant" + missingKey ctxKey = "missing" + ) + + contextFunc := e.adapter.HTTPContextFunc( + authplanemark3labs.WithForwardedContextKeys(requestIDKey, tenantKey, missingKey), + ) + + reqCtx := context.WithValue(t.Context(), requestIDKey, "req-42") + reqCtx = context.WithValue(reqCtx, tenantKey, "acme") + req := httptest.NewRequestWithContext(reqCtx, http.MethodPost, "/mcp", nil) + + mcpCtx := contextFunc(context.Background(), req) + + if got := mcpCtx.Value(requestIDKey); got != "req-42" { + t.Errorf("request-id forwarded = %v, want req-42", got) + } + if got := mcpCtx.Value(tenantKey); got != "acme" { + t.Errorf("tenant forwarded = %v, want acme", got) + } + if got := mcpCtx.Value(missingKey); got != nil { + t.Errorf("missing key should not appear on MCP context, got = %v", got) + } +} + +// TestHTTPContextFuncMergeFunctions verifies that WithContextForwarding merge +// functions are invoked in registration order and compose by chaining the +// mcp context returned by each. +func TestHTTPContextFuncMergeFunctions(t *testing.T) { + e := newTestEnv(t) + + type ctxKey string + const ( + parentMarkerKey ctxKey = "parent-marker" + firstMergeKey ctxKey = "first" + secondMergeKey ctxKey = "second" + ) + + contextFunc := e.adapter.HTTPContextFunc( + authplanemark3labs.WithContextForwarding(func(parent, mcp context.Context) context.Context { + // Reads from parent, writes to mcp. + return context.WithValue(mcp, firstMergeKey, parent.Value(parentMarkerKey)) + }), + authplanemark3labs.WithContextForwarding(nil), // nil fn must be ignored, not panic + authplanemark3labs.WithContextForwarding(func(_, mcp context.Context) context.Context { + // Sees the value written by the previous merge fn. + prev, _ := mcp.Value(firstMergeKey).(string) + return context.WithValue(mcp, secondMergeKey, prev+"-chained") + }), + ) + + reqCtx := context.WithValue(t.Context(), parentMarkerKey, "seed") + req := httptest.NewRequestWithContext(reqCtx, http.MethodPost, "/mcp", nil) + + mcpCtx := contextFunc(context.Background(), req) + + if got := mcpCtx.Value(firstMergeKey); got != "seed" { + t.Errorf("first merge value = %v, want seed", got) + } + if got := mcpCtx.Value(secondMergeKey); got != "seed-chained" { + t.Errorf("second merge value = %v, want seed-chained (merge functions did not compose)", got) + } +} + +// TestAuthMiddlewareDPoPBoundToken verifies that a DPoP-bound access token +// presented with a matching DPoP proof is accepted and the verified claims +// are injected into context. Mirrors http/pkg/authplanehttp's TestMiddlewareDPoPValidProof. +func TestAuthMiddlewareDPoPBoundToken(t *testing.T) { + e := newDPoPTestEnv(t, false) + + signer, err := authplane.NewDPoPSigner(jose.ES256) + if err != nil { + t.Fatalf("NewDPoPSigner: %v", err) + } + token := e.makeTokenWithCnf(t, []string{"tools/add"}, time.Now().Add(time.Hour), + map[string]any{"jkt": signer.Thumbprint()}) + proof, err := signer.GenerateProof("GET", "http://localhost:8080/mcp", &authplane.DPoPProofOptions{ + AccessToken: token, + }) + if err != nil { + t.Fatalf("GenerateProof: %v", err) + } + + 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) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:8080/mcp", nil) + req.Header.Set("Authorization", "DPoP "+token) + req.Header.Set("DPoP", proof) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200; DPoP-bound token with valid proof should be accepted", rec.Code) + } + if gotClaims == nil { + t.Error("ClaimsFromContext returned nil; DPoP-bound token was not accepted") + } +} + +// TestAuthMiddlewareDPoPMissingProof verifies that a DPoP-bound access token +// without a matching DPoP proof header is rejected with 401. +func TestAuthMiddlewareDPoPMissingProof(t *testing.T) { + e := newDPoPTestEnv(t, false) + + signer, err := authplane.NewDPoPSigner(jose.ES256) + if err != nil { + t.Fatalf("NewDPoPSigner: %v", err) + } + token := e.makeTokenWithCnf(t, []string{"tools/add"}, time.Now().Add(time.Hour), + map[string]any{"jkt": signer.Thumbprint()}) + + handler := e.adapter.AuthMiddleware(okHandler()) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:8080/mcp", nil) + req.Header.Set("Authorization", "DPoP "+token) + // No DPoP proof header. + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +// TestProtectedResourceMetadataAdvertisesDPoPFields verifies that when the +// resource is configured with WithInboundDPoP{Required:true}, the PRM JSON +// document carries dpop_signing_alg_values_supported and +// dpop_bound_access_tokens_required, per RFC 9728 §2 / RFC 9449. +func TestProtectedResourceMetadataAdvertisesDPoPFields(t *testing.T) { + e := newDPoPTestEnv(t, true) + 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) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + + algs, ok := body["dpop_signing_alg_values_supported"].([]any) + if !ok { + t.Fatalf("dpop_signing_alg_values_supported missing or wrong type: %v", body["dpop_signing_alg_values_supported"]) + } + if len(algs) == 0 { + t.Error("dpop_signing_alg_values_supported is empty; want at least one algorithm") + } + // Sanity-check that defaults (ES256, RS256, PS256) are present. + seen := map[string]bool{} + for _, a := range algs { + if s, ok := a.(string); ok { + seen[s] = true + } + } + for _, want := range []string{"ES256", "RS256", "PS256"} { + if !seen[want] { + t.Errorf("dpop_signing_alg_values_supported missing %q (got %v)", want, algs) + } + } + + required, ok := body["dpop_bound_access_tokens_required"].(bool) + if !ok { + t.Fatalf("dpop_bound_access_tokens_required missing or wrong type: %v", body["dpop_bound_access_tokens_required"]) + } + if !required { + t.Error("dpop_bound_access_tokens_required = false, want true") + } +} + +// TestCloseIdempotent verifies that calling Close() twice on the same adapter +// does not panic. Idempotent Close is important when an adapter is wrapped in +// cleanup stacks (defer chains, t.Cleanup, etc.) that may invoke it more than +// once. The error (if any) on the second call is tolerated as long as no panic. +func TestCloseIdempotent(t *testing.T) { + e := newTestEnv(t) + + // First close — should succeed (or at least not panic). + if err := e.adapter.Close(); err != nil { + t.Logf("first Close() returned %v (informational)", err) + } + + // Second close — must not panic. The error (if any) is tolerated. + defer func() { + if r := recover(); r != nil { + t.Fatalf("second Close() panicked: %v", r) + } + }() + if err := e.adapter.Close(); err != nil { + t.Logf("second Close() returned %v (informational)", err) + } +} + +// --- 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..96b1861 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/constructor_test.go @@ -0,0 +1,229 @@ +package authplanemark3labs_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "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" +) + +// 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) + } +} + +// TestNewAdapterFromClientAndResourceNilClient verifies that a nil client +// yields a clear error rather than a panic — the constructor's signature +// matches the sibling mcp adapter. +func TestNewAdapterFromClientAndResourceNilClient(t *testing.T) { + _, err := authplanemark3labs.NewAdapterFromClientAndResource(nil, nil) + if err == nil { + t.Fatal("expected error on nil client") + } + if !strings.Contains(err.Error(), "client") { + t.Errorf("error = %v; want it to mention 'client'", err) + } +} + +// TestNewAdapterFromClientAndResourceNilResource verifies the second guard — +// a non-nil client with a nil resource must also return an error, not nil-deref +// inside authplanehttp.New(res). +func TestNewAdapterFromClientAndResourceNilResource(t *testing.T) { + e := newTestEnv(t) + + _, err := authplanemark3labs.NewAdapterFromClientAndResource(e.adapter.Client(), nil) + if err == nil { + t.Fatal("expected error on nil resource") + } + if !strings.Contains(err.Error(), "res") { + t.Errorf("error = %v; want it to mention 'res'", err) + } +} + +// 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..12fa762 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/helpers_test.go @@ -0,0 +1,286 @@ +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/core/resource/verifier" + "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 +} + +// makeTokenWithCnf signs an RS256 JWT carrying an extra "cnf" claim — used +// to bind the token to a DPoP signer's JWK thumbprint per RFC 9449 §6. +func (e *testEnv) makeTokenWithCnf(t *testing.T, scopes []string, exp time.Time, cnf map[string]any) string { + t.Helper() + + headerJSON := mustMarshal(t, map[string]string{ + "alg": "RS256", + "typ": "at+jwt", + "kid": e.kid, + }) + claims := map[string]any{ + "iss": e.issuer, + "aud": testResource, + "sub": "user-test", + "jti": "jti-" + time.Now().Format(time.RFC3339Nano), + "client_id": testResource, + "iat": time.Now().Unix(), + "exp": exp.Unix(), + "scope": strings.Join(scopes, " "), + } + if cnf != nil { + claims["cnf"] = cnf + } + claimsJSON := mustMarshal(t, claims) + + 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) +} + +// newDPoPTestEnv is newTestEnv plus an inbound-DPoP policy on the verifier so +// the adapter accepts DPoP-bound tokens and advertises dpop_* fields in PRM. +func newDPoPTestEnv(t *testing.T, required bool) *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, + VerifierOptions: []verifier.Option{ + verifier.WithInboundDPoP(verifier.InboundDPoPOptions{ + ReplayStore: verifier.NewInMemoryDPoPReplayStore(), + Required: required, + }), + }, + }) + 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} +} + +// 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..7bf00e6 --- /dev/null +++ b/mark3labs/pkg/authplanemark3labs/testing.go @@ -0,0 +1,31 @@ +package authplanemark3labs + +import ( + "context" + + "github.com/authplane/go-sdk/core/resource/verifier" + authplanehttp "github.com/authplane/go-sdk/http/pkg/authplanehttp" +) + +// ContextWithClaims returns a context carrying the given VerifiedClaims under +// the same context key AuthMiddleware uses, so ClaimsFromContext (and the +// per-tool-call context bridged by HTTPContextFunc) can retrieve them +// downstream. Production handlers should rely on AuthMiddleware to populate +// this; these setters exist for tests and for callers that manage the +// bearer/DPoP flow outside AuthMiddleware. +// +// Thin wrapper around authplanehttp.ContextWithClaims so the mark3labs and +// http adapters share a single context-key namespace. +func ContextWithClaims(ctx context.Context, claims *verifier.VerifiedClaims) context.Context { + return authplanehttp.ContextWithClaims(ctx, claims) +} + +// ContextWithToken returns a context carrying the given raw bearer token under +// the same context key AuthMiddleware uses. See ContextWithClaims for usage +// notes. +// +// Thin wrapper around authplanehttp.ContextWithToken for the same reason as +// ContextWithClaims. +func ContextWithToken(ctx context.Context, token string) context.Context { + return authplanehttp.ContextWithToken(ctx, token) +} diff --git a/mcp/go.mod b/mcp/go.mod index 0ef51ce..6d495b4 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -2,6 +2,8 @@ module github.com/authplane/go-sdk/mcp go 1.25.0 +toolchain go1.25.5 + require ( github.com/authplane/go-sdk/core v0.0.0 github.com/go-jose/go-jose/v4 v4.1.4 diff --git a/mcp/pkg/authplanemcp/adapter.go b/mcp/pkg/authplanemcp/adapter.go index 06cf5d4..ed839b9 100644 --- a/mcp/pkg/authplanemcp/adapter.go +++ b/mcp/pkg/authplanemcp/adapter.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "time" "github.com/authplane/go-sdk/core/authplane" @@ -110,17 +109,10 @@ func NewAdapter(ctx context.Context, options Options) (*Adapter, error) { return nil, err } - resourceURL, err := url.Parse(options.Resource) - if err != nil { - _ = client.Close() - return nil, fmt.Errorf("authplane-mcp: invalid resource URL: %w", err) - } - prmURL := resourceURL.ResolveReference(&url.URL{Path: res.WellKnownPRMPath()}).String() - return &Adapter{ client: client, resource: res, - prmURL: prmURL, + prmURL: res.PRMURL(), }, nil } @@ -131,17 +123,23 @@ func NewAdapter(ctx context.Context, options Options) (*Adapter, error) { // // Use this when you need to share a single client across multiple adapters, or // when you need full control over client and resource construction. +// +// Returns an error if client or res is nil. The (*Adapter, error) signature +// mirrors NewAdapter (which legitimately fails on discovery) so the two +// constructors stay swappable; the resource URL no longer needs parsing here +// because resource.Resource precomputes the absolute PRM URL at construction +// time. func NewAdapterFromClientAndResource(client *authplane.Client, res *resource.Resource) (*Adapter, error) { - resourceURL, err := url.Parse(res.URI()) - if err != nil { - return nil, fmt.Errorf("authplane-mcp: invalid resource URL: %w", err) + if client == nil { + return nil, errors.New("authplane-mcp: client must not be nil") + } + if res == nil { + return nil, errors.New("authplane-mcp: res must not be nil") } - prmURL := resourceURL.ResolveReference(&url.URL{Path: res.WellKnownPRMPath()}).String() - return &Adapter{ client: client, resource: res, - prmURL: prmURL, + prmURL: res.PRMURL(), }, nil } diff --git a/mcp/pkg/authplanemcp/constructor_test.go b/mcp/pkg/authplanemcp/constructor_test.go index a4ea0f9..e04fc68 100644 --- a/mcp/pkg/authplanemcp/constructor_test.go +++ b/mcp/pkg/authplanemcp/constructor_test.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -106,6 +107,34 @@ func TestNewAdapterFromClientAndResource(t *testing.T) { } } +// TestNewAdapterFromClientAndResourceNilClient verifies that a nil client +// yields a clear error rather than a later nil-pointer dereference inside +// AuthMiddleware/Close. +func TestNewAdapterFromClientAndResourceNilClient(t *testing.T) { + _, err := authplanemcp.NewAdapterFromClientAndResource(nil, nil) + if err == nil { + t.Fatal("expected error for nil client") + } + if !strings.Contains(err.Error(), "client") { + t.Errorf("error = %v; want it to mention 'client'", err) + } +} + +// TestNewAdapterFromClientAndResourceNilResource verifies the second guard — +// a non-nil client with a nil resource must return an error, not nil-deref +// later on res.PRMURL() / Middleware(). +func TestNewAdapterFromClientAndResourceNilResource(t *testing.T) { + e := newTestEnv(t) + + _, err := authplanemcp.NewAdapterFromClientAndResource(e.adapter.Client(), nil) + if err == nil { + t.Fatal("expected error for nil resource") + } + if !strings.Contains(err.Error(), "res") { + t.Errorf("error = %v; want it to mention 'res'", err) + } +} + // TestAutoWiredIntrospectionNotWipedByEmptyVerifierOptions is a regression test // for the bug where passing an empty VerifierOptions slice wiped the auto-wired // RFC 7662 introspection revocation checker. From 07b1f7fd9fc4d21b7c72ed193cc7c3794e7ebaa0 Mon Sep 17 00:00:00 2001 From: muralx Date: Wed, 10 Jun 2026 19:06:29 +0100 Subject: [PATCH 2/2] chore(release): wire mark3labs into the release tooling The mark3labs adapter module (own go.mod, own mark3labs/vX.Y.Z publish path) was missing from the per-release-tag automation. Extend it everywhere the other modules appear: - release.yml: derive the mark3labs tag; include mark3labs in the vet/lint, require-bump, test, tag-creation, atomic-push, and summary steps. mark3labs wraps the http adapter, so it also gets a require-http bump (after the core-require loop, so the core hash is already stable). - cut-release.yml: extend the pre-cut "tag already exists" guard to the mark3labs tag. --- .github/workflows/cut-release.yml | 11 +++-- .github/workflows/release.yml | 81 ++++++++++++++++++------------- 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 88a0042..25e9262 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -94,10 +94,11 @@ jobs: ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 - - name: Refuse if branch or any of the four tags already exist - # Go SDK publishes four tags per release: per-module tags - # (core/vX.Y.Z, mcp/vX.Y.Z, http/vX.Y.Z) plus an umbrella vX.Y.Z - # tag for the GitHub Release. Any one already present aborts. + - name: Refuse if branch or any of the five tags already exist + # Go SDK publishes five tags per release: per-module tags + # (core/vX.Y.Z, mcp/vX.Y.Z, http/vX.Y.Z, mark3labs/vX.Y.Z) plus + # an umbrella vX.Y.Z tag for the GitHub Release. Any one already + # present aborts. run: | v="${{ steps.validate.outputs.release }}" branch="${{ steps.validate.outputs.branch }}" @@ -105,7 +106,7 @@ jobs: echo "::error::Branch $branch already exists. Aborting." exit 1 fi - for tag in "core/v$v" "mcp/v$v" "http/v$v" "v$v"; do + for tag in "core/v$v" "mcp/v$v" "http/v$v" "mark3labs/v$v" "v$v"; do if git ls-remote --exit-code --tags origin "$tag" >/dev/null; then echo "::error::Tag $tag already exists. Aborting." exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9eaeafa..d871bc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,11 @@ name: Release # Step 2 of the single-branch release: take the stabilized release/v* or -# hotfix/v* branch, bump `require core vX.Y.Z` in mcp/http, run -# `go mod tidy`, run tests (incl. conformance), commit, tag the four -# refs, atomic-push the branch + tags, create the GitHub Release, and -# delete the source branch on success. +# hotfix/v* branch, bump `require core vX.Y.Z` in mcp/http/mark3labs (and +# `require http vX.Y.Z` in mark3labs, which wraps http), run `go mod +# tidy`, run tests (incl. conformance), commit, tag the five refs, +# atomic-push the branch + tags, create the GitHub Release, and delete +# the source branch on success. # # There is no merge into the default branch and no external-registry # publish step. `proxy.golang.org` polls tags and serves the new module @@ -12,10 +13,11 @@ name: Release # publish. # # The require-bump commit lives only on the release branch (now -# deleted). It is reachable from the four annotated tags, so the proxy +# deleted). It is reachable from the five annotated tags, so the proxy # resolves byte-identical hashes when consumers `go get` any of them. -# The default branch keeps its `require core v0.0.0` placeholder; the -# go.work + replace directive cover local dev. +# The default branch keeps its `require core v0.0.0` (and +# `require http v0.0.0` for mark3labs) placeholders; the go.work + +# replace directive cover local dev. # # Branch conventions: # - release/v: current-line release off the default branch. @@ -116,15 +118,17 @@ jobs: echo "coreTag=core/v$version" echo "mcpTag=mcp/v$version" echo "httpTag=http/v$version" + echo "mark3labsTag=mark3labs/v$version" echo "versionTag=v$version" } >> "$GITHUB_OUTPUT" - - name: Refuse if any of the four tags already exist + - name: Refuse if any of the five tags already exist run: | for tag in \ "${{ steps.version.outputs.coreTag }}" \ "${{ steps.version.outputs.mcpTag }}" \ "${{ steps.version.outputs.httpTag }}" \ + "${{ steps.version.outputs.mark3labsTag }}" \ "${{ steps.version.outputs.versionTag }}"; do if git ls-remote --exit-code --tags origin "$tag" >/dev/null; then echo "::error::Tag $tag already exists. Aborting." @@ -178,7 +182,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Install golangci-lint and vet/lint all three modules before any + # Install golangci-lint and vet/lint all four modules before any # state-mutating steps. A linting failure here leaves the workspace # clean (no partial go.mod edits to roll back). - name: Install golangci-lint @@ -187,37 +191,47 @@ jobs: version: v2.11.4 install-only: true - - name: Vet and lint all three modules + - name: Vet and lint all four modules run: | - for mod in core mcp http; do + for mod in core mcp http mark3labs; do (cd "$mod" && go vet ./...) (cd "$mod" && golangci-lint run ./...) done - # Bump require core in mcp and http, then go mod tidy. go.work + the - # replace directive mean go mod tidy resolves core locally, so the - # hash it writes matches what proxy.golang.org will later serve from - # the about-to-be-created core/vX.Y.Z tag. Consumers get - # byte-identical checksums. - - name: Bump require core in mcp and http, then go mod tidy + # Bump require core in mcp, http, and mark3labs, then go mod tidy. + # mark3labs also depends on http, so its go.mod gets a second + # require-bump in the step below. go.work + the replace directive + # mean go mod tidy resolves siblings locally, so the hash it writes + # matches what proxy.golang.org will later serve from the + # about-to-be-created tags. Consumers get byte-identical checksums. + - name: Bump require core in mcp, http, and mark3labs, then go mod tidy run: | v="${{ steps.version.outputs.version }}" - for mod in mcp http; do + for mod in mcp http mark3labs; do (cd "$mod" && go mod edit -require=github.com/authplane/go-sdk/core@v"$v" && go mod tidy) done - - name: Run tests in all three modules + # mark3labs wraps the http adapter, so it also needs require http + # pinned to the matching vX.Y.Z. Done after the core-require loop + # so the previous `go mod tidy` already wrote a stable core hash. + - name: Bump require http in mark3labs, then go mod tidy + run: | + v="${{ steps.version.outputs.version }}" + (cd mark3labs && go mod edit -require=github.com/authplane/go-sdk/http@v"$v" && go mod tidy) + + - name: Run tests in all four modules env: CONFORMANCE_CATALOG_PATH: ${{ github.workspace }}/conformance/oauth-sdk-conformance-catalog.yaml run: | - (cd core && go test ./...) - (cd mcp && go test ./...) - (cd http && go test ./...) + (cd core && go test ./...) + (cd mcp && go test ./...) + (cd http && go test ./...) + (cd mark3labs && go test ./...) # Single release commit on the release/hotfix branch containing the # require bumps. Tag points at this commit. Lives only on the # release branch (deleted after publish) — never reaches the default - # branch. Reachable from the four annotated tags so proxy.golang.org + # branch. Reachable from the five annotated tags so proxy.golang.org # serves it correctly. - name: Commit require-bumps on the release branch run: | @@ -228,17 +242,18 @@ jobs: id: sha run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Create four annotated tags locally + - name: Create five annotated tags locally run: | v="${{ steps.version.outputs.version }}" - git tag -a "core/v$v" -m "core v$v" - git tag -a "mcp/v$v" -m "mcp v$v" - git tag -a "http/v$v" -m "http v$v" - git tag -a "v$v" -m "Release v$v" + git tag -a "core/v$v" -m "core v$v" + git tag -a "mcp/v$v" -m "mcp v$v" + git tag -a "http/v$v" -m "http v$v" + git tag -a "mark3labs/v$v" -m "mark3labs v$v" + git tag -a "v$v" -m "Release v$v" # --- Everything below this point mutates the remote or external state. --- # Dry-run short-circuit: report and skip the mutating phase. The local - # state (release commit + four tags on the runner) is verified but + # state (release commit + five tags on the runner) is verified but # thrown away when the runner tears down. - name: Dry run short-circuit notice if: ${{ inputs.dryRun }} @@ -246,16 +261,16 @@ jobs: echo "::notice::Dry run — skipping atomic push, GitHub Release, and branch deletion. Require-bump, tidy, tests, and local tag creation all succeeded. Would-be released commit: ${{ steps.sha.outputs.sha }}" # Atomic push of the release/hotfix branch tip (with the release - # commit) + four tags. Either everything lands or nothing does. + # commit) + five tags. Either everything lands or nothing does. # The branch is listed first so the commit referenced by the tags # exists on the remote after the transaction. - - name: Atomic push of release branch and four tags + - name: Atomic push of release branch and five tags if: ${{ !inputs.dryRun }} run: | v="${{ steps.version.outputs.version }}" git push --atomic origin \ "$GITHUB_REF_NAME" \ - "core/v$v" "mcp/v$v" "http/v$v" "v$v" + "core/v$v" "mcp/v$v" "http/v$v" "mark3labs/v$v" "v$v" echo "::notice::Released commit: ${{ steps.sha.outputs.sha }}" # --------- From here on, the release is live on proxy.golang.org. --------- @@ -329,7 +344,7 @@ jobs: echo "" echo "- **Version**: \`$v\`" echo "- **Released commit**: \`$sha\`" - echo "- **Tags pushed**: \`core/v$v\`, \`mcp/v$v\`, \`http/v$v\`, \`v$v\`" + echo "- **Tags pushed**: \`core/v$v\`, \`mcp/v$v\`, \`http/v$v\`, \`mark3labs/v$v\`, \`v$v\`" echo "- **Go module proxy**: https://proxy.golang.org/github.com/authplane/go-sdk/core/@v/v$v.info" echo "- **GitHub Release**: ${{ github.server_url }}/${{ github.repository }}/releases/tag/v$v" if [[ "${{ steps.delete.outcome }}" != "success" ]]; then