An issuer identifier written with a terminating slash, like `https://auth.example.com/`, makes authorization server metadata discovery miss every endpoint. `GetAuthServerMetadata` probes three URLs that no conformant server serves, finds nothing, and the caller falls back to the guessed 2025-03-26 endpoints, which are themselves malformed because they are built by appending to the same slash-terminated issuer. `authorizationServerMetadataURLs` in `auth/shared.go` decides whether the issuer has a path component with `baseURL.Path == ""` (line 47), and a trailing-slash issuer has `Path == "/"`, so it takes the path-insertion branch. That branch inserts `strings.TrimLeft(originalPath, "/")` (lines 59 and 62), which strips the leading slash but keeps the trailing one. For `https://auth.example.com/` the probes come out as `/.well-known/oauth-authorization-server/`, `/.well-known/openid-configuration/` and `//.well-known/openid-configuration`. For `https://auth.example.com/tenant1/` the two path-insertion probes come out with a trailing slash. Only the path-appending form was already right, because it uses `strings.Trim`. RFC 8414 section 3.1 settles it: "If the issuer identifier value contains a path component, any terminating "/" MUST be removed before inserting "/.well-known/" and the well-known URI suffix between the host component and the path component." So `https://auth.example.com/` has no path component and gets the two root forms, which is the list the MCP authorization spec gives for issuers without a path component. The change trims slashes off both ends of the issuer path once, uses that to pick between the root and path-insertion branches, and reuses it for all three URLs. An issuer without a terminating slash produces exactly the same list as before. `TestAuthorizationServerMetadataURLs` pins the URL list for all four issuer shapes. `TestGetAuthServerMetadataTrailingSlashIssuer` drives the whole lookup against the existing fake authorization server, serving metadata at the root well-known path. On unmodified main the first fails on both trailing-slash cases and the second fails with `GetAuthServerMetadata() error = Get "/.well-known/openid-configuration": redirect into loopback address "127.0.0.1"`, because `//.well-known/openid-configuration` makes `net/http.ServeMux` issue a canonicalizing redirect that the discovery client's `CheckRedirect` refuses. Both pass with the change. Verification on go1.26.4: `gofmt -l .` clean, `go vet ./...` clean, `staticcheck ./...` clean at v0.6.1 (the version the Test workflow pins), `go test ./auth/...` ok, `go test ./...` ok. I did not file an issue first. The fix is self-contained, confined to one unexported helper, and every issuer shape other than the slash-terminated ones is byte-identical to before. Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
73 lines
2.6 KiB
Go
73 lines
2.6 KiB
Go
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by an MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
// This file contains shared utilities for OAuth handlers.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
)
|
|
|
|
// GetAuthServerMetadata fetches authorization server metadata for the given issuer URL.
|
|
// It tries standard well-known endpoints (OAuth 2.0 and OIDC) and returns the first successful result.
|
|
//
|
|
// Returns (nil, nil) when no metadata endpoints respond (404s), allowing callers to implement
|
|
// fallback logic. Returns an error for any non-client error (network failures, invalid JSON, etc.).
|
|
func GetAuthServerMetadata(ctx context.Context, issuerURL string, httpClient *http.Client) (*oauthex.AuthServerMeta, error) {
|
|
for _, metadataURL := range authorizationServerMetadataURLs(issuerURL) {
|
|
asm, err := oauthex.GetAuthServerMeta(ctx, metadataURL, issuerURL, httpClient)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if asm != nil {
|
|
return asm, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// authorizationServerMetadataURLs returns a list of URLs to try when looking for
|
|
// authorization server metadata as mandated by the MCP specification:
|
|
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery.
|
|
func authorizationServerMetadataURLs(issuerURL string) []string {
|
|
var urls []string
|
|
|
|
baseURL, err := url.Parse(issuerURL)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// RFC 8414, section 3.1 requires any terminating "/" to be removed from the
|
|
// issuer identifier before the well-known suffix is inserted, so an issuer
|
|
// such as "https://auth.example.com/" has no path component at all.
|
|
issuerPath := strings.Trim(baseURL.Path, "/")
|
|
|
|
if issuerPath == "" {
|
|
// "OAuth 2.0 Authorization Server Metadata".
|
|
baseURL.Path = "/.well-known/oauth-authorization-server"
|
|
urls = append(urls, baseURL.String())
|
|
// "OpenID Connect Discovery 1.0".
|
|
baseURL.Path = "/.well-known/openid-configuration"
|
|
urls = append(urls, baseURL.String())
|
|
return urls
|
|
}
|
|
|
|
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
|
baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath
|
|
urls = append(urls, baseURL.String())
|
|
// "OpenID Connect Discovery 1.0 with path insertion".
|
|
baseURL.Path = "/.well-known/openid-configuration/" + issuerPath
|
|
urls = append(urls, baseURL.String())
|
|
// "OpenID Connect Discovery 1.0 with path appending".
|
|
baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration"
|
|
urls = append(urls, baseURL.String())
|
|
|
|
return urls
|
|
}
|