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

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