Feature: spec.md | Plan: plan.md Date: 2025-12-07
This document consolidates research findings for implementing proactive OAuth token refresh, CLI/REST logout functionality, and Web UI improvements. All technical decisions have been validated against the existing codebase patterns.
Implement proactive (scheduled) token refresh at 80% of token lifetime with per-server coordination.
- Industry standard: Major API gateways (Kong, Azure APIM, Envoy) use proactive refresh at 80-90% lifetime
- Race condition prevention: Lazy refresh with single-use refresh tokens causes "refresh token already used" errors when concurrent requests race
- User experience: Prevents tool call failures with "authorization required" errors when tokens expire during operations
- Existing pattern:
internal/oauth/coordinator.goalready provides per-server mutex coordination for OAuth flows
| Alternative | Why Rejected |
|---|---|
| Lazy refresh (current) | Causes tool call failures; race conditions with concurrent requests |
| Event-driven refresh | Over-complex; requires tracking all token usages |
| Fixed-interval refresh | Wastes resources on long-lived tokens; may miss short-lived ones |
// RefreshManager coordinates background token refresh
type RefreshManager struct {
storage *storage.BoltDB
coordinator *OAuthFlowCoordinator // Reuse existing per-server mutex
timers map[string]*time.Timer // Per-server scheduled refresh
mu sync.RWMutex
}
// Schedule refresh at 80% of token lifetime
func (m *RefreshManager) scheduleRefresh(serverName string, expiresAt time.Time) {
lifetime := time.Until(expiresAt)
refreshAt := time.Duration(float64(lifetime) * 0.8)
// ...
}Integrate refresh manager with existing OAuthFlowCoordinator to prevent race conditions between proactive refresh and manual login.
- Existing infrastructure:
coordinator.goprovidesStartFlow(),EndFlow(),IsFlowActive(),WaitForFlow()- all needed for coordination - No duplication: Reuse per-server mutexes from
flowLocks map[string]*sync.Mutex - Clean handoff: Manual login can cancel scheduled refresh; refresh can detect active login and wait
Manual Login Triggered → Cancel pending scheduled refresh → Use existing flow
Proactive Refresh Triggered → Check IsFlowActive() → If active, WaitForFlow()
Refresh Succeeds → Update timer for new expiration
Refresh Fails (3 retries) → Emit oauth.refresh_failed → Show Login button
Add TriggerOAuthLogout() method to ManagementService interface following the existing TriggerOAuthLogin() pattern.
- Consistency: Mirrors existing
TriggerOAuthLogin()signature and behavior - DDD layering: Management service → Runtime → PersistentTokenStore.ClearToken()
- Configuration gates: Respects
disable_managementandread_onlygates - Event emission: Emits
servers.changedafter logout completes
// In internal/management/service.go
type Service interface {
// ... existing methods ...
// TriggerOAuthLogout clears OAuth token and disconnects a specific server.
// Respects disable_management and read_only configuration gates.
// Emits "servers.changed" event on successful logout.
TriggerOAuthLogout(ctx context.Context, name string) error
}
type RuntimeOperations interface {
// ... existing methods ...
TriggerOAuthLogout(serverName string) error
}Add POST /api/v1/servers/{id}/logout endpoint following existing action endpoint patterns.
- Consistency: Mirrors existing
/login,/enable,/restartendpoints - HTTP semantics: POST for state-changing operation (not DELETE - token is not a resource)
- Response format: Returns
{"action": "logout", "success": true}like other action endpoints
// From internal/httpapi/server.go - /login endpoint
r.Post("/api/v1/servers/{id}/login", s.handleServerLogin)
// Response format from existing endpoints
s.writeSuccess(w, map[string]interface{}{
"action": "login",
"server": serverID,
})Add mcpproxy auth logout --server=<name> command with --all flag support.
- Consistency: Parallels existing
mcpproxy auth login --server=<name>command - Daemon support: Uses existing socket communication pattern from
auth_cmd.go - Standalone mode: Works without daemon by directly calling storage methods
// From cmd/mcpproxy/auth_cmd.go - login command
authLoginCmd := &cobra.Command{
Use: "login",
Short: "Authenticate with an OAuth-enabled MCP server",
// ...
}
// Daemon socket communication
if err := client.TriggerOAuthLogin(ctx, serverName); err != nil {
// handle error
}Modify ServerCard.vue to show Login button when server has expired OAuth token, regardless of connection status.
- Root cause: Current condition
notConnected && needsOAuthhides button for connected servers with expired tokens - User scenario: Server shows "Connected" but tool calls fail with auth errors - user needs Login button
- Minimal change: Add computed property
needsReauthenticationthat checksoauth_status === 'expired'
<!-- From frontend/src/components/ServerCard.vue -->
<button v-if="needsOAuth && notConnected" @click="handleLogin">
Login
</button>
<!-- Fix: Show Login when authenticated but token expired -->
<button v-if="needsOAuth && (notConnected || oauthExpired)" @click="handleLogin">
Login
</button>Add new SSE event types oauth.token_refreshed and oauth.refresh_failed following existing event patterns.
- Real-time updates: Web UI needs to update auth status badge immediately after refresh
- Existing pattern:
servers.changedandconfig.reloadedevents already in use - Event payload: Include
server_name,expires_at, anderrorfields
// From internal/runtime/events.go pattern
const (
EventTypeOAuthTokenRefreshed EventType = "oauth.token_refreshed"
EventTypeOAuthRefreshFailed EventType = "oauth.refresh_failed"
)
// Event payload
type OAuthRefreshEvent struct {
ServerName string `json:"server_name"`
ExpiresAt time.Time `json:"expires_at,omitempty"` // Only for success
Error string `json:"error,omitempty"` // Only for failure
}Use existing OAuthTokenRecord.ExpiresAt field for refresh scheduling - no schema changes needed.
- Existing field:
internal/storage/models.goalready hasExpiresAt time.TimeinOAuthTokenRecord - ListOAuthTokens:
internal/storage/bbolt.go:ListOAuthTokens()returns all tokens for refresh manager initialization - No migration: No BBolt schema migration required
// From internal/storage/models.go
type OAuthTokenRecord struct {
ServerName string `json:"server_name"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"`
ExpiresAt time.Time `json:"expires_at"` // Used for refresh scheduling
Scopes []string `json:"scopes,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
// ...
}Add oauth_status and token_expires_at fields to server status response.
- Web UI needs: Display accurate auth status badge ("Authenticated", "Token Expired", "Auth Error")
- CLI needs: Show human-readable expiration in
auth statuscommand - Computed at runtime: Calculate from
OAuthTokenRecordinListServers()response
// In internal/contracts/server.go or similar
type Server struct {
// ... existing fields ...
OAuthStatus string `json:"oauth_status,omitempty"` // "authenticated", "expired", "error", "none"
TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` // ISO 8601 when authenticated
}Implement exponential backoff with 3 retries (1s, 2s, 4s) before falling back to browser re-authentication.
- Transient errors: Network timeouts, rate limits may resolve quickly
- Industry practice: 3 retries with exponential backoff is standard for OAuth
- Existing pattern:
internal/upstream/core/connection.go:1043-1057has similar retry logic
// From internal/upstream/core/connection.go
const maxTokenRefreshRetries = 3
for attempt := 1; attempt <= maxTokenRefreshRetries; attempt++ {
// Exponential backoff: 1s, 2s, 4s
delay := time.Duration(1<<uint(attempt-1)) * time.Second
// ...
}internal/oauth/persistent_token_store.go- Token storage with grace periodinternal/oauth/coordinator.go- Per-server OAuth flow coordinationinternal/oauth/logging.go- Token metadata logginginternal/storage/models.go-OAuthTokenRecordschema
internal/management/service.go- Service interface withTriggerOAuthLogin()internal/management/service.go:666-TriggerOAuthLogin()implementation
internal/httpapi/server.go:1063-/loginendpoint handlerinternal/httpapi/server.go- Swagger annotations for OpenAPI
cmd/mcpproxy/auth_cmd.go- Existingauth loginandauth statuscommandsinternal/cliclient/client.go:614-TriggerOAuthLogin()client method
internal/runtime/events.go- Event type definitionsinternal/runtime/event_bus.go- Event broadcasting
frontend/src/components/ServerCard.vue- Server card with Login buttonfrontend/src/services/api.ts- API clientfrontend/src/stores/servers.ts- Server state management
The existing OAuth test server from spec 007 supports configurable token lifetimes:
- Location:
tests/oauthserver/ - Configure short lifetime (30-60s) via environment variables for refresh testing
- Configure OAuth test server with 30-second token lifetime
- Start mcpproxy with OAuth server configured
- Verify refresh happens at ~24 seconds (80% of 30s)
- Verify SSE event emitted
- Verify tool calls succeed after refresh
All decisions verified against .specify/memory/constitution.md:
| Principle | Compliance |
|---|---|
| I. Performance at Scale | ✅ Background refresh doesn't block API requests |
| II. Actor-Based Concurrency | ✅ Reuses existing OAuthFlowCoordinator |
| III. Configuration-Driven | ✅ Refresh threshold can be configurable |
| IV. Security by Default | ✅ Tokens stored in BBolt, cleared on logout |
| V. TDD | ✅ Unit tests specified for all new components |
| VI. Documentation Hygiene | ✅ CLAUDE.md update for new CLI command |