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
|
// created by authorize stays valid (PHP
|
||||||
// OAuthCodeManager::PENDING_TTL_SECONDS, D-03). PHP default: 600s.
|
// OAuthCodeManager::PENDING_TTL_SECONDS, D-03). PHP default: 600s.
|
||||||
PendingRequestTTL time.Duration
|
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
|
// DefaultOptions returns PHP-parity defaults for every metadata option
|
||||||
@@ -83,6 +93,8 @@ func DefaultOptions() Options {
|
|||||||
RegisterMaxBodyBytes: 65536,
|
RegisterMaxBodyBytes: 65536,
|
||||||
Resource: "https://mcp.plytarium.com/mcp",
|
Resource: "https://mcp.plytarium.com/mcp",
|
||||||
PendingRequestTTL: 600 * time.Second,
|
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