-
-
Notifications
You must be signed in to change notification settings - Fork 3
Description
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
-
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
-
Add retry logic:
- Retry failed refresh attempts with exponential backoff
- Handle network errors gracefully
- Fallback to re-authentication if refresh fails
-
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
-
Token endpoint discovery: How do we get the token endpoint URL? From server metadata? User configuration?
-
Refresh token security: How do we securely store and rotate refresh tokens? What about refresh token expiry?
-
Alternative approach: Should refresh be automatic or require explicit user action? Consider battery/network usage.
-
Race conditions: What if multiple requests try to refresh simultaneously? Need mutex/lock mechanism.
-
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