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";
|
2026-02-13 15:44:41 +09:00
|
|
|
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;
|
2026-02-13 15:44:41 +09:00
|
|
|
token_type?: string;
|
2026-02-11 15:27:03 +09:00
|
|
|
access_token_expired?: string;
|
2026-02-13 15:44:41 +09:00
|
|
|
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;
|
2026-02-13 15:44:41 +09:00
|
|
|
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
|
|
|
|
2026-02-13 15:44:41 +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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
/**
|
|
|
|
|
* @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)}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
/**
|
|
|
|
|
* @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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
/**
|
|
|
|
|
* @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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
/**
|
|
|
|
|
* @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) {
|
2026-02-13 15:44:41 +09:00
|
|
|
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
|
|
|
|
2026-02-13 15:44:41 +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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
/**
|
|
|
|
|
* @description 토큰 만료시각을 계산합니다.
|
|
|
|
|
* @param payload KIS 토큰 발급 응답
|
|
|
|
|
* @returns 만료시각 epoch(ms)
|
|
|
|
|
* @see issueKisToken 토큰 캐시 저장 만료시간 계산
|
|
|
|
|
*/
|
2026-02-11 15:27:03 +09:00
|
|
|
function resolveTokenExpiry(payload: KisTokenResponse) {
|
2026-02-13 15:44:41 +09:00
|
|
|
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시간
|
2026-02-13 15:44:41 +09:00
|
|
|
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)을 확인해 주세요.";
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
/**
|
|
|
|
|
* @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-13 15:44:41 +09:00
|
|
|
}
|
|
|
|
|
|
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
|
2026-02-13 15:44:41 +09:00
|
|
|
* @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);
|
2026-02-13 15:44:41 +09:00
|
|
|
const tokenUrl = `${config.baseUrl}${KIS_TOKEN_ISSUE_PATH}`;
|
2026-02-06 17:50:35 +09:00
|
|
|
|
|
|
|
|
const response = await fetch(tokenUrl, {
|
|
|
|
|
method: "POST",
|
2026-02-13 15:44:41 +09:00
|
|
|
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) {
|
2026-02-13 15:44:41 +09:00
|
|
|
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}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
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);
|
|
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
const response = await fetch(`${config.baseUrl}${KIS_TOKEN_REVOKE_PATH}`, {
|
2026-02-06 17:50:35 +09:00
|
|
|
method: "POST",
|
2026-02-13 15:44:41 +09:00
|
|
|
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
|
|
|
}
|