Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions auth/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"slices"
"strings"

"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
Expand All @@ -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 will be validated against the inferred type
Comment thread
guglielmo-san marked this conversation as resolved.
Outdated
// and an error will be returned if they conflict.
Metadata *oauthex.ClientRegistrationMetadata
Comment thread
guglielmo-san marked this conversation as resolved.
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Comment thread
guglielmo-san marked this conversation as resolved.
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.
Expand Down
130 changes: 130 additions & 0 deletions auth/authorization_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
guglielmo-san marked this conversation as resolved.
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 {
Expand Down
5 changes: 5 additions & 0 deletions oauthex/dcr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading