fix(08-09): match wristband OAuth byte contract to live-recorded PHP
Recording the full mcp-lifecycle fixture against real isolated PHP (08-09-PLAN.md Task 2) uncovered three byte-level gaps between wristband's assumed contract and actual production PHP behavior: - Every explicit "Cache-Control: no-store" PHP sets is actually delivered as "no-store, private" (Laravel's session-cookie default merges "private" onto any explicit value); wristband's own default for unheadered JSON error responses is "no-cache, private" (matching the house convention already used elsewhere), not empty. - PHP's redirect responses (authorize success and every error redirect) render Symfony's default HTML redirect body with Content-Type "text/html; charset=utf-8"; Go's bare 302 with no body never matched. wristband/redirect_html.go ports that exact byte template, including PHP's htmlspecialchars(ENT_QUOTES) escaping (Go's html.EscapeString uses different quote entities). tide/normalize.go: isIDKey now also masks "_ids" plural array fields (e.g. collection_ids), a latent parity-corpus gap no prior fixture had exercised with a literal, non-empty, non-placeholder array value.
This commit is contained in:
@@ -164,16 +164,19 @@ func (s *Server) Authorize(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
spa := s.opts.Issuer + "/connect?request=" + rfc3986Escape(requestID)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Location", spa)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
// Laravel appends ", private" to every explicit Cache-Control this
|
||||
// endpoint sets (session-cookie default merge); recorded PHP traffic is
|
||||
// "no-store, private", never a bare "no-store" (D-04, live-recorded byte
|
||||
// contract, 08-09-PLAN.md Task 2).
|
||||
w.Header().Set("Cache-Control", "no-store, private")
|
||||
writeRedirectHTML(w, http.StatusFound, spa)
|
||||
}
|
||||
|
||||
// writeAuthorizeLocalError writes the PHP localError() response: a bare
|
||||
// text/plain 400 with no Location and no house envelope
|
||||
// (T-08-OPEN-REDIRECT).
|
||||
func writeAuthorizeLocalError(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Cache-Control", "no-store, private")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(message))
|
||||
@@ -191,9 +194,8 @@ func (s *Server) authorizeErrorRedirect(w http.ResponseWriter, redirectURI, errC
|
||||
if state != nil {
|
||||
pairs = append(pairs, [2]string{"state", *state})
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Location", appendOrderedQuery(redirectURI, pairs))
|
||||
w.WriteHeader(http.StatusFound)
|
||||
w.Header().Set("Cache-Control", "no-store, private")
|
||||
writeRedirectHTML(w, http.StatusFound, appendOrderedQuery(redirectURI, pairs))
|
||||
}
|
||||
|
||||
// parseAuthorizeScopes ports OAuthAuthorizeController::parseScopes. An
|
||||
|
||||
@@ -113,8 +113,8 @@ func TestPhase8RedAuthorize(t *testing.T) {
|
||||
if _, has := q["code"]; has {
|
||||
t.Fatalf("PHASE8_RED:authorize: Location %q leaks a code onto our own redirect", loc)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("PHASE8_RED:authorize: Cache-Control = %q, want \"no-store\"", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
||||
t.Fatalf("PHASE8_RED:authorize: Cache-Control = %q, want \"no-store, private\"", cc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestAuthorizeUnknownClientReturnsLocal400NoLocation(t *testing.T) {
|
||||
if body := rec.Body.String(); body != "Unknown client." {
|
||||
t.Fatalf("body = %q, want %q", body, "Unknown client.")
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store, private", cc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,8 +278,8 @@ func TestPKCEChallengeMethodMustBeS256(t *testing.T) {
|
||||
if q["iss"] != "https://plytarium.com" {
|
||||
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store, private", cc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,8 +358,8 @@ func TestAuthorizeValidRequestRedirectsToConnectWithOpaqueHandleOnly(t *testing.
|
||||
if _, has := q["client_secret"]; has {
|
||||
t.Fatal("Location leaks client_secret")
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store, private", cc)
|
||||
}
|
||||
|
||||
backend.mu.Lock()
|
||||
|
||||
60
wristband/redirect_html.go
Normal file
60
wristband/redirect_html.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package wristband
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// htmlEscapePHP ports PHP's htmlspecialchars($s, ENT_QUOTES, 'UTF-8') byte
|
||||
// for byte: Go's stdlib html.EscapeString differs on the quote entities
|
||||
// ('/" vs PHP's '/"), which would diverge from the
|
||||
// recorded redirect body whenever a redirect_uri or state value contains a
|
||||
// quote character.
|
||||
func htmlEscapePHP(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '"':
|
||||
b.WriteString(""")
|
||||
case '\'':
|
||||
b.WriteString("'")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
case '>':
|
||||
b.WriteString(">")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// writeRedirectHTML ports Laravel/Symfony's RedirectResponse default HTML
|
||||
// body byte-for-byte (T-08-OPEN-REDIRECT/D-04). Go's net/http never emits a
|
||||
// body for a 3xx Location redirect; every wristband redirect needs this
|
||||
// exact body plus Content-Type because real recorded PHP traffic includes
|
||||
// it, and an unchanged browser-based client (the Nuxt /connect handoff)
|
||||
// observes it. Cache-Control is the caller's responsibility -- callers set
|
||||
// it before invoking this helper because its value differs between the
|
||||
// authorize success path and error redirects versus other endpoints.
|
||||
func writeRedirectHTML(w http.ResponseWriter, status int, target string) {
|
||||
escaped := htmlEscapePHP(target)
|
||||
var b strings.Builder
|
||||
b.WriteString("<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"UTF-8\" />\n <meta http-equiv=\"refresh\" content=\"0;url='")
|
||||
b.WriteString(escaped)
|
||||
b.WriteString("'\" />\n\n <title>Redirecting to ")
|
||||
b.WriteString(escaped)
|
||||
b.WriteString("</title>\n </head>\n <body>\n Redirecting to <a href=\"")
|
||||
b.WriteString(escaped)
|
||||
b.WriteString("\">")
|
||||
b.WriteString(escaped)
|
||||
b.WriteString("</a>.\n </body>\n</html>")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Location", target)
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}
|
||||
@@ -166,11 +166,11 @@ func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
|
||||
zero := int64(0)
|
||||
resp.ClientSecretExpiresAt = &zero
|
||||
}
|
||||
writeExactJSON(w, http.StatusCreated, resp, map[string]string{"Cache-Control": "no-store"})
|
||||
writeExactJSON(w, http.StatusCreated, resp, map[string]string{"Cache-Control": "no-store, private"})
|
||||
}
|
||||
|
||||
func writeRegisterError(w http.ResponseWriter, status int, code, description string) {
|
||||
writeExactJSON(w, status, rfcErrorBody{Error: code, ErrorDescription: description}, map[string]string{"Cache-Control": "no-store"})
|
||||
writeExactJSON(w, status, rfcErrorBody{Error: code, ErrorDescription: description}, map[string]string{"Cache-Control": "no-store, private"})
|
||||
}
|
||||
|
||||
// isJSONContentType mirrors Laravel's Request::isJson(): the Content-Type
|
||||
|
||||
@@ -314,8 +314,8 @@ func TestPhase8RedRegistration(t *testing.T) {
|
||||
if _, hasSecret := got["client_secret"]; hasSecret {
|
||||
t.Fatal("PHASE8_RED:registration: public client response has a client_secret, want none")
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("PHASE8_RED:registration: Cache-Control = %q, want \"no-store\"", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
||||
t.Fatalf("PHASE8_RED:registration: Cache-Control = %q, want \"no-store, private\"", cc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,7 +614,7 @@ func assertRegisterError(t *testing.T, rec *httptest.ResponseRecorder, status in
|
||||
if got["error_description"] != description {
|
||||
t.Fatalf("error_description = %v, want %q", got["error_description"], description)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want \"no-store\"", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
||||
t.Fatalf("Cache-Control = %q, want \"no-store, private\"", cc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func (s *Server) Token(w http.ResponseWriter, r *http.Request) {
|
||||
Scope: scope,
|
||||
}
|
||||
writeExactJSON(w, http.StatusOK, body, map[string]string{
|
||||
"Cache-Control": "no-store",
|
||||
"Cache-Control": "no-store, private",
|
||||
"Pragma": "no-cache",
|
||||
})
|
||||
}
|
||||
@@ -409,5 +409,9 @@ func (s *Server) Revoke(ctx context.Context, apiTokenID uint) error {
|
||||
}
|
||||
|
||||
func writeTokenError(w http.ResponseWriter, status int, code string) {
|
||||
writeExactJSON(w, status, tokenErrorBody{Error: code}, nil)
|
||||
// PHP's rfcError() sets no explicit Cache-Control; Laravel's own
|
||||
// session-cookie default for an otherwise-unheadered JSON response is
|
||||
// "no-cache, private" (matches the live-recorded byte contract, same
|
||||
// default the house wire.WriteJSON convention already uses elsewhere).
|
||||
writeExactJSON(w, status, tokenErrorBody{Error: code}, map[string]string{"Cache-Control": "no-cache, private"})
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ func TestPhase8RedCodeExchange(t *testing.T) {
|
||||
if got["scope"] != "read write" {
|
||||
t.Fatalf("PHASE8_RED:code-exchange: scope = %v, want %q", got["scope"], "read write")
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("PHASE8_RED:code-exchange: Cache-Control = %q, want \"no-store\"", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
||||
t.Fatalf("PHASE8_RED:code-exchange: Cache-Control = %q, want \"no-store, private\"", cc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,8 +325,8 @@ func TestTokenConfidentialClientWrongSecretIsInvalidClient(t *testing.T) {
|
||||
if wa := rec.Header().Get("WWW-Authenticate"); wa != `Basic realm="OAuth"` {
|
||||
t.Fatalf("WWW-Authenticate = %q, want %q", wa, `Basic realm="OAuth"`)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "" {
|
||||
t.Fatalf("Cache-Control = %q, want none on an error response", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
|
||||
t.Fatalf("Cache-Control = %q, want %q", cc, "no-cache, private")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,8 +558,8 @@ func TestTokenSuccessResponseHasNoEnvelopeAndNoTrailingNewline(t *testing.T) {
|
||||
if got["expires_in"] != float64(3600) {
|
||||
t.Fatalf("expires_in = %v, want 3600", got["expires_in"])
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want \"no-store\"", cc)
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
||||
t.Fatalf("Cache-Control = %q, want \"no-store, private\"", cc)
|
||||
}
|
||||
if p := rec.Header().Get("Pragma"); p != "no-cache" {
|
||||
t.Fatalf("Pragma = %q, want \"no-cache\"", p)
|
||||
|
||||
Reference in New Issue
Block a user