Files
auto-trade/lib/kis/token.ts

306 lines
8.9 KiB
TypeScript
Raw Normal View History

2026-02-10 11:16:39 +09:00
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
2026-02-06 17:50:35 +09:00
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<string, KisTokenCache>();
const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>();
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
2026-02-10 11:16:39 +09:00
const TOKEN_CACHE_FILE_PATH = join(process.cwd(), ".tmp", "kis-token-cache.json");
2026-02-06 17:50:35 +09:00
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)}`;
}
2026-02-10 11:16:39 +09:00
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);
}
2026-02-06 17:50:35 +09:00
/**
* KIS access token
* @param credentials ()
* @returns token + expiresAt
* @see app/api/kis/validate/route.ts POST -
*/
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
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;
}
2026-02-10 11:16:39 +09:00
const persisted = await getPersistedToken(cacheKey);
if (persisted) {
tokenCacheMap.set(cacheKey, persisted);
return persisted.token;
}
2026-02-06 17:50:35 +09:00
// 같은 키로 동시에 요청이 들어오면 토큰 발급을 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);
2026-02-10 11:16:39 +09:00
await setPersistedToken(cacheKey, next);
2026-02-06 17:50:35 +09:00
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);
2026-02-10 11:16:39 +09:00
await clearPersistedToken(cacheKey);
2026-02-06 17:50:35 +09:00
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),
};
}
}