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"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
|
@ -34,6 +35,10 @@ type ClientIDMetadataDocumentConfig struct {
|
|||
type DynamicClientRegistrationConfig struct {
|
||||
// Metadata to be used in dynamic client registration request as per
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +155,12 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
|||
} 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)
|
||||
}
|
||||
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 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 != ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
// It is designed to perform the whole Authorization Code Grant flow.
|
||||
// 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.
|
||||
// Values that are relevant to the test should be set explicitly.
|
||||
func validConfig() *AuthorizationCodeHandlerConfig {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,11 @@ type ClientRegistrationMetadata struct {
|
|||
// SoftwareStatement is an OPTIONAL JWT that asserts client metadata values.
|
||||
// Values in the software statement take precedence over other metadata values.
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue