feat: add automatic application_type inference (#904)
Implement requirements introduced by [SEP-337](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837) Fixes #903 --------- Co-authored-by: Maciej Kisiel <mkisiel@google.com>
This commit is contained in:
parent
db50910c70
commit
2643b22a4e
3 changed files with 176 additions and 0 deletions
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
@ -34,6 +35,10 @@ type ClientIDMetadataDocumentConfig struct {
|
||||||
type DynamicClientRegistrationConfig struct {
|
type DynamicClientRegistrationConfig struct {
|
||||||
// Metadata to be used in dynamic client registration request as per
|
// Metadata to be used in dynamic client registration request as per
|
||||||
// https://datatracker.ietf.org/doc/html/rfc7591#section-2.
|
// https://datatracker.ietf.org/doc/html/rfc7591#section-2.
|
||||||
|
//
|
||||||
|
// If Metadata.ApplicationType is empty, it will be inferred from
|
||||||
|
// Metadata.RedirectURIs. When set, it will be validated against the inferred type
|
||||||
|
// and an error will be returned if they conflict.
|
||||||
Metadata *oauthex.ClientRegistrationMetadata
|
Metadata *oauthex.ClientRegistrationMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +155,12 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
||||||
} else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) {
|
} else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) {
|
||||||
return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL)
|
return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL)
|
||||||
}
|
}
|
||||||
|
applicationType := inferApplicationType(dCfg.Metadata.RedirectURIs)
|
||||||
|
if dCfg.Metadata.ApplicationType == "" {
|
||||||
|
dCfg.Metadata.ApplicationType = applicationType
|
||||||
|
} else if dCfg.Metadata.ApplicationType != applicationType {
|
||||||
|
return nil, fmt.Errorf("application type %q conflicts with the application type inferred from redirect URIs", dCfg.Metadata.ApplicationType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if config.RedirectURL == "" {
|
if config.RedirectURL == "" {
|
||||||
// If the RedirectURL was supposed to be set by the dynamic client registration,
|
// If the RedirectURL was supposed to be set by the dynamic client registration,
|
||||||
|
|
@ -170,6 +181,36 @@ func isNonRootHTTPSURL(u string) bool {
|
||||||
return pu.Scheme == "https" && pu.Path != ""
|
return pu.Scheme == "https" && pu.Path != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inferApplicationType returns an application type based on the redirect URIs.
|
||||||
|
func inferApplicationType(redirectURIs []string) string {
|
||||||
|
hasNative := false
|
||||||
|
hasWeb := false
|
||||||
|
for _, uri := range redirectURIs {
|
||||||
|
u, err := url.Parse(uri)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch u.Scheme {
|
||||||
|
case "http", "https":
|
||||||
|
if util.IsLoopback(u.Hostname()) {
|
||||||
|
hasNative = true
|
||||||
|
} else {
|
||||||
|
hasWeb = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
hasNative = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasNative && hasWeb {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if hasNative {
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
|
|
||||||
// Authorize performs the authorization flow.
|
// Authorize performs the authorization flow.
|
||||||
// It is designed to perform the whole Authorization Code Grant flow.
|
// It is designed to perform the whole Authorization Code Grant flow.
|
||||||
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
|
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,136 @@ func TestDynamicRegistration(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInferApplicationType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
redirectURIs []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "localhost",
|
||||||
|
redirectURIs: []string{"http://localhost:8085/callback"},
|
||||||
|
want: "native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "127.0.0.1",
|
||||||
|
redirectURIs: []string{"http://127.0.0.1:8085/callback"},
|
||||||
|
want: "native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback",
|
||||||
|
redirectURIs: []string{"http://[::1]:8085/callback"},
|
||||||
|
want: "native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom scheme",
|
||||||
|
redirectURIs: []string{"myapp://callback"},
|
||||||
|
want: "native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTPS remote",
|
||||||
|
redirectURIs: []string{"https://myapp.example.com/callback"},
|
||||||
|
want: "web",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed native and web",
|
||||||
|
redirectURIs: []string{"https://myapp.example.com/callback", "http://localhost:8085/callback"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple remote",
|
||||||
|
redirectURIs: []string{"https://app1.example.com/cb", "https://app2.example.com/cb"},
|
||||||
|
want: "web",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := inferApplicationType(tt.redirectURIs)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("inferApplicationType() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplicationTypeInference(t *testing.T) {
|
||||||
|
fetcher := func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
redirectURIs []string
|
||||||
|
initialAppType string
|
||||||
|
wantAppType string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "inferred as native for localhost",
|
||||||
|
redirectURIs: []string{"http://localhost:8085/callback"},
|
||||||
|
wantAppType: "native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inferred as web for remote",
|
||||||
|
redirectURIs: []string{"https://example.com/callback"},
|
||||||
|
wantAppType: "web",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed native and web URIs sets empty application type",
|
||||||
|
redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"},
|
||||||
|
wantAppType: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit value matching inference is preserved",
|
||||||
|
redirectURIs: []string{"http://localhost:8085/callback"},
|
||||||
|
initialAppType: "native",
|
||||||
|
wantAppType: "native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit value conflicts with inference returns error",
|
||||||
|
redirectURIs: []string{"http://localhost:8085/callback"},
|
||||||
|
initialAppType: "web",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit value when inference is ambiguous returns error",
|
||||||
|
redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"},
|
||||||
|
initialAppType: "web",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid URI returns empty application type",
|
||||||
|
redirectURIs: []string{"http://%/"},
|
||||||
|
wantAppType: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &AuthorizationCodeHandlerConfig{
|
||||||
|
DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{
|
||||||
|
Metadata: &oauthex.ClientRegistrationMetadata{
|
||||||
|
RedirectURIs: tt.redirectURIs,
|
||||||
|
ApplicationType: tt.initialAppType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AuthorizationCodeFetcher: fetcher,
|
||||||
|
}
|
||||||
|
_, err := NewAuthorizationCodeHandler(cfg)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("NewAuthorizationCodeHandler() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got := cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType
|
||||||
|
if got != tt.wantAppType {
|
||||||
|
t.Errorf("ApplicationType = %q, want %q", got, tt.wantAppType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
||||||
// Values that are relevant to the test should be set explicitly.
|
// Values that are relevant to the test should be set explicitly.
|
||||||
func validConfig() *AuthorizationCodeHandlerConfig {
|
func validConfig() *AuthorizationCodeHandlerConfig {
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,11 @@ type ClientRegistrationMetadata struct {
|
||||||
// SoftwareStatement is an OPTIONAL JWT that asserts client metadata values.
|
// SoftwareStatement is an OPTIONAL JWT that asserts client metadata values.
|
||||||
// Values in the software statement take precedence over other metadata values.
|
// Values in the software statement take precedence over other metadata values.
|
||||||
SoftwareStatement string `json:"software_statement,omitempty"`
|
SoftwareStatement string `json:"software_statement,omitempty"`
|
||||||
|
|
||||||
|
// ApplicationType is an OPTIONAL string that indicates the type of application.
|
||||||
|
// Valid values are "native" and "web".
|
||||||
|
// If omitted, OIDC-compliant authorization servers default to "web".
|
||||||
|
ApplicationType string `json:"application_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientRegistrationResponse represents the fields returned by the Authorization Server
|
// ClientRegistrationResponse represents the fields returned by the Authorization Server
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue