Authorization: Bearer <access>로 수신 → 클라이언트 메모리 only 보관/api/auth/refresh 1회로 Access 복원Set-Cookie / Authorization을 그대로 전달, Cache-Control: no-store(참고) NestJS 백엔드 예시는 글 맨 아래 부록에 첨부
/auth/login 포워딩Authorization에서 access 추출, refresh는 쿠키로 저장
/api/auth/refresh 호출 → Authorization 헤더로 access 복원Access-Control-Expose-Headers: authorization 필요
Authorization: Bearer <access> 자동 부착
/api/auth/refresh 1회만
refresh_token 존재 여부로 보호 구역 접근 제어/login은 허용, 그 외는 세션 없으면 /login으로
/auth/logout → 서버가 refresh_token 삭제 Set-Cookie 반환/login 이동
// ensureAccessBool(): 토큰 보장 여부만 반환
let inFlight: Promise<boolean> | null = null;
async function refresh(): Promise<string | null> {
const r = await internalClient.post('/api/auth/refresh', null, { withCredentials: true });
const auth = r.headers?.['authorization'];
const t = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
if (t) AuthToken.set(t);
return t ?? null;
}
export async function ensureAccessBool(): Promise<boolean> {
if (AuthToken.get()) return true;
if (!inFlight) {
inFlight = (async () => !!(await refresh()))().finally(() => { inFlight = null; });
}
return await inFlight;
}
// axios 인터셉터 예시
api.interceptors.request.use(async (cfg) => {
if (needsBearer(cfg.url, cfg.baseURL)) {
const ok = await ensureAccessBool();
if (!ok) {
AuthToken.clear();
window.location.replace('/login');
throw new axios.CanceledError('redirecting to login');
}
(cfg.headers ??= {}).Authorization = `Bearer ${AuthToken.get()!}`;
}
return cfg;
});
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const { access, refresh } = await this.authService.login(dto);
res.setHeader('Authorization', 'Bearer ' + access);
res.cookie('refresh_token', refresh, {
httpOnly: true, sameSite: 'lax', secure: isProd, path: '/', maxAge: 604800000,
});
return { ok: true };
}
@Post('refresh')
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const rt = req.cookies?.['refresh_token'];
const { access, refresh } = await this.authService.refresh(rt);
res.setHeader('Authorization', 'Bearer ' + access);
if (refresh) res.cookie('refresh_token', refresh, { httpOnly: true, sameSite: 'lax', secure: isProd, path: '/', maxAge: 604800000 });
return { ok: true };
}
@Post('logout')
async logout(@Res({ passthrough: true }) res: Response) {
res.clearCookie('refresh_token', { httpOnly: true, sameSite: 'lax', secure: isProd, path: '/' });
return { ok: true };
}