mcp/internal/oauthex: auth server metadata (#294)
Implement the Authorization Server Metadata spec.
This commit is contained in:
parent
40b6bd30f1
commit
2a00640e1e
5 changed files with 293 additions and 26 deletions
145
internal/oauthex/auth_meta.go
Normal file
145
internal/oauthex/auth_meta.go
Normal 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...))
|
||||
}
|
||||
28
internal/oauthex/auth_meta_test.go
Normal file
28
internal/oauthex/auth_meta_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
57
internal/oauthex/testdata/google-auth-meta.json
vendored
Normal file
57
internal/oauthex/testdata/google-auth-meta.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue