test(08-04): add failing code-exchange RED test in wristband
- Server.Token 501 stub and TestPhase8RedCodeExchange (PHASE8_RED:code-exchange) - Options gains AccessTokenTTL/RefreshTokenTTL with PHP-parity defaults
This commit is contained in:
@@ -68,6 +68,16 @@ type Options struct {
|
||||
// created by authorize stays valid (PHP
|
||||
// OAuthCodeManager::PENDING_TTL_SECONDS, D-03). PHP default: 600s.
|
||||
PendingRequestTTL time.Duration
|
||||
|
||||
// AccessTokenTTL is how long an inv_ access token minted by a successful
|
||||
// code exchange or refresh rotation stays valid (PHP
|
||||
// OAuthCodeManager::ACCESS_TTL_SECONDS, D-03). PHP default: 3600s (1h).
|
||||
AccessTokenTTL time.Duration
|
||||
|
||||
// RefreshTokenTTL is how long a refresh-token lineage row stays valid
|
||||
// from issuance (PHP OAuthCodeManager::REFRESH_TTL_DAYS, D-03). PHP
|
||||
// default: 30 days.
|
||||
RefreshTokenTTL time.Duration
|
||||
}
|
||||
|
||||
// DefaultOptions returns PHP-parity defaults for every metadata option
|
||||
@@ -83,6 +93,8 @@ func DefaultOptions() Options {
|
||||
RegisterMaxBodyBytes: 65536,
|
||||
Resource: "https://mcp.plytarium.com/mcp",
|
||||
PendingRequestTTL: 600 * time.Second,
|
||||
AccessTokenTTL: 3600 * time.Second,
|
||||
RefreshTokenTTL: 30 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
wristband/token.go
Normal file
21
wristband/token.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// RFC 6749 token endpoint for MCP OAuth, ported from PHP
|
||||
// OAuthTokenController::token / OAuthCodeManager::exchangeCode byte-for-byte
|
||||
// including their validation order (08-CONTEXT.md D-02/D-04/D-05/D-07;
|
||||
// canonical PHP source: OAuthTokenController.php, OAuthCodeManager.php).
|
||||
//
|
||||
// 08-04-PLAN.md ships the authorization_code grant only. grant_type=
|
||||
// refresh_token is dispatched with the exact PHP-parity validity check (an
|
||||
// unknown grant type is unsupported_grant_type; a known-but-not-yet-built
|
||||
// grant is invalid_grant) but its full rotation/lineage-kill semantics
|
||||
// (T-08-REFRESH-REPLAY) are ROADMAP.md Wave 6 (08-06-PLAN.md), not this
|
||||
// plan's threat register.
|
||||
package wristband
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Token handles POST /oauth/mcp/token (D-09: raw route, no middleware).
|
||||
// This is the Phase 8 Wave 4 RED stub (08-04-PLAN.md Task 1): it always
|
||||
// responds 501 until Task 2 implements the real handler.
|
||||
func (s *Server) Token(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
127
wristband/token_test.go
Normal file
127
wristband/token_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package wristband
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tokenTestRedirect = "https://chatgpt.com/connector/oauth/cb"
|
||||
|
||||
// insertTokenTestClient inserts an already-usable ClientRecord directly into
|
||||
// backend (bypassing CreateWithCap's cap/sweep policy, which this plan's
|
||||
// tests do not exercise) and returns it.
|
||||
func insertTokenTestClient(backend *memoryBackend, clientID, authMethod string, secretHash *string, ceiling []string) *ClientRecord {
|
||||
backend.mu.Lock()
|
||||
defer backend.mu.Unlock()
|
||||
backend.nextID++
|
||||
rec := &ClientRecord{
|
||||
ID: backend.nextID,
|
||||
ClientID: clientID,
|
||||
ClientSecretHash: secretHash,
|
||||
ClientName: "Test Client",
|
||||
RedirectURIs: []string{tokenTestRedirect},
|
||||
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||
TokenEndpointAuthMethod: authMethod,
|
||||
ScopeCeiling: ceiling,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
backend.clients = append(backend.clients, rec)
|
||||
return rec
|
||||
}
|
||||
|
||||
// insertTokenTestCode seeds an already-issued (post-consent) code row
|
||||
// directly into backend, matching the shape 08-05's consent flow will
|
||||
// produce via AuthCodeStore.MarkIssued: CodeHash set, RequestID nil, UserID
|
||||
// set. mutate, when non-nil, is applied to the record before it is stored so
|
||||
// individual tests can adjust ExpiresAt/UsedAt/ClientID/etc.
|
||||
func insertTokenTestCode(t *testing.T, backend *memoryBackend, clientID string, challenge string, mutate func(*AuthCodeRecord)) (rawCode string, rec *AuthCodeRecord) {
|
||||
t.Helper()
|
||||
raw, err := randomBase64URL(32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.mu.Lock()
|
||||
defer backend.mu.Unlock()
|
||||
backend.nextID++
|
||||
userID := uint(1)
|
||||
hash := sha256Hex(raw)
|
||||
rec = &AuthCodeRecord{
|
||||
ID: backend.nextID,
|
||||
CodeHash: &hash,
|
||||
ClientID: clientID,
|
||||
UserID: &userID,
|
||||
RedirectURI: tokenTestRedirect,
|
||||
Scopes: []string{"read", "write"},
|
||||
CodeChallenge: challenge,
|
||||
CodeChallengeMethod: "S256",
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(rec)
|
||||
}
|
||||
backend.codes = append(backend.codes, rec)
|
||||
return raw, rec
|
||||
}
|
||||
|
||||
// tokenRequest builds a POST /oauth/mcp/token request from form (encoded as
|
||||
// the body) with an optional Authorization header, matching the D-02 body
|
||||
// parser every test in this file exercises.
|
||||
func tokenRequest(form url.Values, contentType string) *http.Request {
|
||||
if contentType == "" {
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
return req
|
||||
}
|
||||
|
||||
// TestPhase8RedCodeExchange is the Phase 8 Wave 4 RED anchor (08-04-PLAN.md
|
||||
// Task 1, D-02/D-04/D-05/D-07). It drives one valid S256 authorization-code
|
||||
// exchange through the real (in-memory-backed) Server.Token and asserts the
|
||||
// exact RFC 6749 success contract. It fails with the
|
||||
// PHASE8_RED:code-exchange sentinel while Token is the 501 stub;
|
||||
// scripts/check-phase8-red.sh verifies this failure is fail-closed.
|
||||
func TestPhase8RedCodeExchange(t *testing.T) {
|
||||
backend := newMemoryBackend()
|
||||
srv := newTestServer(backend)
|
||||
insertTokenTestClient(backend, "cli-red", "none", nil, nil)
|
||||
verifier, challenge := s256Pair(t)
|
||||
rawCode, _ := insertTokenTestCode(t, backend, "cli-red", challenge, nil)
|
||||
|
||||
req := tokenRequest(url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {rawCode},
|
||||
"code_verifier": {verifier},
|
||||
"redirect_uri": {tokenTestRedirect},
|
||||
"client_id": {"cli-red"},
|
||||
}, "")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Token(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PHASE8_RED:code-exchange: status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("PHASE8_RED:code-exchange: decode response: %v", err)
|
||||
}
|
||||
access, _ := got["access_token"].(string)
|
||||
refresh, _ := got["refresh_token"].(string)
|
||||
if access == "" || refresh == "" {
|
||||
t.Fatalf("PHASE8_RED:code-exchange: access_token/refresh_token empty in %v", got)
|
||||
}
|
||||
if got["token_type"] != "Bearer" {
|
||||
t.Fatalf("PHASE8_RED:code-exchange: token_type = %v, want Bearer", got["token_type"])
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user