diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 846feb7b..758b88e3 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -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. diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index cd4741a5..92b6faf8 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -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 { diff --git a/oauthex/dcr.go b/oauthex/dcr.go index ce21467e..f46d0e8e 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -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