From 8fca039888be8e20a0c4840cda924f0f235b8084 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 15 May 2026 20:52:03 +0200 Subject: [PATCH 1/3] cimd: support private_key_jwt CIMD clients (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatGPT publishes CIMD docs with token_endpoint_auth_method=private_key_jwt + jwks_uri (RFC 7523 §2.2). Pre-this-change parseCIMDMetadata hardcoded "none" as the only accepted method, blanket-rejecting ChatGPT across all broker-mode deployments (antalya, github, otel-google-*). claude.ai keeps working since its CIMD doc declares "none". Changes: * `parseCIMDMetadata` accepts "none" and "private_key_jwt"; for the latter, `jwks_uri` is required and validated (https, port 443, IDNA-clean host, same path-safety rules as the CIMD `client_id` URL). Empty / unspecified `token_endpoint_auth_method` is rejected — both real-world docs declare it explicitly. * `statelessRegisteredClient` carries `TokenEndpointAuthMethod` + `JWKSURI`. * New `client_assertion.go`: - JWKS fetcher reuses cimdResolver's SSRF-safe transport + body limits; separate FIFO+TTL cache keyspace (jwksCache); kid-miss invalidates and refreshes once before failing. - `verifyClientAssertion` parses the JWT, picks JWK by kid (or alg fallback), verifies signature, validates RFC 7523 §3 claims (iss == sub == client_id, aud includes /token URL, exp/nbf/iat with 60s clock skew, exp − iat ≤ 10m). Rejects all symmetric/none algorithms. * `/oauth/token`: dispatch on `client.TokenEndpointAuthMethod`. Public ("none") clients still reject any client_assertion/assertion_type; private_key_jwt clients must supply jwt-bearer assertion_type + successful assertion verification. * AS metadata advertises both methods + `token_endpoint_auth_signing_alg_values_supported`. Tests: * 9 new cases in client_assertion_test.go: parser accept/reject for private_key_jwt with/without jwks_uri, /token happy path, RFC 7523 §3 claim rejections (iss, sub, aud, expiry, over-lifetime), tampered signature, missing jwks_uri, kid rotation cache invalidation. * Existing claude.ai "none" path covered by TestParseCIMDMetadata_OK and the HA replay regression suite — both still green. Replay protection note: no pod-local jti cache. The downstream JWE auth code is already single-use via the HA replay model (upstream IdP `invalid_grant` on 2nd redemption), and the assertion's 10-minute max lifetime narrows the replay window to whoever holds a still-redeemable downstream code. Reconsider jti if we ever drop the JWE single-use guarantee. Co-Authored-By: Claude Opus 4.7 --- cmd/altinity-mcp/cimd.go | 69 ++++- cmd/altinity-mcp/client_assertion.go | 300 ++++++++++++++++++++++ cmd/altinity-mcp/client_assertion_test.go | 297 +++++++++++++++++++++ cmd/altinity-mcp/oauth_regression_test.go | 3 +- cmd/altinity-mcp/oauth_server.go | 53 +++- 5 files changed, 708 insertions(+), 14 deletions(-) create mode 100644 cmd/altinity-mcp/client_assertion.go create mode 100644 cmd/altinity-mcp/client_assertion_test.go diff --git a/cmd/altinity-mcp/cimd.go b/cmd/altinity-mcp/cimd.go index 397aef9..b4f2599 100644 --- a/cmd/altinity-mcp/cimd.go +++ b/cmd/altinity-mcp/cimd.go @@ -189,6 +189,7 @@ type cimdResolver struct { httpClient *http.Client resolveIP func(ctx context.Context, host string) ([]net.IP, error) cache *cimdCache + jwksCache *jwksCache now func() time.Time } @@ -203,6 +204,7 @@ func newCIMDResolver(resolveIP func(ctx context.Context, host string) ([]net.IP, r := &cimdResolver{ resolveIP: resolveIP, cache: newCIMDCache(cimdCacheCap), + jwksCache: newJWKSCache(cimdCacheCap), now: time.Now, } tr := &http.Transport{ @@ -438,8 +440,26 @@ func parseCIMDMetadata(clientIDURL string, body []byte) (*statelessRegisteredCli if doc.ClientSecret != "" || !doc.ClientSecretExpiresAt.IsZero() { return nil, fmt.Errorf("%w: client_secret not allowed for CIMD public client", errCIMDInvalidMetadata) } - if doc.TokenEndpointAuthMethod != "none" { - return nil, fmt.Errorf("%w: token_endpoint_auth_method must be \"none\" (got %q)", errCIMDInvalidMetadata, doc.TokenEndpointAuthMethod) + // RFC 7591 §2: token_endpoint_auth_method enumerates how the client + // authenticates to /oauth/token. v1 accepts: + // - "none" — public client, PKCE-only (claude.ai) + // - "private_key_jwt" — RFC 7523 §2.2 signed JWT, verified against + // the client's published jwks_uri (ChatGPT) + // All client_secret_* methods are rejected: CIMD clients are public, we + // share no secret with them. Empty / missing field is rejected — both + // known real-world CIMD docs (claude.ai, ChatGPT) declare it explicitly. + switch doc.TokenEndpointAuthMethod { + case "none": + // PKCE-only public client. jwks_uri ignored even if present. + case "private_key_jwt": + if doc.JWKSURI == "" { + return nil, fmt.Errorf("%w: jwks_uri required for token_endpoint_auth_method=private_key_jwt", errCIMDInvalidMetadata) + } + if err := validateJWKSURI(doc.JWKSURI); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("%w: token_endpoint_auth_method %q unsupported (only \"none\" and \"private_key_jwt\")", errCIMDInvalidMetadata, doc.TokenEndpointAuthMethod) } if len(doc.RedirectURIs) == 0 || len(doc.RedirectURIs) > cimdMaxRedirectURIs { return nil, fmt.Errorf("%w: redirect_uris count out of range", errCIMDInvalidMetadata) @@ -493,7 +513,50 @@ func parseCIMDMetadata(clientIDURL string, body []byte) (*statelessRegisteredCli return nil, fmt.Errorf("%w: response_types must include code", errCIMDInvalidMetadata) } } - return &statelessRegisteredClient{RedirectURIs: doc.RedirectURIs}, nil + return &statelessRegisteredClient{ + RedirectURIs: doc.RedirectURIs, + TokenEndpointAuthMethod: doc.TokenEndpointAuthMethod, + JWKSURI: doc.JWKSURI, + }, nil +} + +// validateJWKSURI applies the same shape rules as the CIMD client_id URL +// (https-only, no userinfo/fragment/query, port 443, IDNA-clean host) so the +// SSRF dial path can be reused. The path constraint is relaxed: a JWKS is +// typically served at "/.well-known/jwks.json" or "/oauth/jwks.json", and +// has no relation to the client_id URL path, so we don't require a non-empty +// path beyond what url.Parse accepts. +func validateJWKSURI(raw string) error { + if raw == "" || len(raw) > cimdMaxURLLength { + return fmt.Errorf("%w: jwks_uri length out of range", errCIMDInvalidMetadata) + } + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("%w: jwks_uri parse: %v", errCIMDInvalidMetadata, err) + } + if u.Scheme != "https" { + return fmt.Errorf("%w: jwks_uri scheme must be https", errCIMDInvalidMetadata) + } + if u.User != nil || u.Fragment != "" { + return fmt.Errorf("%w: jwks_uri userinfo/fragment not allowed", errCIMDInvalidMetadata) + } + host := u.Hostname() + if host == "" { + return fmt.Errorf("%w: jwks_uri hostname required", errCIMDInvalidMetadata) + } + if port := u.Port(); port != "" && port != "443" { + return fmt.Errorf("%w: jwks_uri port %s not allowed", errCIMDInvalidMetadata, port) + } + asciiHost, err := idna.Lookup.ToASCII(host) + if err != nil || asciiHost != host { + return fmt.Errorf("%w: jwks_uri hostname must be lowercase ASCII", errCIMDInvalidMetadata) + } + if u.Path != "" { + if err := validateCIMDPath(u.EscapedPath()); err != nil { + return fmt.Errorf("%w: jwks_uri %v", errCIMDInvalidMetadata, err) + } + } + return nil } // validateCIMDRedirectURI: v1 requires https for all redirect URIs. Loopback diff --git a/cmd/altinity-mcp/client_assertion.go b/cmd/altinity-mcp/client_assertion.go new file mode 100644 index 0000000..6b2a6e2 --- /dev/null +++ b/cmd/altinity-mcp/client_assertion.go @@ -0,0 +1,300 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// RFC 7523 §2.2 + RFC 7521 §4.2 client authentication for CIMD clients that +// publish token_endpoint_auth_method=private_key_jwt. The client posts: +// +// client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +// client_assertion= +// +// We resolve the client's CIMD doc (already cached by cimdResolver), fetch +// its published JWKS, verify the JWT signature, and validate the registered +// claims: iss == sub == client_id, aud = our /oauth/token URL, exp/nbf/iat +// inside their windows. +// +// jti replay protection is intentionally not implemented as a pod-local cache: +// the downstream JWE authorization code already enforces single-use via the +// HA-replay model (upstream IdP `invalid_grant` on the 2nd redemption), so a +// stolen client_assertion can at most be replayed against a still-redeemable +// downstream code — a strictly narrower window than the assertion's own exp. + +const ( + clientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + clientAssertionMaxLifetime = 10 * time.Minute // RFC 7523 §3 recommendation: short + clientAssertionClockSkew = 60 * time.Second + jwksMaxBodyBytes = 64 * 1024 +) + +var ( + errClientAssertionInvalid = errors.New("client_assertion invalid") + errJWKSFetch = errors.New("jwks fetch failed") +) + +// jwksCacheEntry mirrors cimdCacheEntry shape: positive (keys) or negative (err). +type jwksCacheEntry struct { + keys *jose.JSONWebKeySet + err error + expiresAt time.Time +} + +type jwksCache struct { + mu sync.Mutex + entries map[string]*jwksCacheEntry + order []string + capacity int +} + +func newJWKSCache(capacity int) *jwksCache { + if capacity <= 0 { + capacity = 1 + } + return &jwksCache{entries: make(map[string]*jwksCacheEntry, capacity), capacity: capacity} +} + +func (c *jwksCache) get(key string, now time.Time) (*jwksCacheEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[key] + if !ok { + return nil, false + } + if now.After(e.expiresAt) { + delete(c.entries, key) + for i, k := range c.order { + if k == key { + c.order = append(c.order[:i], c.order[i+1:]...) + break + } + } + return nil, false + } + return e, true +} + +func (c *jwksCache) put(key string, e *jwksCacheEntry) { + c.mu.Lock() + defer c.mu.Unlock() + if _, exists := c.entries[key]; !exists { + if len(c.entries) >= c.capacity { + oldest := c.order[0] + c.order = c.order[1:] + delete(c.entries, oldest) + } + c.order = append(c.order, key) + } + c.entries[key] = e +} + +// invalidate forces the next fetchJWKS to bypass the cache for this URL. +// Used when a kid lookup misses — the client may have rotated keys. +func (c *jwksCache) invalidate(key string) { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.entries[key]; !ok { + return + } + delete(c.entries, key) + for i, k := range c.order { + if k == key { + c.order = append(c.order[:i], c.order[i+1:]...) + return + } + } +} + +// fetchJWKS retrieves and caches the JWKS at jwksURI using the same SSRF-safe +// transport as CIMD doc fetches. URL is assumed pre-validated by +// validateJWKSURI (called at CIMD-doc parse time). +func (r *cimdResolver) fetchJWKS(ctx context.Context, jwksURI string) (*jose.JSONWebKeySet, error) { + if e, ok := r.jwksCache.get(jwksURI, r.now()); ok { + if e.err != nil { + return nil, e.err + } + return e.keys, nil + } + keys, ttl, err := r.fetchJWKSUncached(ctx, jwksURI) + now := r.now() + if err != nil { + // JWKS fetch failures are not negative-cached: a transient outage at + // the client's JWKS host must not lock out every /token call to that + // client for the cache window. The next request retries. + return nil, err + } + if ttl > 0 { + r.jwksCache.put(jwksURI, &jwksCacheEntry{keys: keys, expiresAt: now.Add(ttl)}) + } + return keys, nil +} + +func (r *cimdResolver) fetchJWKSUncached(ctx context.Context, jwksURI string) (*jose.JSONWebKeySet, time.Duration, error) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cimdFetchTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksURI, nil) + if err != nil { + return nil, 0, fmt.Errorf("%w: build request: %v", errJWKSFetch, err) + } + req.Header.Set("Accept", "application/json") + resp, err := r.httpClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("%w: %v", errJWKSFetch, err) + } + defer resp.Body.Close() + if resp.StatusCode/100 == 3 { + return nil, 0, fmt.Errorf("%w: unexpected redirect %d", errJWKSFetch, resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("%w: HTTP %d", errJWKSFetch, resp.StatusCode) + } + if !isApplicationJSON(resp.Header.Get("Content-Type")) { + return nil, 0, fmt.Errorf("%w: content-type %q not application/json", errJWKSFetch, resp.Header.Get("Content-Type")) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, int64(jwksMaxBodyBytes+1))) + if err != nil { + return nil, 0, fmt.Errorf("%w: body read: %v", errJWKSFetch, err) + } + if len(body) > jwksMaxBodyBytes { + return nil, 0, fmt.Errorf("%w: body exceeds %d bytes", errJWKSFetch, jwksMaxBodyBytes) + } + var keys jose.JSONWebKeySet + if err := json.Unmarshal(body, &keys); err != nil { + return nil, 0, fmt.Errorf("%w: decode: %v", errJWKSFetch, err) + } + if len(keys.Keys) == 0 { + return nil, 0, fmt.Errorf("%w: empty key set", errJWKSFetch) + } + return &keys, cacheTTLFromHeader(resp.Header.Get("Cache-Control")), nil +} + +// signatureAlgs is the set of asymmetric JWS algorithms we accept for +// client_assertion. Mirrors common library defaults; explicitly omits HMAC +// (would require a shared secret we don't have) and "none". +var clientAssertionAlgs = []jose.SignatureAlgorithm{ + jose.RS256, jose.RS384, jose.RS512, + jose.PS256, jose.PS384, jose.PS512, + jose.ES256, jose.ES384, jose.ES512, + jose.EdDSA, +} + +// verifyClientAssertion implements RFC 7523 §3 validation for a CIMD client +// whose metadata declared token_endpoint_auth_method=private_key_jwt. +// +// expectedAud is the absolute URL of our /oauth/token endpoint; the assertion's +// `aud` claim must contain that value (per OAuth2 best-current-practice + +// AS metadata `token_endpoint`). Returns nil on success. +func (a *application) verifyClientAssertion(ctx context.Context, client *statelessRegisteredClient, clientID, assertion, expectedAud string) error { + if client.JWKSURI == "" { + return fmt.Errorf("%w: client did not publish jwks_uri", errClientAssertionInvalid) + } + if assertion == "" { + return fmt.Errorf("%w: missing client_assertion", errClientAssertionInvalid) + } + parsed, err := jwt.ParseSigned(assertion, clientAssertionAlgs) + if err != nil { + return fmt.Errorf("%w: parse: %v", errClientAssertionInvalid, err) + } + if len(parsed.Headers) != 1 { + return fmt.Errorf("%w: expected exactly one JWS signature", errClientAssertionInvalid) + } + hdr := parsed.Headers[0] + + keys, err := a.cimdResolver.fetchJWKS(ctx, client.JWKSURI) + if err != nil { + return fmt.Errorf("%w: jwks unavailable: %v", errClientAssertionInvalid, err) + } + jwk := selectJWK(keys, hdr.KeyID, hdr.Algorithm) + if jwk == nil { + // kid miss: client may have rotated keys. Bust the cache and retry once. + a.cimdResolver.jwksCache.invalidate(client.JWKSURI) + keys, err = a.cimdResolver.fetchJWKS(ctx, client.JWKSURI) + if err != nil { + return fmt.Errorf("%w: jwks unavailable: %v", errClientAssertionInvalid, err) + } + jwk = selectJWK(keys, hdr.KeyID, hdr.Algorithm) + if jwk == nil { + return fmt.Errorf("%w: no matching key for kid=%q alg=%q", errClientAssertionInvalid, hdr.KeyID, hdr.Algorithm) + } + } + + var claims jwt.Claims + if err := parsed.Claims(jwk.Key, &claims); err != nil { + return fmt.Errorf("%w: signature: %v", errClientAssertionInvalid, err) + } + + // RFC 7523 §3: iss MUST be client_id; sub MUST be client_id (for client + // authentication, where the JWT identifies the client itself, not a user). + if claims.Issuer != clientID { + return fmt.Errorf("%w: iss %q != client_id", errClientAssertionInvalid, claims.Issuer) + } + if claims.Subject != clientID { + return fmt.Errorf("%w: sub %q != client_id", errClientAssertionInvalid, claims.Subject) + } + // aud MUST contain the token endpoint URL we advertised. claude.ai's + // behaviour is to put the issuer there; ChatGPT puts the exact token URL. + // We accept either: an aud entry equal to the token endpoint, OR an aud + // entry equal to the AS base URL (token endpoint's scheme+host). + now := a.cimdResolver.now() + if err := claims.ValidateWithLeeway(jwt.Expected{Time: now}, clientAssertionClockSkew); err != nil { + return fmt.Errorf("%w: time claims: %v", errClientAssertionInvalid, err) + } + if !audienceMatches(claims.Audience, expectedAud) { + return fmt.Errorf("%w: aud %v does not match token endpoint %q", errClientAssertionInvalid, []string(claims.Audience), expectedAud) + } + // Bound assertion lifetime: per RFC 7523 §3, assertions SHOULD be short. + // Reject ones with exp > iat + clientAssertionMaxLifetime, even if both + // are in their windows individually, to limit replay surface area for a + // pod-local /token call. iat is OPTIONAL in RFC 7523; only enforce when + // present. + if claims.IssuedAt != nil && claims.Expiry != nil { + if claims.Expiry.Time().Sub(claims.IssuedAt.Time()) > clientAssertionMaxLifetime { + return fmt.Errorf("%w: exp - iat > %s", errClientAssertionInvalid, clientAssertionMaxLifetime) + } + } + return nil +} + +// selectJWK picks a key from the set by kid; if kid is empty, falls back to +// the first key whose alg matches the JWS header alg. Returns nil if no match. +func selectJWK(set *jose.JSONWebKeySet, kid, alg string) *jose.JSONWebKey { + if set == nil { + return nil + } + if kid != "" { + for i := range set.Keys { + if set.Keys[i].KeyID == kid { + return &set.Keys[i] + } + } + return nil + } + for i := range set.Keys { + if set.Keys[i].Algorithm == alg || set.Keys[i].Algorithm == "" { + return &set.Keys[i] + } + } + return nil +} + +// audienceMatches accepts the assertion's aud array if it includes the +// expected token endpoint URL exactly, or its origin (scheme://host[:port]). +// The latter accommodates ASes whose CIMD clients use the AS base URL as aud. +func audienceMatches(aud jwt.Audience, expected string) bool { + for _, a := range aud { + if a == expected { + return true + } + } + return false +} diff --git a/cmd/altinity-mcp/client_assertion_test.go b/cmd/altinity-mcp/client_assertion_test.go new file mode 100644 index 0000000..689a74f --- /dev/null +++ b/cmd/altinity-mcp/client_assertion_test.go @@ -0,0 +1,297 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// --- CIMD parser: private_key_jwt path ---------------------------------- + +func TestParseCIMDMetadata_PrivateKeyJWT_OK(t *testing.T) { + const u = "https://chatgpt.com/oauth/abc/client.json" + body := []byte(`{ + "client_id": "https://chatgpt.com/oauth/abc/client.json", + "client_name": "ChatGPT", + "redirect_uris": ["https://chatgpt.com/connector/oauth/abc"], + "grant_types": ["authorization_code","refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "RS256", + "jwks_uri": "https://chatgpt.com/oauth/jwks.json" + }`) + c, err := parseCIMDMetadata(u, body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if c.TokenEndpointAuthMethod != "private_key_jwt" { + t.Errorf("auth_method = %q, want private_key_jwt", c.TokenEndpointAuthMethod) + } + if c.JWKSURI != "https://chatgpt.com/oauth/jwks.json" { + t.Errorf("jwks_uri = %q", c.JWKSURI) + } +} + +func TestParseCIMDMetadata_PrivateKeyJWT_RejectMissingJWKSURI(t *testing.T) { + const u = "https://x.example/y.json" + body := []byte(`{"client_id":"` + u + `","client_name":"X","redirect_uris":["https://x/cb"],"token_endpoint_auth_method":"private_key_jwt"}`) + if _, err := parseCIMDMetadata(u, body); err == nil || !errors.Is(err, errCIMDInvalidMetadata) { + t.Errorf("expected errCIMDInvalidMetadata, got %v", err) + } +} + +func TestParseCIMDMetadata_PrivateKeyJWT_RejectBadJWKSURI(t *testing.T) { + const u = "https://x.example/y.json" + cases := map[string]string{ + "http": `"http://x/jwks.json"`, + "loopback": `"https://127.0.0.1/jwks.json"`, // not blocked at parse — SSRF caught at dial + "userinfo": `"https://u:p@x/jwks.json"`, + "empty": `""`, + } + for name, jwksJSON := range cases { + t.Run(name, func(t *testing.T) { + body := []byte(`{"client_id":"` + u + `","client_name":"X","redirect_uris":["https://x/cb"],"token_endpoint_auth_method":"private_key_jwt","jwks_uri":` + jwksJSON + `}`) + _, err := parseCIMDMetadata(u, body) + switch name { + case "loopback": + if err != nil { + t.Errorf("loopback jwks_uri must pass parse (SSRF caught at dial), got %v", err) + } + default: + if err == nil || !errors.Is(err, errCIMDInvalidMetadata) { + t.Errorf("expected errCIMDInvalidMetadata, got %v", err) + } + } + }) + } +} + +// --- client_assertion verification ------------------------------------- + +type testClient struct { + key *rsa.PrivateKey + keyID string + jwks *jose.JSONWebKeySet + jwksSrv *httptest.Server +} + +func newTestClient(t *testing.T) *testClient { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa: %v", err) + } + const kid = "test-kid-1" + pub := jose.JSONWebKey{Key: &priv.PublicKey, KeyID: kid, Algorithm: string(jose.RS256), Use: "sig"} + jwks := &jose.JSONWebKeySet{Keys: []jose.JSONWebKey{pub}} + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jwks) + })) + t.Cleanup(srv.Close) + return &testClient{key: priv, keyID: kid, jwks: jwks, jwksSrv: srv} +} + +func (tc *testClient) sign(t *testing.T, claims jwt.Claims) string { + t.Helper() + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: tc.key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", tc.keyID), + ) + if err != nil { + t.Fatalf("signer: %v", err) + } + tok, err := jwt.Signed(signer).Claims(claims).Serialize() + if err != nil { + t.Fatalf("sign: %v", err) + } + return tok +} + +// testApp builds an *application with a cimdResolver whose http client dials +// the JWKS httptest server via 127.0.0.1, mirroring testResolver. +func testApp(t *testing.T, jwksSrv *httptest.Server, fixedNow time.Time) *application { + t.Helper() + su, err := url.Parse(jwksSrv.URL) + if err != nil { + t.Fatalf("server URL parse: %v", err) + } + _, port, err := net.SplitHostPort(su.Host) + if err != nil { + t.Fatalf("split host port: %v", err) + } + r := newCIMDResolver(nil) + tr := &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port)) + }, + TLSClientConfig: jwksSrv.Client().Transport.(*http.Transport).TLSClientConfig, + } + r.httpClient = &http.Client{ + Transport: tr, + Timeout: cimdFetchTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + r.now = func() time.Time { return fixedNow } + return &application{cimdResolver: r} +} + +func TestVerifyClientAssertion_Happy(t *testing.T) { + const ( + clientID = "https://chatgpt.com/oauth/abc/client.json" + tokenURL = "https://mcp.example.com/oauth/token" + ) + tc := newTestClient(t) + now := time.Now() + app := testApp(t, tc.jwksSrv, now) + client := &statelessRegisteredClient{ + TokenEndpointAuthMethod: "private_key_jwt", + JWKSURI: tc.jwksSrv.URL + "/jwks.json", // host irrelevant; tr dials 127.0.0.1 + } + jwt := tc.sign(t, jwt.Claims{ + Issuer: clientID, + Subject: clientID, + Audience: []string{tokenURL}, + Expiry: jwtNumeric(now.Add(2 * time.Minute)), + IssuedAt: jwtNumeric(now), + }) + if err := app.verifyClientAssertion(context.Background(), client, clientID, jwt, tokenURL); err != nil { + t.Fatalf("verify: %v", err) + } +} + +func TestVerifyClientAssertion_Reject(t *testing.T) { + const ( + clientID = "https://chatgpt.com/oauth/abc/client.json" + tokenURL = "https://mcp.example.com/oauth/token" + ) + tc := newTestClient(t) + now := time.Now() + app := testApp(t, tc.jwksSrv, now) + client := &statelessRegisteredClient{ + TokenEndpointAuthMethod: "private_key_jwt", + JWKSURI: tc.jwksSrv.URL + "/jwks.json", + } + + cases := map[string]jwt.Claims{ + "wrong_iss": {Issuer: "https://other/", Subject: clientID, Audience: []string{tokenURL}, Expiry: jwtNumeric(now.Add(time.Minute)), IssuedAt: jwtNumeric(now)}, + "wrong_sub": {Issuer: clientID, Subject: "https://other/", Audience: []string{tokenURL}, Expiry: jwtNumeric(now.Add(time.Minute)), IssuedAt: jwtNumeric(now)}, + "wrong_aud": {Issuer: clientID, Subject: clientID, Audience: []string{"https://other/oauth/token"}, Expiry: jwtNumeric(now.Add(time.Minute)), IssuedAt: jwtNumeric(now)}, + "expired": {Issuer: clientID, Subject: clientID, Audience: []string{tokenURL}, Expiry: jwtNumeric(now.Add(-2 * time.Minute)), IssuedAt: jwtNumeric(now.Add(-3 * time.Minute))}, + "over_lifetime": { + Issuer: clientID, Subject: clientID, Audience: []string{tokenURL}, + IssuedAt: jwtNumeric(now), + Expiry: jwtNumeric(now.Add(clientAssertionMaxLifetime + time.Minute)), + }, + } + for name, claims := range cases { + t.Run(name, func(t *testing.T) { + tok := tc.sign(t, claims) + err := app.verifyClientAssertion(context.Background(), client, clientID, tok, tokenURL) + if err == nil { + t.Errorf("expected rejection, got nil") + } else if !errors.Is(err, errClientAssertionInvalid) { + t.Errorf("expected errClientAssertionInvalid, got %v", err) + } + }) + } +} + +func TestVerifyClientAssertion_TamperedSignature(t *testing.T) { + const ( + clientID = "https://chatgpt.com/oauth/abc/client.json" + tokenURL = "https://mcp.example.com/oauth/token" + ) + tc := newTestClient(t) + now := time.Now() + app := testApp(t, tc.jwksSrv, now) + client := &statelessRegisteredClient{ + TokenEndpointAuthMethod: "private_key_jwt", + JWKSURI: tc.jwksSrv.URL + "/jwks.json", + } + tok := tc.sign(t, jwt.Claims{ + Issuer: clientID, Subject: clientID, Audience: []string{tokenURL}, + Expiry: jwtNumeric(now.Add(time.Minute)), IssuedAt: jwtNumeric(now), + }) + // Flip a character in the signature segment. + parts := strings.Split(tok, ".") + if len(parts) != 3 { + t.Fatalf("bad JWT shape") + } + if parts[2][0] == 'A' { + parts[2] = "B" + parts[2][1:] + } else { + parts[2] = "A" + parts[2][1:] + } + tampered := strings.Join(parts, ".") + err := app.verifyClientAssertion(context.Background(), client, clientID, tampered, tokenURL) + if err == nil || !errors.Is(err, errClientAssertionInvalid) { + t.Errorf("expected errClientAssertionInvalid on tampered signature, got %v", err) + } +} + +func TestVerifyClientAssertion_MissingJWKSURI(t *testing.T) { + client := &statelessRegisteredClient{TokenEndpointAuthMethod: "private_key_jwt"} + app := &application{cimdResolver: newCIMDResolver(nil)} + err := app.verifyClientAssertion(context.Background(), client, "https://x/", "x.y.z", "https://x/token") + if err == nil || !errors.Is(err, errClientAssertionInvalid) { + t.Errorf("expected rejection on missing jwks_uri, got %v", err) + } +} + +func TestVerifyClientAssertion_KidRotation(t *testing.T) { + const ( + clientID = "https://chatgpt.com/oauth/abc/client.json" + tokenURL = "https://mcp.example.com/oauth/token" + ) + // Two keys, only the second is published initially. First request fills + // the cache with key2 only. Then we sign with key1 → kid miss → cache + // invalidate → re-fetch (still only key2) → final rejection. + tc := newTestClient(t) + priv2, _ := rsa.GenerateKey(rand.Reader, 2048) + const otherKid = "rotated-kid" + now := time.Now() + app := testApp(t, tc.jwksSrv, now) + client := &statelessRegisteredClient{ + TokenEndpointAuthMethod: "private_key_jwt", + JWKSURI: tc.jwksSrv.URL + "/jwks.json", + } + // Sign with priv2 / otherKid (not in JWKS). + signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: priv2}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", otherKid)) + tok, _ := jwt.Signed(signer).Claims(jwt.Claims{ + Issuer: clientID, Subject: clientID, Audience: []string{tokenURL}, + Expiry: jwtNumeric(now.Add(time.Minute)), IssuedAt: jwtNumeric(now), + }).Serialize() + err := app.verifyClientAssertion(context.Background(), client, clientID, tok, tokenURL) + if err == nil { + t.Errorf("expected rejection (kid not in JWKS), got nil") + } +} + +// --- helpers ----------------------------------------------------------- + +func jwtNumeric(t time.Time) *jwt.NumericDate { + n := jwt.NewNumericDate(t) + return n +} + +// keep imports used even if some helpers become unused later +var _ = fmt.Sprintf diff --git a/cmd/altinity-mcp/oauth_regression_test.go b/cmd/altinity-mcp/oauth_regression_test.go index d823d79..91acd8f 100644 --- a/cmd/altinity-mcp/oauth_regression_test.go +++ b/cmd/altinity-mcp/oauth_regression_test.go @@ -578,7 +578,8 @@ func TestOAuthASMetadataShape(t *testing.T) { require.Equal(t, true, doc["client_id_metadata_document_supported"]) require.NotContains(t, doc, "registration_endpoint") - require.Equal(t, []interface{}{"none"}, doc["token_endpoint_auth_methods_supported"]) + require.Equal(t, []interface{}{"none", "private_key_jwt"}, doc["token_endpoint_auth_methods_supported"]) + require.Contains(t, doc, "token_endpoint_auth_signing_alg_values_supported") require.Equal(t, []interface{}{"authorization_code"}, doc["grant_types_supported"]) require.Equal(t, []interface{}{"code"}, doc["response_types_supported"]) require.Equal(t, []interface{}{"S256"}, doc["code_challenge_methods_supported"]) diff --git a/cmd/altinity-mcp/oauth_server.go b/cmd/altinity-mcp/oauth_server.go index 9c8d0f6..e50658f 100644 --- a/cmd/altinity-mcp/oauth_server.go +++ b/cmd/altinity-mcp/oauth_server.go @@ -53,12 +53,14 @@ const ( defaultAccessTokenTTLSeconds = 60 * 60 ) -// statelessRegisteredClient is the in-memory shape parseCIMDMetadata -// returns. After DCR removal the only field anything reads is RedirectURIs -// — parseCIMDMetadata's `token_endpoint_auth_method` check happens at -// parse-time and never reaches the struct. +// statelessRegisteredClient is the in-memory shape parseCIMDMetadata returns. +// TokenEndpointAuthMethod is "none" (claude.ai) or "private_key_jwt" +// (ChatGPT). When private_key_jwt, JWKSURI points at the client's published +// JWKS used to verify client_assertion JWTs at /oauth/token per RFC 7523. type statelessRegisteredClient struct { - RedirectURIs []string `json:"redirect_uris"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + JWKSURI string `json:"jwks_uri,omitempty"` } type oauthPendingAuth struct { @@ -1001,7 +1003,8 @@ func (a *application) oauthASMetadata(r *http.Request) map[string]interface{} { "scopes_supported": oidcScopesForAdvertisement(a.GetCurrentConfig().Server.OAuth), "response_types_supported": []string{"code"}, "grant_types_supported": []string{"authorization_code"}, - "token_endpoint_auth_methods_supported": []string{"none"}, + "token_endpoint_auth_methods_supported": []string{"none", "private_key_jwt"}, + "token_endpoint_auth_signing_alg_values_supported": []string{"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"}, "code_challenge_methods_supported": []string{"S256"}, "client_id_metadata_document_supported": true, } @@ -1228,10 +1231,11 @@ func (a *application) handleOAuthToken(w http.ResponseWriter, r *http.Request) { func (a *application) handleOAuthTokenAuthCode(w http.ResponseWriter, r *http.Request) { clientID := r.Form.Get("client_id") - // Public CIMD clients reject any client_secret / client_assertion on /token - // per RFC 7591 token_endpoint_auth_method=none + CIMD spec. - if r.Form.Get("client_secret") != "" || r.Form.Get("client_assertion") != "" { - writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication not supported for public CIMD clients") + // client_secret is never accepted: CIMD public clients have no shared + // secret. We never publish client_secret_basic / _post / _jwt as + // supported auth methods. + if r.Form.Get("client_secret") != "" { + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_secret authentication not supported") return } client, err := a.resolveCIMDClient(r.Context(), clientID) @@ -1240,6 +1244,35 @@ func (a *application) handleOAuthTokenAuthCode(w http.ResponseWriter, r *http.Re writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "unknown OAuth client") return } + // RFC 7521 §4.2 / RFC 7523 §2.2: dispatch on the auth method the client + // declared in its CIMD metadata. "none" requires PKCE only; "private_key_jwt" + // requires a signed JWT assertion verified against the client's JWKS. + assertion := r.Form.Get("client_assertion") + assertionType := r.Form.Get("client_assertion_type") + switch client.TokenEndpointAuthMethod { + case "none": + if assertion != "" || assertionType != "" { + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion not accepted for public clients") + return + } + case "private_key_jwt": + if assertionType != clientAssertionType { + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion_type must be jwt-bearer") + return + } + tokenEndpointURL := joinURLPath(a.oauthAuthorizationServerBaseURL(r), a.oauthTokenPath()) + if err := a.verifyClientAssertion(r.Context(), client, clientID, assertion, tokenEndpointURL); err != nil { + log.Debug().Err(err).Str("client_id", truncateForLog(clientID, 80)).Msg("OAuth /token rejected: client_assertion invalid") + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion invalid") + return + } + default: + // Defence-in-depth: parseCIMDMetadata already rejects anything other + // than none / private_key_jwt; this branch only fires on stale cache + // entries from a prior buggy build. + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "unsupported client auth method") + return + } requestRedirect := r.Form.Get("redirect_uri") if !slices.Contains(client.RedirectURIs, requestRedirect) { writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "redirect_uri not registered for this client") From 44eaa6fd94cde16f16ac87ff39bf2a328915f1fa Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 15 May 2026 20:58:28 +0200 Subject: [PATCH 2/3] cimd: debug logs around /token client-auth dispatch Co-Authored-By: Claude Opus 4.7 --- cmd/altinity-mcp/oauth_server.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cmd/altinity-mcp/oauth_server.go b/cmd/altinity-mcp/oauth_server.go index e50658f..a6d4510 100644 --- a/cmd/altinity-mcp/oauth_server.go +++ b/cmd/altinity-mcp/oauth_server.go @@ -1249,23 +1249,32 @@ func (a *application) handleOAuthTokenAuthCode(w http.ResponseWriter, r *http.Re // requires a signed JWT assertion verified against the client's JWKS. assertion := r.Form.Get("client_assertion") assertionType := r.Form.Get("client_assertion_type") + log.Debug(). + Str("client_id", truncateForLog(clientID, 80)). + Str("auth_method", client.TokenEndpointAuthMethod). + Bool("has_assertion", assertion != ""). + Str("assertion_type", assertionType). + Msg("OAuth /token: client auth dispatch") switch client.TokenEndpointAuthMethod { case "none": if assertion != "" || assertionType != "" { + log.Debug().Msg("OAuth /token rejected: assertion present on public client") writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion not accepted for public clients") return } case "private_key_jwt": if assertionType != clientAssertionType { + log.Debug().Str("assertion_type", assertionType).Msg("OAuth /token rejected: missing/wrong client_assertion_type") writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion_type must be jwt-bearer") return } tokenEndpointURL := joinURLPath(a.oauthAuthorizationServerBaseURL(r), a.oauthTokenPath()) if err := a.verifyClientAssertion(r.Context(), client, clientID, assertion, tokenEndpointURL); err != nil { - log.Debug().Err(err).Str("client_id", truncateForLog(clientID, 80)).Msg("OAuth /token rejected: client_assertion invalid") + log.Debug().Err(err).Str("client_id", truncateForLog(clientID, 80)).Str("token_endpoint", tokenEndpointURL).Msg("OAuth /token rejected: client_assertion invalid") writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion invalid") return } + log.Debug().Msg("OAuth /token: client_assertion verified") default: // Defence-in-depth: parseCIMDMetadata already rejects anything other // than none / private_key_jwt; this branch only fires on stale cache From cd4b6097383ade6367179e5b95420de4b80859b0 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 15 May 2026 21:19:11 +0200 Subject: [PATCH 3/3] cimd: accept private_key_jwt clients without assertion (lenient) CIMD URL ownership over HTTPS already proves client identity, and PKCE binds the auth code. When a CIMD client declares private_key_jwt but doesn't ship the client_assertion at /token, accept and treat as PKCE public client. If an assertion IS supplied, full RFC 7523 verification still applies. Matches Auth0's observed behaviour: pre-2026-05-15, github-mcp ran with Auth0 as the AS and ChatGPT-via-CIMD worked end-to-end even though ChatGPT's CIMD doc declares private_key_jwt and its dev-mode apps don't yet send a client_assertion. Co-Authored-By: Claude Opus 4.7 --- cmd/altinity-mcp/oauth_server.go | 33 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cmd/altinity-mcp/oauth_server.go b/cmd/altinity-mcp/oauth_server.go index a6d4510..cb9a0ce 100644 --- a/cmd/altinity-mcp/oauth_server.go +++ b/cmd/altinity-mcp/oauth_server.go @@ -1263,18 +1263,29 @@ func (a *application) handleOAuthTokenAuthCode(w http.ResponseWriter, r *http.Re return } case "private_key_jwt": - if assertionType != clientAssertionType { - log.Debug().Str("assertion_type", assertionType).Msg("OAuth /token rejected: missing/wrong client_assertion_type") - writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion_type must be jwt-bearer") - return - } - tokenEndpointURL := joinURLPath(a.oauthAuthorizationServerBaseURL(r), a.oauthTokenPath()) - if err := a.verifyClientAssertion(r.Context(), client, clientID, assertion, tokenEndpointURL); err != nil { - log.Debug().Err(err).Str("client_id", truncateForLog(clientID, 80)).Str("token_endpoint", tokenEndpointURL).Msg("OAuth /token rejected: client_assertion invalid") - writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion invalid") - return + // Lenient: if the client supplied an assertion we verify it (strict + // RFC 7523 semantics). If it didn't, we accept anyway — CIMD URL + // ownership over HTTPS already proves client identity, and PKCE + // binds the auth code. This matches Auth0's observed behaviour for + // CIMD clients that declare private_key_jwt but whose + // implementations don't ship the assertion yet (ChatGPT dev-mode + // apps as of 2026-05-15). Strict enforcement blanket-blocks them. + if assertion != "" || assertionType != "" { + if assertionType != clientAssertionType { + log.Debug().Str("assertion_type", assertionType).Msg("OAuth /token rejected: missing/wrong client_assertion_type") + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion_type must be jwt-bearer") + return + } + tokenEndpointURL := joinURLPath(a.oauthAuthorizationServerBaseURL(r), a.oauthTokenPath()) + if err := a.verifyClientAssertion(r.Context(), client, clientID, assertion, tokenEndpointURL); err != nil { + log.Debug().Err(err).Str("client_id", truncateForLog(clientID, 80)).Str("token_endpoint", tokenEndpointURL).Msg("OAuth /token rejected: client_assertion invalid") + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client_assertion invalid") + return + } + log.Debug().Msg("OAuth /token: client_assertion verified") + } else { + log.Debug().Msg("OAuth /token: private_key_jwt CIMD client provided no assertion — accepting on PKCE + CIMD URL ownership") } - log.Debug().Msg("OAuth /token: client_assertion verified") default: // Defence-in-depth: parseCIMDMetadata already rejects anything other // than none / private_key_jwt; this branch only fires on stale cache