221 lines
6.0 KiB
Plaintext
221 lines
6.0 KiB
Plaintext
/**
|
|
* JWT를 decoding하면 만료시간을 읽을 수 있다.
|
|
* JWT는 3부분으로 구성 : Header.Payload.Signature
|
|
* Payload 부분에 만료시간(exp)이 있다.
|
|
*/
|
|
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { authService } from "@/src/entities/auth";
|
|
import { CLAIM_NAME } from "@/src/shared/config";
|
|
import { getCookie } from "@/src/shared/lib";
|
|
|
|
interface JwtPayload {
|
|
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
|
|
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
|
|
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
|
|
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
|
|
}
|
|
|
|
interface JwtState {
|
|
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
|
|
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
|
|
exp: number | null; // payload에서 추출한 exp 값
|
|
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
|
|
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
|
|
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
|
|
}
|
|
|
|
const initialJwtState: JwtState = {
|
|
token: "",
|
|
payload: null,
|
|
exp: null,
|
|
expDateLocal: null,
|
|
remainingMs: null,
|
|
error: null,
|
|
};
|
|
|
|
function parseJwt(token: string): JwtPayload {
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) {
|
|
throw new Error("유효한 JWT 형식이 아닙니다.");
|
|
}
|
|
|
|
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
|
|
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
const jsonPayload = decodeURIComponent(
|
|
atob(padded)
|
|
.split("")
|
|
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
|
|
.join("")
|
|
);
|
|
|
|
return JSON.parse(jsonPayload) as JwtPayload;
|
|
}
|
|
|
|
function formatRemainingMs(remainingMs: number | null): string {
|
|
if (remainingMs === null) return "-";
|
|
if (remainingMs <= 0) return "만료됨";
|
|
|
|
const totalSeconds = Math.floor(remainingMs / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
return `${hours}시간 ${minutes}분 ${seconds}초`;
|
|
}
|
|
|
|
function readJwtState(): JwtState {
|
|
const token = getCookie(CLAIM_NAME) ?? "";
|
|
|
|
if (!token) {
|
|
return {
|
|
token: "",
|
|
payload: null,
|
|
exp: null,
|
|
expDateLocal: null,
|
|
remainingMs: null,
|
|
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const payload = parseJwt(token);
|
|
|
|
if (typeof payload.exp !== "number") {
|
|
return {
|
|
token,
|
|
payload,
|
|
exp: null,
|
|
expDateLocal: null,
|
|
remainingMs: null,
|
|
error: "JWT payload에 exp 값이 없습니다.",
|
|
};
|
|
}
|
|
|
|
const expMs = payload.exp * 1000;
|
|
|
|
return {
|
|
token,
|
|
payload,
|
|
exp: payload.exp,
|
|
expDateLocal: new Date(expMs).toLocaleString(),
|
|
remainingMs: Math.max(expMs - Date.now(), 0),
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
token,
|
|
payload: null,
|
|
exp: null,
|
|
expDateLocal: null,
|
|
remainingMs: null,
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "JWT 파싱 중 오류가 발생했습니다.",
|
|
};
|
|
}
|
|
}
|
|
|
|
export default function Page() {
|
|
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
|
|
const isRefreshingRef = useRef(false);
|
|
|
|
const handleSilentRefresh = async () => {
|
|
try {
|
|
await authService.silentRefresh();
|
|
} catch (error) {
|
|
console.error("Manual silent refresh failed:", error);
|
|
} finally {
|
|
setJwtState(readJwtState());
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const syncJwtState = async () => {
|
|
const nextState = readJwtState();
|
|
|
|
if (
|
|
nextState.remainingMs !== null &&
|
|
nextState.remainingMs > 0 &&
|
|
nextState.remainingMs < 5 * 60 * 1000 &&
|
|
!isRefreshingRef.current
|
|
) {
|
|
isRefreshingRef.current = true;
|
|
try {
|
|
await authService.silentRefresh();
|
|
} catch (error) {
|
|
console.error("JWT test silent refresh failed:", error);
|
|
}
|
|
}
|
|
|
|
setJwtState(readJwtState());
|
|
|
|
if (
|
|
nextState.remainingMs === null ||
|
|
nextState.remainingMs >= 5 * 60 * 1000 ||
|
|
nextState.remainingMs <= 0
|
|
) {
|
|
isRefreshingRef.current = false;
|
|
}
|
|
};
|
|
|
|
void syncJwtState();
|
|
const timer = window.setInterval(() => {
|
|
void syncJwtState();
|
|
}, 1000);
|
|
|
|
return () => window.clearInterval(timer);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-4 p-8 text-base">
|
|
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
|
|
|
|
<button
|
|
type="button"
|
|
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
|
onClick={() => {
|
|
void handleSilentRefresh();
|
|
}}
|
|
>
|
|
Silent Refresh 호출
|
|
</button>
|
|
|
|
<div className="rounded border p-4">
|
|
<div className="font-semibold">쿠키명</div>
|
|
<div>{CLAIM_NAME}</div>
|
|
</div>
|
|
|
|
<div className="rounded border p-4">
|
|
<div className="font-semibold">exp</div>
|
|
<div>{jwtState.exp ?? "-"}</div>
|
|
</div>
|
|
|
|
<div className="rounded border p-4">
|
|
<div className="font-semibold">만료시각</div>
|
|
<div>{jwtState.expDateLocal ?? "-"}</div>
|
|
</div>
|
|
|
|
<div className="rounded border p-4">
|
|
<div className="font-semibold">남은시간</div>
|
|
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
|
|
</div>
|
|
|
|
<div className="rounded border p-4">
|
|
<div className="font-semibold">상태</div>
|
|
<div>{jwtState.error ?? "정상"}</div>
|
|
</div>
|
|
|
|
<div className="rounded border p-4">
|
|
<div className="font-semibold">payload</div>
|
|
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
|
|
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|