Skip to content

Commit a11d002

Browse files
fix(oauth): treat zero ExpiresAt as never-expires in HasPersistedToken (#453)
Authorization servers that omit `expires_in` from the /token response (e.g. Atlassian's MCP endpoint at https://mcp.atlassian.com/v1/mcp) land in BBolt with ExpiresAt = time.Time{}. HasValidToken and mcp-go's own Token.IsExpired() already treat that as "never expires", but HasPersistedToken was computing `time.Now().After(time.Time{})` which is always true — so the persisted token was reported as expired on every reconnect, skipBrowserFlow stayed false, and the upstream looped forever asking for re-auth even though a valid access token sat in storage. Fix HasPersistedToken to short-circuit on IsZero(), matching HasValidToken. Also clean up PersistentTokenStore.GetToken so it no longer logs a spurious "token has expired" warning for those records. Add three unit tests covering the zero / future / past ExpiresAt cases against HasPersistedToken so the IsZero branch can't regress.
1 parent 6a5d39d commit a11d002

3 files changed

Lines changed: 104 additions & 7 deletions

File tree

internal/oauth/config.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ func (m *TokenStoreManager) HasTokenStore(serverName string) bool {
100100

101101
// HasPersistedToken checks if a token exists in persistent storage (BBolt) for the server.
102102
// This is the preferred method to check for existing tokens as it reflects actual token availability.
103+
//
104+
// A zero ExpiresAt (time.Time{}) is treated as "no expiration" — some authorization servers
105+
// (e.g. Atlassian's MCP endpoint) issue access tokens without an `expires_in` field, which
106+
// the oauth2 library deserializes as the Go zero time. Returning isExpired=true for those
107+
// records would make the upstream connection loop forever asking for re-auth even though a
108+
// valid token is on disk. This mirrors HasValidToken (see below), which already treats
109+
// IsZero() as "never expires".
103110
func HasPersistedToken(serverName, serverURL string, boltStorage *storage.BoltDB) (hasToken bool, hasRefreshToken bool, isExpired bool) {
104111
if boltStorage == nil {
105112
return false, false, false
@@ -113,7 +120,11 @@ func HasPersistedToken(serverName, serverURL string, boltStorage *storage.BoltDB
113120

114121
hasToken = record.AccessToken != ""
115122
hasRefreshToken = record.RefreshToken != ""
116-
isExpired = time.Now().After(record.ExpiresAt)
123+
if record.ExpiresAt.IsZero() {
124+
isExpired = false
125+
} else {
126+
isExpired = time.Now().After(record.ExpiresAt)
127+
}
117128
return
118129
}
119130

internal/oauth/config_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,78 @@ func TestTokenStoreManager_HasValidToken_PersistentStore_NoExpiration(t *testing
271271
assert.True(t, result, "Expected true for token with no expiration (zero time)")
272272
}
273273

274+
// TestHasPersistedToken_ZeroExpiresAt validates that a persisted token with no expiration
275+
// (time.Time zero value) is reported as NOT expired. Some authorization servers (e.g. the
276+
// Atlassian MCP endpoint) omit `expires_in` from /token responses, which the oauth2
277+
// library deserializes as ExpiresAt = time.Time{}. Treating that as "expired" would loop
278+
// the upstream connection forever asking for re-auth even though a valid access token is
279+
// on disk. This must match HasValidToken's IsZero() == valid semantics.
280+
func TestHasPersistedToken_ZeroExpiresAt(t *testing.T) {
281+
db := setupTestStorage(t)
282+
283+
serverName := "Atlassian"
284+
serverURL := "https://mcp.atlassian.com/v1/mcp"
285+
serverKey := GenerateServerKey(serverName, serverURL)
286+
287+
record := &storage.OAuthTokenRecord{
288+
ServerName: serverKey,
289+
AccessToken: "opaque-access-token-without-expiry",
290+
TokenType: "Bearer",
291+
ExpiresAt: time.Time{}, // No expires_in returned by the auth server
292+
}
293+
require.NoError(t, db.SaveOAuthToken(record))
294+
295+
hasToken, hasRefresh, isExpired := HasPersistedToken(serverName, serverURL, db)
296+
297+
assert.True(t, hasToken, "Expected hasToken=true for a record with a non-empty access token")
298+
assert.False(t, hasRefresh, "Expected hasRefresh=false for a record without a refresh token")
299+
assert.False(t, isExpired, "Expected isExpired=false for a record with zero ExpiresAt (no expiration)")
300+
}
301+
302+
// TestHasPersistedToken_FutureExpiresAt sanity-checks that a normal future expiry is
303+
// reported as not expired (regression guard alongside the zero-expiry test above).
304+
func TestHasPersistedToken_FutureExpiresAt(t *testing.T) {
305+
db := setupTestStorage(t)
306+
307+
serverName := "future-server"
308+
serverURL := "https://example.com/mcp"
309+
serverKey := GenerateServerKey(serverName, serverURL)
310+
311+
require.NoError(t, db.SaveOAuthToken(&storage.OAuthTokenRecord{
312+
ServerName: serverKey,
313+
AccessToken: "valid-token",
314+
RefreshToken: "valid-refresh",
315+
TokenType: "Bearer",
316+
ExpiresAt: time.Now().Add(1 * time.Hour),
317+
}))
318+
319+
hasToken, hasRefresh, isExpired := HasPersistedToken(serverName, serverURL, db)
320+
assert.True(t, hasToken)
321+
assert.True(t, hasRefresh)
322+
assert.False(t, isExpired)
323+
}
324+
325+
// TestHasPersistedToken_PastExpiresAt sanity-checks that an actually-expired token is
326+
// still reported as expired (we only want IsZero() to be the special case).
327+
func TestHasPersistedToken_PastExpiresAt(t *testing.T) {
328+
db := setupTestStorage(t)
329+
330+
serverName := "past-server"
331+
serverURL := "https://example.com/mcp"
332+
serverKey := GenerateServerKey(serverName, serverURL)
333+
334+
require.NoError(t, db.SaveOAuthToken(&storage.OAuthTokenRecord{
335+
ServerName: serverKey,
336+
AccessToken: "expired-token",
337+
TokenType: "Bearer",
338+
ExpiresAt: time.Now().Add(-1 * time.Hour),
339+
}))
340+
341+
hasToken, _, isExpired := HasPersistedToken(serverName, serverURL, db)
342+
assert.True(t, hasToken)
343+
assert.True(t, isExpired, "Expected isExpired=true for a record with a past ExpiresAt")
344+
}
345+
274346
// TestTokenStoreManager_HasValidToken_NilStorage validates graceful handling of nil storage
275347
func TestTokenStoreManager_HasValidToken_NilStorage(t *testing.T) {
276348
manager := &TokenStoreManager{

internal/oauth/persistent_token_store.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,25 +99,39 @@ func (p *PersistentTokenStore) GetToken(ctx context.Context) (*client.Token, err
9999
}
100100

101101
now := time.Now()
102-
timeUntilExpiry := record.ExpiresAt.Sub(now)
103-
isExpired := now.After(record.ExpiresAt)
104-
needsRefresh := timeUntilExpiry < TokenRefreshGracePeriod
102+
// A zero ExpiresAt means the authorization server did not return an `expires_in`
103+
// claim. Treat such tokens as never-expiring (matches Go's oauth2 convention and
104+
// HasValidToken/HasPersistedToken) instead of perpetually-expired.
105+
hasExpiry := !record.ExpiresAt.IsZero()
106+
timeUntilExpiry := time.Duration(0)
107+
isExpired := false
108+
needsRefresh := false
109+
if hasExpiry {
110+
timeUntilExpiry = record.ExpiresAt.Sub(now)
111+
isExpired = now.After(record.ExpiresAt)
112+
needsRefresh = timeUntilExpiry < TokenRefreshGracePeriod
113+
}
105114

106115
// Log token status for debugging
107-
if isExpired {
116+
switch {
117+
case !hasExpiry:
118+
p.logger.Debug("✅ OAuth token has no expiration, treating as long-lived",
119+
zap.String("server_key", p.serverKey),
120+
zap.Bool("has_refresh_token", record.RefreshToken != ""))
121+
case isExpired:
108122
p.logger.Warn("⚠️ OAuth token has expired and needs refresh",
109123
zap.String("server_key", p.serverKey),
110124
zap.Time("expires_at", record.ExpiresAt),
111125
zap.Duration("expired_since", -timeUntilExpiry),
112126
zap.Bool("has_refresh_token", record.RefreshToken != ""))
113-
} else if needsRefresh {
127+
case needsRefresh:
114128
p.logger.Info("⏰ OAuth token will expire soon, proactive refresh recommended",
115129
zap.String("server_key", p.serverKey),
116130
zap.Time("expires_at", record.ExpiresAt),
117131
zap.Duration("time_until_expiry", timeUntilExpiry),
118132
zap.Duration("grace_period", TokenRefreshGracePeriod),
119133
zap.Bool("has_refresh_token", record.RefreshToken != ""))
120-
} else {
134+
default:
121135
p.logger.Debug("✅ OAuth token is valid and not expiring soon",
122136
zap.String("server_key", p.serverKey),
123137
zap.Time("expires_at", record.ExpiresAt),

0 commit comments

Comments
 (0)