feat(08-01): implement exact RFC 8414 metadata writer in wristband
- Server.Metadata now writes the unwrapped 11-field PHP-parity document through a local no-envelope, no-trailing-newline JSON writer with the PHP Cache-Control: no-cache, private header (D-06); response types, grant types and PKCE method stay fixed protocol constants - TestPhase8RedMetadata now passes; TestMetadataExactBytes and TestMetadataUsesConfiguredOptions cover byte-exact output and the four configurable Options fields
This commit is contained in:
@@ -10,7 +10,11 @@
|
|||||||
// values exposed on Options.
|
// values exposed on Options.
|
||||||
package wristband
|
package wristband
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
// Options configures a Server's advertised endpoints and metadata values.
|
// Options configures a Server's advertised endpoints and metadata values.
|
||||||
// Every field has a PHP-parity default via DefaultOptions except Issuer,
|
// Every field has a PHP-parity default via DefaultOptions except Issuer,
|
||||||
@@ -62,11 +66,64 @@ func NewServer(opts Options) *Server {
|
|||||||
return &Server{opts: opts}
|
return &Server{opts: opts}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata handles GET /.well-known/oauth-authorization-server, writing the
|
// metadataDocument is the exact unwrapped RFC 8414 body. Field order matches
|
||||||
// exact unwrapped RFC 8414 document (D-06).
|
// the PHP array literal in OAuthMetadataController::show() byte for byte;
|
||||||
//
|
// encoding/json preserves struct declaration order, so this struct is the
|
||||||
// TODO(08-01 Task 2): wire the exact writer and PHP-parity document; this
|
// single source of truth for the wire order.
|
||||||
// stub intentionally does not yet satisfy TestPhase8RedMetadata.
|
type metadataDocument struct {
|
||||||
func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) {
|
Issuer string `json:"issuer"`
|
||||||
w.WriteHeader(http.StatusNotImplemented)
|
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")))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package wristband
|
package wristband
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,3 +39,85 @@ func TestPhase8RedMetadata(t *testing.T) {
|
|||||||
t.Fatalf("PHASE8_RED:metadata: Cache-Control = %q, want \"no-cache, private\"", cc)
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user