Feature: 007-oauth-e2e-testing Date: 2025-12-02
Decision: Use github.com/golang-jwt/jwt/v5
Rationale:
- Industry standard Go JWT library (successor to dgrijalva/jwt-go)
- Already a transitive dependency in many Go OAuth implementations
- Supports RS256, ES256, and other algorithms
- Well-documented API for custom claims
Alternatives Considered:
gopkg.in/square/go-jose.v2: More complex, designed for full JOSE suite (not just JWT)github.com/lestrrat-go/jwx: Feature-rich but heavier dependency- Manual JWT creation: Too error-prone and maintenance burden
Decision: Single HTTP server with handler registration pattern
Rationale:
- Mirrors existing mcpproxy test patterns (
internal/server/e2e_test.go) - Ephemeral port allocation via
net.Listen(":0")avoids conflicts http.ServeMuxprovides clean endpoint routing- Shutdown via
httptest.Serveror manualhttp.Server.Shutdown()
Alternatives Considered:
- Multiple servers per endpoint: Unnecessary complexity
- Docker-based test OAuth server: Slower, harder to configure dynamically
- External OAuth provider mocks (WireMock): Additional dependency, less Go-native
Decision: Standard SHA-256 code challenge method (S256)
Rationale:
- RFC 7636 mandates S256 as the preferred method
- mcpproxy's existing OAuth implementation uses S256
crypto/sha256in stdlib for verification- Plain method should be rejected in tests (security best practice)
Implementation Pattern:
// Generate verifier (client-side, 43-128 chars)
verifier := base64.RawURLEncoding.EncodeToString(randomBytes(32))
// Generate challenge (client-side)
hash := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
// Verify (server-side)
expectedHash := sha256.Sum256([]byte(receivedVerifier))
expectedChallenge := base64.RawURLEncoding.EncodeToString(expectedHash[:])
if expectedChallenge != storedChallenge { return error }Decision: Implement full RFC 8628 device authorization grant
Rationale:
- mcpproxy needs headless/CLI OAuth support
- Device code flow is the standard for CLI tools
- Configurable polling interval and timeout for fast tests
Endpoints Required:
POST /device_authorization: Returnsdevice_code,user_code,verification_uri,intervalGET /device_verification: Shows form for entering user_codePOST /device_verification: Approves/denies device codePOST /tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code: Polls for token
Test Server State Machine:
pending: Device code issued, awaiting user actionapproved: User entered code and approveddenied: User explicitly deniedexpired: Timeout exceeded
Decision: Implement RFC 7591 with minimal required fields
Rationale:
- mcpproxy supports DCR for zero-configuration OAuth
- Test server needs to issue client credentials dynamically
- Minimal implementation sufficient for testing
Required Fields (Request):
redirect_uris: Array of callback URLsgrant_types: Optional, defaults to["authorization_code"]response_types: Optional, defaults to["code"]client_name: Optional for identification
Response Fields:
client_id: Generated UUIDclient_secret: Generated random string (for confidential clients)client_id_issued_at: Unix timestampclient_secret_expires_at: 0 for non-expiring
Decision: Simple HTML template with Go's html/template
Rationale:
- Playwright can interact with standard HTML forms
- No JavaScript framework needed for test UI
- Template can be embedded via
embeddirective
Required Form Elements:
<input name="username">: Test username<input name="password" type="password">: Test password<input name="consent" type="checkbox">: Consent checkbox<button type="submit">: Submit button- Error message display area
Test Credentials:
- Valid:
testuser/testpass - Invalid password triggers error page
- Unchecked consent triggers
error=access_deniedredirect
Decision: Options struct with toggles for each error type
Rationale:
- Test-specific configuration without global state
- Clear mapping from option to expected error behavior
- Supports per-test customization
Error Types:
type ErrorMode struct {
TokenEndpoint struct {
InvalidClient bool // Return invalid_client
InvalidGrant bool // Return invalid_grant
InvalidScope bool // Return invalid_scope
ServerError bool // Return 500
SlowResponse time.Duration // Delay before response
UnsupportedGrant bool // Return unsupported_grant_type
}
AuthorizeEndpoint struct {
AccessDenied bool // Return error=access_denied
InvalidRequest bool // Return error=invalid_request
}
}Decision: Test server maintains key ring with key ID (kid) tracking
Rationale:
- Production OAuth providers rotate keys periodically
- mcpproxy must handle key rotation gracefully
- Test server needs ability to swap keys mid-test
Implementation:
type KeyRing struct {
keys map[string]*rsa.PrivateKey // kid -> private key
activeKid string // Current signing key
}
func (kr *KeyRing) RotateTo(newKid string) error
func (kr *KeyRing) GetJWKS() *jose.JSONWebKeySet
func (kr *KeyRing) SignToken(claims jwt.MapClaims) (string, error)Decision: Echo resource parameter into JWT aud claim
Rationale:
- RFC 8707 specifies resource indicators for audience-restricted tokens
- mcpproxy passes
resourceon authorize and token requests - Test server must validate and echo back
Flow:
- Authorization request includes
resource=https://api.example.com - Store resource with authorization code
- Token request includes
resource=https://api.example.com - Validate resource matches stored value
- Issue JWT with
aud: "https://api.example.com"
Decision: Tests will exercise existing code paths without modification to OAuth core
Key Integration Points Identified:
internal/oauth/config.go:CreateOAuthConfig()- main entry pointinternal/oauth/discovery.go:DetectOAuthAvailability(),DiscoverScopesFromProtectedResource()internal/upstream/core/connection.go:tryOAuthAuth(),handleOAuthAuthorization()internal/upstream/cli/client.go:TriggerManualOAuth(),GetOAuthStatus()cmd/mcpproxy/auth_cmd.go:runAuthLogin(),runAuthStatus()
Test Approach:
- Create mcpproxy config pointing to test OAuth server
- Start mcpproxy with this config
- Trigger OAuth flows via CLI commands or API
- Assert tokens are stored and status reflects success
Decision: Use npx playwright test with TypeScript specs
Rationale:
- Playwright provides cross-browser testing (Chromium by default)
- TypeScript specs align with existing frontend conventions
npxavoids global installation requirements- Headless mode for CI compatibility
Test Flow:
- Start OAuth test server
- Start mcpproxy with test config
- Playwright navigates to authorization URL
- Fill credentials form, submit
- Assert redirect to mcpproxy callback
- Assert
auth statusshows authenticated
Decision: Separate CI job with OAuth tests behind flag
Rationale:
- OAuth E2E tests are slower than unit tests
- Can be skipped for quick PR checks if needed
- Playwright requires browser installation
CI Configuration:
- name: Run OAuth E2E Tests
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'test-oauth')
run: ./scripts/run-oauth-e2e.shAll technical context items from the plan are resolved:
| Item | Resolution |
|---|---|
| JWT library | github.com/golang-jwt/jwt/v5 |
| Test server pattern | Single HTTP server with http.ServeMux |
| PKCE verification | SHA-256 (S256 method) |
| Device code flow | Full RFC 8628 implementation |
| DCR | Minimal RFC 7591 implementation |
| Login UI | Go html/template embedded template |
| Error injection | Options struct with toggles |
| JWKS rotation | Key ring with kid tracking |
| Resource indicator | Echo to JWT aud claim |
| Playwright | npx playwright test with TypeScript |
| CI | Separate job with conditional execution |
- RFC 6749: OAuth 2.0 Authorization Framework
- RFC 7636: PKCE for OAuth Public Clients
- RFC 7591: OAuth 2.0 Dynamic Client Registration
- RFC 8414: OAuth 2.0 Authorization Server Metadata
- RFC 8628: OAuth 2.0 Device Authorization Grant
- RFC 8707: Resource Indicators for OAuth 2.0
- RFC 9728: OAuth 2.0 Protected Resource Metadata
- go-sdk
oauthexpackage for reference patterns