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