Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
38 changes: 38 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 Down Expand Up @@ -150,6 +151,13 @@ 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)
}
if dCfg.Metadata.ApplicationType == "" {
applicationType, err := inferApplicationType(dCfg.Metadata.RedirectURIs)
Comment thread
maciej-kisiel marked this conversation as resolved.
Outdated
if err != nil {
return nil, fmt.Errorf("failed to infer application type from redirect URIs: %w", err)
}
dCfg.Metadata.ApplicationType = applicationType
}
}
if config.RedirectURL == "" {
// If the RedirectURL was supposed to be set by the dynamic client registration,
Expand All @@ -170,6 +178,36 @@ func isNonRootHTTPSURL(u string) bool {
return pu.Scheme == "https" && pu.Path != ""
}

// inferApplicationType returns "native" or "web" based on the redirect URIs.
func inferApplicationType(redirectURIs []string) (string, error) {
hasNative := false
hasWeb := false
for _, uri := range redirectURIs {
u, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("invalid redirect URI %q: %w", uri, err)
}
switch u.Scheme {
case "http", "https":
if util.IsLoopback(u.Hostname()) {
hasNative = true
} else {
hasWeb = true
}
default:
hasNative = true
}
}

if hasNative && hasWeb {
return "", errors.New("mixed redirect URI types: found both native and web URIs")
}
if hasNative {
return "native", nil
}
return "web", nil
}

// 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
128 changes: 128 additions & 0 deletions auth/authorization_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,134 @@ func TestDynamicRegistration(t *testing.T) {
}
}

func TestInferApplicationType(t *testing.T) {
tests := []struct {
name string
redirectURIs []string
want string
wantErr bool
}{
{
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"},
wantErr: true,
},
{
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, err := inferApplicationType(tt.redirectURIs)
if (err != nil) != tt.wantErr {
t.Fatalf("inferApplicationType() error = %v, wantErr %v", err, tt.wantErr)
}
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
}

t.Run("inferred as native for localhost", func(t *testing.T) {
cfg := &AuthorizationCodeHandlerConfig{
DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{
Metadata: &oauthex.ClientRegistrationMetadata{
RedirectURIs: []string{"http://localhost:8085/callback"},
},
},
AuthorizationCodeFetcher: fetcher,
}
if _, err := NewAuthorizationCodeHandler(cfg); err != nil {
t.Fatalf("NewAuthorizationCodeHandler() error = %v", err)
}
if cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType != "native" {
t.Errorf("ApplicationType = %q, want %q", cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType, "native")
}
})

t.Run("inferred as web for remote", func(t *testing.T) {
cfg := &AuthorizationCodeHandlerConfig{
DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{
Metadata: &oauthex.ClientRegistrationMetadata{
RedirectURIs: []string{"https://example.com/callback"},
},
},
AuthorizationCodeFetcher: fetcher,
}
if _, err := NewAuthorizationCodeHandler(cfg); err != nil {
t.Fatalf("NewAuthorizationCodeHandler() error = %v", err)
}
if cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType != "web" {
t.Errorf("ApplicationType = %q, want %q", cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType, "web")
}
})

t.Run("mixed native and web URIs returns error", func(t *testing.T) {
cfg := &AuthorizationCodeHandlerConfig{
DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{
Metadata: &oauthex.ClientRegistrationMetadata{
RedirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"},
},
},
AuthorizationCodeFetcher: fetcher,
}
if _, err := NewAuthorizationCodeHandler(cfg); err == nil {
t.Fatal("NewAuthorizationCodeHandler() = nil error, want error for mixed redirect URI types")
}
})

t.Run("explicit value preserved", func(t *testing.T) {
cfg := &AuthorizationCodeHandlerConfig{
DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{
Metadata: &oauthex.ClientRegistrationMetadata{
RedirectURIs: []string{"http://localhost:8085/callback"},
ApplicationType: "web",
},
},
AuthorizationCodeFetcher: fetcher,
}
if _, err := NewAuthorizationCodeHandler(cfg); err != nil {
t.Fatalf("NewAuthorizationCodeHandler() error = %v", err)
}
if cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType != "web" {
t.Errorf("ApplicationType = %q, want %q (should not be overridden)", cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType, "web")
}
})
}

// 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