diff --git a/wristband/server.go b/wristband/server.go index fba4946..ebc478b 100644 --- a/wristband/server.go +++ b/wristband/server.go @@ -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, } } diff --git a/wristband/token.go b/wristband/token.go new file mode 100644 index 0000000..2fbcff6 --- /dev/null +++ b/wristband/token.go @@ -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) +} diff --git a/wristband/token_test.go b/wristband/token_test.go new file mode 100644 index 0000000..604a768 --- /dev/null +++ b/wristband/token_test.go @@ -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) + } +}