import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { getConfig } from '../config.js';
import { CONFIG_FILE_PERMISSION, getConfigDir, USER_AGENT } from '../constants.js';
import { parseJsonResponse } from '../utils/error.js';
import { createTokenData } from './token-utils.js';
import { tryMigrateLegacyTokens, clearLegacyTokens } from './token-migration.js';
export const TokenDataSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_at: z.number(),
token_type: z.string(),
scope: z.string(),
});
// OAuth token response schema (from freee API)
export const OAuthTokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string().optional(),
expires_in: z.number(),
token_type: z.string().optional(),
scope: z.string().optional(),
});
export type OAuthTokenResponse = z.infer<typeof OAuthTokenResponseSchema>;
export interface TokenData {
access_token: string;
refresh_token: string;
expires_at: number;
token_type: string;
scope: string;
}
function getTokenFilePath(): string {
return path.join(getConfigDir(), 'tokens.json');
}
export async function saveTokens(tokens: TokenData): Promise<void> {
const tokenPath = getTokenFilePath();
const configDir = path.dirname(tokenPath);
try {
console.error(`[info] Creating directory: ${configDir}`);
await fs.mkdir(configDir, { recursive: true });
console.error(`[info] Writing tokens to: ${tokenPath}`);
await fs.writeFile(tokenPath, JSON.stringify(tokens, null, 2), { mode: CONFIG_FILE_PERMISSION });
console.error('[info] Tokens saved successfully');
} catch (error) {
console.error('[error] Failed to save tokens:', error);
throw error;
}
}
export async function loadTokens(): Promise<TokenData | null> {
const tokenPath = getTokenFilePath();
try {
const data = await fs.readFile(tokenPath, 'utf8');
const parsed = JSON.parse(data);
const result = TokenDataSchema.safeParse(parsed);
if (!result.success) {
console.error('[error] Invalid token file:', result.error.message);
return null;
}
return result.data;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// Try to migrate from legacy company-specific token files
const legacyTokens = await tryMigrateLegacyTokens(saveTokens);
if (legacyTokens) {
return legacyTokens;
}
return null;
}
console.error('[error] Failed to load tokens:', error);
throw error;
}
}
export function isTokenValid(tokens: TokenData): boolean {
return Date.now() < tokens.expires_at;
}
export async function refreshAccessToken(refreshToken: string): Promise<TokenData> {
const cfg = getConfig();
const response = await fetch(cfg.oauth.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: cfg.freee.clientId,
client_secret: cfg.freee.clientSecret,
}),
});
if (!response.ok) {
const result = await parseJsonResponse(response);
const errorInfo = result.success
? JSON.stringify(result.data)
: `(JSON parse failed: ${result.error})`;
throw new Error(`Token refresh failed: ${response.status} ${errorInfo}`);
}
const jsonData: unknown = await response.json();
const parseResult = OAuthTokenResponseSchema.safeParse(jsonData);
if (!parseResult.success) {
throw new Error(`Invalid token response format: ${parseResult.error.message}`);
}
const tokens = createTokenData(parseResult.data, {
refreshToken,
scope: cfg.oauth.scope,
});
await saveTokens(tokens);
return tokens;
}
export async function clearTokens(): Promise<void> {
const tokenPath = getTokenFilePath();
try {
await fs.unlink(tokenPath);
console.error('[info] Tokens cleared successfully');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
console.error('[info] No tokens to clear (file does not exist)');
return;
}
console.error('[error] Failed to clear tokens:', error);
throw error;
}
// Also try to clear any legacy company-specific token files
await clearLegacyTokens();
}
export async function getValidAccessToken(): Promise<string | null> {
const tokens = await loadTokens();
if (!tokens) {
return null;
}
if (isTokenValid(tokens)) {
return tokens.access_token;
}
// Let refresh errors propagate to caller for proper error handling
const newTokens = await refreshAccessToken(tokens.refresh_token);
return newTokens.access_token;
}