import { createHash } from "node:crypto"; import { clearKisApprovalKeyCache } from "@/lib/kis/approval"; import type { KisConfig, KisCredentialInput } from "@/lib/kis/config"; import { getKisConfig } from "@/lib/kis/config"; import { buildKisErrorDetail } from "@/lib/kis/error-codes"; /** * @file lib/kis/token.ts * @description KIS 액세스 토큰 발급/폐기/캐시를 관리합니다. */ interface KisTokenResponse { access_token?: string; access_token_token_expired?: string; token_type?: string; access_token_expired?: string; expires_in?: number | string; 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_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"; /** * @description 토큰 캐시 식별용 해시를 생성합니다. * @param value 해시 입력값 * @returns sha256 hex 문자열 * @see getTokenCacheKey 앱키 원문을 직접 저장하지 않기 위한 보조 함수 */ function hashKey(value: string) { return createHash("sha256").update(value).digest("hex"); } /** * @description 거래환경+앱키 기준 토큰 캐시 키를 생성합니다. * @param credentials 사용자 입력 KIS 인증정보 * @returns 캐시 키 * @see getKisAccessToken 토큰 캐시 조회/갱신 키로 사용 */ function getTokenCacheKey(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); return `${config.tradingEnv}:${hashKey(config.appKey)}`; } /** * @description 토큰 응답 문자열을 안전하게 JSON 파싱합니다. * @param rawText 응답 원문 * @returns 파싱된 토큰 응답 객체 * @see issueKisToken 토큰 발급 응답 파싱 단계 */ 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 토큰 폐기 응답 파싱 단계 */ 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 파싱 단계 */ 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; } } const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T"); const parsed = Date.parse(normalized); if (Number.isNaN(parsed)) return null; return parsed; } /** * @description 토큰 만료시각을 계산합니다. * @param payload KIS 토큰 발급 응답 * @returns 만료시각 epoch(ms) * @see issueKisToken 토큰 캐시 저장 만료시간 계산 */ function resolveTokenExpiry(payload: KisTokenResponse) { const expiresInSeconds = parseNumericSeconds(payload.expires_in); if (expiresInSeconds && expiresInSeconds > 0) { return Date.now() + expiresInSeconds * 1000; } 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; } /** * @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) { return buildKisErrorDetail({ message: payload.msg1, msgCode: payload.msg_cd, extraMessages: [payload.error_description, payload.error], }); } /** * @description KIS 액세스 토큰을 발급합니다. * @see app/api/kis/validate/route.ts * @see C:/Users/이지훈/Downloads/접근토큰발급(P)[인증-001].xlsx 접근토큰발급(P) 명세 */ async function issueKisToken(credentials?: KisCredentialInput): Promise { const config = getKisConfig(credentials); const tokenUrl = `${config.baseUrl}${KIS_TOKEN_ISSUE_PATH}`; const response = await fetch(tokenUrl, { method: "POST", headers: buildOauthHeaders(), body: JSON.stringify(buildTokenIssueBody(config)), cache: "no-store", }); const rawText = await response.text(); const payload = tryParseTokenResponse(rawText); if (!response.ok || !payload.access_token) { const detail = buildTokenIssueDetail(payload); 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}`, ); } return { token: payload.access_token, expiresAt: resolveTokenExpiry(payload), }; } /** * @description 캐시된 토큰을 반환하거나 새로 발급합니다. * @see lib/kis/domestic.ts */ 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); const next = await nextPromise.finally(() => { tokenIssueInFlightMap.delete(cacheKey); }); tokenCacheMap.set(cacheKey, next); return next.token; } /** * @description 현재 KIS 액세스 토큰을 폐기합니다. * @see app/api/kis/revoke/route.ts */ 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}`, { method: "POST", headers: buildOauthHeaders(), 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 = buildKisErrorDetail({ message: payload.message, extraMessages: [payload.msg1], }); throw new Error( detail ? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}` : `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status})`, ); } tokenCacheMap.delete(cacheKey); tokenIssueInFlightMap.delete(cacheKey); clearKisApprovalKeyCache(credentials); return payload.message ?? "액세스 토큰 폐기가 완료되었습니다."; }