Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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.
- `core/authplane`: `TokenResponse`/`IntrospectionResponse` expose the RFC 9449 §6 confirmation — `Cnf json.RawMessage` (raw `cnf`, verbatim) and `CnfJkt string` (DPoP thumbprint from `cnf.jkt`, empty when unbound). Derived at parse time and preserved across `TokenCache` hits.

### 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 `/`.
- `core/internal/cache`: `TokenCache.Set` distinguishes a missing `expires_in` from `expires_in: 0` (RFC 6749 §5.1) — nil applies the default TTL, `0` is refused as born-expired instead of cached for the default hour.
- `core/internal/dpop`: `NormalizePath` upper-cases percent-encoded hex triplets (RFC 3986 §6.2.2.1) on both comparison sides, so a proof signed with `%2f` matches a request reconstructed with `%2F`.

### Changed
- **BREAKING (pre-1.0)** `core/authplane`: `TokenResponse.ExpiresIn` changes from `int64` to `*int64`, so a response that omits `expires_in` is now `nil` rather than `0`. This distinguishes an absent field from an explicit `expires_in: 0` (RFC 6749 §5.1 permits a deliberately-expired one-shot token) and aligns the Go shape with the optional `expires_in` wire contract (absent vs. `0` vs. positive). **Migration:** any caller reading `resp.ExpiresIn` directly (arithmetic, comparison, formatting) must dereference and nil-check; treat `nil` as "apply your default" and `*v == 0` as "already expired".
- **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.
Expand Down
22 changes: 19 additions & 3 deletions core/authplane/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package authplane

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -187,11 +188,26 @@ func (c *Client) currentMetadata(ctx context.Context) (*metadata.ASMetadata, err
}

func (c *Client) tokenResponseFromCache(entry *cache.CacheEntry) *TokenResponse {
// Clone Cnf so a caller mutating resp.Cnf bytes cannot corrupt the
// shared backing slice held in the cache entry.
var cnf json.RawMessage
if len(entry.Cnf) > 0 {
cnf = append(json.RawMessage(nil), entry.Cnf...)
}
// Same reason for ExpiresIn: a caller writing through `*resp.ExpiresIn`
// would otherwise mutate the cached entry's lifetime in place.
var expiresIn *int64
if entry.ExpiresIn != nil {
v := *entry.ExpiresIn
expiresIn = &v
}
return &TokenResponse{
AccessToken: entry.AccessToken,
TokenType: entry.TokenType,
ExpiresIn: entry.ExpiresIn,
ExpiresIn: expiresIn,
Scope: entry.Scope,
Cnf: cnf,
CnfJkt: entry.CnfJkt,
}
}

Expand Down Expand Up @@ -237,7 +253,7 @@ func (c *Client) ClientCredentials(ctx context.Context, scopes, resources []stri
}

// Cache the token.
c.tokenCache.Set(cacheKey, resp.AccessToken, resp.TokenType, resp.ExpiresIn, resp.Scope)
c.tokenCache.Set(cacheKey, resp.AccessToken, resp.TokenType, resp.ExpiresIn, resp.Scope, resp.Cnf, resp.CnfJkt)

return resp, nil
}
Expand All @@ -257,7 +273,7 @@ func (c *Client) TokenExchange(ctx context.Context, opts TokenExchangeInput) (*T
return nil, err
}

c.tokenCache.Set(cacheKey, resp.AccessToken, resp.TokenType, resp.ExpiresIn, resp.Scope)
c.tokenCache.Set(cacheKey, resp.AccessToken, resp.TokenType, resp.ExpiresIn, resp.Scope, resp.Cnf, resp.CnfJkt)

return resp, nil
}
Expand Down
9 changes: 7 additions & 2 deletions core/conformancetests/rfc6749_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -37,8 +38,12 @@ func TestRFC6749ClientCredentialsSuccessResponse(t *testing.T) {
if resp.TokenType != "Bearer" {
t.Errorf("token_type = %q, want %q", resp.TokenType, "Bearer")
}
if resp.ExpiresIn != 3600 {
t.Errorf("expires_in = %d, want %d", resp.ExpiresIn, 3600)
if resp.ExpiresIn == nil || *resp.ExpiresIn != 3600 {
got := "<nil>"
if resp.ExpiresIn != nil {
got = fmt.Sprintf("%d", *resp.ExpiresIn)
}
t.Errorf("expires_in = %s, want %d", got, 3600)
}
}

Expand Down
56 changes: 48 additions & 8 deletions core/internal/cache/token_cache.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cache

import (
"encoding/json"
"sort"
"strings"
"sync"
Expand All @@ -11,9 +12,27 @@ import (
type CacheEntry struct { //nolint:revive // CacheEntry is the established name used across the SDK
AccessToken string
TokenType string
ExpiresIn int64
Scope string
ExpiresAt time.Time

// ExpiresIn mirrors the wire field's tri-state (nil = AS omitted it,
// `*v > 0` = AS-issued lifetime). `*v == 0` never reaches the cache —
// Set refuses it — so callers reading from cache see a sentinel that
// always round-trips to the AS-issued shape.
ExpiresIn *int64

Scope string
ExpiresAt time.Time

// Cnf is the raw RFC 9449 §6.1 confirmation object the AS bound to
// the token (when DPoP-sender-constrained). Persisted so callers
// served from cache see the same sender-constrained shape as on the
// first miss — without this, every cache hit silently degraded the
// apparent security model of a DPoP-bound token to bearer-only.
Cnf json.RawMessage

// CnfJkt is the DPoP key thumbprint at `cnf.jkt`. Stored alongside
// `Cnf` so downstream code reading the convenience accessor cannot
// mistake a sender-constrained cache hit for an unbound bearer.
CnfJkt string
}

// TokenCache is a thread-safe in-memory cache for OAuth access tokens, keyed by
Expand Down Expand Up @@ -56,12 +75,31 @@ func (c *TokenCache) Get(key string) *CacheEntry {
return entry
}

// Set stores a token in the cache under key. If the effective TTL (expiresIn minus
// ttlBuffer) is <= 0 the entry is not stored.
func (c *TokenCache) Set(key, accessToken, tokenType string, expiresIn int64, scope string) {
ttl := time.Duration(expiresIn) * time.Second
if expiresIn <= 0 {
// Set stores a token in the cache under key.
//
// `expiresIn` is tri-state:
//
// - nil ⇒ the AS omitted `expires_in`; apply `defaultTTL`.
// - `*v == 0` ⇒ RFC 6749 §5.1 explicit zero (one-shot, born-expired);
// refuse to store so the next Get is a miss instead of a stale hit.
// - `*v > 0` ⇒ honor `*v` seconds.
//
// The effective TTL (chosen value minus `ttlBuffer`) must be > 0; otherwise
// the entry is not stored.
//
// `cnf` and `cnfJkt` carry the RFC 9449 §6.1 confirmation through the cache so
// a DPoP-bound token retrieved from cache reports its binding to downstream
// callers instead of silently degrading to a bearer-only shape. Both may be
// zero-valued for bearer tokens.
func (c *TokenCache) Set(key, accessToken, tokenType string, expiresIn *int64, scope string, cnf json.RawMessage, cnfJkt string) {
var ttl time.Duration
switch {
case expiresIn == nil:
ttl = c.defaultTTL
case *expiresIn == 0:
return
default:
ttl = time.Duration(*expiresIn) * time.Second
}
ttl -= c.ttlBuffer
if ttl <= 0 {
Expand All @@ -74,6 +112,8 @@ func (c *TokenCache) Set(key, accessToken, tokenType string, expiresIn int64, sc
ExpiresIn: expiresIn,
Scope: scope,
ExpiresAt: time.Now().Add(ttl),
Cnf: cnf,
CnfJkt: cnfJkt,
}
c.mu.Unlock()
}
Expand Down
84 changes: 72 additions & 12 deletions core/internal/cache/token_cache_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package cache

import (
"encoding/json"
"testing"
"time"
)

func int64Ptr(v int64) *int64 { return &v }

func TestTokenCache_SetGet_RoundTrip(t *testing.T) {
tc := NewTokenCache(30*time.Second, 3600*time.Second)
tc.Set("key1", "token-abc", "Bearer", 3600, "read write")
tc.Set("key1", "token-abc", "Bearer", int64Ptr(3600), "read write", nil, "")
entry := tc.Get("key1")
if entry == nil {
t.Fatal("expected entry")
Expand All @@ -26,7 +29,7 @@ func TestTokenCache_SetGet_RoundTrip(t *testing.T) {
func TestTokenCache_Get_Expired(t *testing.T) {
tc := NewTokenCache(0, 3600*time.Second)
// Set with very short TTL
tc.Set("expiring", "token", "Bearer", 1, "")
tc.Set("expiring", "token", "Bearer", int64Ptr(1), "", nil, "")
time.Sleep(1100 * time.Millisecond)
if tc.Get("expiring") != nil {
t.Error("expected nil for expired entry")
Expand All @@ -36,7 +39,7 @@ func TestTokenCache_Get_Expired(t *testing.T) {
func TestTokenCache_TTLBuffer(t *testing.T) {
// Buffer of 50s, expires_in=60s → effective TTL = 10s
tc := NewTokenCache(50*time.Second, 3600*time.Second)
tc.Set("buffered", "token", "Bearer", 60, "")
tc.Set("buffered", "token", "Bearer", int64Ptr(60), "", nil, "")
entry := tc.Get("buffered")
if entry == nil {
t.Fatal("expected entry (10s effective TTL)")
Expand All @@ -51,29 +54,45 @@ func TestTokenCache_TTLBuffer(t *testing.T) {
func TestTokenCache_SkipCaching_TTLTooSmall(t *testing.T) {
// Buffer of 100s, expires_in=50s → effective TTL = -50s → skip caching
tc := NewTokenCache(100*time.Second, 3600*time.Second)
tc.Set("skip", "token", "Bearer", 50, "")
tc.Set("skip", "token", "Bearer", int64Ptr(50), "", nil, "")
if tc.Get("skip") != nil {
t.Error("should not cache when effective TTL <= 0")
}
}

func TestTokenCache_DefaultTTL(t *testing.T) {
// TestTokenCache_NilExpiresIn_HonorsDefaultTTL pins the tri-state contract:
// when the AS omits `expires_in` (decoded as nil), the cache applies
// `defaultTTL` minus the buffer.
func TestTokenCache_NilExpiresIn_HonorsDefaultTTL(t *testing.T) {
tc := NewTokenCache(30*time.Second, 300*time.Second)
// expires_in=0 → use default TTL (300s) minus buffer (30s) = 270s
tc.Set("default", "token", "Bearer", 0, "")
tc.Set("default", "token", "Bearer", nil, "", nil, "")
entry := tc.Get("default")
if entry == nil {
t.Fatal("expected entry with default TTL")
}
if entry.ExpiresIn != nil {
t.Errorf("ExpiresIn: expected nil to round-trip, got %d", *entry.ExpiresIn)
}
remaining := time.Until(entry.ExpiresAt)
if remaining < 265*time.Second || remaining > 275*time.Second {
t.Errorf("expected effective TTL ~270s, got %v", remaining)
}
}

// TestTokenCache_ZeroExpiresIn_RefusesToStore pins the RFC 6749 §5.1
// one-shot contract: a token the AS deliberately marks `expires_in: 0`
// is born-expired and must not be returned on the next read.
func TestTokenCache_ZeroExpiresIn_RefusesToStore(t *testing.T) {
tc := NewTokenCache(30*time.Second, 3600*time.Second)
tc.Set("oneshot", "token", "Bearer", int64Ptr(0), "", nil, "")
if tc.Get("oneshot") != nil {
t.Error("expected nil for expires_in=0 (one-shot) — refuse to store")
}
}

func TestTokenCache_Delete(t *testing.T) {
tc := NewTokenCache(30*time.Second, 3600*time.Second)
tc.Set("del", "token", "Bearer", 3600, "")
tc.Set("del", "token", "Bearer", int64Ptr(3600), "", nil, "")
tc.Delete("del")
if tc.Get("del") != nil {
t.Error("expected nil after delete")
Expand Down Expand Up @@ -128,9 +147,9 @@ func TestCacheKey_ScopeOnly(t *testing.T) {

func TestTokenCache_DeleteByAccessToken(t *testing.T) {
tc := NewTokenCache(30*time.Second, 3600*time.Second)
tc.Set("key1", "token-A", "Bearer", 3600, "read")
tc.Set("key2", "token-B", "Bearer", 3600, "write")
tc.Set("key3", "token-A", "Bearer", 3600, "admin") // same token, different key
tc.Set("key1", "token-A", "Bearer", int64Ptr(3600), "read", nil, "")
tc.Set("key2", "token-B", "Bearer", int64Ptr(3600), "write", nil, "")
tc.Set("key3", "token-A", "Bearer", int64Ptr(3600), "admin", nil, "") // same token, different key

tc.DeleteByAccessToken("token-A")

Expand All @@ -147,11 +166,52 @@ func TestTokenCache_DeleteByAccessToken(t *testing.T) {

func TestTokenCache_DeleteByAccessToken_NoMatch(t *testing.T) {
tc := NewTokenCache(30*time.Second, 3600*time.Second)
tc.Set("key1", "token-X", "Bearer", 3600, "read")
tc.Set("key1", "token-X", "Bearer", int64Ptr(3600), "read", nil, "")

tc.DeleteByAccessToken("no-such-token")

if tc.Get("key1") == nil {
t.Error("key1 should still exist")
}
}

// TestTokenCache_DpopBindingSurvivesRoundTrip pins the DPoP binding
// through the cache round-trip: a DPoP-bound token must report its
// binding (both raw `Cnf` and the convenience `CnfJkt`) when served
// from cache, not silently degrade to a bearer-only shape.
func TestTokenCache_DpopBindingSurvivesRoundTrip(t *testing.T) {
tc := NewTokenCache(30*time.Second, 3600*time.Second)
cnf := json.RawMessage(`{"jkt":"thumbprint-abc"}`)
tc.Set("dpop", "dpop-bound-token", "DPoP", int64Ptr(3600), "tools/echo", cnf, "thumbprint-abc")

entry := tc.Get("dpop")
if entry == nil {
t.Fatal("expected entry")
}
if entry.CnfJkt != "thumbprint-abc" {
t.Errorf("CnfJkt: expected %q, got %q", "thumbprint-abc", entry.CnfJkt)
}
if string(entry.Cnf) != `{"jkt":"thumbprint-abc"}` {
t.Errorf("Cnf: expected raw object, got %q", string(entry.Cnf))
}
}

// TestTokenCache_BearerTokenDefaultsCnfToZero pins that callers
// caching a bearer-only token (no DPoP binding) keep `Cnf` nil and
// `CnfJkt` empty so downstream code reading them cannot mistake
// the entry for sender-constrained.
func TestTokenCache_BearerTokenDefaultsCnfToZero(t *testing.T) {
tc := NewTokenCache(30*time.Second, 3600*time.Second)
tc.Set("bearer", "bearer-tok", "Bearer", int64Ptr(3600), "read", nil, "")

entry := tc.Get("bearer")
if entry == nil {
t.Fatal("expected entry")
}
if entry.Cnf != nil {
t.Errorf("Cnf: expected nil, got %q", string(entry.Cnf))
}
if entry.CnfJkt != "" {
t.Errorf("CnfJkt: expected empty, got %q", entry.CnfJkt)
}
}
Loading
Loading