mcp/internal/oauthex: auth server metadata (#294)

Implement the Authorization Server Metadata spec.
This commit is contained in:
Jonathan Amsterdam 2025-09-05 14:17:21 -04:00 • committed by GitHub
commit 2a00640e1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 293 additions and 26 deletions

View file

@ -0,0 +1,145 @@
// 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 Authorization Server Metadata.
// See https://www.rfc-editor.org/rfc/rfc8414.html.
package oauthex
import (
"context"
"errors"
"fmt"
"net/http"
)
// AuthServerMeta represents the metadata for an OAuth 2.0 authorization server,
// as defined in [RFC 8414].
//
// Not supported:
// - signed metadata
//
// [RFC 8414]: https://tools.ietf.org/html/rfc8414)
type AuthServerMeta struct {
// GENERATED BY GEMINI 2.5.
// Issuer is the REQUIRED URL identifying the authorization server.
Issuer string `json:"issuer"`
// AuthorizationEndpoint is the REQUIRED URL of the server's OAuth 2.0 authorization endpoint.
AuthorizationEndpoint string `json:"authorization_endpoint"`
// TokenEndpoint is the REQUIRED URL of the server's OAuth 2.0 token endpoint.
TokenEndpoint string `json:"token_endpoint"`
// JWKSURI is the REQUIRED URL of the server's JSON Web Key Set [JWK] document.
JWKSURI string `json:"jwks_uri"`
// RegistrationEndpoint is the RECOMMENDED URL of the server's OAuth 2.0 Dynamic Client Registration endpoint.
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
// ScopesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
// "scope" values that this server supports.
ScopesSupported []string `json:"scopes_supported,omitempty"`
// ResponseTypesSupported is a REQUIRED JSON array of strings containing a list of the OAuth 2.0
// "response_type" values that this server supports.
ResponseTypesSupported []string `json:"response_types_supported"`
// ResponseModesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
// "response_mode" values that this server supports.
ResponseModesSupported []string `json:"response_modes_supported,omitempty"`
// GrantTypesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
// grant type values that this server supports.
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
// TokenEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing a list of
// client authentication methods supported by this token endpoint.
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
// TokenEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings containing
// a list of the JWS signing algorithms ("alg" values) supported by the token endpoint for
// the signature on the JWT used to authenticate the client.
TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"`
// ServiceDocumentation is a RECOMMENDED URL of a page containing human-readable documentation
// for the service.
ServiceDocumentation string `json:"service_documentation,omitempty"`
// UILocalesSupported is a RECOMMENDED JSON array of strings representing supported
// BCP47 [RFC5646] language tag values for display in the user interface.
UILocalesSupported []string `json:"ui_locales_supported,omitempty"`
// OpPolicyURI is a RECOMMENDED URL that the server provides to the person registering
// the client to read about the server's operator policies.
OpPolicyURI string `json:"op_policy_uri,omitempty"`
// OpTOSURI is a RECOMMENDED URL that the server provides to the person registering the
// client to read about the server's terms of service.
OpTOSURI string `json:"op_tos_uri,omitempty"`
// RevocationEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 revocation endpoint.
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
// RevocationEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing
// a list of client authentication methods supported by this revocation endpoint.
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"`
// RevocationEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings
// containing a list of the JWS signing algorithms ("alg" values) supported by the revocation
// endpoint for the signature on the JWT used to authenticate the client.
RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"`
// IntrospectionEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 introspection endpoint.
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`
// IntrospectionEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing
// a list of client authentication methods supported by this introspection endpoint.
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"`
// IntrospectionEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings
// containing a list of the JWS signing algorithms ("alg" values) supported by the introspection
// endpoint for the signature on the JWT used to authenticate the client.
IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"`
// CodeChallengeMethodsSupported is a RECOMMENDED JSON array of strings containing a list of
// PKCE code challenge methods supported by this authorization server.
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
}
var wellKnownPaths = []string{
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
}
// GetAuthServerMeta issues a GET request to retrieve authorization server metadata
// from an OAuth authorization server with the given issuerURL.
//
// It follows [RFC 8414]:
// - The well-known paths specified there are inserted into the URL's path, one at time.
// The first to succeed is used.
// - The Issuer field is checked against issuerURL.
//
// [RFC 8414]: https://tools.ietf.org/html/rfc8414
func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*AuthServerMeta, error) {
var errs []error
for _, p := range wellKnownPaths {
u, err := prependToPath(issuerURL, p)
if err != nil {
// issuerURL is bad; no point in continuing.
return nil, err
}
asm, err := getJSON[AuthServerMeta](ctx, c, u, 1<<20)
if err == nil {
if asm.Issuer != issuerURL { // section 3.3
// Security violation; don't keep trying.
return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuerURL)
}
return asm, nil
}
errs = append(errs, err)
}
return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...))
}

View file

@ -0,0 +1,28 @@
// 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 (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestAuthMetaParse(t *testing.T) {
// Verify that we parse Google's auth server metadata.
data, err := os.ReadFile(filepath.FromSlash("testdata/google-auth-meta.json"))
if err != nil {
t.Fatal(err)
}
var a AuthServerMeta
if err := json.Unmarshal(data, &a); err != nil {
t.Fatal(err)
}
// Spot check.
if g, w := a.Issuer, "https://accounts.google.com"; g != w {
t.Errorf("got %q, want %q", g, w)
}
}

View file

@ -4,3 +4,64 @@
// Package oauthex implements extensions to OAuth2.
package oauthex
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// prependToPath prepends pre to the path of urlStr.
// When pre is the well-known path, this is the algorithm specified in both RFC 9728
// section 3.1 and RFC 8414 section 3.1.
func prependToPath(urlStr, pre string) (string, error) {
u, err := url.Parse(urlStr)
if err != nil {
return "", err
}
p := "/" + strings.Trim(pre, "/")
if u.Path != "" {
p += "/"
}
u.Path = p + strings.TrimLeft(u.Path, "/")
return u.String(), nil
}
// getJSON retrieves JSON and unmarshals JSON from the URL, as specified in both
// RFC 9728 and RFC 8414.
// It will not read more than limit bytes from the body.
func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64) (*T, 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()
// Specs require a 200.
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad status %s", res.Status)
}
// Specs require application/json.
if ct := res.Header.Get("Content-Type"); ct != "application/json" {
return nil, fmt.Errorf("bad content type %q", ct)
}
var t T
dec := json.NewDecoder(io.LimitReader(res.Body, limit))
if err := dec.Decode(&t); err != nil {
return nil, err
}
return &t, nil
}

View file

@ -9,7 +9,6 @@ package oauthex
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -164,38 +163,15 @@ func getPRM(ctx context.Context, url string, c *http.Client, wantResource string
if !strings.HasPrefix(strings.ToUpper(url), "HTTPS://") {
return nil, fmt.Errorf("resource URL %q does not use HTTPS", url)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
prm, err := getJSON[ProtectedResourceMetadata](ctx, c, url, 1<<20)
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 §3.2 requires a 200.
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad status %s", res.Status)
}
// Spec §3.2 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
return prm, nil
}
// challenge represents a single authentication challenge from a WWW-Authenticate header.

View file

@ -0,0 +1,57 @@
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": [
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid",
"email",
"profile"
],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic"
],
"claims_supported": [
"aud",
"email",
"email_verified",
"exp",
"family_name",
"given_name",
"iat",
"iss",
"name",
"picture",
"sub"
],
"code_challenge_methods_supported": [
"plain",
"S256"
],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:jwt-bearer"
]
}