feat(oauth): /.well-known discovery endpoints (forgejo-mcp-broker-b2o)
Adds RFC 8414 (oauth-authorization-server) and RFC 9728 (oauth- protected-resource) metadata documents. Both URLs are derived from cfg.Issuer at construction time, never from inbound request headers. Test TestDiscovery_IssuerIgnoresHostHeader explicitly probes this — a malicious Host: evil.example.com value must not leak into the published metadata. Defense against the OAuth metadata-spoofing class starts at the discovery layer. Capabilities published reflect the actual OAuth surface: - response_types_supported = ["code"] - grant_types_supported = ["authorization_code", "refresh_token"] - code_challenge_methods_supported = ["S256"] (PKCE only, no plain) - token_endpoint_auth_methods_supported = ["none"] (PKCE-only public clients) Protected-resource metadata advertises /mcp as the resource; phase 5 will mount the gated MCP endpoint there. Closes forgejo-mcp-broker-b2o. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d16b18ea38
commit
fee12a2ac0
3 changed files with 187 additions and 8 deletions
|
|
@ -127,7 +127,10 @@ func (s *Server) Close() {
|
|||
}
|
||||
}
|
||||
|
||||
// Handler returns the http.Handler exposing all five OAuth endpoints.
|
||||
// Handler returns the http.Handler exposing OAuth endpoints plus the two
|
||||
// discovery documents. The broker's outer mux should mount this at the
|
||||
// root (not under /oauth) so the .well-known paths land at their spec-
|
||||
// mandated location.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /oauth/register", s.handleRegister)
|
||||
|
|
@ -135,6 +138,8 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.HandleFunc("GET /oauth/callback", s.handleCallback)
|
||||
mux.HandleFunc("POST /oauth/token", s.handleToken)
|
||||
mux.HandleFunc("POST /oauth/revoke", s.handleRevoke)
|
||||
mux.HandleFunc("GET /.well-known/oauth-authorization-server", s.handleASMetadata)
|
||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource", s.handlePRMetadata)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +229,68 @@ func (s *Server) reapPending() {
|
|||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /.well-known/* — discovery metadata (RFC 8414, RFC 9728)
|
||||
// ============================================================================
|
||||
//
|
||||
// All URLs in these documents are built from the configured issuer, never
|
||||
// from inbound request headers. Publishing an attacker-controlled issuer
|
||||
// is a classic OAuth metadata-spoofing vector — defending against it has
|
||||
// to start at the discovery layer.
|
||||
|
||||
type asMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleASMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
md := asMetadata{
|
||||
Issuer: s.issuer,
|
||||
AuthorizationEndpoint: s.issuer + "/oauth/authorize",
|
||||
TokenEndpoint: s.issuer + "/oauth/token",
|
||||
RegistrationEndpoint: s.issuer + "/oauth/register",
|
||||
RevocationEndpoint: s.issuer + "/oauth/revoke",
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||
CodeChallengeMethodsSupported: []string{"S256"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"none"},
|
||||
}
|
||||
if s.scopes != "" {
|
||||
md.ScopesSupported = strings.Fields(s.scopes)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, md)
|
||||
}
|
||||
|
||||
type prMetadata struct {
|
||||
Resource string `json:"resource"`
|
||||
AuthorizationServers []string `json:"authorization_servers"`
|
||||
BearerMethodsSupported []string `json:"bearer_methods_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handlePRMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
md := prMetadata{
|
||||
// The protected resource is the MCP endpoint that ships in phase 5.
|
||||
// Publishing it here lets clients discover where to send Bearer
|
||||
// tokens once they've completed the OAuth dance.
|
||||
Resource: s.issuer + "/mcp",
|
||||
AuthorizationServers: []string{s.issuer},
|
||||
BearerMethodsSupported: []string{"header"},
|
||||
}
|
||||
if s.scopes != "" {
|
||||
md.ScopesSupported = strings.Fields(s.scopes)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, md)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /oauth/register — RFC 7591 dynamic client registration
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -639,10 +639,6 @@ func TestRevoke_MissingToken(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Server validation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Callback error paths
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
@ -987,7 +983,123 @@ func TestReap_RemovesExpiredPending(t *testing.T) {
|
|||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Server validation
|
||||
// Discovery (.well-known)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestDiscovery_AuthorizationServerMetadata(t *testing.T) {
|
||||
fx := newFixture(t)
|
||||
resp, err := http.Get(fx.httpServer.URL + "/.well-known/oauth-authorization-server")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
|
||||
var md map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&md); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
// Issuer MUST come from cfg.Issuer, not from the request URL.
|
||||
if md["issuer"] != "https://broker.example.com" {
|
||||
t.Errorf("issuer = %v, want https://broker.example.com (config)", md["issuer"])
|
||||
}
|
||||
for _, k := range []string{"authorization_endpoint", "token_endpoint", "registration_endpoint", "revocation_endpoint"} {
|
||||
v, _ := md[k].(string)
|
||||
if !strings.HasPrefix(v, "https://broker.example.com/") {
|
||||
t.Errorf("%s = %q, want issuer-rooted URL", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
wantContains := map[string][]string{
|
||||
"code_challenge_methods_supported": {"S256"},
|
||||
"grant_types_supported": {"authorization_code", "refresh_token"},
|
||||
"response_types_supported": {"code"},
|
||||
"token_endpoint_auth_methods_supported": {"none"},
|
||||
}
|
||||
for field, vals := range wantContains {
|
||||
got, _ := md[field].([]any)
|
||||
if len(got) == 0 {
|
||||
t.Errorf("%s missing or empty", field)
|
||||
continue
|
||||
}
|
||||
for _, want := range vals {
|
||||
found := false
|
||||
for _, g := range got {
|
||||
if g == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s does not include %q (got %v)", field, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopes, _ := md["scopes_supported"].([]any)
|
||||
if len(scopes) != 2 {
|
||||
t.Errorf("scopes_supported = %v, want 2 entries", scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovery_ProtectedResourceMetadata(t *testing.T) {
|
||||
fx := newFixture(t)
|
||||
resp, err := http.Get(fx.httpServer.URL + "/.well-known/oauth-protected-resource")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var md map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&md); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if md["resource"] != "https://broker.example.com/mcp" {
|
||||
t.Errorf("resource = %v, want issuer-rooted /mcp", md["resource"])
|
||||
}
|
||||
servers, _ := md["authorization_servers"].([]any)
|
||||
if len(servers) != 1 || servers[0] != "https://broker.example.com" {
|
||||
t.Errorf("authorization_servers = %v, want [config issuer]", servers)
|
||||
}
|
||||
bearer, _ := md["bearer_methods_supported"].([]any)
|
||||
if len(bearer) == 0 || bearer[0] != "header" {
|
||||
t.Errorf("bearer_methods_supported = %v, want [\"header\"]", bearer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovery_IssuerIgnoresHostHeader(t *testing.T) {
|
||||
// Crafting a malicious Host header must not leak that host into the
|
||||
// discovery document. Defense against the metadata-spoofing class of
|
||||
// OAuth attack starts here.
|
||||
fx := newFixture(t)
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
fx.httpServer.URL+"/.well-known/oauth-authorization-server", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Host = "evil.example.com"
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(string(body), "evil.example.com") {
|
||||
t.Errorf("discovery doc leaked attacker-supplied Host: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers used by /callback flow tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// startAuthorize walks just steps 1+2 of the flow (client → broker /authorize)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue