Token Refresh
SPEC #003 §2-4 + SPEC #006 §2-5 정합. helper apps/admin/src/lib/refresh.ts.
Overview
access (15min) 만료 임박 또는 401 시 BFF 가 refresh (7d, 임퍼소네이션은 60min) 사용해 새 토큰 rotation. 사용자에게 투명.
Trigger 시점
| 시점 | 트리거 | 책임 |
|---|---|---|
| 만료 임박 (≤30s) | BFF helper refreshIfNeeded(session) | proactive |
| backend 401 | BFF helper forceRefresh(session) 1회 재시도 | reactive |
/auth/me 호출 시 | server component 도 동일 | RSC 정합 |
helper API (apps/admin/src/lib/refresh.ts)
// 의사
// 공통 내부: refresh 토큰 교환 + 세션 재봉인. 실패는 swallow → boolean.
// 세션 파기(destroy)는 helper 가 하지 않고 호출자(BFF route)가 결정한다.
const applyAuth = async (session: Session, refreshToken: string): Promise<boolean> => {
try {
const auth = await backendRefresh(refreshToken); // 5xx·네트워크 → throw
session.accessToken = auth.accessToken;
session.refreshToken = auth.refreshToken;
// accessExpiresInSeconds(수명, 초) → 절대 시각 / refreshExpiresAt(ISO 절대시각) → epoch ms
session.accessExpiresAt = computeAccessExpiresAt(auth.accessExpiresInSeconds);
session.refreshExpiresAt = new Date(auth.refreshExpiresAt).getTime();
session.accountId = auth.accountId;
session.role = auth.role;
await session.save();
return true;
} catch {
return false; // swallow — destroy 안 함
}
};
// proactive: 만료 임박일 때만 교환. 불필요/refresh 토큰 없음 → 세션 그대로 반환 (throw X).
export const refreshIfNeeded = async (session: Session): Promise<Session> => {
if (!accessIsLikelyExpired(session)) return session;
if (!session.refreshToken) return session;
await applyAuth(session, session.refreshToken);
return session;
};
// reactive: backend 401 후 강제. 성공 여부만 boolean 으로 반환 (throw·destroy X).
export const forceRefresh = async (session: Session): Promise<boolean> => {
if (!session.refreshToken) return false;
return applyAuth(session, session.refreshToken);
};Rotation 정책 (backend)
- 매
/refresh호출 시 prev refreshrevokedAt = now(). - 새 refresh 발급 + DB INSERT.
- 같은 client 가 race condition 으로 두 번 refresh 호출 → 두 번째는 revoked → 401.
- BFF 가 1회 재시도 후 또 401 이면 session.destroy().
Constraints ([[feedback-1]] · [[feedback-3]])
- 5xx 에서 session 유지 — refresh 자체가 5xx 면 BACKEND_ERROR. 다음 호출에서 다시 시도.
- 401 만 session.destroy() — 진짜 refresh 만료 / revoked.
- Server Component 도 refresh-aware —
(protected)/layout.tsx에서 backend 호출 시 동일 패턴. /api/*호출은 middleware 가 redirect X — BFF 가 JSON 401 응답.
RSC 패턴
// apps/admin/src/app/(protected)/layout.tsx
const ProtectedLayout = async ({ children }) => {
const session = await getSession();
if (!session.accessToken) redirect("/login");
await refreshIfNeeded(session); // proactive (세션 반환, throw X)
if (!session.accessToken) redirect("/login");
const fetchMe = () => backendMe(session.accessToken!); // 시그니처: (accessToken: string)
let me = null;
try {
me = await fetchMe();
} catch (err) {
if (err instanceof BackendCallError && err.status === 401) {
// 늦게 잡힌 만료 — reactive refresh 1회 후 재호출
if (await forceRefresh(session) && session.accessToken) {
try { me = await fetchMe(); }
catch { session.destroy(); redirect("/login"); }
} else {
session.destroy(); redirect("/login"); // 파기는 호출자 책임
}
}
// 5xx·네트워크(비-401): shell 유지, 페이지 단에서 재시도
}
if (me?.passwordMustChange) redirect("/onboarding/change-password");
// 운영사 셸은 단일 컴포넌트가 아니라 Topbar + Sidebar 조합 (apps/admin)
return (
<ToastHostProvider>
<div data-surface="ops">
<Topbar email={me?.email ?? null} name={me?.name ?? null} />
<div>
<Sidebar />
<main>{children}</main>
</div>
</div>
</ToastHostProvider>
);
};States & Edge Cases
| 상태 | 처리 |
|---|---|
| access 유효 (≥30s) | refresh skip |
| access 만료 임박 | proactive refresh |
| access 만료 + refresh 유효 | refresh OK |
| access 만료 + refresh 만료 (401) | session.destroy() + redirect /login |
| refresh race condition (revoked) | 1회 재시도 후 fail → destroy |
| backend 5xx | session 유지 + 502 BACKEND_ERROR |
Roadmap
- sliding 갱신 활성화 (사용자 활동 기준 refresh TTL 연장) — 현재 backend 정책 의존
- all-device logout (현재는 token 단위만)
References
- SPEC #003 §2-4
- SPEC #006 §2-5-3
linkmusic-frontend-space/apps/admin/src/lib/refresh.tslinkmusic-frontend-space/apps/admin/src/lib/backend.ts