import { createHash } from "node:crypto"; import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { KisCredentialInput } from "@/lib/kis/config"; import { clearKisApprovalKeyCache } from "@/lib/kis/approval"; import { getKisConfig } from "@/lib/kis/config"; /** * @file lib/kis/token.ts * @description KIS access token 발급/캐시 관리(실전/모의 공통) */ interface KisTokenResponse { access_token?: string; access_token_token_expired?: string; expires_in?: number; msg1?: string; msg_cd?: string; error?: string; error_description?: string; } interface KisTokenCache { token: string; expiresAt: number; } interface KisRevokeResponse { code?: number | string; message?: string; msg1?: string; } const tokenCacheMap = new Map(); const tokenIssueInFlightMap = new Map>(); const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; const TOKEN_CACHE_FILE_PATH = join(process.cwd(), ".tmp", "kis-token-cache.json"); function hashKey(value: string) { return createHash("sha256").update(value).digest("hex"); } function getTokenCacheKey(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); return `${config.tradingEnv}:${hashKey(config.appKey)}`; } interface PersistedTokenCache { [cacheKey: string]: KisTokenCache; } async function readPersistedTokenCache() { try { const raw = await readFile(TOKEN_CACHE_FILE_PATH, "utf8"); return JSON.parse(raw) as PersistedTokenCache; } catch { return {}; } } async function writePersistedTokenCache(next: PersistedTokenCache) { await mkdir(join(process.cwd(), ".tmp"), { recursive: true }); await writeFile(TOKEN_CACHE_FILE_PATH, JSON.stringify(next), "utf8"); } async function getPersistedToken(cacheKey: string) { const cache = await readPersistedTokenCache(); const token = cache[cacheKey]; if (!token) return null; if (token.expiresAt - TOKEN_REFRESH_BUFFER_MS <= Date.now()) { delete cache[cacheKey]; await writePersistedTokenCache(cache); return null; } return token; } async function setPersistedToken(cacheKey: string, token: KisTokenCache) { const cache = await readPersistedTokenCache(); cache[cacheKey] = token; await writePersistedTokenCache(cache); } async function clearPersistedToken(cacheKey: string) { const cache = await readPersistedTokenCache(); if (!(cacheKey in cache)) return; delete cache[cacheKey]; if (Object.keys(cache).length === 0) { try { await unlink(TOKEN_CACHE_FILE_PATH); } catch { // ignore } return; } await writePersistedTokenCache(cache); } /** * KIS access token 발급 * @param credentials 사용자 입력 키(선택) * @returns token + expiresAt * @see app/api/kis/validate/route.ts POST - 사용자 키 검증 시 토큰 발급 경로 */ async function issueKisToken(credentials?: KisCredentialInput): Promise { const config = getKisConfig(credentials); const tokenUrl = `${config.baseUrl}/oauth2/tokenP`; const response = await fetch(tokenUrl, { method: "POST", headers: { "content-type": "application/json; charset=utf-8", }, body: JSON.stringify({ grant_type: "client_credentials", appkey: config.appKey, appsecret: config.appSecret, }), cache: "no-store", }); const rawText = await response.text(); const payload = tryParseTokenResponse(rawText); if (!response.ok || !payload.access_token) { const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd] .filter(Boolean) .join(" / "); const hint = buildTokenIssueHint(detail, config.tradingEnv); throw new Error( detail ? `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}${hint}` : `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status})${hint}`, ); } return { token: payload.access_token, expiresAt: resolveTokenExpiry(payload), }; } /** * 토큰 발급 실패 시 점검 안내를 생성합니다. * @param detail KIS 응답 메시지 * @param tradingEnv 거래 모드(real/mock) * @returns 점검 안내 문자열 * @see https://apiportal.koreainvestment.com/apiservice-apiservice */ function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") { const lower = detail.toLowerCase(); const keyError = lower.includes("appkey") || lower.includes("appsecret") || lower.includes("secret") || lower.includes("invalid") || lower.includes("인증"); if (keyError) { return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱키/시크릿 쌍이 맞는지 확인하세요.`; } return " | 점검: KIS API 포털에서 앱 상태(사용 가능/차단)와 실전·모의 구분을 다시 확인하세요."; } /** * 토큰 응답 문자열을 안전하게 JSON으로 변환합니다. * @param rawText fetch 응답 원문 * @returns KisTokenResponse */ function tryParseTokenResponse(rawText: string): KisTokenResponse { try { return JSON.parse(rawText) as KisTokenResponse; } catch { // JSON 파싱 실패 시에도 호출부에서 상태코드 기반 에러를 만들 수 있게 기본 객체를 반환합니다. return { msg1: rawText.slice(0, 200), }; } } /** * 토큰 만료시각 계산 * @param payload 토큰 응답 * @returns epoch ms */ function resolveTokenExpiry(payload: KisTokenResponse) { if (payload.access_token_token_expired) { const parsed = Date.parse(payload.access_token_token_expired.replace(" ", "T")); if (!Number.isNaN(parsed)) return parsed; } if (typeof payload.expires_in === "number" && payload.expires_in > 0) { return Date.now() + payload.expires_in * 1000; } return Date.now() + 23 * 60 * 60 * 1000; } /** * access token 반환(환경/키 단위 메모리 캐시) * @param credentials 사용자 입력 키(선택) * @returns access token * @see lib/kis/domestic.ts getDomesticOverview - 현재가/일봉 병렬 조회 시 공용 토큰 사용 */ export async function getKisAccessToken(credentials?: KisCredentialInput) { const cacheKey = getTokenCacheKey(credentials); const cached = tokenCacheMap.get(cacheKey); if (cached && cached.expiresAt - TOKEN_REFRESH_BUFFER_MS > Date.now()) { return cached.token; } const persisted = await getPersistedToken(cacheKey); if (persisted) { tokenCacheMap.set(cacheKey, persisted); return persisted.token; } // 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다. const inFlight = tokenIssueInFlightMap.get(cacheKey); if (inFlight) { const shared = await inFlight; return shared.token; } const nextPromise = issueKisToken(credentials); tokenIssueInFlightMap.set(cacheKey, nextPromise); const next = await nextPromise.finally(() => { tokenIssueInFlightMap.delete(cacheKey); }); tokenCacheMap.set(cacheKey, next); await setPersistedToken(cacheKey, next); return next.token; } /** * KIS access token 폐기 요청 * @param credentials 사용자 입력 키(선택) * @returns 폐기 응답 메시지 * @see app/api/kis/revoke/route.ts POST - 대시보드 접근 폐기 버튼 처리 */ export async function revokeKisAccessToken(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); const cacheKey = getTokenCacheKey(credentials); const token = await getKisAccessToken(credentials); const response = await fetch(`${config.baseUrl}/oauth2/revokeP`, { method: "POST", headers: { "content-type": "application/json; charset=utf-8", }, body: JSON.stringify({ appkey: config.appKey, appsecret: config.appSecret, token, }), cache: "no-store", }); const rawText = await response.text(); const payload = tryParseRevokeResponse(rawText); const code = payload.code != null ? String(payload.code) : ""; const isSuccessCode = code === "" || code === "200"; if (!response.ok || !isSuccessCode) { const detail = [payload.message, payload.msg1].filter(Boolean).join(" / "); throw new Error( detail ? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}` : `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status})`, ); } tokenCacheMap.delete(cacheKey); tokenIssueInFlightMap.delete(cacheKey); await clearPersistedToken(cacheKey); clearKisApprovalKeyCache(credentials); return payload.message ?? "접근토큰 폐기에 성공하였습니다."; } /** * 토큰 폐기 응답 문자열을 안전하게 JSON으로 변환합니다. * @param rawText fetch 응답 원문 * @returns KisRevokeResponse * @see https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/revokeP */ function tryParseRevokeResponse(rawText: string): KisRevokeResponse { try { return JSON.parse(rawText) as KisRevokeResponse; } catch { return { message: rawText.slice(0, 200), }; } }