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:
Ole-Morten Duesund 2026-04-27 17:08:12 +02:00
commit fee12a2ac0
3 changed files with 187 additions and 8 deletions

View file

@ -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
// ============================================================================