Harden CIMD client resolution and caching
Tighten redirect URI validation for metadata documents, honor Cache-Control no-store when caching fetched documents, and resolve clients on the same transaction as authorization. Load external_client_id from the database and parse unbounded max-stale directives in cachecontrol. Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
@@ -17,6 +17,7 @@ package oauth2
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -196,12 +197,8 @@ func validateClientMetadataDocument(clientIDURL string, doc *ClientMetadataDocum
|
||||
}
|
||||
|
||||
for _, redirectURI := range doc.RedirectURIs {
|
||||
parsed, err := url.Parse(redirectURI)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document contains invalid redirect_uri"),
|
||||
)
|
||||
if err := validateCIMDRedirectURI(redirectURI); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +220,41 @@ func validateClientMetadataDocument(clientIDURL string, doc *ClientMetadataDocum
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCIMDRedirectURI(redirectURI string) error {
|
||||
parsed, err := url.Parse(redirectURI)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document contains invalid redirect_uri"),
|
||||
)
|
||||
}
|
||||
|
||||
if parsed.User != nil || parsed.Fragment != "" {
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document contains invalid redirect_uri"),
|
||||
)
|
||||
}
|
||||
|
||||
switch parsed.Scheme {
|
||||
case "https":
|
||||
case "http":
|
||||
if !net.IsLoopback(parsed.Hostname()) {
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document contains invalid redirect_uri"),
|
||||
)
|
||||
}
|
||||
default:
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document contains invalid redirect_uri"),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cimdRedirectURIAllowed(doc *ClientMetadataDocument, redirectURI string) bool {
|
||||
for _, allowed := range doc.RedirectURIs {
|
||||
if redirectURI == allowed {
|
||||
@@ -280,8 +312,14 @@ func (f *cimdFetcher) loadCache(clientIDURL string) (*ClientMetadataDocument, bo
|
||||
}
|
||||
|
||||
func (f *cimdFetcher) storeCache(clientIDURL string, doc *ClientMetadataDocument, cacheControl string) {
|
||||
dir, err := cachecontrol.ParseResponse(cacheControl)
|
||||
if err == nil && dir.NoStore() {
|
||||
return
|
||||
}
|
||||
|
||||
ttl := cimdDefaultCacheTTL
|
||||
if dir, err := cachecontrol.ParseResponse(cacheControl); err == nil {
|
||||
|
||||
if err == nil {
|
||||
if maxAge, ok := dir.MaxAgeDuration(); ok {
|
||||
ttl = min(ttl, maxAge)
|
||||
}
|
||||
@@ -300,8 +338,30 @@ func (s *Service) ResolveClient(
|
||||
ctx context.Context,
|
||||
clientIDRaw string,
|
||||
redirectURI string,
|
||||
) (*coredata.OAuth2Client, error) {
|
||||
return s.resolveClient(ctx, nil, clientIDRaw, redirectURI)
|
||||
}
|
||||
|
||||
func (s *Service) resolveClient(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
clientIDRaw string,
|
||||
redirectURI string,
|
||||
) (*coredata.OAuth2Client, error) {
|
||||
if clientID, err := gid.ParseGID(clientIDRaw); err == nil {
|
||||
if tx != nil {
|
||||
client := coredata.OAuth2Client{}
|
||||
if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), clientID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, NewError(ErrInvalidClient, WithDescription("client not found"))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot load oauth2 client: %w", err)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
return s.GetClientByID(ctx, clientID)
|
||||
}
|
||||
|
||||
@@ -325,7 +385,7 @@ func (s *Service) ResolveClient(
|
||||
return nil, ErrInvalidRedirectURI
|
||||
}
|
||||
|
||||
client, err := s.upsertCIMDClient(ctx, clientIDRaw, doc)
|
||||
client, err := s.upsertCIMDClient(ctx, tx, clientIDRaw, doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -335,6 +395,7 @@ func (s *Service) ResolveClient(
|
||||
|
||||
func (s *Service) upsertCIMDClient(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
externalClientID string,
|
||||
doc *ClientMetadataDocument,
|
||||
) (*coredata.OAuth2Client, error) {
|
||||
@@ -358,6 +419,7 @@ func (s *Service) upsertCIMDClient(
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
candidate, err := coredata.NewCIMDClient(
|
||||
externalClientID,
|
||||
doc.ClientName,
|
||||
@@ -373,16 +435,28 @@ func (s *Service) upsertCIMDClient(
|
||||
|
||||
var client coredata.OAuth2Client
|
||||
|
||||
upsert := func(ctx context.Context, conn pg.Tx) error {
|
||||
client = *candidate
|
||||
|
||||
if err := client.UpsertCIMD(ctx, conn); err != nil {
|
||||
return fmt.Errorf("cannot upsert cimd oauth2 client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if tx != nil {
|
||||
if err := upsert(ctx, tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
client = *candidate
|
||||
|
||||
if err := client.UpsertCIMD(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot upsert cimd oauth2 client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
return upsert(ctx, conn)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -102,6 +102,31 @@ func TestValidateClientMetadataDocument(t *testing.T) {
|
||||
|
||||
require.NoError(t, validateClientMetadataDocument(clientID, &doc))
|
||||
|
||||
t.Run(
|
||||
"http redirect on non-loopback rejected",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bad := doc
|
||||
bad.RedirectURIs = []string{"http://example.com/callback"}
|
||||
|
||||
err := validateClientMetadataDocument(clientID, &bad)
|
||||
require.Error(t, err)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"http loopback redirect allowed",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loopback := doc
|
||||
loopback.RedirectURIs = []string{"http://127.0.0.1:3000/callback"}
|
||||
|
||||
require.NoError(t, validateClientMetadataDocument(clientID, &loopback))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"mismatched client_id",
|
||||
func(t *testing.T) {
|
||||
@@ -143,35 +168,82 @@ func TestCIMDRedirectURIAllowed(t *testing.T) {
|
||||
func TestCIMDFetcherFetch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := ClientMetadataDocument{
|
||||
ClientName: "Test MCP Client",
|
||||
RedirectURIs: []string{"http://127.0.0.1:3000/callback"},
|
||||
TokenEndpointAuthMethod: "none",
|
||||
}
|
||||
t.Run(
|
||||
"caches response with max-age",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewTLSServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=60")
|
||||
_ = json.NewEncoder(w).Encode(doc)
|
||||
},
|
||||
),
|
||||
doc := ClientMetadataDocument{
|
||||
ClientName: "Test MCP Client",
|
||||
RedirectURIs: []string{"http://127.0.0.1:3000/callback"},
|
||||
TokenEndpointAuthMethod: "none",
|
||||
}
|
||||
|
||||
server := httptest.NewTLSServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=60")
|
||||
_ = json.NewEncoder(w).Encode(doc)
|
||||
},
|
||||
),
|
||||
)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clientID := server.URL + "/oauth/client.json"
|
||||
doc.ClientID = clientID
|
||||
|
||||
fetcher := &cimdFetcher{
|
||||
httpClient: server.Client(),
|
||||
logger: log.NewLogger(),
|
||||
}
|
||||
|
||||
fetched, err := fetcher.fetch(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, doc.ClientName, fetched.ClientName)
|
||||
|
||||
cached, err := fetcher.fetch(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fetched.ClientName, cached.ClientName)
|
||||
},
|
||||
)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clientID := server.URL + "/oauth/client.json"
|
||||
doc.ClientID = clientID
|
||||
t.Run(
|
||||
"no-store response is not cached",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fetcher := &cimdFetcher{
|
||||
httpClient: server.Client(),
|
||||
logger: log.NewLogger(),
|
||||
}
|
||||
doc := ClientMetadataDocument{
|
||||
ClientName: "Test MCP Client",
|
||||
RedirectURIs: []string{"http://127.0.0.1:3000/callback"},
|
||||
TokenEndpointAuthMethod: "none",
|
||||
}
|
||||
|
||||
fetched, err := fetcher.fetch(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, doc.ClientName, fetched.ClientName)
|
||||
requestCount := 0
|
||||
server := httptest.NewTLSServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
requestCount++
|
||||
|
||||
cached, err := fetcher.fetch(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fetched.ClientName, cached.ClientName)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(doc)
|
||||
},
|
||||
),
|
||||
)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clientID := server.URL + "/oauth/client.json"
|
||||
doc.ClientID = clientID
|
||||
|
||||
fetcher := &cimdFetcher{
|
||||
httpClient: server.Client(),
|
||||
logger: log.NewLogger(),
|
||||
}
|
||||
|
||||
_, err := fetcher.fetch(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
_, err = fetcher.fetch(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, requestCount)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1433,7 +1433,7 @@ func (s *Service) Authorize(
|
||||
if err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
client, err := s.ResolveClient(ctx, req.ClientIDRaw, req.RedirectURI)
|
||||
client, err := s.resolveClient(ctx, tx, req.ClientIDRaw, req.RedirectURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user