Skip to content

Implement token refresh mechanism for browserAuth #15

@koistya

Description

@koistya

Description

Complete the TODO at src/auth/browser-auth.ts:450 to implement automatic token refresh functionality. The current implementation lacks the ability to refresh expired tokens, forcing users to re-authenticate.

What needs to be done

  1. Implement refresh token flow:

    • Detect when tokens are expired or about to expire
    • Use refresh token to obtain new access token
    • Handle refresh token rotation
    • Update stored tokens after refresh
  2. Add retry logic:

    • Retry failed refresh attempts with exponential backoff
    • Handle network errors gracefully
    • Fallback to re-authentication if refresh fails
  3. Support different refresh strategies:

    • Automatic refresh before expiry
    • On-demand refresh when needed
    • Background refresh with timer

Why this matters

Token refresh is critical for user experience:

  • Seamless experience: Users don't need to re-authenticate frequently
  • Security: Short-lived access tokens with long-lived refresh tokens
  • Compliance: Many providers require proper refresh token handling
  • Reliability: Automatic recovery from expired tokens

Without refresh:

  • Users must re-authenticate when tokens expire
  • Poor user experience in long-running applications
  • Manual token management burden

Current code reference

// src/auth/browser-auth.ts:450
/** TODO: Implement refresh when token endpoint URL is available from server metadata. */

Implementation considerations

⚠️ Note: This feature requires critical thinking during implementation. Consider:

  1. Token endpoint discovery: How do we get the token endpoint URL? From server metadata? User configuration?

  2. Refresh token security: How do we securely store and rotate refresh tokens? What about refresh token expiry?

  3. Alternative approach: Should refresh be automatic or require explicit user action? Consider battery/network usage.

  4. Race conditions: What if multiple requests try to refresh simultaneously? Need mutex/lock mechanism.

  5. Graceful degradation: What if the provider doesn't support refresh tokens? How do we handle this elegantly?

Suggested implementation

// src/auth/browser-auth.ts

interface RefreshOptions {
  /** Refresh tokens this many seconds before expiry */
  refreshBuffer?: number; // default: 300 (5 minutes)
  /** Maximum refresh retry attempts */
  maxRetries?: number; // default: 3
  /** Auto-refresh in background */
  autoRefresh?: boolean; // default: true
  /** Callback when refresh fails permanently */
  onRefreshFailure?: (error: Error) => void;
}

class BrowserAuth implements OAuthProvider {
  private refreshTimer?: NodeJS.Timeout;
  private refreshPromise?: Promise<Tokens>;
  private refreshOptions: RefreshOptions;
  
  async getAccessToken(): Promise<string> {
    const tokens = await this.store.get(this.storeKey);
    
    if (!tokens) {
      // No tokens, need fresh authorization
      return this.authorize();
    }
    
    // Check if token is expired or about to expire
    if (this.isTokenExpired(tokens)) {
      // Refresh the token
      const refreshed = await this.refreshToken(tokens);
      return refreshed.access_token;
    }
    
    return tokens.access_token;
  }
  
  private isTokenExpired(tokens: Tokens): boolean {
    if (!tokens.expires_at) {
      return false; // No expiry info, assume valid
    }
    
    const expiresAt = new Date(tokens.expires_at).getTime();
    const now = Date.now();
    const buffer = (this.refreshOptions.refreshBuffer || 300) * 1000;
    
    return now >= expiresAt - buffer;
  }
  
  private async refreshToken(tokens: Tokens): Promise<Tokens> {
    // Prevent concurrent refresh attempts
    if (this.refreshPromise) {
      return this.refreshPromise;
    }
    
    this.refreshPromise = this.doRefresh(tokens).finally(() => {
      this.refreshPromise = undefined;
    });
    
    return this.refreshPromise;
  }
  
  private async doRefresh(tokens: Tokens): Promise<Tokens> {
    if (!tokens.refresh_token) {
      throw new Error("No refresh token available");
    }
    
    // Get token endpoint from metadata or configuration
    const tokenEndpoint = await this.getTokenEndpoint();
    
    if (!tokenEndpoint) {
      throw new Error("Token endpoint not available for refresh");
    }
    
    const maxRetries = this.refreshOptions.maxRetries || 3;
    let lastError: Error;
    
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        const response = await fetch(tokenEndpoint, {
          method: "POST",
          headers: {
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept": "application/json"
          },
          body: new URLSearchParams({
            grant_type: "refresh_token",
            refresh_token: tokens.refresh_token,
            client_id: this.clientId || "",
            ...(this.clientSecret && { client_secret: this.clientSecret })
          })
        });
        
        if (!response.ok) {
          const error = await response.json();
          
          // Check if refresh token is invalid (non-retryable)
          if (error.error === "invalid_grant") {
            // Refresh token is invalid, need re-authorization
            await this.store.delete(this.storeKey);
            throw new Error("Refresh token invalid, re-authorization required");
          }
          
          throw new Error(`Refresh failed: ${error.error_description || error.error}`);
        }
        
        const newTokens = await response.json();
        
        // Handle refresh token rotation
        const updatedTokens: Tokens = {
          access_token: newTokens.access_token,
          refresh_token: newTokens.refresh_token || tokens.refresh_token, // Keep old if not rotated
          expires_at: this.calculateExpiry(newTokens.expires_in),
          token_type: newTokens.token_type || "Bearer",
          scope: newTokens.scope || tokens.scope
        };
        
        // Store updated tokens
        await this.store.set(this.storeKey, updatedTokens);
        
        // Schedule next refresh if auto-refresh is enabled
        if (this.refreshOptions.autoRefresh) {
          this.scheduleRefresh(updatedTokens);
        }
        
        return updatedTokens;
        
      } catch (error) {
        lastError = error as Error;
        
        if (attempt < maxRetries) {
          // Exponential backoff
          const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    }
    
    // All retries failed
    this.refreshOptions.onRefreshFailure?.(lastError!);
    throw lastError!;
  }
  
  private async getTokenEndpoint(): Promise<string | null> {
    // Option 1: From server metadata (OAuth 2.0 discovery)
    if (this.metadataUrl) {
      try {
        const response = await fetch(this.metadataUrl);
        const metadata = await response.json();
        return metadata.token_endpoint;
      } catch {
        // Fallback to configured endpoint
      }
    }
    
    // Option 2: From configuration
    return this.tokenEndpoint || null;
  }
  
  private scheduleRefresh(tokens: Tokens): void {
    // Clear existing timer
    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer);
    }
    
    if (!tokens.expires_at) {
      return; // Can't schedule without expiry
    }
    
    const expiresAt = new Date(tokens.expires_at).getTime();
    const now = Date.now();
    const buffer = (this.refreshOptions.refreshBuffer || 300) * 1000;
    const delay = Math.max(0, expiresAt - now - buffer);
    
    this.refreshTimer = setTimeout(async () => {
      try {
        await this.refreshToken(tokens);
      } catch (error) {
        console.error("Background token refresh failed:", error);
        this.refreshOptions.onRefreshFailure?.(error as Error);
      }
    }, delay);
  }
  
  private calculateExpiry(expiresIn?: number): string | undefined {
    if (!expiresIn) {
      return undefined;
    }
    
    const expiresAt = new Date(Date.now() + expiresIn * 1000);
    return expiresAt.toISOString();
  }
}

// Usage example
const authProvider = browserAuth({
  store: fileStore(),
  refreshOptions: {
    refreshBuffer: 300, // Refresh 5 minutes before expiry
    autoRefresh: true,
    maxRetries: 3,
    onRefreshFailure: (error) => {
      console.error("Token refresh failed, user needs to re-authenticate:", error);
      // Show re-auth prompt to user
    }
  }
});

Testing requirements

  • Test successful token refresh
  • Test refresh token rotation
  • Test concurrent refresh attempts (mutex)
  • Test retry logic with failures
  • Test auto-refresh scheduling
  • Test expired token detection
  • Test missing refresh token scenario
  • Test invalid refresh token handling
  • Test with different OAuth providers

Integration considerations

  • MCP SDK compatibility
  • Different OAuth provider behaviors
  • Token storage synchronization
  • Background refresh in different environments

Skills required

  • TypeScript
  • OAuth 2.0 refresh token flow
  • Async programming and promises
  • Error handling and retry logic
  • Token lifecycle management

Difficulty

Hard - Complex feature requiring careful handling of edge cases and security

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions