Skip to content

Commit 2a00640

Browse files
authored
mcp/internal/oauthex: auth server metadata (#294)
Implement the Authorization Server Metadata spec.
1 parent 40b6bd3 commit 2a00640

File tree

5 files changed

+293
-26
lines changed

5 files changed

+293
-26
lines changed

internal/oauthex/auth_meta.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// This file implements Authorization Server Metadata.
6+
// See https://www.rfc-editor.org/rfc/rfc8414.html.
7+
8+
package oauthex
9+
10+
import (
11+
"context"
12+
"errors"
13+
"fmt"
14+
"net/http"
15+
)
16+
17+
// AuthServerMeta represents the metadata for an OAuth 2.0 authorization server,
18+
// as defined in [RFC 8414].
19+
//
20+
// Not supported:
21+
// - signed metadata
22+
//
23+
// [RFC 8414]: https://tools.ietf.org/html/rfc8414)
24+
type AuthServerMeta struct {
25+
// GENERATED BY GEMINI 2.5.
26+
27+
// Issuer is the REQUIRED URL identifying the authorization server.
28+
Issuer string `json:"issuer"`
29+
30+
// AuthorizationEndpoint is the REQUIRED URL of the server's OAuth 2.0 authorization endpoint.
31+
AuthorizationEndpoint string `json:"authorization_endpoint"`
32+
33+
// TokenEndpoint is the REQUIRED URL of the server's OAuth 2.0 token endpoint.
34+
TokenEndpoint string `json:"token_endpoint"`
35+
36+
// JWKSURI is the REQUIRED URL of the server's JSON Web Key Set [JWK] document.
37+
JWKSURI string `json:"jwks_uri"`
38+
39+
// RegistrationEndpoint is the RECOMMENDED URL of the server's OAuth 2.0 Dynamic Client Registration endpoint.
40+
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
41+
42+
// ScopesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
43+
// "scope" values that this server supports.
44+
ScopesSupported []string `json:"scopes_supported,omitempty"`
45+
46+
// ResponseTypesSupported is a REQUIRED JSON array of strings containing a list of the OAuth 2.0
47+
// "response_type" values that this server supports.
48+
ResponseTypesSupported []string `json:"response_types_supported"`
49+
50+
// ResponseModesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
51+
// "response_mode" values that this server supports.
52+
ResponseModesSupported []string `json:"response_modes_supported,omitempty"`
53+
54+
// GrantTypesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
55+
// grant type values that this server supports.
56+
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
57+
58+
// TokenEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing a list of
59+
// client authentication methods supported by this token endpoint.
60+
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
61+
62+
// TokenEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings containing
63+
// a list of the JWS signing algorithms ("alg" values) supported by the token endpoint for
64+
// the signature on the JWT used to authenticate the client.
65+
TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"`
66+
67+
// ServiceDocumentation is a RECOMMENDED URL of a page containing human-readable documentation
68+
// for the service.
69+
ServiceDocumentation string `json:"service_documentation,omitempty"`
70+
71+
// UILocalesSupported is a RECOMMENDED JSON array of strings representing supported
72+
// BCP47 [RFC5646] language tag values for display in the user interface.
73+
UILocalesSupported []string `json:"ui_locales_supported,omitempty"`
74+
75+
// OpPolicyURI is a RECOMMENDED URL that the server provides to the person registering
76+
// the client to read about the server's operator policies.
77+
OpPolicyURI string `json:"op_policy_uri,omitempty"`
78+
79+
// OpTOSURI is a RECOMMENDED URL that the server provides to the person registering the
80+
// client to read about the server's terms of service.
81+
OpTOSURI string `json:"op_tos_uri,omitempty"`
82+
83+
// RevocationEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 revocation endpoint.
84+
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
85+
86+
// RevocationEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing
87+
// a list of client authentication methods supported by this revocation endpoint.
88+
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"`
89+
90+
// RevocationEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings
91+
// containing a list of the JWS signing algorithms ("alg" values) supported by the revocation
92+
// endpoint for the signature on the JWT used to authenticate the client.
93+
RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"`
94+
95+
// IntrospectionEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 introspection endpoint.
96+
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`
97+
98+
// IntrospectionEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing
99+
// a list of client authentication methods supported by this introspection endpoint.
100+
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"`
101+
102+
// IntrospectionEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings
103+
// containing a list of the JWS signing algorithms ("alg" values) supported by the introspection
104+
// endpoint for the signature on the JWT used to authenticate the client.
105+
IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"`
106+
107+
// CodeChallengeMethodsSupported is a RECOMMENDED JSON array of strings containing a list of
108+
// PKCE code challenge methods supported by this authorization server.
109+
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
110+
}
111+
112+
var wellKnownPaths = []string{
113+
"/.well-known/oauth-authorization-server",
114+
"/.well-known/openid-configuration",
115+
}
116+
117+
// GetAuthServerMeta issues a GET request to retrieve authorization server metadata
118+
// from an OAuth authorization server with the given issuerURL.
119+
//
120+
// It follows [RFC 8414]:
121+
// - The well-known paths specified there are inserted into the URL's path, one at time.
122+
// The first to succeed is used.
123+
// - The Issuer field is checked against issuerURL.
124+
//
125+
// [RFC 8414]: https://tools.ietf.org/html/rfc8414
126+
func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*AuthServerMeta, error) {
127+
var errs []error
128+
for _, p := range wellKnownPaths {
129+
u, err := prependToPath(issuerURL, p)
130+
if err != nil {
131+
// issuerURL is bad; no point in continuing.
132+
return nil, err
133+
}
134+
asm, err := getJSON[AuthServerMeta](ctx, c, u, 1<<20)
135+
if err == nil {
136+
if asm.Issuer != issuerURL { // section 3.3
137+
// Security violation; don't keep trying.
138+
return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuerURL)
139+
}
140+
return asm, nil
141+
}
142+
errs = append(errs, err)
143+
}
144+
return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...))
145+
}

internal/oauthex/auth_meta_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package oauthex
6+
7+
import (
8+
"encoding/json"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
)
13+
14+
func TestAuthMetaParse(t *testing.T) {
15+
// Verify that we parse Google's auth server metadata.
16+
data, err := os.ReadFile(filepath.FromSlash("testdata/google-auth-meta.json"))
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
var a AuthServerMeta
21+
if err := json.Unmarshal(data, &a); err != nil {
22+
t.Fatal(err)
23+
}
24+
// Spot check.
25+
if g, w := a.Issuer, "https://accounts.google.com"; g != w {
26+
t.Errorf("got %q, want %q", g, w)
27+
}
28+
}

internal/oauthex/oauth2.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,64 @@
44

55
// Package oauthex implements extensions to OAuth2.
66
package oauthex
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"net/url"
15+
"strings"
16+
)
17+
18+
// prependToPath prepends pre to the path of urlStr.
19+
// When pre is the well-known path, this is the algorithm specified in both RFC 9728
20+
// section 3.1 and RFC 8414 section 3.1.
21+
func prependToPath(urlStr, pre string) (string, error) {
22+
u, err := url.Parse(urlStr)
23+
if err != nil {
24+
return "", err
25+
}
26+
p := "/" + strings.Trim(pre, "/")
27+
if u.Path != "" {
28+
p += "/"
29+
}
30+
31+
u.Path = p + strings.TrimLeft(u.Path, "/")
32+
return u.String(), nil
33+
}
34+
35+
// getJSON retrieves JSON and unmarshals JSON from the URL, as specified in both
36+
// RFC 9728 and RFC 8414.
37+
// It will not read more than limit bytes from the body.
38+
func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64) (*T, error) {
39+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
40+
if err != nil {
41+
return nil, err
42+
}
43+
if c == nil {
44+
c = http.DefaultClient
45+
}
46+
res, err := c.Do(req)
47+
if err != nil {
48+
return nil, err
49+
}
50+
defer res.Body.Close()
51+
52+
// Specs require a 200.
53+
if res.StatusCode != http.StatusOK {
54+
return nil, fmt.Errorf("bad status %s", res.Status)
55+
}
56+
// Specs require application/json.
57+
if ct := res.Header.Get("Content-Type"); ct != "application/json" {
58+
return nil, fmt.Errorf("bad content type %q", ct)
59+
}
60+
61+
var t T
62+
dec := json.NewDecoder(io.LimitReader(res.Body, limit))
63+
if err := dec.Decode(&t); err != nil {
64+
return nil, err
65+
}
66+
return &t, nil
67+
}

internal/oauthex/resource_meta.go

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ package oauthex
99

1010
import (
1111
"context"
12-
"encoding/json"
1312
"errors"
1413
"fmt"
1514
"net/http"
@@ -164,38 +163,15 @@ func getPRM(ctx context.Context, url string, c *http.Client, wantResource string
164163
if !strings.HasPrefix(strings.ToUpper(url), "HTTPS://") {
165164
return nil, fmt.Errorf("resource URL %q does not use HTTPS", url)
166165
}
167-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
166+
prm, err := getJSON[ProtectedResourceMetadata](ctx, c, url, 1<<20)
168167
if err != nil {
169168
return nil, err
170169
}
171-
if c == nil {
172-
c = http.DefaultClient
173-
}
174-
res, err := c.Do(req)
175-
if err != nil {
176-
return nil, err
177-
}
178-
defer res.Body.Close()
179-
180-
// Spec §3.2 requires a 200.
181-
if res.StatusCode != http.StatusOK {
182-
return nil, fmt.Errorf("bad status %s", res.Status)
183-
}
184-
// Spec §3.2 requires application/json.
185-
if ct := res.Header.Get("Content-Type"); ct != "application/json" {
186-
return nil, fmt.Errorf("bad content type %q", ct)
187-
}
188-
189-
var prm ProtectedResourceMetadata
190-
dec := json.NewDecoder(res.Body)
191-
if err := dec.Decode(&prm); err != nil {
192-
return nil, err
193-
}
194170
// Validate the Resource field to thwart impersonation attacks (section 3.3).
195171
if prm.Resource != wantResource {
196172
return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, wantResource)
197173
}
198-
return &prm, nil
174+
return prm, nil
199175
}
200176

201177
// challenge represents a single authentication challenge from a WWW-Authenticate header.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"issuer": "https://accounts.google.com",
3+
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
4+
"device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
5+
"token_endpoint": "https://oauth2.googleapis.com/token",
6+
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
7+
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
8+
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
9+
"response_types_supported": [
10+
"code",
11+
"token",
12+
"id_token",
13+
"code token",
14+
"code id_token",
15+
"token id_token",
16+
"code token id_token",
17+
"none"
18+
],
19+
"subject_types_supported": [
20+
"public"
21+
],
22+
"id_token_signing_alg_values_supported": [
23+
"RS256"
24+
],
25+
"scopes_supported": [
26+
"openid",
27+
"email",
28+
"profile"
29+
],
30+
"token_endpoint_auth_methods_supported": [
31+
"client_secret_post",
32+
"client_secret_basic"
33+
],
34+
"claims_supported": [
35+
"aud",
36+
"email",
37+
"email_verified",
38+
"exp",
39+
"family_name",
40+
"given_name",
41+
"iat",
42+
"iss",
43+
"name",
44+
"picture",
45+
"sub"
46+
],
47+
"code_challenge_methods_supported": [
48+
"plain",
49+
"S256"
50+
],
51+
"grant_types_supported": [
52+
"authorization_code",
53+
"refresh_token",
54+
"urn:ietf:params:oauth:grant-type:device_code",
55+
"urn:ietf:params:oauth:grant-type:jwt-bearer"
56+
]
57+
}

0 commit comments

Comments
 (0)