From 244d0a99dfb60e4da8a054154fe734f080287925 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Fri, 11 Jul 2025 12:26:35 -0400 Subject: [PATCH] internal/oauthex: OAuth extensions Add a package for the extensions to OAuth 2.0 required by MCP. This first PR adds Protected Resource Metadata. --- internal/oauthex/oauth2.go | 6 + internal/oauthex/oauth2_test.go | 215 +++++++++++++++++ internal/oauthex/resource_meta.go | 376 ++++++++++++++++++++++++++++++ 3 files changed, 597 insertions(+) create mode 100644 internal/oauthex/oauth2.go create mode 100644 internal/oauthex/oauth2_test.go create mode 100644 internal/oauthex/resource_meta.go diff --git a/internal/oauthex/oauth2.go b/internal/oauthex/oauth2.go new file mode 100644 index 00000000..d1166fe1 --- /dev/null +++ b/internal/oauthex/oauth2.go @@ -0,0 +1,6 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package oauthex implements extensions to OAuth2. +package oauthex diff --git a/internal/oauthex/oauth2_test.go b/internal/oauthex/oauth2_test.go new file mode 100644 index 00000000..4e67ec88 --- /dev/null +++ b/internal/oauthex/oauth2_test.go @@ -0,0 +1,215 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestSplitChallenges(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "single challenge no params", + input: `Basic`, + want: []string{`Basic`}, + }, + { + name: "single challenge with params", + input: `Bearer realm="example.com", error="invalid_token"`, + want: []string{`Bearer realm="example.com", error="invalid_token"`}, + }, + { + name: "single challenge with comma in quoted string", + input: `Bearer realm="example, with comma"`, + want: []string{`Bearer realm="example, with comma"`}, + }, + { + name: "two challenges", + input: `Basic, Bearer realm="example"`, + want: []string{`Basic`, ` Bearer realm="example"`}, + }, + { + name: "multiple challenges complex", + input: `Newauth realm="apps", Basic, Bearer realm="example.com", error="invalid_token"`, + want: []string{`Newauth realm="apps"`, ` Basic`, ` Bearer realm="example.com", error="invalid_token"`}, + }, + { + name: "challenge with escaped quote", + input: `Bearer realm="example \"quoted\""`, + want: []string{`Bearer realm="example \"quoted\""`}, + }, + { + name: "empty input", + input: "", + want: []string{""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitChallenges(tt.input) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("splitChallenges() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseSingleChallenge(t *testing.T) { + tests := []struct { + name string + input string + want challenge + wantErr bool + }{ + { + name: "scheme only", + input: "Basic", + want: challenge{ + Scheme: "basic", + }, + wantErr: false, + }, + { + name: "scheme with one quoted param", + input: `Bearer realm="example.com"`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": "example.com"}, + }, + wantErr: false, + }, + { + name: "scheme with multiple params", + input: `Bearer realm="example", error="invalid_token", error_description="The token expired"`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{ + "realm": "example", + "error": "invalid_token", + "error_description": "The token expired", + }, + }, + wantErr: false, + }, + { + name: "case-insensitive scheme and keys", + input: `BEARER ReAlM="example"`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": "example"}, + }, + wantErr: false, + }, + { + name: "param with escaped quote", + input: `Bearer realm="example \"foo\" bar"`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": `example "foo" bar`}, + }, + wantErr: false, + }, + { + name: "param without quotes (token)", + input: "Bearer realm=example.com", + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": "example.com"}, + }, + wantErr: false, + }, + { + name: "malformed param - no value", + input: "Bearer realm=", + wantErr: true, + }, + { + name: "malformed param - unterminated quote", + input: `Bearer realm="example`, + wantErr: true, + }, + { + name: "malformed param - missing comma", + input: `Bearer realm="a" error="b"`, + wantErr: true, + }, + { + name: "empty input", + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSingleChallenge(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseSingleChallenge() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseSingleChallenge() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetProtectedResourceMetadataFromRequest(t *testing.T) { + h := &fakeResourceHandler{} + server := httptest.NewServer(h) + h.installHandlers(server.URL) + client := server.Client() + res, err := http.Get(server.URL + "/resource") + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Fatal("want unauth") + } + prm, err := GetProtectedResourceMetadataFromHeader(context.Background(), res.Header, client) + if err != nil { + t.Fatal(err) + } + if prm == nil { + t.Fatal("nil prm") + } +} + +type fakeResourceHandler struct { + http.ServeMux + serverURL string + authenticated bool +} + +func (h *fakeResourceHandler) installHandlers(serverURL string) { + path := "/.well-known/oauth-protected-resource" + url := serverURL + path + h.Handle("GET /resource", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h.authenticated { + fmt.Fprintln(w, "resource") + return + } + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, url)) + w.WriteHeader(http.StatusUnauthorized) + })) + h.Handle("GET "+path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + prm := &ProtectedResourceMetadata{Resource: url} + if err := json.NewEncoder(w).Encode(prm); err != nil { + panic(err) + } + })) +} diff --git a/internal/oauthex/resource_meta.go b/internal/oauthex/resource_meta.go new file mode 100644 index 00000000..d2241ae6 --- /dev/null +++ b/internal/oauthex/resource_meta.go @@ -0,0 +1,376 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This file implements Protected Resource Metadata. +// See https://www.rfc-editor.org/rfc/rfc9728.html. + +package oauthex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "unicode" + + "github.com/modelcontextprotocol/go-sdk/internal/util" +) + +const defaultProtectedResourceMetadataURI = "/.well-known/oauth-protected-resource" + +// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, +// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. +// +// The following features are not supported: +// - additional keys (§2, last sentence) +// - human-readable metadata (§2.1) +// - signed metadata (§2.2) +type ProtectedResourceMetadata struct { + // GENERATED BY GEMINI 2.5. + + // Resource (resource) is the protected resource's resource identifier. + // Required. + Resource string `json:"resource"` + + // AuthorizationServers (authorization_servers) is an optional slice containing a list of + // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be + // used with this protected resource. + AuthorizationServers []string `json:"authorization_servers,omitempty"` + + // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set + // document. This contains public keys belonging to the protected resource, such as + // signing key(s) that the resource server uses to sign resource responses. + JWKSURI string `json:"jwks_uri,omitempty"` + + // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope + // values (as defined in RFC 6749) used in authorization requests to request access + // to this protected resource. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing + // a list of the supported methods of sending an OAuth 2.0 bearer token to the + // protected resource. Defined values are "header", "body", and "query". + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + + // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms (alg values) supported by the protected + // resource for signing resource responses. + ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` + + // ResourceName (resource_name) is a human-readable name of the protected resource + // intended for display to the end user. It is RECOMMENDED that this field be included. + // This value may be internationalized. + ResourceName string `json:"resource_name,omitempty"` + + // ResourceDocumentation (resource_documentation) is an optional URL of a page containing + // human-readable information for developers using the protected resource. + // This value may be internationalized. + ResourceDocumentation string `json:"resource_documentation,omitempty"` + + // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing + // human-readable policy information on how a client can use the data provided. + // This value may be internationalized. + ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` + + // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected + // resource's human-readable terms of service. This value may be internationalized. + ResourceTOSURI string `json:"resource_tos_uri,omitempty"` + + // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an + // optional boolean indicating support for mutual-TLS client certificate-bound + // access tokens (RFC 8705). Defaults to false if omitted. + TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` + + // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional + // slice of 'type' values supported by the resource server for the + // 'authorization_details' parameter (RFC 9396). + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + + // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms supported by the resource server for validating + // DPoP proof JWTs (RFC 9449). + DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + + // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean + // specifying whether the protected resource always requires the use of DPoP-bound + // access tokens (RFC 9449). Defaults to false if omitted. + DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` + + // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters + // about the protected resource as claims. If present, these values take precedence + // over values conveyed in plain JSON. + // TODO:implement. + // Note that §2.2 says it's okay to ignore this. + // SignedMetadata string `json:"signed_metadata,omitempty"` +} + +// GetProtectedResourceMetadataFromID issues a GET request to retrieve protected resource +// metadata from a resource server by its ID. +// Typically the ID is an HTTPS URL with a host:port and possibly a path. For example: +// +// https://example.com/server +// +// This function, following the spec (§3), inserts the default well-known path into the +// URL. In our example, the result would be +// +// https://example.com/.well-known/oauth-protected-resource/server +// +// It then retrieves the metadata at that location using the given client (or the +// default client if nil) and validates its resource field against resourceID. +func GetProtectedResourceMetadataFromID(ctx context.Context, resourceID string, c *http.Client) (_ *ProtectedResourceMetadata, err error) { + defer util.Wrapf(&err, "GetProtectedResourceMetadataFromID(%q)", resourceID) + + u, err := url.Parse(resourceID) + if err != nil { + return nil, err + } + // Insert well-known URI into URL. + var suffix string + if len(u.Path) > 0 { + suffix = u.Path[1:] // remove initial slash + } + u.Path = defaultProtectedResourceMetadataURI + suffix + return getPRM(ctx, u.String(), c, resourceID) +} + +// GetProtectedResourceMetadataFromHeader retrieves protected resource metadata +// using information in the given header, using the given client (or the default +// client if nil). +// It issues a GET request to a URL discovered by parsing the WWW-Authenticate headers in the given request, +// It then validates the resource field of the resulting metadata against the given URL. +// If there is no URL in the request, it returns nil, nil. +func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Header, c *http.Client) (_ *ProtectedResourceMetadata, err error) { + defer util.Wrapf(&err, "GetProtectedResourceMetadataFromHeader") + headers := header[http.CanonicalHeaderKey("WWW-Authenticate")] + if len(headers) == 0 { + return nil, nil + } + cs, err := parseWWWAuthenticate(headers) + if err != nil { + return nil, err + } + url := resourceMetadataURL(cs) + if url == "" { + return nil, nil + } + return getPRM(ctx, url, c, url) +} + +// getPRM makes a GET request to the given URL, and validates the response. +// As part of the validation, it compares the returned resource field to wantResource. +func getPRM(ctx context.Context, url string, c *http.Client, wantResource string) (*ProtectedResourceMetadata, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if c == nil { + c = http.DefaultClient + } + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + // Spec requires a 200. + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad status %s", res.Status) + } + // Spec requires application/json. + if ct := res.Header.Get("Content-Type"); ct != "application/json" { + return nil, fmt.Errorf("bad content type %q", ct) + } + + var prm ProtectedResourceMetadata + dec := json.NewDecoder(res.Body) + if err := dec.Decode(&prm); err != nil { + return nil, err + } + // Validate the Resource field to thwart impersonation attacks (section 3.3). + if prm.Resource != wantResource { + return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, wantResource) + } + return &prm, nil +} + +// challenge represents a single authentication challenge from a WWW-Authenticate header. +// As per RFC 9110, Section 11.6.1, a challenge consists of a scheme and optional parameters. +type challenge struct { + // GENERATED BY GEMINI 2.5. + // + // Scheme is the authentication scheme (e.g., "Bearer", "Basic"). + // It is case-insensitive. A parsed value will always be lower-case. + Scheme string + // Params is a map of authentication parameters. + // Keys are case-insensitive. Parsed keys are always lower-case. + Params map[string]string +} + +// resourceMetadataURL returns a resource metadata URL from the given challenges, +// or the empty string if there is none. +func resourceMetadataURL(cs []challenge) string { + for _, c := range cs { + if u := c.Params["resource_metadata"]; u != "" { + return u + } + } + return "" +} + +// parseWWWAuthenticate parses a WWW-Authenticate header string. +// The header format is defined in RFC 9110, Section 11.6.1, and can contain +// one or more challenges, separated by commas. +// It returns a slice of challenges or an error if one of the headers is malformed. +func parseWWWAuthenticate(headers []string) ([]challenge, error) { + // GENERATED BY GEMINI 2.5 (human-tweaked) + var challenges []challenge + for _, h := range headers { + challengeStrings := splitChallenges(h) + for _, cs := range challengeStrings { + if strings.TrimSpace(cs) == "" { + continue + } + challenge, err := parseSingleChallenge(cs) + if err != nil { + return nil, fmt.Errorf("failed to parse challenge %q: %w", cs, err) + } + challenges = append(challenges, challenge) + } + } + return challenges, nil +} + +// splitChallenges splits a header value containing one or more challenges. +// It correctly handles commas within quoted strings and distinguishes between +// commas separating auth-params and commas separating challenges. +func splitChallenges(header string) []string { + // GENERATED BY GEMINI 2.5. + var challenges []string + inQuotes := false + start := 0 + for i, r := range header { + if r == '"' { + if i > 0 && header[i-1] != '\\' { + inQuotes = !inQuotes + } else if i == 0 { + inQuotes = !inQuotes + } + } else if r == ',' && !inQuotes { + // This is a potential challenge separator. + // A new challenge does not start with `key=value`. + // We check if the part after the comma looks like a parameter. + lookahead := strings.TrimSpace(header[i+1:]) + eqPos := strings.Index(lookahead, "=") + + isParam := false + if eqPos > 0 { + // Check if the part before '=' is a single token (no spaces). + token := lookahead[:eqPos] + if strings.IndexFunc(token, unicode.IsSpace) == -1 { + isParam = true + } + } + + if !isParam { + // The part after the comma does not look like a parameter, + // so this comma separates challenges. + challenges = append(challenges, header[start:i]) + start = i + 1 + } + } + } + // Add the last (or only) challenge to the list. + challenges = append(challenges, header[start:]) + return challenges +} + +// parseSingleChallenge parses a string containing exactly one challenge. +// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ] +func parseSingleChallenge(s string) (challenge, error) { + // GENERATED BY GEMINI 2.5, human-tweaked. + s = strings.TrimSpace(s) + if s == "" { + return challenge{}, errors.New("empty challenge string") + } + + scheme, paramsStr, found := strings.Cut(s, " ") + c := challenge{Scheme: strings.ToLower(scheme)} + if !found { + return c, nil + } + + params := make(map[string]string) + + // Parse the key-value parameters. + for paramsStr != "" { + // Find the end of the parameter key. + keyEnd := strings.Index(paramsStr, "=") + if keyEnd <= 0 { + return challenge{}, fmt.Errorf("malformed auth parameter: expected key=value, but got %q", paramsStr) + } + key := strings.TrimSpace(paramsStr[:keyEnd]) + + // Move the string past the key and the '='. + paramsStr = strings.TrimSpace(paramsStr[keyEnd+1:]) + + var value string + if strings.HasPrefix(paramsStr, "\"") { + // The value is a quoted string. + paramsStr = paramsStr[1:] // Consume the opening quote. + var valBuilder strings.Builder + i := 0 + for ; i < len(paramsStr); i++ { + // Handle escaped characters. + if paramsStr[i] == '\\' && i+1 < len(paramsStr) { + valBuilder.WriteByte(paramsStr[i+1]) + i++ // We've consumed two characters. + } else if paramsStr[i] == '"' { + // End of the quoted string. + break + } else { + valBuilder.WriteByte(paramsStr[i]) + } + } + + // A quoted string must be terminated. + if i == len(paramsStr) { + return challenge{}, fmt.Errorf("unterminated quoted string in auth parameter") + } + + value = valBuilder.String() + // Move the string past the value and the closing quote. + paramsStr = strings.TrimSpace(paramsStr[i+1:]) + } else { + // The value is a token. It ends at the next comma or the end of the string. + commaPos := strings.Index(paramsStr, ",") + if commaPos == -1 { + value = paramsStr + paramsStr = "" + } else { + value = strings.TrimSpace(paramsStr[:commaPos]) + paramsStr = strings.TrimSpace(paramsStr[commaPos:]) // Keep comma for next check + } + } + if value == "" { + return challenge{}, fmt.Errorf("no value for auth param %q", key) + } + + // Per RFC 9110, parameter keys are case-insensitive. + params[strings.ToLower(key)] = value + + // If there is a comma, consume it and continue to the next parameter. + if strings.HasPrefix(paramsStr, ",") { + paramsStr = strings.TrimSpace(paramsStr[1:]) + } else if paramsStr != "" { + // If there's content but it's not a new parameter, the format is wrong. + return challenge{}, fmt.Errorf("malformed auth parameter: expected comma after value, but got %q", paramsStr) + } + } + + // Per RFC 9110, the scheme is case-insensitive. + return challenge{Scheme: strings.ToLower(scheme), Params: params}, nil +}