Skip to content

Standardize token expiry handling with conversion utilities #19

@koistya

Description

@koistya

Description

The library has inconsistent token expiry handling - oauth-callback stores expires_at as an absolute ISO timestamp, while MCP SDK and OAuth providers use expires_in as seconds from now. The conversion logic is scattered throughout the codebase, leading to bugs, timezone issues, and maintenance burden.

Current Problems

Token expiry is handled inconsistently across the library:

// oauth-callback format (absolute time)
interface Tokens {
  access_token: string;
  expires_at?: string;  // "2024-12-31T23:59:59.000Z"
}

// MCP SDK / OAuth response format (relative seconds)
interface OAuthTokenResponse {
  access_token: string;
  expires_in?: number;  // 3600 (seconds from now)
}

// Scattered conversion logic
// In file A:
const expiresAt = new Date(Date.now() + expires_in * 1000).toISOString();

// In file B (slightly different):
const expiresAt = expires_in 
  ? new Date(Date.now() + (expires_in * 1000)).toISOString()
  : undefined;

// In file C (with bug - forgot * 1000):
const expiresAt = new Date(Date.now() + expires_in).toISOString(); // BUG!

// Reverse conversion also inconsistent:
const expiresIn = Math.floor((new Date(expires_at).getTime() - Date.now()) / 1000);
// vs
const expiresIn = Math.round((Date.parse(expires_at) - Date.now()) / 1000);

Specific Issues

  1. Scattered conversion: Same logic repeated in multiple places
  2. Inconsistent implementations: Slightly different conversion formulas
  3. Error-prone: Easy to forget * 1000 for milliseconds
  4. No validation: No checks for already-expired tokens
  5. Timezone issues: String parsing can have timezone problems
  6. No utilities: Developers must write their own conversions

What needs to be done

  1. Create centralized conversion utilities for token expiry handling
  2. Standardize on absolute timestamps internally (ISO 8601 strings)
  3. Add validation for expired tokens and invalid timestamps
  4. Provide type-safe converters with proper error handling
  5. Update all existing code to use the new utilities
  6. Add comprehensive tests for edge cases

Why this matters

  • Bugs: Incorrect expiry calculations cause premature or delayed re-authentication
  • Security: Expired tokens might be used if expiry is calculated wrong
  • Maintenance: Scattered logic is hard to maintain and fix
  • Developer experience: No clear guidance on how to handle expiry
  • Interoperability: Different formats between libraries cause integration issues

Without standardization:

  • Token refresh happens at wrong times
  • Users get logged out unexpectedly
  • Silent failures when tokens expire
  • Difficult to debug expiry-related issues

Implementation considerations

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

  1. Format choice: Why absolute time over relative? Consider clock skew, storage, serialization.

  2. Timezone handling: Should we use UTC always? How to handle user timezone preferences?

  3. Alternative approach: Use Unix timestamps (numbers) instead of ISO strings for easier math?

  4. Clock skew: How do we handle server/client time differences?

  5. Grace period: Should we refresh tokens slightly before expiry to avoid race conditions?

Suggested implementation

// src/utils/token-expiry.ts

/**
 * Token expiry utilities for consistent handling across the library
 */

/**
 * Converts OAuth expires_in (seconds) to absolute ISO timestamp
 * 
 * @param expiresIn - Seconds until token expires (from OAuth response)
 * @param issuedAt - When the token was issued (defaults to now)
 * @returns ISO 8601 timestamp string or undefined if no expiry
 * 
 * @example
 * ```typescript
 * const response = { access_token: "...", expires_in: 3600 };
 * const expiresAt = expiresInToAbsolute(response.expires_in);
 * // "2024-01-15T14:30:00.000Z"
 * ```
 */
export function expiresInToAbsolute(
  expiresIn: number | undefined,
  issuedAt: Date = new Date()
): string | undefined {
  if (!expiresIn || expiresIn <= 0) {
    return undefined;
  }
  
  const expiresAtMs = issuedAt.getTime() + (expiresIn * 1000);
  return new Date(expiresAtMs).toISOString();
}

/**
 * Converts absolute ISO timestamp to expires_in seconds
 * 
 * @param expiresAt - ISO 8601 timestamp string
 * @param now - Current time (defaults to now)
 * @returns Seconds until expiry (0 if expired, undefined if no expiry)
 * 
 * @example
 * ```typescript
 * const tokens = { access_token: "...", expires_at: "2024-01-15T14:30:00.000Z" };
 * const expiresIn = absoluteToExpiresIn(tokens.expires_at);
 * // 3542 (seconds remaining)
 * ```
 */
export function absoluteToExpiresIn(
  expiresAt: string | undefined,
  now: Date = new Date()
): number | undefined {
  if (!expiresAt) {
    return undefined;
  }
  
  try {
    const expiryMs = new Date(expiresAt).getTime();
    const nowMs = now.getTime();
    
    const secondsRemaining = Math.max(0, Math.floor((expiryMs - nowMs) / 1000));
    return secondsRemaining;
  } catch (error) {
    console.warn(`Invalid expires_at timestamp: ${expiresAt}`, error);
    return 0; // Treat as expired if invalid
  }
}

/**
 * Checks if a token is expired or will expire soon
 * 
 * @param expiresAt - ISO 8601 timestamp string
 * @param bufferSeconds - Consider expired if expires within this many seconds (default: 60)
 * @returns true if expired or expiring soon
 * 
 * @example
 * ```typescript
 * if (isTokenExpired(tokens.expires_at)) {
 *   // Refresh the token
 * }
 * 
 * // With 5-minute buffer
 * if (isTokenExpired(tokens.expires_at, 300)) {
 *   // Refresh if expires in next 5 minutes
 * }
 * ```
 */
export function isTokenExpired(
  expiresAt: string | undefined,
  bufferSeconds: number = 60
): boolean {
  if (!expiresAt) {
    return false; // No expiry = never expires
  }
  
  const remainingSeconds = absoluteToExpiresIn(expiresAt);
  if (remainingSeconds === undefined) {
    return false; // No expiry
  }
  
  return remainingSeconds <= bufferSeconds;
}

/**
 * Calculates when a token will expire based on expires_in
 * 
 * @param expiresIn - Seconds until expiry from OAuth response
 * @returns Date object for when token expires
 */
export function calculateExpiryDate(expiresIn: number | undefined): Date | undefined {
  if (!expiresIn || expiresIn <= 0) {
    return undefined;
  }
  
  return new Date(Date.now() + (expiresIn * 1000));
}

/**
 * Gets remaining lifetime of a token as human-readable string
 * 
 * @param expiresAt - ISO 8601 timestamp string
 * @returns Human-readable time remaining (e.g., "5 minutes", "2 hours")
 * 
 * @example
 * ```typescript
 * const remaining = getTokenLifetime(tokens.expires_at);
 * console.log(`Token expires in ${remaining}`); // "Token expires in 5 minutes"
 * ```
 */
export function getTokenLifetime(expiresAt: string | undefined): string {
  if (!expiresAt) {
    return "never";
  }
  
  const seconds = absoluteToExpiresIn(expiresAt);
  if (seconds === undefined) {
    return "never";
  }
  
  if (seconds <= 0) {
    return "expired";
  }
  
  if (seconds < 60) {
    return `${seconds} seconds`;
  }
  
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) {
    return `${minutes} minute${minutes === 1 ? '' : 's'}`;
  }
  
  const hours = Math.floor(minutes / 60);
  if (hours < 24) {
    return `${hours} hour${hours === 1 ? '' : 's'}`;
  }
  
  const days = Math.floor(hours / 24);
  return `${days} day${days === 1 ? '' : 's'}`;
}

/**
 * Validates and normalizes an expires_at timestamp
 * 
 * @param expiresAt - Timestamp to validate (string, number, or Date)
 * @returns Normalized ISO 8601 string or undefined if invalid
 */
export function normalizeExpiresAt(
  expiresAt: string | number | Date | undefined
): string | undefined {
  if (!expiresAt) {
    return undefined;
  }
  
  try {
    let date: Date;
    
    if (typeof expiresAt === 'string') {
      date = new Date(expiresAt);
    } else if (typeof expiresAt === 'number') {
      // Assume Unix timestamp (seconds) if reasonable, otherwise milliseconds
      date = new Date(expiresAt < 10000000000 ? expiresAt * 1000 : expiresAt);
    } else if (expiresAt instanceof Date) {
      date = expiresAt;
    } else {
      return undefined;
    }
    
    // Check for valid date
    if (isNaN(date.getTime())) {
      return undefined;
    }
    
    return date.toISOString();
  } catch {
    return undefined;
  }
}

/**
 * Type guard to check if a token response has expiry information
 */
export function hasExpiry(token: any): token is { expires_in: number } | { expires_at: string } {
  return (
    (typeof token.expires_in === 'number' && token.expires_in > 0) ||
    (typeof token.expires_at === 'string' && token.expires_at.length > 0)
  );
}

/**
 * Converts between different token formats with expiry handling
 * 
 * @example
 * ```typescript
 * // OAuth response to internal format
 * const internalToken = convertTokenFormat({
 *   access_token: "abc",
 *   expires_in: 3600
 * }, 'internal');
 * // { access_token: "abc", expires_at: "2024-01-15T14:30:00.000Z" }
 * 
 * // Internal format to MCP SDK format
 * const mcpToken = convertTokenFormat({
 *   access_token: "abc",
 *   expires_at: "2024-01-15T14:30:00.000Z"
 * }, 'mcp');
 * // { accessToken: "abc", expiresIn: 3600 }
 * ```
 */
export function convertTokenFormat(
  token: any,
  targetFormat: 'internal' | 'oauth' | 'mcp'
): any {
  const result: any = {};
  
  // Copy access token with correct field name
  if (token.access_token) {
    result[targetFormat === 'mcp' ? 'accessToken' : 'access_token'] = token.access_token;
  } else if (token.accessToken) {
    result[targetFormat === 'mcp' ? 'accessToken' : 'access_token'] = token.accessToken;
  }
  
  // Handle expiry conversion
  if (targetFormat === 'internal') {
    // Convert to absolute timestamp
    if (token.expires_in !== undefined) {
      result.expires_at = expiresInToAbsolute(token.expires_in);
    } else if (token.expiresIn !== undefined) {
      result.expires_at = expiresInToAbsolute(token.expiresIn);
    } else if (token.expires_at) {
      result.expires_at = normalizeExpiresAt(token.expires_at);
    } else if (token.expiresAt) {
      result.expires_at = normalizeExpiresAt(token.expiresAt);
    }
  } else {
    // Convert to relative seconds
    const fieldName = targetFormat === 'mcp' ? 'expiresIn' : 'expires_in';
    
    if (token.expires_at) {
      result[fieldName] = absoluteToExpiresIn(token.expires_at);
    } else if (token.expiresAt) {
      result[fieldName] = absoluteToExpiresIn(token.expiresAt);
    } else if (token.expires_in !== undefined) {
      result[fieldName] = token.expires_in;
    } else if (token.expiresIn !== undefined) {
      result[fieldName] = token.expiresIn;
    }
  }
  
  // Copy other fields
  const otherFields = ['refresh_token', 'refreshToken', 'token_type', 'tokenType', 'scope'];
  for (const field of otherFields) {
    if (token[field] !== undefined) {
      result[field] = token[field];
    }
  }
  
  return result;
}

Update existing code

Replace scattered conversions throughout the codebase:

// Before (in multiple files):
const expiresAt = new Date(Date.now() + expires_in * 1000).toISOString();

// After:
import { expiresInToAbsolute } from './utils/token-expiry';
const expiresAt = expiresInToAbsolute(expires_in);

// Before:
if (tokens.expires_at && new Date(tokens.expires_at) < new Date()) {
  // Expired
}

// After:
import { isTokenExpired } from './utils/token-expiry';
if (isTokenExpired(tokens.expires_at)) {
  // Expired
}

Testing requirements

  1. Conversion accuracy: Test expires_in to expires_at and back
  2. Edge cases:
    • Zero/negative expires_in
    • Invalid timestamps
    • Expired tokens
    • Far future dates
  3. Timezone handling: Test with different timezones
  4. Clock skew: Test with past/future issuedAt times
  5. Format compatibility: Test with OAuth, MCP, and internal formats
  6. Performance: Ensure conversions are fast for high-volume use

Documentation needs

  • Add "Token Expiry Handling" section to docs
  • Document the chosen format (absolute ISO timestamps) and why
  • Provide migration guide for existing code
  • Include examples for common scenarios

Skills required

  • TypeScript
  • Date/time handling in JavaScript
  • OAuth 2.0 token lifecycle
  • Testing with dates/times
  • API design

Difficulty

Easy - This is about consolidating existing logic and creating clean utilities. Great for someone who wants to improve code quality and prevent bugs!

Related

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