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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue