Skip to content

Commit 47d87b3

Browse files
Dumbrisclaude
andauthored
feat: proactive OAuth token refresh and logout commands (#180)
* feat: proactive OAuth token refresh and logout commands Add RefreshManager to proactively refresh OAuth tokens at 80% of their lifetime, preventing authentication disruptions for long-running sessions. Features: - Proactive token refresh at 80% of token lifetime with exponential backoff - CLI `mcpproxy auth logout --server=<name>` command for token revocation - REST `POST /api/v1/servers/{id}/logout` endpoint for programmatic logout - OAuth flow coordination to prevent refresh conflicts during active flows - SSE events for token refresh status updates (oauth.token.refreshed/failed) Implementation: - RefreshManager with configurable threshold and retry logic - Integration with PersistentTokenStore for BBolt-backed token lifecycle - Management service methods: TriggerOAuthLogout, RefreshOAuthToken - CLI supports both daemon mode (via socket) and standalone mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add Logout button to Web UI server card - Add Logout button that appears when server is authenticated - Button triggers OAuth logout via existing triggerOAuthLogout store action - Uses btn-warning styling to indicate destructive action - Shows loading spinner during logout operation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add bug report for OAuth reconnection after login Documents pre-existing issue where server doesn't connect immediately after OAuth login completes. Includes root cause analysis and proposed fixes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4c5b565 commit 47d87b3

39 files changed

Lines changed: 4704 additions & 7 deletions

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,8 @@ When making changes to this codebase, ensure you understand the modular architec
952952
- BBolt (existing `internal/storage/`) for token persistence (007-oauth-e2e-testing)
953953
- Go 1.24.0 + mcp-go (OAuth transport), zap (logging), BBolt (token persistence), google/uuid (correlation IDs) (008-oauth-token-refresh)
954954
- BBolt embedded database (`~/.mcpproxy/config.db`) - `oauth_tokens` and `oauth_completion` buckets (008-oauth-token-refresh)
955+
- Go 1.24.0 + mcp-go (v0.43.1), zap (logging), chi (HTTP router), BBolt (storage), Vue 3 + TypeScript (frontend) (009-proactive-oauth-refresh)
956+
- BBolt embedded database (`~/.mcpproxy/config.db`) - `oauth_tokens` bucke (009-proactive-oauth-refresh)
955957

956958
## Recent Changes
957959
- 003-tool-annotations-webui: Added Go 1.21+, TypeScript/Vue 3

cmd/mcpproxy/auth_cmd.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ Examples:
5656
RunE: runAuthStatus,
5757
}
5858

59+
authLogoutCmd = &cobra.Command{
60+
Use: "logout",
61+
Short: "Clear OAuth token and disconnect from a server",
62+
Long: `Clear OAuth authentication token and disconnect from a specific upstream server.
63+
This is useful when:
64+
- You want to revoke access to an OAuth-enabled server
65+
- You need to re-authenticate with different credentials
66+
- Troubleshooting authentication issues
67+
68+
The command clears the stored OAuth token and disconnects the server.
69+
You will need to re-authenticate before using the server's tools again.
70+
71+
Examples:
72+
mcpproxy auth logout --server=Sentry-2
73+
mcpproxy auth logout --server=github-server --log-level=debug`,
74+
RunE: runAuthLogout,
75+
}
76+
5977
// Command flags for auth commands
6078
authServerName string
6179
authLogLevel string
@@ -73,6 +91,7 @@ func init() {
7391
// Add subcommands to auth command
7492
authCmd.AddCommand(authLoginCmd)
7593
authCmd.AddCommand(authStatusCmd)
94+
authCmd.AddCommand(authLogoutCmd)
7695

7796
// Define flags for auth login command
7897
authLoginCmd.Flags().StringVarP(&authServerName, "server", "s", "", "Server name to authenticate with (required)")
@@ -86,12 +105,23 @@ func init() {
86105
authStatusCmd.Flags().StringVarP(&authConfigPath, "config", "c", "", "Path to MCP configuration file (default: ~/.mcpproxy/mcp_config.json)")
87106
authStatusCmd.Flags().BoolVar(&authAll, "all", false, "Show status for all servers")
88107

108+
// Define flags for auth logout command
109+
authLogoutCmd.Flags().StringVarP(&authServerName, "server", "s", "", "Server name to logout from (required)")
110+
authLogoutCmd.Flags().StringVarP(&authLogLevel, "log-level", "l", "info", "Log level (trace, debug, info, warn, error)")
111+
authLogoutCmd.Flags().StringVarP(&authConfigPath, "config", "c", "", "Path to MCP configuration file (default: ~/.mcpproxy/mcp_config.json)")
112+
authLogoutCmd.Flags().DurationVar(&authTimeout, "timeout", 30*time.Second, "Logout timeout")
113+
89114
// Mark required flags
90115
err := authLoginCmd.MarkFlagRequired("server")
91116
if err != nil {
92117
panic(fmt.Sprintf("Failed to mark server flag as required: %v", err))
93118
}
94119

120+
err = authLogoutCmd.MarkFlagRequired("server")
121+
if err != nil {
122+
panic(fmt.Sprintf("Failed to mark server flag as required: %v", err))
123+
}
124+
95125
// Add examples
96126
authLoginCmd.Example = ` # Authenticate with Sentry-2 server
97127
mcpproxy auth login --server=Sentry-2
@@ -110,6 +140,12 @@ func init() {
110140
111141
# Check status with debug logging
112142
mcpproxy auth status --all --log-level=debug`
143+
144+
authLogoutCmd.Example = ` # Logout from Sentry-2 server
145+
mcpproxy auth logout --server=Sentry-2
146+
147+
# Logout with debug logging
148+
mcpproxy auth logout --server=github-server --log-level=debug`
113149
}
114150

115151
func runAuthLogin(_ *cobra.Command, _ []string) error {
@@ -466,3 +502,97 @@ func formatDuration(d time.Duration) string {
466502
return fmt.Sprintf("%dm", minutes)
467503
}
468504
}
505+
506+
func runAuthLogout(_ *cobra.Command, _ []string) error {
507+
ctx, cancel := context.WithTimeout(context.Background(), authTimeout)
508+
defer cancel()
509+
510+
// Load configuration to get data directory
511+
cfg, err := loadAuthConfig()
512+
if err != nil {
513+
return fmt.Errorf("failed to load configuration: %w", err)
514+
}
515+
516+
// Check if daemon is running and use client mode
517+
if shouldUseAuthDaemon(cfg.DataDir) {
518+
return runAuthLogoutClientMode(ctx, cfg.DataDir, authServerName)
519+
}
520+
521+
// No daemon detected, use standalone mode
522+
return runAuthLogoutStandalone(ctx, authServerName, cfg)
523+
}
524+
525+
// runAuthLogoutClientMode triggers OAuth logout via daemon HTTP API over socket.
526+
func runAuthLogoutClientMode(ctx context.Context, dataDir, serverName string) error {
527+
socketPath := socket.DetectSocketPath(dataDir)
528+
// Create simple logger for client (no file logging for command)
529+
logger, err := logs.SetupCommandLogger(false, authLogLevel, false, "")
530+
if err != nil {
531+
return fmt.Errorf("failed to create logger: %w", err)
532+
}
533+
defer func() { _ = logger.Sync() }()
534+
535+
client := cliclient.NewClient(socketPath, logger.Sugar())
536+
537+
// Ping daemon to verify connectivity
538+
pingCtx, pingCancel := context.WithTimeout(ctx, 2*time.Second)
539+
defer pingCancel()
540+
if err := client.Ping(pingCtx); err != nil {
541+
logger.Warn("Failed to ping daemon, falling back to standalone mode",
542+
zap.Error(err),
543+
zap.String("socket_path", socketPath))
544+
545+
// Load config for standalone mode
546+
cfg, loadErr := loadAuthConfig()
547+
if loadErr != nil {
548+
return fmt.Errorf("failed to load configuration for standalone: %w", loadErr)
549+
}
550+
return runAuthLogoutStandalone(ctx, serverName, cfg)
551+
}
552+
553+
fmt.Fprintf(os.Stderr, "ℹ️ Using daemon mode (via socket) - coordinating OAuth logout with running server\n\n")
554+
555+
// Trigger OAuth logout via daemon
556+
if err := client.TriggerOAuthLogout(ctx, serverName); err != nil {
557+
return fmt.Errorf("failed to trigger OAuth logout via daemon: %w", err)
558+
}
559+
560+
fmt.Printf("✅ OAuth logout completed successfully for server: %s\n", serverName)
561+
fmt.Println(" The OAuth token has been cleared and the server has been disconnected.")
562+
fmt.Println(" Use 'mcpproxy auth login --server=" + serverName + "' to re-authenticate.")
563+
564+
return nil
565+
}
566+
567+
// runAuthLogoutStandalone clears OAuth token in standalone mode.
568+
func runAuthLogoutStandalone(ctx context.Context, serverName string, cfg *config.Config) error {
569+
fmt.Printf("🔐 OAuth Logout - Server: %s\n", serverName)
570+
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
571+
572+
// Validate server exists in config
573+
if !authServerExistsInConfig(serverName, cfg) {
574+
return fmt.Errorf("server '%s' not found in configuration. Available servers: %v",
575+
serverName, getAuthAvailableServerNames(cfg))
576+
}
577+
578+
// Create CLI client for OAuth logout
579+
fmt.Printf("🔗 Clearing OAuth token for server '%s'...\n", serverName)
580+
fmt.Println(" Note: Running in standalone mode (no daemon detected)")
581+
fmt.Println()
582+
583+
cliClient, err := cli.NewClient(serverName, cfg, authLogLevel)
584+
if err != nil {
585+
return fmt.Errorf("failed to create CLI client: %w", err)
586+
}
587+
defer cliClient.Close()
588+
589+
// Clear OAuth token
590+
if err := cliClient.ClearOAuthToken(ctx); err != nil {
591+
return fmt.Errorf("failed to clear OAuth token: %w", err)
592+
}
593+
594+
fmt.Printf("✅ OAuth token cleared successfully for server '%s'!\n", serverName)
595+
fmt.Println(" Use 'mcpproxy auth login --server=" + serverName + "' to re-authenticate.")
596+
597+
return nil
598+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Bug: Server doesn't connect immediately after OAuth login
2+
3+
## Summary
4+
After successful OAuth authentication via browser flow, the server remains in "Disconnected" status instead of automatically connecting. Users must manually restart the server to establish a connection.
5+
6+
## Expected Behavior
7+
After OAuth login completes successfully:
8+
1. Token is saved to persistent storage
9+
2. Server automatically attempts connection using the new token
10+
3. Server status updates to "Connected" in UI
11+
12+
## Actual Behavior
13+
After OAuth login completes:
14+
1. Token is saved to persistent storage ✅
15+
2. `RetryConnection` is called but fails with "OAuth authentication required"
16+
3. Server remains "Disconnected" despite having a valid token
17+
18+
## Root Cause Analysis
19+
20+
The issue is in `internal/upstream/core/connection.go` lines 1028-1033:
21+
22+
```go
23+
// Check if we have a recently completed OAuth flow that should have tokens
24+
if oauth.GetTokenStoreManager().HasRecentOAuthCompletion(c.serverName) {
25+
c.logger.Info("OAuth flow recently completed, tokens should be available",
26+
zap.String("server", c.serverName))
27+
// Skip the browser flow since tokens should be available
28+
}
29+
```
30+
31+
**Problem**: The code logs "Skip the browser flow since tokens should be available" but doesn't actually change the control flow. The browser OAuth flow is still triggered, which fails because OAuth is already complete.
32+
33+
### Why this happens:
34+
35+
1. **Managed client calls `RetryConnection`** (`internal/upstream/manager.go:1326-1355`)
36+
2. **Core client's `Connect()` is called** which goes through `connectWithOAuth()`
37+
3. **`connectWithOAuth()` checks `HasRecentOAuthCompletion()`** but only logs - doesn't skip OAuth
38+
4. **OAuth flow coordinator returns "flow already active" or times out**
39+
5. **Connection fails** even though valid token exists in storage
40+
41+
### The architectural issue:
42+
43+
The core client (`internal/upstream/core/`) doesn't directly read tokens from the persistent token store during connection. Instead:
44+
- Tokens are managed by `PersistentTokenStore` in `internal/oauth/persistent_token_store.go`
45+
- Core client relies on OAuth flow to provide tokens
46+
- When OAuth flow is skipped/already complete, no mechanism exists to retrieve existing tokens
47+
48+
## Proposed Fix
49+
50+
Option A: **Skip OAuth flow when recent completion detected**
51+
```go
52+
if oauth.GetTokenStoreManager().HasRecentOAuthCompletion(c.serverName) {
53+
c.logger.Info("OAuth flow recently completed, using existing token")
54+
// Actually skip the OAuth flow - just attempt connection with existing token
55+
return c.connectWithExistingToken(ctx)
56+
}
57+
```
58+
59+
Option B: **Add token retrieval to core client**
60+
- Add method to retrieve token from persistent store
61+
- Use token directly in HTTP transport without triggering OAuth flow
62+
63+
Option C: **Fix RetryConnection to reinitialize transport with token**
64+
- When `RetryConnection` is called after OAuth completion
65+
- Reinitialize the HTTP transport with the token from storage
66+
- Skip the full OAuth flow
67+
68+
## Affected Files
69+
- `internal/upstream/core/connection.go` - Main fix location
70+
- `internal/upstream/core/client.go` - May need token retrieval method
71+
- `internal/upstream/manager.go` - RetryConnection logic
72+
73+
## Reproduction Steps
74+
1. Configure an OAuth-enabled server (e.g., cloudflare-logs)
75+
2. Start mcpproxy - server shows as "Disconnected"
76+
3. Click "Login" button or run `mcpproxy auth login --server=<name>`
77+
4. Complete OAuth flow in browser
78+
5. Observe: Server still shows "Disconnected" despite successful login
79+
6. Click "Restart" to manually connect
80+
81+
## Impact
82+
- User experience degradation - requires manual intervention after OAuth
83+
- Confusion about whether OAuth succeeded
84+
- Extra clicks/commands to get server connected
85+
86+
## Priority
87+
Medium - Workaround exists (manual restart), but UX is poor
88+
89+
## Related
90+
- Feature 009: Proactive OAuth Token Refresh (this bug was discovered during testing)
91+
- PR #177: fix: clear OAuth error on successful reconnection
92+
- PR #178: fix: make Login button consistent with other server action buttons

frontend/src/components/ServerCard.vue

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@
9797
Login
9898
</button>
9999

100+
<button
101+
v-if="canLogout"
102+
@click="triggerLogout"
103+
:disabled="loading"
104+
class="btn btn-sm btn-outline btn-warning"
105+
>
106+
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
107+
Logout
108+
</button>
109+
100110
<router-link
101111
:to="`/servers/${server.name}`"
102112
class="btn btn-sm btn-outline"
@@ -187,6 +197,14 @@ const needsOAuth = computed(() => {
187197
return isHttpProtocol && notConnected && isEnabled && (hasOAuthError || (hasOAuthConfig && notAuthenticated))
188198
})
189199
200+
const canLogout = computed(() => {
201+
// Show logout button if server is authenticated (has OAuth token)
202+
const isHttpProtocol = props.server.protocol === 'http' || props.server.protocol === 'streamable-http'
203+
const isAuthenticated = props.server.authenticated === true
204+
205+
return isHttpProtocol && isAuthenticated
206+
})
207+
190208
async function toggleEnabled() {
191209
loading.value = true
192210
try {
@@ -256,6 +274,26 @@ async function triggerOAuth() {
256274
}
257275
}
258276
277+
async function triggerLogout() {
278+
loading.value = true
279+
try {
280+
await serversStore.triggerOAuthLogout(props.server.name)
281+
systemStore.addToast({
282+
type: 'success',
283+
title: 'OAuth Logout Successful',
284+
message: `${props.server.name} has been logged out`,
285+
})
286+
} catch (error) {
287+
systemStore.addToast({
288+
type: 'error',
289+
title: 'Logout Failed',
290+
message: error instanceof Error ? error.message : 'Unknown error',
291+
})
292+
} finally {
293+
loading.value = false
294+
}
295+
}
296+
259297
async function unquarantine() {
260298
loading.value = true
261299
try {

frontend/src/services/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ class APIService {
225225
})
226226
}
227227

228+
async triggerOAuthLogout(serverName: string): Promise<APIResponse> {
229+
return this.request(`/api/v1/servers/${encodeURIComponent(serverName)}/logout`, {
230+
method: 'POST',
231+
})
232+
}
233+
228234
async quarantineServer(serverName: string): Promise<APIResponse> {
229235
return this.request(`/api/v1/servers/${encodeURIComponent(serverName)}/quarantine`, {
230236
method: 'POST',

frontend/src/stores/servers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,21 @@ export const useServersStore = defineStore('servers', () => {
205205
}
206206
}
207207

208+
async function triggerOAuthLogout(serverName: string) {
209+
try {
210+
const response = await api.triggerOAuthLogout(serverName)
211+
if (response.success) {
212+
// The SSE event will trigger a full refresh with actual state
213+
return true
214+
} else {
215+
throw new Error(response.error || 'Failed to trigger OAuth logout')
216+
}
217+
} catch (error) {
218+
console.error('Failed to trigger OAuth logout:', error)
219+
throw error
220+
}
221+
}
222+
208223
async function quarantineServer(serverName: string) {
209224
try {
210225
const response = await api.quarantineServer(serverName)
@@ -332,6 +347,7 @@ export const useServersStore = defineStore('servers', () => {
332347
disableServer,
333348
restartServer,
334349
triggerOAuthLogin,
350+
triggerOAuthLogout,
335351
quarantineServer,
336352
unquarantineServer,
337353
deleteServer,

0 commit comments

Comments
 (0)