kriasoft / kriasoft/oauth-callback
Standardize token expiry handling with conversion utilities
- Dominant language
- TypeScript
- Stars
- 11
- Forks
- 4
- PR merge metrics
- No merged PRs in 30d
Description
## 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:
```typescript
// 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
```typescript
// 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:
```typescript
// 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
- Issue #17 (Storage interface mismatch) - Related to token format differences
- Issue #13 (Token refresh mechanism) - Uses expiry calculations
Contributor guide
Research direction
Start by locating the scattered expiry conversions described in the issue and review the proposed API in src/utils/token-expiry.ts. Define the utility and integration scope before updating callers, then add tests for conversion accuracy, invalid and expired timestamps, timezones, clock skew, and OAuth/MCP compatibility. Done means existing code uses the centralized utilities and the listed edge cases are covered.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100