Skip to content

Automatic Token Refresh

Status:IMPLEMENTED (2026-01-06) Priority: 🔴 HIGH Implementation Time: ~3 hours Risk Level: MEDIUM Impact: Prevent session interruptions during extended conversations

Implementation Available

This enhancement has been implemented! See the Token Refresh Architecture Documentation for complete details on the implementation, configuration, and usage.

Key Features Implemented:

  • ✅ Two-phase token storage (pending → claimed)
  • ✅ Proactive refresh (before expiration)
  • ✅ Reactive refresh (after expiration)
  • ✅ Best-effort design (doesn't break core flows)
  • ✅ Full configuration support
  • ✅ Session cleanup integration

Implementation Summary: IMPLEMENTATION_SUMMARY.md

← Back to Enhancements


Problem Statement

OAuth access tokens expire during extended Claude conversations, causing tool calls to fail with authentication errors:

Token exchange failed (400): The provided authorization grant or refresh token is invalid, expired, revoked...

This forces users to manually reconnect the MCP server mid-conversation, which:

  • Disrupts workflow during extended planning/documentation sessions
  • Forces context switching when users must reconnect
  • Loses conversation flow and momentum
  • Particularly problematic for long-running operations

Current Behavior

Implementation Analysis

1. Token Storage (src/routes/oauth-token.ts)

  • Currently a pure proxy - forwards requests without storage
  • Refresh tokens returned to client but never stored server-side
  • No server-side refresh capability

2. Token Validation (src/middleware/auth.ts)

  • Uses jwtVerify() to validate access tokens
  • Returns error on expiration - no refresh attempt
  • No fallback mechanism for expired tokens

3. Current Flow:

1. User authenticates → receives access_token (5-60 min lifetime)
2. Claude makes MCP requests → works initially
3. Token expires during conversation
4. JWT validation fails with "expired_token"
5. Auth middleware returns 401
6. User must manually reconnect

Proposed Solution

Implement server-side automatic token refresh - store refresh tokens server-side and transparently refresh expired access tokens before returning authentication errors.

Enhanced Flow


Implementation Plan

Phase 1: Token Storage Service

Create src/services/token-store.ts:

typescript
/**
 * OAuth token storage interface
 */
export interface StoredTokens {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;  // Unix timestamp
  issuedAt: number;   // Unix timestamp
  scope?: string;
  tokenType: string;
}

/**
 * Token store interface for managing OAuth tokens
 */
export interface TokenStore {
  /**
   * Store tokens for a session
   */
  set(sessionId: string, tokens: StoredTokens): Promise<void>;

  /**
   * Get tokens for a session
   */
  get(sessionId: string): Promise<StoredTokens | null>;

  /**
   * Delete tokens for a session
   */
  delete(sessionId: string): Promise<void>;

  /**
   * Check if tokens exist and are not expired
   */
  hasValidTokens(sessionId: string): Promise<boolean>;

  /**
   * Update access token (after refresh)
   */
  updateAccessToken(
    sessionId: string,
    accessToken: string,
    expiresAt: number
  ): Promise<void>;
}

/**
 * Redis-backed token store implementation
 */
export class RedisTokenStore implements TokenStore {
  private keyPrefix = "seed:tokens:";

  constructor(private redis: RedisClientType) {}

  private getKey(sessionId: string): string {
    return `${this.keyPrefix}${sessionId}`;
  }

  async set(sessionId: string, tokens: StoredTokens): Promise<void> {
    const key = this.getKey(sessionId);
    const value = JSON.stringify(tokens);

    // TTL should match or slightly exceed refresh token lifetime
    // Default: 7 days (typical refresh token lifetime)
    const ttl = config.oauth.refreshTokenTTL || 7 * 24 * 60 * 60;

    await this.redis.setEx(key, ttl, value);

    logger.debug("Tokens stored", { sessionId, expiresAt: tokens.expiresAt });
  }

  async get(sessionId: string): Promise<StoredTokens | null> {
    const key = this.getKey(sessionId);
    const value = await this.redis.get(key);

    if (!value) {
      return null;
    }

    try {
      return JSON.parse(value) as StoredTokens;
    } catch (error) {
      logger.error("Failed to parse stored tokens", { sessionId, error });
      return null;
    }
  }

  async delete(sessionId: string): Promise<void> {
    const key = this.getKey(sessionId);
    await this.redis.del(key);
    logger.debug("Tokens deleted", { sessionId });
  }

  async hasValidTokens(sessionId: string): Promise<boolean> {
    const tokens = await this.get(sessionId);
    if (!tokens) {
      return false;
    }

    // Check if access token is still valid (not expired)
    const now = Date.now();
    return tokens.expiresAt > now;
  }

  async updateAccessToken(
    sessionId: string,
    accessToken: string,
    expiresAt: number
  ): Promise<void> {
    const tokens = await this.get(sessionId);
    if (!tokens) {
      throw new Error("No tokens found for session");
    }

    tokens.accessToken = accessToken;
    tokens.expiresAt = expiresAt;

    await this.set(sessionId, tokens);
  }
}

// Singleton instance
let tokenStore: TokenStore | null = null;

/**
 * Get singleton token store instance
 */
export function getTokenStore(): TokenStore {
  if (!tokenStore) {
    const redis = getRedisClient();
    tokenStore = new RedisTokenStore(redis);
  }
  return tokenStore;
}

Phase 2: Configuration Options

Update src/config/index.ts:

typescript
export interface OAuthConfig {
  // ... existing fields ...

  /**
   * Enable automatic token refresh
   * @default true
   */
  autoRefreshEnabled: boolean;

  /**
   * Refresh token TTL in seconds
   * @default 604800 (7 days)
   */
  refreshTokenTTL: number;

  /**
   * Buffer time before token expiration to trigger refresh (seconds)
   * @default 300 (5 minutes)
   */
  refreshBufferTime: number;
}

export const config = {
  // ... existing config ...

  oauth: {
    // ... existing oauth config ...
    autoRefreshEnabled: process.env.OAUTH_AUTO_REFRESH !== "false",
    refreshTokenTTL: parseInt(process.env.OAUTH_REFRESH_TOKEN_TTL || "604800", 10),
    refreshBufferTime: parseInt(process.env.OAUTH_REFRESH_BUFFER_TIME || "300", 10),
  },
};

Phase 3: Capture Refresh Tokens

Update src/routes/oauth-token.ts to store tokens after successful exchange:

typescript
import { getTokenStore } from "../services/token-store.js";

oauthTokenRouter.post("/", async (req: Request, res: Response): Promise<void> => {
  // ... existing validation code ...

  try {
    const upstreamResponse = await proxyTokenRequest(proxyParams);
    const responseText = await (upstreamResponse as unknown as globalThis.Response).text();

    let responseData: OAuthTokenResponse | OAuthErrorResponse;
    try {
      responseData = JSON.parse(responseText) as OAuthTokenResponse | OAuthErrorResponse;
    } catch {
      sendOAuthError(res, "server_error", "Invalid response from authorization server", 502);
      return;
    }

    const status = (upstreamResponse as unknown as globalThis.Response).status;

    // NEW: Store tokens for automatic refresh if successful
    if (status === 200 && "access_token" in responseData) {
      const sessionId = req.headers["mcp-session-id"] as string | undefined;

      if (sessionId && responseData.refresh_token) {
        const tokenStore = getTokenStore();

        // Calculate expiration time
        const expiresIn = responseData.expires_in || 3600; // Default 1 hour
        const expiresAt = Date.now() + expiresIn * 1000;

        await tokenStore.set(sessionId, {
          accessToken: responseData.access_token,
          refreshToken: responseData.refresh_token,
          expiresAt,
          issuedAt: Date.now(),
          scope: responseData.scope,
          tokenType: responseData.token_type,
        });

        logger.info("Tokens stored for session", { sessionId, expiresIn });
      }
    }

    res.status(status).json(responseData);
  } catch (error) {
    // ... existing error handling ...
  }
});

Phase 4: Automatic Refresh Middleware

Update src/middleware/auth.ts to add refresh logic:

typescript
import { getTokenStore } from "../services/token-store.js";

/**
 * Refresh expired access token using stored refresh token
 */
async function refreshAccessToken(sessionId: string): Promise<string | null> {
  const tokenStore = getTokenStore();
  const tokens = await tokenStore.get(sessionId);

  if (!tokens || !tokens.refreshToken) {
    logger.warn("No refresh token available for session", { sessionId });
    return null;
  }

  try {
    // Exchange refresh token for new access token
    const tokenUrl = config.oidc.tokenUrl;
    if (!tokenUrl) {
      throw new Error("Token URL not configured");
    }

    const params = new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: tokens.refreshToken,
      client_id: config.oidc.audience,
    });

    const response = await fetch(tokenUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: params.toString(),
    });

    if (!response.ok) {
      const errorText = await response.text();
      logger.error("Token refresh failed", {
        sessionId,
        status: response.status,
        error: errorText,
      });

      // Delete invalid tokens
      await tokenStore.delete(sessionId);
      return null;
    }

    const data = await response.json() as OAuthTokenResponse;

    // Update stored tokens
    const expiresIn = data.expires_in || 3600;
    const expiresAt = Date.now() + expiresIn * 1000;

    await tokenStore.set(sessionId, {
      accessToken: data.access_token,
      refreshToken: data.refresh_token || tokens.refreshToken,
      expiresAt,
      issuedAt: Date.now(),
      scope: data.scope,
      tokenType: data.token_type,
    });

    logger.info("Token refreshed successfully", { sessionId, expiresIn });

    return data.access_token;
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error";
    logger.error("Token refresh error", { sessionId, error: message });
    return null;
  }
}

/**
 * Enhanced JWT validation middleware with automatic token refresh
 */
export async function authMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): Promise<void> {
  const start = Date.now();

  // ... existing auth bypass checks ...

  const authHeader = req.headers.authorization;
  const sessionId = req.headers["mcp-session-id"] as string | undefined;

  if (!authHeader) {
    authAttempts.inc({ result: "failure" });
    sendAuthError(res, "missing_token", "No Authorization header provided");
    return;
  }

  let token = extractBearerToken(authHeader);

  if (!token) {
    authAttempts.inc({ result: "failure" });
    sendAuthError(res, "invalid_format", "Authorization header must use Bearer scheme");
    return;
  }

  try {
    const verifyOptions: { issuer?: string; audience?: string } = {};
    if (config.oidc.issuer) {
      verifyOptions.issuer = config.oidc.issuer;
    }
    if (config.oidc.audience) {
      verifyOptions.audience = config.oidc.audience;
    }

    const { payload } = await jwtVerify<OIDCPayload>(token, jwksService.getKey, verifyOptions);

    // ... existing claim validation and user context ...

    req.user = userContext;

    authAttempts.inc({ result: "success" });
    authLatency.observe(Date.now() - start);

    next();
  } catch (error) {
    // NEW: Try to refresh token on expiration
    if (
      config.oauth.autoRefreshEnabled &&
      sessionId &&
      error instanceof Error &&
      (error.message.includes("expired") || error.message.includes("exp"))
    ) {
      logger.info("Token expired, attempting refresh", { sessionId });

      const newToken = await refreshAccessToken(sessionId);

      if (newToken) {
        // Retry validation with new token
        try {
          const { payload } = await jwtVerify<OIDCPayload>(
            newToken,
            jwksService.getKey,
            verifyOptions
          );

          if (!payload.sub) {
            sendAuthError(res, "missing_claim", "Token missing required 'sub' claim");
            return;
          }

          const userContext: UserContext = {
            sub: payload.sub,
            token: newToken,
          };

          if (payload.email !== undefined) {
            userContext.email = payload.email;
          }
          if (payload.name !== undefined) {
            userContext.name = payload.name;
          }
          if (payload.groups !== undefined) {
            userContext.groups = payload.groups;
          }

          req.user = userContext;

          authAttempts.inc({ result: "success_refreshed" });
          authLatency.observe(Date.now() - start);

          logger.info("Request succeeded with refreshed token", { sessionId });

          next();
          return;
        } catch (retryError) {
          logger.error("Token validation failed after refresh", { sessionId, retryError });
        }
      }
    }

    // Original error handling for all other cases
    authAttempts.inc({ result: "failure" });

    if (error instanceof Error) {
      const message = error.message.toLowerCase();

      if (message.includes("expired") || message.includes("exp")) {
        sendAuthError(res, "expired_token", "Token has expired");
      } else if (message.includes("signature")) {
        sendAuthError(res, "invalid_signature", "Invalid token signature");
      } else if (message.includes("issuer")) {
        sendAuthError(res, "invalid_issuer", "Token issuer does not match");
      } else if (message.includes("audience")) {
        sendAuthError(res, "invalid_audience", "Token audience does not match");
      } else {
        logger.error("JWT verification failed", { error: message });
        sendAuthError(res, "invalid_token", "Token validation failed");
      }
    } else {
      sendAuthError(res, "invalid_token", "Token validation failed");
    }
  }
}

Phase 5: Session Cleanup

Update session deletion to also clean up tokens:

typescript
// In src/routes/mcp.ts
mcpRouter.delete("/", async (req: Request, res: Response): Promise<void> => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;

  if (!sessionId) {
    res.status(400).json({
      jsonrpc: "2.0",
      error: {
        code: -32600,
        message: "Bad Request",
        data: { reason: "missing_session_id" },
      },
      id: null,
    });
    return;
  }

  try {
    const transport = transports[sessionId];

    if (transport) {
      await transport.close();
      delete transports[sessionId];
    }

    const sessionStore = getSessionStore();
    await sessionStore.delete(sessionId);

    // NEW: Clean up stored tokens
    const tokenStore = getTokenStore();
    await tokenStore.delete(sessionId);

    mcpSessionsActive.dec();
    mcpSessionsTotal.inc({ status: "deleted" });

    logger.info("Session and tokens deleted", { sessionId });

    res.status(200).json({
      jsonrpc: "2.0",
      result: { success: true },
      id: null,
    });
  } catch (error) {
    // ... existing error handling ...
  }
});

Testing Strategy

Unit Tests

Create src/services/token-store.test.ts:

typescript
import { describe, it, expect, beforeEach, vi } from "vitest";
import { RedisTokenStore, type StoredTokens } from "./token-store.js";
import type { RedisClientType } from "redis";

describe("RedisTokenStore", () => {
  let mockRedis: RedisClientType;
  let tokenStore: RedisTokenStore;

  beforeEach(() => {
    mockRedis = {
      setEx: vi.fn(),
      get: vi.fn(),
      del: vi.fn(),
    } as unknown as RedisClientType;

    tokenStore = new RedisTokenStore(mockRedis);
  });

  describe("set", () => {
    it("should store tokens with TTL", async () => {
      const sessionId = "test-session-123";
      const tokens: StoredTokens = {
        accessToken: "access-token-xyz",
        refreshToken: "refresh-token-abc",
        expiresAt: Date.now() + 3600000,
        issuedAt: Date.now(),
        tokenType: "Bearer",
      };

      await tokenStore.set(sessionId, tokens);

      expect(mockRedis.setEx).toHaveBeenCalledWith(
        "seed:tokens:test-session-123",
        604800, // 7 days default TTL
        JSON.stringify(tokens)
      );
    });
  });

  describe("get", () => {
    it("should retrieve stored tokens", async () => {
      const sessionId = "test-session-123";
      const tokens: StoredTokens = {
        accessToken: "access-token-xyz",
        refreshToken: "refresh-token-abc",
        expiresAt: Date.now() + 3600000,
        issuedAt: Date.now(),
        tokenType: "Bearer",
      };

      vi.mocked(mockRedis.get).mockResolvedValue(JSON.stringify(tokens));

      const result = await tokenStore.get(sessionId);

      expect(result).toEqual(tokens);
      expect(mockRedis.get).toHaveBeenCalledWith("seed:tokens:test-session-123");
    });

    it("should return null for non-existent session", async () => {
      vi.mocked(mockRedis.get).mockResolvedValue(null);

      const result = await tokenStore.get("non-existent");

      expect(result).toBeNull();
    });
  });

  describe("hasValidTokens", () => {
    it("should return true for valid non-expired tokens", async () => {
      const sessionId = "test-session-123";
      const tokens: StoredTokens = {
        accessToken: "access-token-xyz",
        refreshToken: "refresh-token-abc",
        expiresAt: Date.now() + 3600000, // Future expiration
        issuedAt: Date.now(),
        tokenType: "Bearer",
      };

      vi.mocked(mockRedis.get).mockResolvedValue(JSON.stringify(tokens));

      const result = await tokenStore.hasValidTokens(sessionId);

      expect(result).toBe(true);
    });

    it("should return false for expired tokens", async () => {
      const sessionId = "test-session-123";
      const tokens: StoredTokens = {
        accessToken: "access-token-xyz",
        refreshToken: "refresh-token-abc",
        expiresAt: Date.now() - 1000, // Past expiration
        issuedAt: Date.now() - 4000,
        tokenType: "Bearer",
      };

      vi.mocked(mockRedis.get).mockResolvedValue(JSON.stringify(tokens));

      const result = await tokenStore.hasValidTokens(sessionId);

      expect(result).toBe(false);
    });
  });

  describe("delete", () => {
    it("should delete tokens", async () => {
      const sessionId = "test-session-123";

      await tokenStore.delete(sessionId);

      expect(mockRedis.del).toHaveBeenCalledWith("seed:tokens:test-session-123");
    });
  });
});

Integration Tests

Create src/middleware/auth-refresh.test.ts:

typescript
import { describe, it, expect, beforeEach, vi } from "vitest";
import request from "supertest";
import express from "express";
import { authMiddleware } from "./auth.js";

describe("Auth Middleware - Token Refresh", () => {
  it("should refresh expired token and continue request", async () => {
    // Mock setup with expired token
    // Mock successful refresh
    // Verify request succeeds
  });

  it("should return 401 if refresh fails", async () => {
    // Mock setup with expired token
    // Mock failed refresh (invalid refresh token)
    // Verify 401 response
  });

  it("should not attempt refresh if auto-refresh disabled", async () => {
    // Mock config with autoRefreshEnabled=false
    // Verify immediate 401 on expired token
  });
});

Environment Variables

Add to .env.example:

bash
# OAuth Token Refresh Configuration
OAUTH_AUTO_REFRESH=true                 # Enable automatic token refresh (default: true)
OAUTH_REFRESH_TOKEN_TTL=604800          # Refresh token TTL in seconds (default: 7 days)
OAUTH_REFRESH_BUFFER_TIME=300           # Buffer before expiration to refresh (default: 5 min)

Security Considerations

Token Storage Security

1. Encryption at Rest (Optional Enhancement)

  • Consider encrypting tokens before storing in Redis
  • Use AES-256-GCM with key from environment
  • Trade-off: Performance vs. defense-in-depth

2. Redis Security

  • Ensure Redis has password authentication enabled
  • Use TLS for Redis connections in production
  • Configure appropriate Redis ACLs

3. Token TTL Management

  • Refresh token TTL should match IdP configuration
  • Clean up tokens on session deletion
  • Redis TTL ensures automatic cleanup

Attack Surface Analysis

Mitigations:

  • ✅ Tokens encrypted in transit (HTTPS)
  • ✅ Redis access requires authentication
  • ✅ Session IDs are cryptographically secure UUIDs
  • ✅ Tokens automatically expire via Redis TTL
  • ⚠️ Consider encryption at rest for high-security deployments

Metrics and Observability

Add Prometheus metrics:

typescript
// In src/middleware/auth.ts
const tokenRefreshAttempts = new Counter({
  name: "seed_token_refresh_attempts_total",
  help: "Total number of token refresh attempts",
  labelNames: ["result"] as const,
});

const tokenRefreshLatency = new Histogram({
  name: "seed_token_refresh_duration_seconds",
  help: "Token refresh latency",
  buckets: [0.1, 0.5, 1, 2, 5],
});

Track:

  • Token refresh attempts (success/failure)
  • Refresh latency
  • Tokens stored/deleted
  • Expired token errors before/after implementation

Rollout Strategy

Phase 1: Development

  1. Implement token-store service
  2. Add unit tests
  3. Update oauth-token endpoint to store tokens
  4. Test with local OIDC provider

Phase 2: Staging

  1. Deploy with OAUTH_AUTO_REFRESH=false initially
  2. Enable token storage only (capture tokens)
  3. Monitor for storage issues
  4. Enable auto-refresh for staging testing

Phase 3: Production

  1. Deploy with OAUTH_AUTO_REFRESH=true
  2. Monitor metrics for refresh success rate
  3. Alert on high failure rates
  4. Document for users

Benefits

For Users:

  • ✅ No mid-conversation interruptions
  • ✅ Seamless extended sessions
  • ✅ Better user experience during long operations

For Operations:

  • ✅ Reduced support requests
  • ✅ Better session reliability
  • ✅ Improved metrics visibility

For Security:

  • ✅ Tokens stored with appropriate TTL
  • ✅ Automatic cleanup on expiration
  • ✅ Audit trail via logging

Alternative Approaches Considered

Option B: Client-Side Refresh

  • Approach: Have Claude Desktop/Code handle refresh
  • Rejected: Requires Claude client changes, not under our control

Option C: Longer Token Lifetimes

  • Approach: Configure IdP for longer access token TTL
  • Rejected: Security trade-off, doesn't solve underlying issue

Option D: Stateless JWT Refresh

  • Approach: Use refresh token in every request
  • Rejected: Requires client changes, not feasible

  • Session hijacking protection (IP binding)
  • Audit logging for token refresh events
  • Distributed rate limiting for refresh endpoint

References


Implementation Owner: Backend Team Review Required: Security Team Documentation Updates: Configuration guide, troubleshooting guide

Released under the MIT License.