diff --git a/wristband/server.go b/wristband/server.go index b10afe6..4d2e241 100644 --- a/wristband/server.go +++ b/wristband/server.go @@ -10,7 +10,11 @@ // values exposed on Options. package wristband -import "net/http" +import ( + "bytes" + "encoding/json" + "net/http" +) // Options configures a Server's advertised endpoints and metadata values. // Every field has a PHP-parity default via DefaultOptions except Issuer, @@ -62,11 +66,64 @@ func NewServer(opts Options) *Server { return &Server{opts: opts} } -// Metadata handles GET /.well-known/oauth-authorization-server, writing the -// exact unwrapped RFC 8414 document (D-06). -// -// TODO(08-01 Task 2): wire the exact writer and PHP-parity document; this -// stub intentionally does not yet satisfy TestPhase8RedMetadata. -func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) +// metadataDocument is the exact unwrapped RFC 8414 body. Field order matches +// the PHP array literal in OAuthMetadataController::show() byte for byte; +// encoding/json preserves struct declaration order, so this struct is the +// single source of truth for the wire order. +type metadataDocument struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + 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"` + ServiceDocumentation string `json:"service_documentation"` + AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported"` +} + +// Metadata handles GET /.well-known/oauth-authorization-server, writing the +// exact unwrapped RFC 8414 document (D-06). response_types_supported, +// grant_types_supported and code_challenge_methods_supported are fixed +// protocol constants, not Options: this phase's authorization server only +// ever supports the authorization_code/refresh_token grants with S256 PKCE +// (D-01), so there is nothing app-specific to configure there. +func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) { + doc := metadataDocument{ + Issuer: s.opts.Issuer, + AuthorizationEndpoint: s.opts.Issuer + "/oauth/mcp/authorize", + TokenEndpoint: s.opts.Issuer + "/oauth/mcp/token", + RegistrationEndpoint: s.opts.Issuer + "/oauth/mcp/register", + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpointAuthMethodsSupported: s.opts.TokenEndpointAuthMethodsSupported, + ScopesSupported: s.opts.ScopesSupported, + ServiceDocumentation: s.opts.Issuer + s.opts.ServiceDocumentationPath, + AuthorizationResponseIssParameterSupported: s.opts.AuthorizationResponseIssParameterSupported, + } + writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": "no-cache, private"}) +} + +// writeExactJSON writes v as an unwrapped, no-trailing-newline JSON document +// (matching the wire/response.go WriteJSON technique) but never falls back to +// the house opaque-500 envelope: raw RFC responses must never acquire a +// house-shaped body (D-06/D-09). +func writeExactJSON(w http.ResponseWriter, status int, v any, extraHeaders map[string]string) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + h := w.Header() + h.Set("Content-Type", "application/json") + for k, v := range extraHeaders { + h.Set(k, v) + } + w.WriteHeader(status) + _, _ = w.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n"))) } diff --git a/wristband/server_test.go b/wristband/server_test.go index 7d3b28f..a78586d 100644 --- a/wristband/server_test.go +++ b/wristband/server_test.go @@ -1,8 +1,10 @@ package wristband import ( + "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -37,3 +39,85 @@ func TestPhase8RedMetadata(t *testing.T) { t.Fatalf("PHASE8_RED:metadata: Cache-Control = %q, want \"no-cache, private\"", cc) } } + +// TestMetadataExactBytes is the GREEN direct-handler regression for +// TestPhase8RedMetadata: same PHP-fixture bytes, now expected to pass. +func TestMetadataExactBytes(t *testing.T) { + opts := DefaultOptions() + opts.Issuer = "https://plytarium.com" + srv := NewServer(opts) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil) + req.Header.Set("Accept", "application/json") + rec := httptest.NewRecorder() + srv.Metadata(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + const want = `{"issuer":"https://plytarium.com","authorization_endpoint":"https://plytarium.com/oauth/mcp/authorize","token_endpoint":"https://plytarium.com/oauth/mcp/token","registration_endpoint":"https://plytarium.com/oauth/mcp/register","response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["none","client_secret_post","client_secret_basic"],"scopes_supported":["read","write","ai","offline_access"],"service_documentation":"https://plytarium.com/help","authorization_response_iss_parameter_supported":true}` + + if got := rec.Body.String(); got != want { + t.Fatalf("body mismatch\n got: %s\nwant: %s", got, want) + } + if strings.HasSuffix(rec.Body.String(), "\n") { + t.Fatal("body has a trailing newline, want none") + } + if _, hasData := decodeAsMap(t, rec.Body.Bytes())["data"]; hasData { + t.Fatal("body has a house \"data\" envelope, want unwrapped RFC 8414 document") + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" { + t.Fatalf("Cache-Control = %q, want %q", cc, "no-cache, private") + } +} + +// TestMetadataUsesConfiguredOptions proves service_documentation, +// scopes_supported and the auth-methods list come from Options, not a +// hardcoded literal, while the three protocol-constant fields never change +// (D-06: only those four fields are configurable). +func TestMetadataUsesConfiguredOptions(t *testing.T) { + opts := Options{ + Issuer: "https://example.test", + ServiceDocumentationPath: "/docs/oauth", + ScopesSupported: []string{"read"}, + TokenEndpointAuthMethodsSupported: []string{"client_secret_post"}, + AuthorizationResponseIssParameterSupported: false, + } + srv := NewServer(opts) + + rec := httptest.NewRecorder() + srv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)) + + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got["service_documentation"] != "https://example.test/docs/oauth" { + t.Fatalf("service_documentation = %v, want configured path", got["service_documentation"]) + } + if scopes, _ := got["scopes_supported"].([]any); len(scopes) != 1 || scopes[0] != "read" { + t.Fatalf("scopes_supported = %v, want [read]", got["scopes_supported"]) + } + if methods, _ := got["token_endpoint_auth_methods_supported"].([]any); len(methods) != 1 || methods[0] != "client_secret_post" { + t.Fatalf("token_endpoint_auth_methods_supported = %v, want [client_secret_post]", got["token_endpoint_auth_methods_supported"]) + } + if got["authorization_response_iss_parameter_supported"] != false { + t.Fatalf("authorization_response_iss_parameter_supported = %v, want false", got["authorization_response_iss_parameter_supported"]) + } + if rts, _ := got["response_types_supported"].([]any); len(rts) != 1 || rts[0] != "code" { + t.Fatalf("response_types_supported = %v, want the fixed [code] constant", got["response_types_supported"]) + } +} + +func decodeAsMap(t *testing.T, body []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("decode response: %v", err) + } + return m +}