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

345 lines
10 KiB
TypeScript
Raw Normal View History

2026-02-10 11:16:39 +09:00
import { createHash } from "node:crypto";
2026-02-06 17:50:35 +09:00
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import type { KisConfig, KisCredentialInput } from "@/lib/kis/config";
2026-02-06 17:50:35 +09:00
import { getKisConfig } from "@/lib/kis/config";
2026-02-26 09:05:17 +09:00
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
2026-02-06 17:50:35 +09:00
/**
* @file lib/kis/token.ts
2026-02-11 15:27:03 +09:00
* @description KIS // .
2026-02-06 17:50:35 +09:00
*/
interface KisTokenResponse {
access_token?: string;
access_token_token_expired?: string;
token_type?: string;
2026-02-11 15:27:03 +09:00
access_token_expired?: string;
expires_in?: number | string;
2026-02-06 17:50:35 +09:00
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;
const TOKEN_DEFAULT_TTL_MS = 23 * 60 * 60 * 1000;
const KIS_TOKEN_ISSUE_PATH = "/oauth2/tokenP";
const KIS_TOKEN_REVOKE_PATH = "/oauth2/revokeP";
const KIS_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
const KIS_TOKEN_TYPE_BEARER = "bearer";
2026-02-06 17:50:35 +09:00
/**
* @description .
* @param value
* @returns sha256 hex
* @see getTokenCacheKey
*/
2026-02-06 17:50:35 +09:00
function hashKey(value: string) {
return createHash("sha256").update(value).digest("hex");
}
/**
* @description + .
* @param credentials KIS
* @returns
* @see getKisAccessToken /
*/
2026-02-06 17:50:35 +09:00
function getTokenCacheKey(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
}
/**
* @description JSON .
* @param rawText
* @returns
* @see issueKisToken
*/
2026-02-11 15:27:03 +09:00
function tryParseTokenResponse(rawText: string): KisTokenResponse {
try {
return JSON.parse(rawText) as KisTokenResponse;
} catch {
return {
msg1: rawText.slice(0, 200),
};
}
}
/**
* @description JSON .
* @param rawText
* @returns
* @see revokeKisAccessToken
*/
2026-02-11 15:27:03 +09:00
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
try {
return JSON.parse(rawText) as KisRevokeResponse;
} catch {
return {
message: rawText.slice(0, 200),
};
}
}
/**
* @description number .
* @param value
* @returns null
* @see resolveTokenExpiry expires_in
*/
function parseNumericSeconds(value?: number | string) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
/**
* @description KIS epoch(ms) .
* @param value (: 2023-12-22 08:16:59)
* @returns epoch(ms) null
* @see resolveTokenExpiry access_token_token_expired
*/
2026-02-11 15:27:03 +09:00
function parseTokenExpiryText(value?: string) {
if (!value?.trim()) return null;
const trimmed = value.trim();
// 명세 샘플("YYYY-MM-DD HH:mm:ss")은 한국시간(KST)으로 해석해 UTC 서버에서도 오차를 줄입니다.
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}$/.test(trimmed)) {
const parsedKst = Date.parse(`${trimmed.replace(" ", "T")}+09:00`);
if (!Number.isNaN(parsedKst)) {
return parsedKst;
}
}
2026-02-11 15:27:03 +09:00
const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
2026-02-11 15:27:03 +09:00
const parsed = Date.parse(normalized);
if (Number.isNaN(parsed)) return null;
return parsed;
}
/**
* @description .
* @param payload KIS
* @returns epoch(ms)
* @see issueKisToken
*/
2026-02-11 15:27:03 +09:00
function resolveTokenExpiry(payload: KisTokenResponse) {
const expiresInSeconds = parseNumericSeconds(payload.expires_in);
if (expiresInSeconds && expiresInSeconds > 0) {
return Date.now() + expiresInSeconds * 1000;
2026-02-11 15:27:03 +09:00
}
const absoluteExpiry =
parseTokenExpiryText(payload.access_token_token_expired) ??
parseTokenExpiryText(payload.access_token_expired);
if (absoluteExpiry) {
return absoluteExpiry;
}
// 예외 상황 기본값: 23시간
return Date.now() + TOKEN_DEFAULT_TTL_MS;
2026-02-11 15:27:03 +09:00
}
/**
* @description .
* @see https://github.com/koreainvestment/open-trading-api
*/
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("auth");
if (keyError) {
return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱 키/시크릿 쌍을 확인해 주세요.`;
}
return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요.";
}
/**
* @description / .
* @returns OAuth
* @see issueKisToken (P)
* @see revokeKisAccessToken (P)
*/
function buildOauthHeaders(): HeadersInit {
return {
"content-type": "application/json; charset=utf-8",
accept: "application/json, text/plain, */*",
};
}
/**
* @description (P) .
* @param config KIS
* @returns tokenP
* @see (P)[-001].xlsx grant_type/appkey/appsecret
*/
function buildTokenIssueBody(config: KisConfig) {
return {
grant_type: KIS_GRANT_TYPE_CLIENT_CREDENTIALS,
appkey: config.appKey,
appsecret: config.appSecret,
};
}
/**
* @description .
* @param payload KIS
* @returns
* @see issueKisToken
*/
function buildTokenIssueDetail(payload: KisTokenResponse) {
2026-02-26 09:05:17 +09:00
return buildKisErrorDetail({
message: payload.msg1,
msgCode: payload.msg_cd,
extraMessages: [payload.error_description, payload.error],
});
}
2026-02-06 17:50:35 +09:00
/**
2026-02-11 15:27:03 +09:00
* @description KIS .
* @see app/api/kis/validate/route.ts
* @see C:/Users//Downloads/(P)[-001].xlsx (P)
2026-02-06 17:50:35 +09:00
*/
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
const config = getKisConfig(credentials);
const tokenUrl = `${config.baseUrl}${KIS_TOKEN_ISSUE_PATH}`;
2026-02-06 17:50:35 +09:00
const response = await fetch(tokenUrl, {
method: "POST",
headers: buildOauthHeaders(),
body: JSON.stringify(buildTokenIssueBody(config)),
2026-02-06 17:50:35 +09:00
cache: "no-store",
});
const rawText = await response.text();
const payload = tryParseTokenResponse(rawText);
if (!response.ok || !payload.access_token) {
const detail = buildTokenIssueDetail(payload);
2026-02-06 17:50:35 +09:00
const hint = buildTokenIssueHint(detail, config.tradingEnv);
throw new Error(
detail
? `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}${hint}`
: `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status})${hint}`,
);
}
const tokenType = (payload.token_type ?? "").toLowerCase();
if (tokenType && tokenType !== KIS_TOKEN_TYPE_BEARER) {
throw new Error(
`KIS 토큰 발급 응답 검증 실패 (${config.tradingEnv}): token_type=${payload.token_type}`,
);
}
2026-02-06 17:50:35 +09:00
return {
token: payload.access_token,
expiresAt: resolveTokenExpiry(payload),
};
}
/**
2026-02-11 15:27:03 +09:00
* @description .
* @see lib/kis/domestic.ts
2026-02-06 17:50:35 +09:00
*/
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 inFlight = tokenIssueInFlightMap.get(cacheKey);
if (inFlight) {
const shared = await inFlight;
return shared.token;
}
const nextPromise = issueKisToken(credentials);
tokenIssueInFlightMap.set(cacheKey, nextPromise);
2026-02-11 15:27:03 +09:00
2026-02-06 17:50:35 +09:00
const next = await nextPromise.finally(() => {
tokenIssueInFlightMap.delete(cacheKey);
});
tokenCacheMap.set(cacheKey, next);
return next.token;
}
/**
2026-02-11 15:27:03 +09:00
* @description KIS .
* @see app/api/kis/revoke/route.ts
2026-02-06 17:50:35 +09:00
*/
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}${KIS_TOKEN_REVOKE_PATH}`, {
2026-02-06 17:50:35 +09:00
method: "POST",
headers: buildOauthHeaders(),
2026-02-06 17:50:35 +09:00
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) {
2026-02-26 09:05:17 +09:00
const detail = buildKisErrorDetail({
message: payload.message,
extraMessages: [payload.msg1],
});
2026-02-11 15:27:03 +09:00
2026-02-06 17:50:35 +09:00
throw new Error(
detail
? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
: `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status})`,
);
}
tokenCacheMap.delete(cacheKey);
tokenIssueInFlightMap.delete(cacheKey);
clearKisApprovalKeyCache(credentials);
2026-02-11 15:27:03 +09:00
return payload.message ?? "액세스 토큰 폐기가 완료되었습니다.";
2026-02-06 17:50:35 +09:00
}