- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
152 lines
6.6 KiB
Go
152 lines
6.6 KiB
Go
package wristband
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// This file closes the last named-Go-evidence gaps 08-10-PLAN.md Task 1's
|
|
// 103-method PHP audit found in wristband: cases the existing 08-01..08-09
|
|
// test files exercised only partially, or not at all. See
|
|
// .planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md for
|
|
// the full map; each test below is named after (and comments cite) the PHP
|
|
// method it closes.
|
|
|
|
// TestPhase08Coverage is the 08-10-PLAN.md Task 1 focused-verify aggregator
|
|
// (`go test ./wristband -run '^Test(Phase08Coverage|PHPTestMap)'`): each
|
|
// individually-named test in this file also runs directly and is the
|
|
// evidence 08-PHP-TEST-MAP.md cites by name, but this wrapper gives the
|
|
// task's own prescribed fast command something to match.
|
|
func TestPhase08Coverage(t *testing.T) {
|
|
t.Run("TestRegisterLoopbackHTTPIsAccepted", TestRegisterLoopbackHTTPIsAccepted)
|
|
t.Run("TestRegisterJavascriptURIIsRejected", TestRegisterJavascriptURIIsRejected)
|
|
t.Run("TestRegisterErrorBodyHasNoSecretOrStackTrace", TestRegisterErrorBodyHasNoSecretOrStackTrace)
|
|
t.Run("TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge", TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge)
|
|
}
|
|
|
|
// TestRegisterLoopbackHTTPIsAccepted ports
|
|
// OAuthRegisterTest::test_loopback_http_is_accepted: an http:// redirect
|
|
// URI on 127.0.0.1 or localhost is not rejected the way any other
|
|
// http:// URI is.
|
|
func TestRegisterLoopbackHTTPIsAccepted(t *testing.T) {
|
|
for _, host := range []string{"127.0.0.1", "localhost"} {
|
|
t.Run(host, func(t *testing.T) {
|
|
srv := newTestServer(newMemoryBackend())
|
|
body := `{"redirect_uris":["http://` + host + `:8080/callback"]}`
|
|
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
srv.Register(rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusCreated, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRegisterJavascriptURIIsRejected ports the "javascript uris" half of
|
|
// OAuthRegisterTest::test_http_non_loopback_and_javascript_uris_are_rejected
|
|
// (the http-non-loopback half is TestRegisterRedirectURIBounds/http-non-loopback).
|
|
func TestRegisterJavascriptURIIsRejected(t *testing.T) {
|
|
srv := newTestServer(newMemoryBackend())
|
|
body := `{"redirect_uris":["javascript:alert(1)"]}`
|
|
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
srv.Register(rec, req)
|
|
// javascript: has no host component, so it fails the same "not a valid
|
|
// URL" branch a hostless URI does (rejectRedirectURI), not the
|
|
// scheme-specific message -- still rejected, never accepted.
|
|
assertRegisterError(t, rec, http.StatusBadRequest, "invalid_redirect_uri",
|
|
"Redirect URI is not a valid URL: javascript:alert(1)")
|
|
}
|
|
|
|
// TestRegisterErrorBodyHasNoSecretOrStackTrace ports
|
|
// OAuthRegisterTest::test_error_body_has_no_secret_or_stack: every DCR
|
|
// error path returns exactly {"error","error_description"} -- no stray
|
|
// field, no client_secret, no Go internal type/path/stack leakage -- and a
|
|
// still-later valid registration is unaffected by the earlier failures.
|
|
func TestRegisterErrorBodyHasNoSecretOrStackTrace(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
|
|
badBodies := []string{
|
|
`{not json`,
|
|
`{"redirect_uris":[]}`,
|
|
`{"redirect_uris":["not-a-url"]}`,
|
|
`{"redirect_uris":["https://client.example.test/cb"],"token_endpoint_auth_method":"bogus"}`,
|
|
}
|
|
for _, body := range badBodies {
|
|
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
srv.Register(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("body %q: status = %d, want %d", body, rec.Code, http.StatusBadRequest)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body %q: decode: %v", body, err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("body %q: error body has %d fields, want exactly 2 (error, error_description): %v", body, len(got), got)
|
|
}
|
|
if _, ok := got["error"]; !ok {
|
|
t.Fatalf("body %q: missing error field: %v", body, got)
|
|
}
|
|
if _, ok := got["error_description"]; !ok {
|
|
t.Fatalf("body %q: missing error_description field: %v", body, got)
|
|
}
|
|
raw := rec.Body.String()
|
|
for _, forbidden := range []string{"client_secret", "runtime error", "goroutine", ".go:", "panic"} {
|
|
if strings.Contains(raw, forbidden) {
|
|
t.Fatalf("body %q: error response leaked %q: %s", body, forbidden, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A still-later valid registration is unaffected by the prior failures
|
|
// (no partial state, no leaked cap consumption).
|
|
okReq := httptest.NewRequest(http.MethodPost, "/oauth/mcp/register", strings.NewReader(
|
|
`{"redirect_uris":["https://client.example.test/cb"]}`))
|
|
okReq.Header.Set("Content-Type", "application/json")
|
|
okRec := httptest.NewRecorder()
|
|
srv.Register(okRec, okReq)
|
|
if okRec.Code != http.StatusCreated {
|
|
t.Fatalf("valid registration after failures: status = %d, want %d (body=%s)", okRec.Code, http.StatusCreated, okRec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge ports
|
|
// security/OAuthRefreshRotationTest::test_plain_pkce_is_rejected_even_when_verifier_equals_challenge.
|
|
// Authorize itself never persists a "plain" code_challenge_method
|
|
// (TestPKCEChallengeMethodMustBeS256), so this seeds a code row directly to
|
|
// prove the defense also holds at the token endpoint: verifyPkce rejects
|
|
// any non-S256 method outright, never falling back to a naive
|
|
// verifier == stored-challenge string compare.
|
|
func TestTokenPlainPKCEIsRejectedEvenWhenVerifierEqualsChallenge(t *testing.T) {
|
|
backend := newMemoryBackend()
|
|
srv := newTestServer(backend)
|
|
insertTokenTestClient(backend, "cli-plain-exchange", "none", nil, nil)
|
|
const verifier = "same-value-used-as-both-verifier-and-challenge-01234"
|
|
rawCode, _ := insertTokenTestCode(t, backend, "cli-plain-exchange", verifier, func(rec *AuthCodeRecord) {
|
|
rec.CodeChallengeMethod = "plain"
|
|
})
|
|
|
|
form := validExchangeForm(rawCode, verifier, "cli-plain-exchange")
|
|
req := tokenRequest(form, "")
|
|
rec := httptest.NewRecorder()
|
|
srv.Token(rec, req)
|
|
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
if len(backend.tokens) != 0 {
|
|
t.Fatal("a plain-method exchange must not mint an access token even when verifier equals the stored challenge")
|
|
}
|
|
}
|